Skip to content

Bedrock-derived error contract across every client surface - #13

Merged
CMGS merged 6 commits into
mainfrom
feat/error-contract
Jul 28, 2026
Merged

Bedrock-derived error contract across every client surface#13
CMGS merged 6 commits into
mainfrom
feat/error-contract

Conversation

@CMGS

@CMGS CMGS commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What

Every client-visible error now carries one classification from a closed, Bedrock-derived set, exposed on two machine channels: the x-amzn-errortype response header (PascalCase, e.g. ThrottlingException) and the body code field (snake_case, e.g. throttling_exception). Envelope shapes stay surface-native — OpenAI surfaces keep {"error":{...}}, /v1/messages keeps the Anthropic shape, realtime WS uses one nested in-band event — so official SDKs keep working while custom clients get a single error handler for every endpoint (see docs/api.md).

Highlights

  • ErrClass closed set (consts): name/code/status plus per-envelope coarse types; ErrCode goes internal-only (logs/metrics).
  • Internal vs external status split: failover (>=500) and abuse noting are untouched; classification happens only at the render boundary. Terminal vendor errors land as 424 ModelErrorException with original_status_code (explicitly recorded, never a synthesized status) and resource_name (attached on the DAG error path only).
  • Terminal upstream 429/503 keep Throttling/ServiceUnavailable semantics; upstream deadlines (header, body, stream-idle) split off as FED_RESP_TIMEOUT and render 408; non-timeout body faults join the upstream fault family instead of masquerading as internal 500s.
  • Hard quota/balance exhaustion is 400 service_quota_exceeded_exception (non-retryable, does not feed abuse tiers); transient rate limits stay 429.
  • Streaming: committed mid-stream aborts now deliver a structured terminal error frame (previously silent); client disconnects stay silent. Responses SSE uses the official flat error event with sequence_number; chat frames carry the envelope plus original_status_code.
  • Json extractor rejections, unknown routes, and wrong methods render in the envelope instead of axum's bare responses; realtime gate denials carry real classes (banned key vs throttle vs quota).
  • vendor_error tolerates the AWS auth layer's capitalized Message; message ingest stops cloning plain-text content it immediately drops.

Breaking change

External error bodies change: error.code switches from the stringified numeric code to the classification name, type: "gateway_error" is gone, and terminal vendor errors return 424 instead of the upstream's raw status. Clients should classify via the header or code and retry only on 408/429/500/503.

Evidence

  • fmt / clippy -D warnings / full test suite green on every commit; new coverage: machine-channel e2e (header, CORS exposure, extractor rejection, 404/405 fallbacks), 413 envelope, WS validation frame, timeout + mid-body-break wire classification, committed-abort terminal frames, quota-vs-abuse separation.
  • Bare-metal A/B (mock transport, oha 20s x 64 conn, two interleaved rounds): chat-small / chat-big / responses all within noise of main (212-216k / 162-165k / 212-216k rps; p50/p99 overlap). The success path gained no work.

CMGS added 6 commits July 28, 2026 19:23
…dline split

ErrClass (consts) is the Bedrock-derived closed classification set from the
gateway error contract; ErrCode stays internal. StreamChunk.error becomes a
structured StreamError (class + message + original upstream status). The pump
now delivers a terminal error frame on committed mid-stream aborts instead of
ending silently; client disconnects stay silent. Upstream deadlines split off
as FED_RESP_TIMEOUT (internal 502 keeps failover, renders as 408). vendor_error
accepts the AWS auth layer's capitalized Message field.
One classify-then-render path replaces the three ad-hoc serializers: OpenAI
and Anthropic envelopes both carry the classification code, every HTTP error
carries x-amzn-errortype plus the CORS exposure, and external statuses come
from the classification (terminal vendor errors land as 424 ModelError with
original_status_code/resource_name instead of leaking the upstream status).
Json extractor rejections, unknown routes, and wrong methods render in the
envelope instead of axum's bare responses. Realtime WS uses one nested
in-band error shape with real classes from the gate; SSE error frames carry
class-driven types (Responses uses the official flat error event with
sequence_number). Terminal upstream 429/503 keep Throttling/
ServiceUnavailable semantics; the wrong-surface factory error is a 400
validation error, not a 501 internal.
Simplify convergence from the four-lens pass: one render core in views
(bare_status + class_response shared by every envelope), the 424-extras rule
lives once as GatewayError::original_status, StreamError::from_error takes
ownership and moves the message, the pump's decode-fault branch reuses
StreamFault instead of hand-rolling both error forms, the ApiJson/AnthJson
extractors share one body, the chat SSE frame reuses openai_error_body, and
the realtime send_err alias is gone. abort_frame's live-channel invariant is
now a debug_assert; ServiceQuotaExceeded documents that it is contract-
reserved with no construction site yet; the wrong-surface e2e asserts the
classification, not just the status.
Idle-gap timeouts now assert the FED_RESP_TIMEOUT code (the ModelTimeout
classification link), oversized bodies assert the 413 envelope, and the
realtime mock session asserts the nested validation error frame. Spec
section references in code comments replaced with self-contained wording —
the numbered contract lives in a private repo public comments must not
point at.
Non-timeout body-read failures join the upstream fault family (502
FED_RESP_RPC_FAILED -> external 424) instead of masquerading as internal
500s, with wire tests for both the timeout and mid-body-break paths. The
timeout-vs-rpc code split now lives once (transport::upstream_fault_code);
the pump's decode-fault arms build one StreamFault; the Anthropic error
body has one owner shared by HTTP and SSE like the OpenAI side; realtime
quota denials use a named closure beside throttled; wire tests share one
serve_router bootstrap. Message ingest stops cloning plain-text content
just to drop the original (into_text_and_parts / blocks_text move it).
Comment pass: two docs tightened to the one-line budget, limit_denied's
stale every-limit claim scoped to throttling now that quota_denied exists.
Whole-repo sweep found no layout, unwrap, or comment-budget violations;
DAG ordering guards adjudicated keep (runtime-enforced topology).
@CMGS
CMGS merged commit 461a29c into main Jul 28, 2026
2 checks passed
@CMGS
CMGS deleted the feat/error-contract branch July 28, 2026 15:32
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