Skip to content

feat(config): validate boundary values at load time#16

Merged
hidetzu merged 2 commits into
mainfrom
feat/config-boundary-validation
Apr 11, 2026
Merged

feat(config): validate boundary values at load time#16
hidetzu merged 2 commits into
mainfrom
feat/config-boundary-validation

Conversation

@hidetzu
Copy link
Copy Markdown
Owner

@hidetzu hidetzu commented Apr 11, 2026

Summary

First of the v0.2.0 quality cleanup PRs. Closes a QA gap flagged in the release review: `Load()` previously accepted any integer, so `MAX_REQUEST_BYTES=-100` or `REQUEST_TIMEOUT=-1s` would load silently and cause undefined downstream behavior.

Adds an explicit `Config.Validate()` method called at the end of `Load()`. Misconfiguration now fails loudly at startup with an actionable error message naming the offending env var and the raw value.

Rules

Field category Rule Rationale
Duration (`REQUEST_TIMEOUT`, `SHUTDOWN_TIMEOUT`) must be strictly positive (`> 0`) Zero or negative is never a useful timeout
Count/byte (`MAX_REQUEST_BYTES`, `RATE_LIMIT_RPM`, `RATE_LIMIT_BURST`, `MAX_CONCURRENT_REQUESTS`, `MAX_CHANGED_FILES`, `MAX_DIFF_BYTES`, `MAX_RESPONSE_BYTES`) must be non-negative (`>= 0`) Zero is preserved because `body_limit` / `rate_limit` / `concurrency_limit` middleware document it as the "disable this guard" convention — rejecting it here would break that contract

Error message format:

```
REQUEST_TIMEOUT must be positive, got -1s
MAX_REQUEST_BYTES must be non-negative, got -100
```

Test plan

Three new tests added to `config_test.go`:

Test Cases Purpose
`TestLoad_RejectsOutOfRangeValues` 11 table-driven Every field × out-of-range value: zero duration, negative duration, negative count/byte. Checks error message mentions the field name.
`TestLoad_ZeroIsValidForCountFields` 7 fields set to zero simultaneously Confirms the disable-path semantics still work
`TestConfig_Validate_ValidDefault` Default Config Sanity check that default-loaded Config passes Validate

Local checks

  • `go vet ./...` clean
  • `go test ./... -race -count=1` all pass (13 new cases + 101 existing)
  • `golangci-lint run` 0 issues
  • `gofmt` clean (CI tidy check passes)

v0.2.0 release cleanup context

This is PR 1/4 of the v0.2.0 cleanup pass before tagging. The sequence is:

  1. This PR: config boundary validation (quality fix)
  2. Next: close remaining QA coverage gaps (`parseLevel`, IPv6 edge cases, `newRequestID` sentinel path, E2E chain tests for concurrency/analyze/prompt/timeout)
  3. After that: extract shared `writePrismError` helper (deduplicate handler error mapping)
  4. Finally: README v0.2.0 rewrite + `apiVersion` bump, followed by `v0.2.0` git tag + final Fly deploy

Intended to be squash-merged.

hidetzu added 2 commits April 12, 2026 08:08
Close a QA gap flagged in the v0.2.0 release review. Previously
Load() accepted any integer, including negative values like
MAX_REQUEST_BYTES=-100 or REQUEST_TIMEOUT=-1s, which are never
valid in production and cause undefined downstream behavior.
Add an explicit Validate() method and call it at the end of
Load() so bad env configuration fails loudly at startup rather
than producing weird runtime behavior under load.

Rules applied:

- Duration fields (REQUEST_TIMEOUT, SHUTDOWN_TIMEOUT) must be
  strictly positive. Zero or negative is never a useful timeout.
- Count and byte fields (MAX_REQUEST_BYTES, RATE_LIMIT_RPM,
  RATE_LIMIT_BURST, MAX_CONCURRENT_REQUESTS, MAX_CHANGED_FILES,
  MAX_DIFF_BYTES, MAX_RESPONSE_BYTES) must be non-negative. Zero
  is still accepted because body_limit, rate_limit, and
  concurrency_limit middleware document it as the "disable this
  guard" convention; rejecting zero here would break that
  contract.

Error messages name the offending env var and include the raw
value so operators can fix misconfiguration without guesswork.

Tests:

- TestLoad_RejectsOutOfRangeValues (11 table-driven cases)
  covers every field and both the zero-duration and negative-
  count paths.
- TestLoad_ZeroIsValidForCountFields confirms the disable-path
  semantics still work for all seven count/byte fields.
- TestConfig_Validate_ValidDefault is a sanity check that the
  default-loaded Config (no env vars set) passes Validate.

Existing TestLoad_Defaults / TestLoad_OverrideFromEnv /
TestLoad_InvalidDurationReturnsError /
TestLoad_InvalidIntReturnsError still pass unchanged.
Apply the nits surfaced in the code review:

1. Validate PORT as integer in [0, 65535] so PORT=abcxyz or
   PORT=99999 fail at startup instead of at ListenAndServe time.
   Zero is kept valid so ephemeral-port tests and dev setups
   still work.

2. Validate LOG_LEVEL against the exact set
   {debug, info, warn, warning, error}, case-insensitive. This
   closes the QA review's P0 finding that logging.parseLevel
   silently falls back to info on unknown values; now an
   operator who typos the level gets an actionable startup
   error instead of mystified info-only logs in production.
   The parseLevel default branch stays as defense in depth.

3. Return aggregated errors via errors.Join so an operator who
   has multiple misconfigured values at once sees every
   problem in a single Load() error rather than discovering
   them one restart at a time.

4. Drop TestConfig_Validate_ValidDefault. It was redundant:
   Load() internally calls Validate(), so if the default
   Config ever stopped validating, TestLoad_Defaults would
   catch it first. Removing reduces noise.

New tests:

- PORT: three reject cases (non-numeric, negative, out of
  range) added to TestLoad_RejectsOutOfRangeValues; accept
  cases (0, 1, 8080, 65535) in TestLoad_PortAcceptsValidRange.
- LOG_LEVEL: two reject cases (unknown, numeric) added to
  TestLoad_RejectsOutOfRangeValues; six accept cases (all
  case variants of debug/info/warn/warning/error) in
  TestLoad_LogLevelIsCaseInsensitive.
- TestLoad_ReportsAllErrorsAtOnce confirms errors.Join
  surfaces multiple violations in one error.

Design note: validLogLevels is hardcoded in config.go rather
than imported from internal/logging to avoid a config→logging
import cycle (logging already depends on config). The set is
five entries and stable, so the duplication cost is minimal
and is flagged as a comment.
@hidetzu hidetzu merged commit 56642d2 into main Apr 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant