Skip to content

fix(api): keep every 500 on the ADR 0031 envelope; narrow the fail-closed handler (#101, #102)#116

Merged
JumpTechCode merged 1 commit into
mainfrom
fix/api-error-envelope
Jul 20, 2026
Merged

fix(api): keep every 500 on the ADR 0031 envelope; narrow the fail-closed handler (#101, #102)#116
JumpTechCode merged 1 commit into
mainfrom
fix/api-error-envelope

Conversation

@JumpTechCode

Copy link
Copy Markdown
Contributor

#101 — 500s not consistently enveloped / internal messages leaking

Two gaps:

  1. Unmapped TollgateError echoed str(exc). A TollgateError subtype not in the mapping fell to the 500 fallback but still surfaced str(exc), so internal invariant strings (e.g. "idempotency replay is missing its stored response") leaked into the 500 body. Unmapped errors now use the generic fallback message; mapped errors keep echoing their controlled domain message (e.g. insufficient budget at user:u1), unchanged.
  2. No catch-all. A non-TollgateError/non-datastore exception (a genuine bug) was handled by Starlette's default middleware as plain-text Internal Server Error, off the {"error": {...}} contract. A catch-all Exception handler now returns the enveloped generic 500 and logs the traceback so the defect isn't lost.

#102 — fail-closed handler on bare OSError was too broad

handle_unavailable was registered for OSError — a very broad base, so any incidental non-datastore OSError/TimeoutError on the request path became 503 enforcement_unavailable, telling the SDK the datastore is down (inviting fail-closed-with-grace) while masking a real defect. Narrowed to ConnectionError + the builtin TimeoutError plus the SQLAlchemy connectivity types (OperationalError, InterfaceError, SQLAlchemy TimeoutError). Driver-wrapped connect/DNS/timeout failures still fail closed to 503; a bare OSError now surfaces as a 500 via the catch-all.

Handler resolution is by MRO, so ConnectionRefusedErrorConnectionError handler (503), while a bare OSError → the Exception catch-all (500).

Tests

  • New: an unmapped TollgateError("<internal string>") → 500 with the generic message (no leak); a ValueError → enveloped 500 (not plain text); a bare OSError → 500 internal_error (not 503).
  • Unchanged and green: the datastore-connectivity → 503 cases (ConnectionRefusedError, TimeoutError, OperationalError, InterfaceError, SQLAlchemy TimeoutError) and the full domain-error mapping.

Local gate: ruff, mypy --strict, import-linter, full unit suite (492), API-http integration — all green.

Closes #101, #102.

…osed handler (#101, #102)

#101 (two gaps in the error envelope):
- An unmapped TollgateError fell to the 500 fallback but still echoed str(exc),
  so an internal invariant string (e.g. "idempotency replay is missing its
  stored response") surfaced in the body. Unmapped errors now use the generic
  fallback message; mapped errors keep their controlled domain message.
- A non-TollgateError/non-datastore exception (a genuine bug) was handled by
  Starlette's default middleware as plain-text "Internal Server Error", off the
  documented envelope. A catch-all Exception handler now returns the enveloped
  generic 500 and logs the traceback so the defect is not lost.

#102: the fail-closed handler was registered for bare OSError, a very broad base
— any incidental non-datastore OSError/TimeoutError on the request path became a
retryable 503 enforcement_unavailable, telling the SDK the datastore is down and
masking a real defect. Narrowed to ConnectionError + the builtin TimeoutError
plus the SQLAlchemy connectivity types (OperationalError, InterfaceError,
TimeoutError); driver-wrapped connect/DNS failures still fail closed, while a
bare OSError now surfaces as a 500 via the catch-all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjJ4QScUKbrMzF1jujL5Bg
@JumpTechCode
JumpTechCode merged commit 2852a28 into main Jul 20, 2026
8 checks passed
@JumpTechCode
JumpTechCode deleted the fix/api-error-envelope branch July 20, 2026 17:53
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.

500 responses are not consistently enveloped and can echo internal messages

1 participant