Error envelope
_shared/error-envelope
| Kind | shared |
|---|---|
| Applies to | backend |
| State | written |
| Maturity | prod |
| Reusable | yes |
| Stack | fastapi, starlette-middleware, pydantic-2, sse |
| Depends on | _shared/fastapi-project-layout |
| Reference implementation |
|
| Measured on | prima feature/per-model-pricing cd2c248 2026-09-11 |
| Also measured on |
|
| Version | 1 |
Purpose
Whoever made the request (a browser, an OpenAI SDK, a curl in a support thread) can read why it failed, act on a stable machine code, and quote one id that finds the server-side detail. The invariant that makes it trustworthy is that the message is a sentence written in the code at the raise site, the shape is decided by the path prefix and nothing else, and no exception text, provider body or customer content crosses into the response (every interpolation Prima carries is of its own tokens and integers except two, both quoting caller text, named under Gotchas as the reference's own deviation).
This foundation fixes, for every other recipe: the shapes in the wild and where each applies (FastAPI's {detail}, the Labs envelope {error, code, request_id, timestamp, details}, the three middleware envelopes {error, message}, and the OpenAI-shaped {error: {message, type, param, code, request_id}} on /api/prima/v1 only), how a route raises so the handler renders it correctly, what a streaming failure looks like once the headers are sent, and how every frontend reads all of them. Prima (prima cd2c248) is the reference per decision D3 (2026-09-11); the Labs handlers are described as the code that answers on the Labs hosts and as legacy where the two differ.
When to use
Include it from every kind: backend recipe when filling the "error codes" column of API surface, and from every kind: frontend recipe when writing the copy a refusal turns into. Read it before raising an HTTPException, before adding an exception handler, before writing a try/except that returns a response, before parsing a failed response in a client, and before deciding whether a 404 means "no such thing" or "not enabled here". Trigger phrases: "the SDK shows an empty error", "the message is a Python dict", "which key holds the code", "is it detail or error", "we leaked the prompt in a 422", "the 502 had no body", "hide the panel when it is not enabled", "why does a 400 say SRV_001".
Not this recipe: logging and the content-free field registry (Prima's prima/logging.py and the monitoring-ops recipes); rate-limit policy (the limiter's 429 body is only listed here); which surfaces a token may reach (_shared/auth-dependency-pattern, S5); which workspace a request is in (_shared/workspace-scoping, S6, whose 400, 403 and 409 bodies are rendered through this recipe).
Stack assumptions
FastAPI on Starlette, pydantic v2, exception handlers registered in app composition (_shared/fastapi-project-layout, S4). Prima's app factory installs its handlers in _install_error_handlers and its request-id middleware in _install_middleware (prima/app.py lines 84 to 139 and 166 to 187 at cd2c248). Labs registers its handlers at module level in app_fastapi.py (lines 551 to 808 at fd8558f) and its middleware stack at lines 446 to 533.
The Starlette fact this recipe depends on: @app.exception_handler(HTTPException) and the domain-exception handlers run inside the router (Starlette's ExceptionMiddleware), while @app.exception_handler(Exception) is installed on the outermost ServerErrorMiddleware; a BaseHTTPMiddleware that wraps call_next in try/except Exception therefore catches a crashing route before the Exception handler ever sees it. Where that matters is stated under Application logic.
Versions, measured 2026-09-11: Prima's request id is uuid.uuid4().hex (prima/logging.py lines 307 to 308), Labs' is str(uuid.uuid4()) (api/middleware/unified_middleware.py lines 58 to 59). Both are opaque; neither is sequential.
Environment: none. The envelope has no configuration key. X-Request-ID is CORS-exposed on Labs (app_fastapi.py line 452, expose_headers=["X-Total-Count", "X-Request-ID"]); Prima sets X-Request-Id on every response, error or not (prima/app.py line 172). Fail behaviour: a handler that cannot render (a broken template, an exception inside the handler) is Starlette's plain-text 500; both services keep their handlers free of anything that can raise.
Data model
No table. The data is the wire shape, and the machine-code vocabulary.
Shape A, the OpenAI object (the gateway, /api/prima/v1/**). Prima PrimaError.body (prima/errors.py lines 71 to 80); Labs http_exception_handler gateway branch (app_fastapi.py lines 753 to 767):
{"error": {"message": "<a sentence we wrote>",
"type": "invalid_request_error | rate_limit_error | insufficient_quota | api_error",
"param": "<field or header name, or null>",
"code": "<token, or null>",
"request_id": "<opaque>"}}
type is the OpenAI family the SDKs switch on; code is the token our clients switch on; param names the offending field when there is one. Labs fills type from _PRIMA_ERROR_TYPES (lines 712 to 720: 400 and 422 invalid_request_error, 401 authentication_error, 403 permission_error, 404 not_found_error, 429 rate_limit_error, 500 api_error, anything else api_error) and code from its ErrorCode enum. Prima writes both at the raise site, and the status-to-type pairing is fixed by convention rather than by a table, measured over every PrimaError construction at cd2c248 (2026-09-11): 400, 401, 403, 404 and 410 carry invalid_request_error; 402 carries insufficient_quota (prima/errors.py line 155); 429 carries rate_limit_error (lines 123 and 140); 500, 502, 503 and 504 carry api_error. The one construction that breaks the pairing is the layered-jobs spend refusal (prima/runtime/jobs/router.py lines 268 to 273, a route mounted under the gateway prefix, line 124): it carries the refusal's own status with rate_limit_error for 429 and invalid_request_error for every other status, so monthly_ceiling_reached is 402 invalid_request_error (prima/runtime/spend.py line 509), meter_unavailable is 503 invalid_request_error (lines 945 and 1007) and job_ceiling_too_large is 400 invalid_request_error (line 489). A client that keys on type for a 402 or 503 from that route reads the wrong family; it keys on the status and on code (Deviations). A Starlette route or method miss gets invalid_request_error below 500 and api_error otherwise (prima/app.py line 104).
Shape B, the Labs envelope (every Labs route outside the gateway). schemas/error_schemas.py ErrorResponse (lines 75 to 110) built by create_error_response (lines 112 to 138):
{"error": "<message string>",
"code": "AUTH_001 | AUTHZ_001 | VAL_001 | RES_001 | RES_003 | RATE_001 | SRV_001 | ...",
"request_id": "<uuid4>",
"timestamp": "<utc isoformat>Z",
"details": null | {"error": "<token>", "...": "..."}}
error is a string, always, and the Labs frontend depends on that (tests/unit/test_primaapi_error_shape.py lines 33 to 41). code is a status-class code from ErrorCode (error_schemas.py lines 11 to 64: AUTH_*, AUTHZ_*, VAL_*, RES_*, DB_*, AI_*, BILL_*, RATE_*, SRV_*), never the route's token. The route's token, when there is one, is the error string itself and is repeated inside details (a360b2c, 2026-09-04).
The status-to-code map a new Shape B service ships, complete where the reference's error_code_map (app_fastapi.py lines 731 to 739) is not (Gotchas), drawn from ErrorCode (error_schemas.py lines 11 to 64): 400 VAL_002 (INVALID_INPUT; the validation handler's own 400 below carries VAL_001), 401 AUTH_001, 402 BILL_001, 403 AUTHZ_001, 404 RES_001, 405 RES_001 (a method miss is a route this host does not serve, the reading a client already gives a 404), 409 RES_003, 422 VAL_001, 429 RATE_001, 500 SRV_001, 502 SRV_002, 503 SRV_002, 504 SRV_003, anything else SRV_001. A validation failure on a Shape B service is rendered as 400 {"error": "Request body failed validation.", "code": "VAL_001", "request_id": ..., "timestamp": ..., "details": {"error": "invalid_request", "param": "<first location, dotted>"}}: Prima's reduction (Application logic step 9) in Shape B's own places, the token in details.error where Shape B keeps tokens, param beside it, and no msg, ctx or input. A client still switches on the status first; the code is what tells a 400 from a crash in a log line.
Shape C, the middleware envelopes (before any handler runs). Three, all on Labs, none carrying code:
| Producer | Body | When |
|---|---|---|
WorkspaceMiddleware (workspace_middleware.py lines 107 to 114, 116 to 120, 121 to 129) | {"error": "workspace_required", "message": "..."}; {"error": "<HTTPException detail>"}; {"error": "internal_server_error", "message": "An internal error occurred."} | no header on a required prefix; a 403 from its own access check (desk_only_role, "You don't have access to this workspace"); an exception inside the middleware |
UnifiedMiddleware (api/middleware/unified_middleware.py lines 184 to 192) | 429 {"error": "rate_limit_exceeded", "message": "Too many requests. Please slow down."} plus Retry-After | the platform limiter |
ErrorLoggingMiddleware (middleware/error_logging_middleware.py lines 167 to 180) | 500 {"detail": "Internal server error. This has been logged and will be reviewed.", "error_id": "<uuid or null>", "timestamp": "..."} | any exception that escapes a route (see Application logic step 6) |
Shape D, FastAPI's own. {"detail": "<string>"} from Starlette's route and method misses, and 422 {"detail": [{"loc": [...], "msg": "...", "type": "...", "input": ...}]} from pydantic when a body fails validation. On Labs both reach the client unchanged (no RequestValidationError handler is registered at fd8558f); on Prima neither does (Application logic step 9).
Shape E, the non-gateway Prima envelope. {"detail": "<message>", "request_id": "<hex>"} (prima/app.py line 74) for every path outside /api/prima/v1 (/healthz, /readyz, the internal seams). Kept as a plain envelope on purpose so the two services cannot drift while both serve (the docstring at lines 61 to 68 and tests/test_app.py::test_non_gateway_errors_keep_the_plain_envelope). Shape E has no code key: an error raised under /internal/keys or /internal/credits (prima/api/keys_sync.py line 181, prima/api/internal.py line 79) reaches the caller as detail and request_id only, and its code survives in the request_failed log event (prima/app.py line 93), not on the wire.
Shape F, the in-band streaming frame. Once 200 text/event-stream is on the wire the status is spent; the failure is a terminal data: frame whose top-level error is Shape A's object (message, type, code, param: null, request_id) beside id (the request id), object (chat.completion.chunk), created, model (the alias the caller sent), choices: [] and a prima block (prima/streaming.py error_chunk, lines 217 to 262), followed by data: [DONE]. openai-python raises APIError on any frame with a truthy top-level error before it looks at choices (the docstring, lines 226 to 233), which is why the shape is the SDK's and not ours.
The machine codes Prima writes. The set is not closed by construction: errors.py offers ten factories (lines 104 to 183), invalid_request and service_unavailable accept any code, and a route or dependency may construct PrimaError directly (21 constructions outside errors.py at cd2c248, two of them the renderers in prima/app.py; the rest in prima/auth/sessions.py, prima/auth/keys.py, prima/auth/surfaces.py, prima/seams/identity.py and prima/runtime/jobs/router.py; ClaimError subclasses it in prima/auth/claims.py line 75). What is closed is the message: every one is a literal at the raise site, or interpolates the service's own tokens and integers (spend_cap_exceeded, insufficient_scope, node_at_capacity, _refusal_message), and Gotchas names the two that quote caller text instead. Measured 2026-09-11 with grep -rhoE 'code="[a-z_]+"' prima/ | sort -u (42 distinct keyword codes), plus the positional helpers _reject (prima/auth/claims.py line 79, 13 more) and _deny (prima/auth/surfaces.py line 89, the aud_* and insufficient_surface codes, counted in those 13) and the six RefusalReason values a spend refusal writes into code (prima/runtime/spend.py lines 220 to 226, via prima/runtime/jobs/router.py line 272). The ones a gateway client meets, by status:
Status, type | code | Where raised at cd2c248 | What the client does |
|---|---|---|---|
400 invalid_request_error | invalid_request (param = first failing field) | prima/app.py 121 to 127 | fix the body |
| 400 | model_not_found (param: model, the valid list in the message) | prima/api/v1.py 249 to 253 (chat), 927 to 933 (embeddings) | pick a listed alias |
| 400 | unknown_model_role, unpinned_model (param: pins; both messages quote the caller's own text, Gotchas) | prima/runtime/jobs/composition.py 189 to 194, 204 | fix the pins |
| 400, 403, 409 | workspace_invalid and workspace_ambiguous (400), workspace_forbidden (403), no_workspace (409; 400 on the layered-jobs route, prima/runtime/jobs/router.py 192 to 195) (S6) | prima/auth/claims.py 214 to 252, five direct ClaimError constructions | S6 |
401 invalid_request_error | invalid_api_key, one message for every rejected API key | prima/errors.py 108 to 111, raised by check_key (prima/auth/keys.py 159 to 213) | fix the credential |
| 401 | token_expired, credential_revoked (a session token, not a key) | prima/auth/sessions.py 304 to 306 and 235 to 239; token_expired also prima/auth/claims.py 157 | refresh the session |
| 401 | the ClaimError codes: iss_untrusted, token_type_invalid, token_version_unsupported, token_not_yet_valid, token_lifetime_excessive, claim_<name>_invalid, claim_tids_n_invalid, claim_tid_missing, claim_tid_unexpected | prima/auth/claims.py 103 to 180 | a signed token with a bad claim set; the issuer's bug, not the caller's |
402 insufficient_quota | insufficient_credit (the top-up URL, never the balance) | prima/errors.py 146 to 158 | top up and retry |
402 invalid_request_error | monthly_ceiling_reached (the layered-jobs route; the message is _refusal_message's Refused: <reason> (limit N, observed M)., prima/runtime/jobs/router.py 321 to 335, never the refusal's own text) | prima/runtime/jobs/router.py 268 to 273 (prima/runtime/spend.py 509) | raise the cap or wait for the period; a top-up does not clear it |
403 invalid_request_error | insufficient_permissions | prima/errors.py 114 to 115 | you may not |
| 403 | insufficient_scope, scope_not_recognised, key_exhausted | prima/auth/keys.py 184 to 206; the first two again in prima/seams/identity.py 507 to 519 | a legitimate holder acts: mint a key with the scope, or a new key |
| 403 | surface_not_granted, insufficient_surface, aud_missing, aud_ambiguous, aud_unknown, aud_not_issuable | prima/auth/surfaces.py 112 to 134; surface_not_granted again in prima/seams/identity.py 525 to 530 | a session from the wrong door (S5) |
404 invalid_request_error | not_found; job_not_found | prima/errors.py 118 to 119; prima/runtime/jobs/router.py 205 to 209, 355 to 359 | no such thing under your tenant |
| 404 | null | prima/app.py 97 to 108 (a route or method miss; 405 for a method) | this host does not serve that path |
410 invalid_request_error | job_id_not_routable, job_owner_retired | prima/runtime/jobs/router.py 367 to 372, 381 to 386 | the job is gone; do not poll again |
429 rate_limit_error | rate_limit_exceeded | prima/errors.py 122 to 123 | wait |
| 429 | spend_cap_exceeded (unit, used, cap in the message) | prima/errors.py 126 to 143 | raise the cap or wait for the period |
429 rate_limit_error; 400 and 503 invalid_request_error | rate_limit_hour, rate_limit_day, concurrency_limit (429); job_ceiling_too_large (400); meter_unavailable (503; the refusal carries retry_after_s, prima/runtime/spend.py line 946, but the PrimaError at 268 to 273 sets no header, so no Retry-After reaches the wire); node_at_capacity (429) | prima/runtime/jobs/router.py 262 to 273 and 244 to 249 | the layered-jobs limits; the message says which and by how much; type is invalid_request_error on the 400 and the 503 (above) |
500 api_error | internal_error | prima/errors.py 177 to 183 | quote the request_id |
502 api_error | upstream_error; cluster_misrouted | prima/errors.py 161 to 162; prima/runtime/jobs/router.py 402 to 407 | retry may help |
503 api_error | provider_not_configured, credits_unavailable (the money path failing closed); identity_provider_unavailable (with Retry-After); cluster_unconfigured, job_owner_unreachable | prima/api/v1.py 161 to 163, 353 to 355, 445 to 447, 956 to 958, 967 to 969; prima/auth/sessions.py 223 to 231; prima/runtime/jobs/router.py 171 to 176, 460 to 465 | our side; back off, honour Retry-After |
504 api_error | provenance_may_be_lost | prima/runtime/jobs/router.py 450 to 457 | poll again for the counters |
The codes raised under the internal seams (key_sync_not_configured, unknown_issuer, unrepresentable_scopes, not_a_gateway_key, subject_suspended in prima/api/keys_sync.py 240 to 446; grant_not_configured, unsupported_currency, amount_out_of_range, reversal_contended in prima/api/internal.py 131 to 459) never reach a body, because Shape E carries no code (above); they are for the log and for the recipes that own those seams (_shared/hmac-seam-pattern, the credits recipes). A downstream recipe filling its "error codes" column runs the grep on its own router rather than copying this table.
The tokens Labs routes raise as HTTPException(detail={"error": "<token>", ...}): 89 sites in 13 files under api/ and services/ at fd8558f (no_workspace, desk_only_role, invalid_transition, topup_not_configured, owner_required on the W1 branch, the desk lifecycle vocabulary). They land in Shape B's error and details, or in Shape A's error.message as text (Gotchas).
What is NOT stored: the message. Labs' ErrorLoggingMiddleware._log_exception writes a SystemLog row for a crash (lines 182 onward) and returns its id as error_id; Prima writes an event("request_failed", ...) with status, error_type and error_code and never the message (prima/app.py lines 87 to 94; tests/test_logging_is_content_free.py).
API surface
No route of its own. The contract is "which shape on which path, from which layer":
| Path | Layer that answers | Shape | request_id | Read by |
|---|---|---|---|---|
/api/prima/v1/** on Prima | PrimaError handler, Starlette miss handler, validation handler, unhandled handler | A | in the object and in X-Request-Id | SDKs, the chat server, the developer portal |
/api/prima/v1/** on Labs (legacy) | http_exception_handler gateway branch | A (code from ErrorCode, message = str(detail)) | in the object; X-Request-ID from UnifiedMiddleware | the same clients while the Labs hosts still answer |
| any other Prima path | _render non-gateway branch | E | in the body and the header | health probes, the internal seams |
any Labs route, HTTPException or a core.exceptions class | http_exception_handler, the nine domain handlers (eight typed plus the AlchemyException catch-all, lines 551 to 688) | B | in the body and in X-Request-ID | the SPA (api singleton and the two extractErrors), the desk portal |
| a Labs route whose body fails pydantic | FastAPI default | D (422 list) | header only (X-Request-ID, unified_middleware.py line 103); not in the body | the SPA's detail readers |
| a Labs route that crashes | ErrorLoggingMiddleware | C (500 {detail, error_id, timestamp}) | header only; the body carries error_id instead | the SPA's detail readers |
| a path no Labs route matches | FastAPI default (the handler at app_fastapi.py 724 is registered on fastapi.HTTPException; Starlette's router raises the base class, S4 Gotcha 2026-09-11) | D (404 {"detail": "Not Found"}, no error key) | header only | routeMiss (a missing code reads as a miss) |
| before the router on Labs (workspace context, rate limit) | the middleware itself | C | no, neither body nor header (the 429 returns at line 89, before the header is set at 103) | the SPA (desk_only_role, rate_limit_exceeded) |
| a streaming completion that fails after the headers | the streaming generator | F | in the frame | SDKs |
Status codes and what a client does with them, stated once here and inherited by every recipe: 400 fix the request (param says what); 401 fix the credential (an API key gets one message and no hints; a session token may be told token_expired or credential_revoked because the holder acts differently on each); 402 with insufficient_credit top up and retry, and 402 with monthly_ceiling_reached (the layered-jobs route, type: invalid_request_error) raise the cap or wait for the period, because a top-up does not clear it; 403 you may not, and the token says why (insufficient_permissions, insufficient_scope, key_exhausted, surface_not_granted, workspace_forbidden on Prima; desk_only_role, owner_required on Labs); 404 no such thing under your tenant (S6), or on the gateway with code: null, no such route on this host; 409 a state conflict the body names (no_workspace, invalid_transition); 410 gone for good (the layered-jobs codes); 422 (Labs only) the body failed validation; 429 wait (rate_limit_exceeded, Retry-After) or raise the cap (spend_cap_exceeded); 500 quote the id; 502 the provider failed, a retry may help; 503 our side, do not retry blindly (Retry-After when the error carries one, prima/errors.py lines 62 to 69, tests/auth/test_error_transport.py::test_retry_after_reaches_the_client); 504 a node did not answer in time.
Application logic
How a failure becomes a response, in order:
- A route refuses with a typed error. On Prima: a frozen
PrimaError(status, type, message, code, param, headers), built by one of the ten factories inprima/errors.py(raise errors.not_found("...")and friends) or constructed directly where the factory vocabulary does not fit (21 direct constructions outsideerrors.pyat cd2c248, Data model). What holds is not a closed constructor set but a closed message set: every message is a literal written at the raise site, a factory default, or an interpolation of the service's own tokens and integers, with the two exceptions under Gotchas (they quote caller text and are the reference's deviation from its own rule), and no catch-all handsstr(exc)to aPrimaError. On Labs:raise HTTPException(status_code=..., detail="<sentence>")for a message,raise HTTPException(..., detail={"error": "<token>", ...})for a token the client switches on, or one of thecore.exceptionsclasses (NotFoundError,ValidationError,ForbiddenError,UnauthorizedError,ConflictError,RateLimitExceededError,ServiceUnavailableError,ServiceConfigurationError,core/exceptions.pylines 24 to 230) that a service raises without knowing HTTP. - The request id is read off
request.state.request_id, minted earlier by the request-context middleware (Primaprima/app.pyline 169; Labsunified_middleware.pyline 59) and'unknown'when the middleware did not run. - Prima renders through one function,
_render(prima/app.pylines 60 to 81): gateway path, Shape A viaexc.body(request_id); other path, Shape E.X-Request-Idis set last so a raised error can never overwrite it; the error's own headers (Retry-After) are added withsetdefault. Every handler calls_render; there is no second renderer. - Labs renders through
http_exception_handler(app_fastapi.pylines 724 to 788): the status is mapped to anErrorCode(error_code_map, lines 731 to 739: 401, 403, 404, 409, 422, 429; every other status becomesINTERNAL_ERROR,SRV_001); a gateway path gets Shape A withmessage = str(exc.detail)(line 758); any other path gets Shape B, where a dict detail becomeserror = detail["error"] or detail["message"]anddetails = <the dict>(lines 774 to 779, a360b2c, 2026-09-04) and a string detail is the message unchanged. The nine domain-exception handlers (eight typed subclasses plus theAlchemyExceptioncatch-all at line 672, lines 551 to 688) render Shape B directly with a fixedErrorCodeeach anderror_message=str(exc), the domain exception's own authored message (measured 2026-09-11: no raise site of those classes outsidetests/interpolates another exception into it, so the text is ours at fd8558f; a new service keeps it that way and never wraps a vendor or driver exception into a domain exception's message);ServiceConfigurationErroraddsdetails: {service, missing_env, hint}so a missing configuration key is a 503 with a name, not an opaque 500. - An exception nobody raised on purpose. Prima:
_unhandledlogsexception_classand rendersinternal_error(); "str(exc)never reaches the client.request_idis the join key" (prima/app.pylines 130 to 139). Labs:general_exception_handler(lines 790 to 808) renders Shape B with the fixed message "Internal server error" and logs the traceback. - On Labs, step 5's handler is not what a crashing route reaches.
ErrorLoggingMiddleware.dispatchwrapscall_nextintry/except Exception(error_logging_middleware.pylines 151 to 180), logs the crash once, and returns Shape C's 500{detail, error_id, timestamp}; because theExceptionhandler lives on the outermostServerErrorMiddleware, it answers only exceptions raised above the logging middleware (measured by reading the stack, 2026-09-11: registration orderCORSMiddleware446, the@app.middleware("http")decoratorpublic_endpoint_cors483 (a plainawait call_next(request)with notry/except, so it adds no exception handling),SecurityHeadersMiddleware507,TrustedHostMiddleware511 in production only,ErrorLoggingMiddleware518,UnifiedMiddleware523,WorkspaceMiddleware528, soWorkspaceMiddlewareis outermost andCORSMiddlewareinnermost). A Labs crash therefore haserror_idin the body and norequest_idthere; theX-Request-IDheader is still present, because the 500 travels back out throughUnifiedMiddleware.dispatch, which sets it aftercall_nextreturns (unified_middleware.pylines 93 and 103). The same holds for a 422. AWorkspaceMiddlewarerefusal, and the limiter's own 429 (returned at line 89, before line 103), carry the id nowhere, because the id is minted one layer further in or the response leaves before the header step. - A refusal before the router.
WorkspaceMiddlewareanswers its own 400, 403 and 500 with Shape C (S6, steps 1 to 4);UnifiedMiddlewareanswers 429 with Shape C andRetry-After; the surface gate refuses withHTTPException(403, "<sentence>")(api/surface_gate.pylines 82 to 91), which the router's handler renders as Shape B or A by path. - Validation on Labs: pydantic's
RequestValidationErroris not handled, so FastAPI's default 422 answers withloc,msg,typeand the offendinginputechoed back (Shape D). Nothing in this list is attacker-controlled beyond their own body, but the body may be a customer's prompt or an end user's message, and the log line that carries it is the platform's, not the customer's. - Validation on Prima:
_validation_error(prima/app.pylines 110 to 128) reduces pydantic's errors to field locations, dropsinput,ctxandmsg, and renders400 invalid_request_error / invalid_requestwithparam= the first location (messages.0.content, say). The comment says why: "the offending input is a customer's prompt". Route and method misses map Starlette's own detail through the same renderer withcode: None(lines 97 to 108), which is the signal a client reads as "this host does not serve that path" (Frontend). That works because the handler is registered onstarlette.exceptions.HTTPException(prima/app.pylines 28 and 97), the class the router raises; Labs registers onfastapi.HTTPException(app_fastapi.py724), so an unmatched Labs path is not enveloped (API surface, S4 Gotcha). A new service registers on the Starlette class, as Prima does, and renders the validation reduction in its own shape: Shape A under a gateway prefix, the Shape B form given under Data model everywhere else. - A provider failure.
ProviderAPIError.from_response(prima/upstream/provider.pylines 73 to 86) keeps the vendor, the status and the byte count and drops the body, because a 4xx from a model provider echoes the request; the gateway then raiseserrors.upstream_error()with its fixed message and neverstr(exc)(prima/api/v1.pylines 455 to 462: "the rule here is stronger than 'this particular one happens to be safe'"). - A failure after the headers. The streaming generator emits one
error_chunk(step F above) with a message from the fixed set (prima/api/v1.pylines 791 to 793 for aPrimaErrorraised mid-plan, 867 to 869 for an upstream error, 888 to 893 for anything else), then[DONE]; partial content already sent stays sent, and the credit reservation is settled infinally(theai-gatewayrecipes). - The client parses by shape, not by status: Frontend below.
Invariants
- One renderer per service; every handler goes through it. Prima:
_render. Labs:create_error_response, plus the gateway branch in the same function. - Shape A on
/api/prima/v1and nowhere else; Shape B on every other Labs route; changing either globally is the wrong fix and both repos carry a test that says so (tests/unit/test_primaapi_error_shape.py138b054, 2026-08-03;tests/test_app.py::test_non_gateway_errors_keep_the_plain_envelope). error.messageon the gateway is a non-empty string on an object;erroron a Labs route is a string.- No
str(exc)reaches a client from a catch-all; a message is written by us at the raise site (Prima's two catch-alls atprima/app.pylines 130 to 139 andprima/api/v1.pylines 455 to 462 render fixed messages; Labs'general_exception_handlerrenders a fixed message, and itsAlchemyExceptioncatch-all rendersstr(exc), which is the domain exception's own authored message and never a wrapped vendor exception at fd8558f, Application logic step 4; the Labs router-level exceptions and Prima's onestr(exc)-to-PrimaErrorsite are in Gotchas). - No provider body, no validation
input, no prompt fragment in any error, streaming or not (Prima:tests/gateway/test_streaming.py::test_a_provider_error_never_carries_the_vendor_bodyline 460,::test_an_internal_error_frame_carries_no_exception_textline 471,tests/test_app.py::test_unhandled_exception_does_not_leak_its_message; the Labs equivalents are inbackend/tests/test_primaapi_streaming.py, Tests). - Every error on the gateway carries a request id, in the body and in the header, and the two are equal (
tests/test_app.py::test_gateway_prefix_errors_are_openai_shapedline 86); every response carries the header (::test_every_response_carries_a_request_id, which asserts the header on/healthz); the id is opaque. - A rejected API key is one 401: one message,
code: invalid_api_key, whatever the reason (prima/errors.pylines 108 to 111;check_key,prima/auth/keys.pylines 159 to 213, whose docstring names the two deliberate exceptions, wrong scope and exhausted, both 403 with their own codes;tests/gateway/test_gateway.py::test_the_401_does_not_say_which_credential_was_wrongline 500 asserts no-credentials and junk-bearer return the samecode). A rejected session token is not uniform on purpose:token_expiredandcredential_revoked(prima/auth/sessions.pylines 304 to 306 and 235 to 239) tell a legitimate client to refresh or to sign in again; neither says which tenant or which check failed. - 402 with
insufficient_creditmeans exactly "top up and retry", 402 withmonthly_ceiling_reachedmeans exactly "raise the cap" (the layered-jobs route, Data model), and 429 withspend_cap_exceededmeans exactly "not a wait"; a client readscodeand never parses a message to tell them apart. - A
PrimaErrorcan be raised through an ASGI stack: the frozen dataclass delegates dunder writes toobject.__setattr__(prima/errors.pylines 83 to 101) andtests/auth/test_error_transport.py::test_every_error_renders_over_a_real_asgi_round_tripproves it per factory over a real round trip, becausepytest.raisesnever puts an exception through a task group. - The streaming error frame is a real JSON object, never a stringified dict (Prima:
tests/gateway/test_streaming.py::test_the_error_frame_is_real_json_not_a_python_reprline 437,::test_upstream_error_is_a_terminal_json_error_frame_then_doneline 420).
Frontend
The modules that render refusals, by id (ids from the inventory of 2026-09-11; the recipes are in their domains):
billing-credits/
ui/chat-billing-panel chat: readFailure over the four shapes; code:null = hide the panel (503 billing_not_enabled)
service-desk/
ui/desk-client-portal SPA (portal, German): extractError -> DeskApiError(code), errorText(e, surface) picks the sentence
ui/desk-agency-queue-and-metrics SPA (agency, English): the same helper, surface = agency
identity-auth/
ui/workspace-switcher-and-bootstrap SPA: desk_only_role read from data.detail.error ?? data.error and rendered as "no role"
ai-gateway/
ui/chat-conversation-ui chat: a non-2xx before the stream opens is thrown as PrimaError and its sentence shown; the in-band Shape F frame is not read yet (Gotchas, 2026-09-11)
ui/developer-portal-playground-and-models portal: Shape A rendered under the response
How each client reads, measured 2026-09-11:
- The chat billing client (
prima-chatw5-billing-panel 743b840,web/src/lib/billing.tsreadFailure, lines 215 to 249, pure and unit-tested inbilling.test.ts) accepts four shapes and returns{ok: false, status, code, message}: its own server's{error: <sentence>, code: <token>}; its own{error: <token>, message: <sentence>}(billing_not_enabled,billing_unreachable); the Labs gateway envelope, wherecodeis the status class and the token is found by regex insideerror.message(/'error':\s*'([a-z_]+)'/, because Labs stringifies a dict detail on that path) or, for Prima's envelope, read fromerror.code; and FastAPI's{detail: [...]}, which becomesinvalid_request. A sentence the server wrote is shown as is; a token with no sentence is looked up inREFUSALS(lines 169 to 192); an unknown token renders "That did not work (<code>)". Never the raw Python repr. - The chat server (
server/routes/billing.tslines 172 to 187,routeMiss) turns a gateway 404 whoseerror.codeisnullor missing into503 {"error": "billing_not_enabled", "message": "Top-ups are not enabled on this installation yet. ..."}(lines 248 to 252), and passes a Labs 404 (a stringerror, or an object with acode) through untouched, because "that tier does not exist" is a real answer.server/billing.test.ts::a Prima route-miss 404 becomes billing_not_enabledpins it. This is the rule every frontend inherits:code: nullunder the gateway prefix means "not enabled here", rendered as absence, never as a red banner. - The Labs SPA (
alchemy_labsfd8558f): the desk services'extractError(frontend/src/services/serviceDeskService.tslines 262 to 283) readsbody.detail.error(a structured 409 or 403) as the code, a slug-shapedbody.error(the middleware'srate_limit_exceeded,desk_only_role) as the code, and otherwisedetail ?? error ?? messageas the message; the code is what matters "because a code has two languages, a string has one", anderrorText(e, surface)picks the German or English sentence (lines 123 and 180 are thedesk_only_rolepair). The commerce service'sextractError(commerceService.tslines 30 to 42) readsdetail ?? error ?? messageas a message and shows it; its comment records why the second key was added (an EmDash push failure reached the user as "Failed to push (502)" untilerrorwas read).WorkspaceContext.tsxlines 203 to 206 readdata.detail?.error ?? data.errorfordesk_only_role. - The S2 skeleton's
apisingleton retries a 401 once through the shared refresh and is meant to bootstrap on a 409no_workspace; which key it reads is S6's Gotcha. - What a new app copies from
readFailure, stated so the two sentences above do not contradict each other: the Shape A read oferror.code, the Shape E read ofdetail, and the Shape D read of{detail: [...]}asinvalid_request. The regex branch that finds a token insideerror.messageis compatibility with the Labs gateway copy's stringified dict (Gotchas, 2026-09-04); a client that must still talk to that copy carries it, and deletes it when the copy retires (D3). A client written against Prima only never ships it.
The admin and debug surface behind the ids: on Labs, a crash's error_id is the SystemLog row ErrorLoggingMiddleware._log_exception writes (middleware/error_logging_middleware.py line 238), read through the admin router's /api/admin/system-logs routes (api/routers/admin_router.py lines 1759, 1827, 1869 and 1890: stats, list, one row, resolve; mounted at app_fastapi.py line 874). On Prima there is none: the request_failed and request_unhandled_exception log events keyed by request_id (prima/app.py lines 87 to 94 and 131 to 136) are the debug surface, and an operator greps the log for the id the client quoted.
Copy: a code is rendered as a sentence in the surface's language (du-form Swiss High German on client surfaces, English on agency and chat surfaces, _shared/react-app-skeleton "Surface-explicit copy"); a message the server wrote is English and dev-facing on Labs, and customer-facing on Prima's gateway because it is the only text an SDK user sees. A message is never localised by string-matching it.
Security & tenancy
Auth dependency: none; this recipe runs after or instead of every dependency. Workspace derivation: none; S6's refusals are rendered here.
Fail direction, stated: the envelope fails CLOSED on content and OPEN on diagnostics. Closed: no exception text in a catch-all, no provider body, no validation input on Prima, one uniform 401 for a rejected API key, no balance in a 402, no vendor name in a gateway message (the alias design exists so vendor names never surface, platform/primaapi.md "Open items", 2026-08-25). Open: every failure names its status family in type, its token in code, its field in param, and its trace handle in request_id; a support conversation that has the id has everything, and one that has lost it has nothing, because the meter and the log are content-free by design.
Attacker-controlled inputs and what stops them: a crafted body cannot choose the message, with one measured exception: the two sites in prima/runtime/jobs/composition.py (unknown_model_role, lines 189 to 194, and unpinned_model, line 204) interpolate the caller's own pin key and pin alias into a 400 body, unbounded (Gotchas); the fix and the rule for a new service are there; a crafted path cannot choose the shape (the prefix check is a string prefix on the server's own routing, prima/app.py line 57, app_fastapi.py line 753); a crafted Origin gets CORS headers on an error response only when it is in the configured allow-list (_error_cors_headers, app_fastapi.py lines 691 to 708, added so a browser shows the real status instead of "CORS Missing Allow Origin" on a 500); a stolen API key cannot learn whether it is malformed or unknown (prima/seams/identity.py lines 628 and 638, before the record is read), belongs to an inactive subject (line 501, and check_key), or is revoked, expired, suspended or IP-denied (prima/auth/keys.py check_key, lines 159 to 213) from a 401: every one of those raises authentication_error() with the one message (nine sites at cd2c248, grep -rn 'authentication_error()' prima/), and the two answers it can learn, wrong scope and exhausted, are the two a legitimate holder must act on. A session token is told token_expired or credential_revoked (prima/auth/sessions.py lines 235 to 239 and 304 to 306), which reveals the state of a credential the holder already has and nothing about any other. What an error may reveal: the existence of a route (a method miss is 405), the name of a missing configuration key on Labs (ServiceConfigurationError.missing_env in details), the valid alias list on model_not_found (ours, not the caller's), and the numbers in a spend-cap message (unit, used, cap, bounded tokens and integers by construction, prima/errors.py lines 126 to 143).
Credentials at rest: none. Rate limits: the 429 body is Shape C on Labs (the limiter fails open when its store errors, unified_middleware.py lines 193 to 196) and rate_limited() on Prima.
Tests
- Prima cd2c248, hermetic,
pytest tests/test_app.py tests/auth/test_error_transport.py(36 passed on 2026-09-11):test_gateway_prefix_errors_are_openai_shaped(the five keys,messagenon-empty,request_idequals the header),test_non_gateway_errors_keep_the_plain_envelope,test_every_response_carries_a_request_id,test_unhandled_exception_does_not_leak_its_message,test_every_error_renders_over_a_real_asgi_round_trip(parametrised over the factories, asserts status, message, code, request id and contract headers over a real ASGI round trip),test_the_payload_is_still_frozen,test_python_can_set_the_exception_dunders_it_owns,test_retry_after_reaches_the_client. - Prima:
tests/test_logging_is_content_free.py,tests/test_meter_is_content_free.py(the log line and the meter row never carry a message);tests/gateway/test_gateway.py::test_the_401_does_not_say_which_credential_was_wrong(line 500, the uniform key 401);tests/gateway/test_streaming.pylines 420 to 488 (test_upstream_error_is_a_terminal_json_error_frame_then_done,test_the_error_frame_is_real_json_not_a_python_repr,test_the_error_frame_carries_the_request_id,test_a_provider_error_never_carries_the_vendor_body,test_an_internal_error_frame_carries_no_exception_text,test_partial_content_before_the_error_is_kept; the frame shape from captured traffic). - Labs fd8558f:
tests/unit/test_primaapi_error_shape.py(three tests, 138b054):test_gateway_error_is_an_object_with_message(401 on/api/prima/v1/modelswithout credentials:erroris a dict,messagenon-empty,type == "authentication_error",request_idpresent),test_labs_routes_keep_the_old_envelope,test_type_varies_with_status;tests/test_primaapi_streaming.pylines 458 to 512 (terminal error frame then[DONE], real JSON not a stringified dict, no vendor body, no exception text, partial content kept). - prima-chat 743b840:
web/src/lib/billing.test.ts(readFailureover the four shapes,node --test),server/billing.test.ts(the route-miss mapping, a real listening harness). - Known-answer vectors a new service pins before its first route: the five gateway keys;
code: nullon a route miss;400 invalid_requestwithparamon a bad body; the fixed 500 message with a request id; one uniform 401 for two different bad API keys; the streaming frame withchoices: []and[DONE]after it. - Playwright gate: none dedicated; the copy rendering is asserted per surface in the frontend recipes above.
Deploy & ops
Config keys: none. Handlers ship with the app; there is no flag that changes a shape. Migrations: none. Scheduler jobs: none.
Order of registration matters and is part of the deploy: on a Labs-style app, the request-id middleware must be registered after (so it runs outside) any middleware whose refusals should carry the id; at fd8558f it is not, and the consequence is listed under Application logic step 6. On Prima there is one middleware and one renderer, so the question does not arise.
The proxy in front of the gateway must never answer with an empty body on a 5xx: the wiki records a 502 during the 2026-08-03 outage that came back with no body at all, which throws a raw SyntaxError in every stock client (platform/primaapi.md "Open items", updated 2026-08-25). An edge that cannot reach the origin answers with Shape A and code: upstream_unavailable or is configured to pass the origin's body through; a recipe that puts a Worker or Caddy in front of the gateway (S13) says which.
Argus checks: a /readyz that fails answers 503 with the readiness report {status: "not_ready", version, build, checks} (prima/api/health.py lines 110 to 118; tests/test_app.py::test_readyz_returns_503_when_a_required_check_fails asserts the status and status == "not_ready"), not an error envelope: no detail, no request_id in the body, the X-Request-Id header from the middleware only. The check reads the HTTP status, not the body.
Rollback: none; the envelope is code.
Gotchas
- 2026-08-03 (Labs 138b054, "primaapi: fail the way an OpenAI client expects"): the Labs envelope put a string in
error; every OpenAI SDK readserror.messageoff an object, so a stock client showed the developer an empty message on the error path. Fixed by the gateway-prefix branch inhttp_exception_handlerand pinned bytests/unit/test_primaapi_error_shape.py. Changing the envelope globally was the easy wrong fix; the Labs SPA readserroras a string. - 2026-08-03 (Prima,
prima/errors.pydocstring lines 28 to 54): a frozen dataclass that subclassesExceptioncannot be raised through an ASGI stack, because anyio and Python set__context__and__notes__on a live exception and the generated__setattr__refuses them; every 4xx became a 500 withcode: internal_errorand no unit test could see it. Fixed by_allow_exception_dundersand the round-trip test intests/auth/test_error_transport.py. - 2026-09-04 (Labs a360b2c): the Labs branch of
http_exception_handlerstopped flattening a dict detail withstr(); the token is nowerrorand the dict rides indetails. The gateway branch was not changed: at fd8558f line 758 still renders"message": str(exc.detail), so a dict detail raised under/api/prima/v1(the W1 credits routes onw1-auto-topup,topup_not_configuredatprimaapi_router.pyline 873 on fd8558f) reaches the SDK as the Python repr"{'error': 'owner_required', 'hint': '...'}"withcode: AUTHZ_001. The chat'sreadFailureregex and the W1 docstring ("the token inerror.message",w1-auto-topupprimaapi_router.pylines 856 to 858) both encode this defect rather than fix it. On the Labs copy the fix is one line (message = detail["error"]when the detail is a dict,code = detail["error"]); on Prima it does not exist, because aPrimaErrorcarries its code incode. D3 makes Prima the reference, so the recipe's rule is Prima's: the token is incode, never insidemessage. - 2026-09-11 (measured at fd8558f):
error_code_map(app_fastapi.pylines 731 to 739) has no entry for 400, 402, 502 or 503, so those statuses carrycode: SRV_001(INTERNAL_ERROR) in Shape B, and on the gateway a 402 or 503 istype: api_error. A client that readscodeon a Labs 400 learns nothing; it reads the status and theerrorstring. - 2026-09-11 (measured at fd8558f): 89
detail=str(e)ordetail=str(exc)sites in 24 router files underapi/routers/(and 27 more f-strings interpolating the exception), each a path from an exception message to a client;prima_router.pyline 1197 is one (500 detail=str(e)on/conversation/history). The rule "nostr(exc)to clients" holds at the two catch-alls and nowhere else on Labs. - 2026-09-11 (measured at cd2c248): two Prima messages quote caller text, both under
prima/runtime/jobs/composition.pyand both400 invalid_request_errorwithparam: pins(every other interpolation,grep -rnE -A3 'raise (PrimaError|invalid_request|errors\.[a-z_]+|_reject|_deny)\(' prima/ | grep 'f"', is a scope constant, a node count, a refusal token or a spend integer of ours). First,unknown_model_role(lines 189 to 194) quotes the role key the caller sent (raw_role!r, a key ofJobRequest.pins, adict[str, str]unbounded in length at line 65). Second,unpinned_model(line 204) raisesinvalid_request(str(exc), ...), and the comment above it (lines 200 to 203) claims the messages "quote no request content". That comment is wrong and the first draft of this recipe repeated it as a measurement: line 195 passes the caller's pin alias, the dict's value, intoModelPins.resolve(prima/runtime/types.pylines 374 to 376), which callsPinnedModel.pin(alias)with the raw string, and every message raised on that path interpolates it:f"{alias!r} is a FLOATING alias..."(types.py289 to 293),UnpinnedModelError(str(exc))at 297 wrappingf"Unknown PrimaAPI model alias {alias!r}..."(prima/aliases.py126 to 128),f"{alias!r} has no floating parent..."(301 to 303), andf"{self.alias!r} is not a pinned chat alias..."(271 to 275). APOSTto the layered-jobs route withpins: {"verifier": "<any text>"}therefore answers a 400 whoseerror.messagecontains that text, the leakmodel_not_foundatprima/api/v1.pylines 245 to 253 was written to avoid ("NOTstr(exc): Labs' message interpolates the alias the caller sent"). Acceptance check 4 counts thestr(exc)-to-PrimaErrorsites (one) and says nothing about what the message contains; check 12 is the probe that does. The rule for a new service, and the fix on the reference: both sites name the valid roles and the valid pinned aliases (sorted(PINNED_CHAT_ALIASES), which theUnpinnedModelErrormessages already carry) and drop the caller's key and alias, exactly asmodel_not_founddoes; the comment at lines 200 to 203 goes with them. - 2026-09-11 (measured at fd8558f): no
RequestValidationErrorhandler on Labs; a 422 echoes the offendinginput. Prima answers 400 withparamonly (prima/app.pylines 110 to 128). A new service copies Prima's reduction on day one (locations only,input,ctxandmsgdropped, 400 not 422) and renders it in its own shape; Prima's handler renders Shape A, and a Labs-style service renders the Shape B form under Data model (code: VAL_001, the token andparamindetails). - 2026-09-11 (measured at fd8558f): three middleware envelopes precede the handlers and none carries
code; the crash body a Labs route actually produces isErrorLoggingMiddleware's{detail, error_id, timestamp}, notgeneral_exception_handler's Shape B, because the logging middleware catches first (Application logic step 6). A client that searches a Labs 500 forrequest_idfindserror_id. Read from the code, not exercised against a running Labs. - 2026-09-11 (measured, both repos): a 401 on the gateway is
type: authentication_error, code: AUTH_001from Labs (_PRIMA_ERROR_TYPES) andtype: invalid_request_error, code: invalid_api_keyfrom Prima (prima/errors.pylines 108 to 111, the shape OpenAI itself uses). Same status, differenttypeandcode. D3: Prima wins. A client switches on the status and, where it must, on Prima'scode; never ontype. - 2026-08-25 (wiki
platform/primaapi.md, measured there on 2026-08-04, not re-measured here): a prompt that exceeds the model's window comes back as502 upstream_error, indistinguishable from an outage, so retry logic retries what can never succeed; OpenAI returns400 context_length_exceededwith both numbers. The same page records a message naming the provider on one path ("... API error (400); 234 byte response body omitted") that reached a user-facing surface; at cd2c248 the streaming and non-streaming gateway paths use the fixed message "The upstream model provider returned an error." (prima/api/v1.pylines 462 and 867), so the leak is on a path outside those two or on the Labs copy. Both stay open items until the founder answers them. - 2026-09-11 (measured at fd8558f):
test_labs_routes_keep_the_old_envelopeskips when the probed route does not error and asserts only whenerroris present in the body, so it passes on a 422 and on a middleware refusal without proving anything. The Prima test asserts the exact key set; a new service copies that one. - 2026-09-11 (measured):
readFailureandrouteMissexist only onprima-chatbranchw5-billing-panel(743b840), unmerged, and they parse the W1 credits routes, which exist only onalchemy_labsw1-auto-topup(8b5361c), unmerged. The parsing rules are the recipe's rules; the references for them are those branches until they land. - 2026-09-11 (measured at fd8558f): the Labs SPA carries two
extractErrorhelpers with different return types (DeskApiErrorwith a code; a plain string) and many ad hoc reads beside them: infrontend/src,grep -rnE 'data\??\.error\b' . | wc -lprints 28 andgrep -rnE 'data\??\.detail\b' . | wc -lprints 148 (the optional-chained reads included; the strictdata.erroranddata.detailreads alone, 11 and 2). A new app has one parser, the four-shapereadFailure, and renders codes not strings. - 2026-09-11 (measured at cd2c248 and 743b840): the chat does not read the in-band Shape F frame. The chat consumes streams through
@prima/ui(prima-chatweb/src/lib/api.tsline 9 importshttpTransportandPrimaClient), andPrimaClient.streamChat(primapackages/ui/src/core/client.tslines 53 to 75) throwsPrimaErroron a non-2xx before the stream opens and then yields every SSE frame as aChatCompletionwithout looking for a top-levelerror;accumulator.tshas no read of anerrorkey andconversation.tslines 210 to 231 only catch what the generator throws. A Shape F frame is therefore absorbed as a chunk withchoices: []and the turn ends at[DONE]with no sentence; the sentence the chat shows (web/src/App.tsxlines 1010 to 1013,state.error.message) comes only from the thrownPrimaError. The client's own docstring (lines 49 to 51) still describes the pre-stream case as the only error path. Reading the frame is theai-gateway/ui/chat-conversation-uirecipe's work; until it lands, a mid-stream failure on the chat looks like a short answer. - 2026-09-11 (measured at cd2c248): the first draft of this recipe said the factories were the only constructors a route calls and listed 17 codes. Neither was true: 21 direct
PrimaErrorconstructions outsideerrors.py,ClaimErroras a subclass, and 61 distinct code literals plus one f-string family (Data model). The invariant that survives measurement is the one about messages, not constructors, and Acceptance check 1 now pins both counts so the next drift is visible.
Reference implementation
Paths are relative to each repo's root; commits as in measured_on and also_measured_on.
prima(feature/per-model-pricingat cd2c248, the reference per D3):prima/errors.py:GATEWAY_PREFIX(23),PrimaErrorandbody(26 to 80, the headers comment 62 to 69),_allow_exception_dunders(83 to 101), the factoriesinvalid_request(104),authentication_error(108),permission_error(114),not_found(118),rate_limited(122),spend_cap_exceeded(126),insufficient_credit(146),upstream_error(161),service_unavailable(165),internal_error(177).prima/app.py:_is_gateway(56 to 57),_render(60 to 81),_install_error_handlers(84 to 139:_prima_error,_http_error97 to 108,_validation_error110 to 128,_unhandled130 to 139),_route_token(147 to 163),_install_middleware(166 to 187), the router mounts (345 to 364).prima/streaming.py:CHUNK_OBJECT(136),sse(140),error_chunk(217 to 262).prima/upstream/provider.py:ProviderAPIError.from_response(73 to 86).prima/api/v1.py:model_not_found(245 to 253, 927 to 933), theupstream_errorraise (455 to 462), the three streaming frames (786 to 796, 863 to 871, 884 to 896).- The direct constructions and the codes outside
errors.py:prima/auth/claims.py(ClaimError75,_reject79, the claim codes 103 to 180, the workspace codes 214 to 252);prima/auth/keys.py(check_key159 to 213: the uniform 401 and the three 403 codes);prima/auth/sessions.py(_unavailable223 to 231,_revoked234 to 239,token_expired304 to 306);prima/auth/surfaces.py(_deny89 to 90, the six audience codes 112 to 134);prima/seams/identity.py500 to 531;prima/runtime/jobs/router.py171 to 176, 192 to 195, 205 to 209, 244 to 249, 262 to 273, 355 to 407, 450 to 465;prima/runtime/jobs/composition.py186 to 204;prima/runtime/spend.pyRefusalReason220 to 226;prima/api/keys_sync.py181 (the internal prefix) and 240 to 446;prima/api/internal.py79 and 131 to 459. prima/api/health.pylines 110 to 118 (the readiness report a failing/readyzreturns with 503).prima/logging.py:event(276),new_request_id(307 to 308).packages/ui/src/core/client.tsstreamChat(53 to 75) andconversation.ts(210 to 231): the stream consumer that does not read Shape F.
alchemy_labs(d1a-token-sourceat fd8558f, backend tree unchanged fromfeat/service-deskb1089b6; legacy for the gateway, current for every Labs route):backend/app_fastapi.py: CORSexpose_headers(446 to 453), middleware registration (507 to 533), the domain handlers (551 to 688),_error_cors_headers(691 to 708),PRIMA_GATEWAY_PREFIXand_PRIMA_ERROR_TYPES(711 to 720),http_exception_handler(724 to 788),general_exception_handler(790 to 808).backend/schemas/error_schemas.py:ErrorCode(11 to 64),ErrorResponse(75 to 110),create_error_response(112 to 138).backend/core/exceptions.py:AlchemyException(9 to 15) and its subclasses (24 to 230).backend/api/middleware/unified_middleware.py: the request id (58 to 59), the 429 (184 to 192), the header (103).backend/middleware/error_logging_middleware.py:dispatch(151 to 180),_log_exception(182 onward).backend/middleware/workspace_middleware.pylines 106 to 129 (the three bodies).backend/api/surface_gate.py:_deny(82 to 91).backend/tests/unit/test_primaapi_error_shape.py;backend/tests/test_primaapi_streaming.pylines 458 to 512 (the Labs streaming tests; the Prima equivalents aretests/gateway/test_streaming.py420 to 488 above).frontend/src/services/serviceDeskService.tslines 255 to 283 and 123, 180;frontend/src/services/commerceService.tslines 30 to 42;frontend/src/contexts/WorkspaceContext.tsxlines 197 to 209.
alchemy_labs(w1-auto-topupat 8b5361c, unmerged):backend/api/routers/primaapi_router.pylines 845 to 869 (theowner_requireddict detail and the docstring that describes it landing inerror.message).prima-chat(w5-billing-panelat 743b840, unmerged):web/src/lib/billing.ts(BillingFailure26 to 38,call131 to 158,REFUSALS169 to 192,PLAIN_DETAILS195 to 198,readFailure215 to 249),web/src/lib/billing.test.ts,web/src/lib/api.tsline 9 (the@prima/uiimport),web/src/App.tsxlines 1010 to 1013 (the error banner),server/routes/billing.ts(routeMiss172 to 187,NOT_ENABLED248 to 252),server/billing.test.tsline 225.- Wiki
philosophers_stone(d4f5b95):platform/primaapi.md"Errors are OpenAI-shaped on/api/prima/v1only" (line 208), "Context overflow returns 502, not 400", "Open items" (the empty-body 502, the vendor-name audit, one envelope always populated);operations/prima-modules-inventory.md§1.1 row S7, §2 "Every write has a real endpoint and a visible outcome" (code:nullas "not enabled here"), §6 decision D3.
Deviations of the references from this recipe, all dated 2026-09-11: the Labs gateway branch's str(exc.detail) and status-class code; SRV_001 on 400, 402, 502 and 503; 89 str(e) details in Labs routers; no validation handler on Labs; the Labs crash body without a request id (header present); the two type values for a 401; Prima's unknown_model_role and unpinned_model messages quoting the caller's pin key and pin alias (the comment at composition.py 200 to 203 that says otherwise); the layered-jobs spend refusal carrying invalid_request_error on a 402 and a 503 (prima/runtime/jobs/router.py 268 to 273); Labs' HTTPException handler registered on the FastAPI subclass, so an unmatched path is a bare {detail}; the chat not reading the in-band frame; the wiki's 502-on-overflow and the unlocated vendor-name path; the weak Labs envelope test; the parsers and the routes they parse living on unmerged branches.
Acceptance checks
Run without the author at the commits named above. Expected observations are exact.
- Prima, repo root:
pytest tests/test_app.py tests/auth/test_error_transport.py -qpasses 36 tests;grep -c "^def " prima/errors.pyprints11(ten factories plus_allow_exception_dunders);grep -rn 'PrimaError(' prima/ | grep -v '^prima/errors.py' | wc -lprints21(the direct constructions, Data model);grep -rhoE 'code="[a-z_]+"' prima/ | sort -u | wc -lprints42. A different number on a later commit means the vocabulary table has moved, not that the check is wrong. - Prima, with the app running locally (
python -m prima,PRIMA_PORTdefault 8080,_shared/fastapi-project-layout):curl -s -i http://127.0.0.1:8080/api/prima/v1/models(no credentials) returns401, a body whoseerrorobject has exactly the keysmessage,type,param,code,request_id, withcodeequal toinvalid_api_keyandtypeequal toinvalid_request_error, and anX-Request-Idheader equal toerror.request_id;curl -s http://127.0.0.1:8080/api/prima/v1/no-such-routereturns404witherror.codeequal tonull;curl -s http://127.0.0.1:8080/no-such-routereturns404with the keysdetailandrequest_idand noerrorkey. On a new Shape B service: the no-credential call returns401with exactly the keyserror,code,request_id,timestamp,detailsand anX-Request-IDheader equal torequest_id; an unmatched path returns404in the same envelope with the same equality (the handler is on the Starlette class). - Prima:
curl -s -X POST -H "Content-Type: application/json" -H "X-API-Key: $KEY" -d '{"model":"prima-core","messages":"not-a-list"}' http://127.0.0.1:8080/api/prima/v1/chat/completionsreturns400witherror.codeequal toinvalid_request,error.paramequal tomessages, and a body that does not contain the stringnot-a-list. On a new Shape B service the same request against any body-taking route returns400,codeequal toVAL_001,details.errorequal toinvalid_request,details.paramnaming the field, and nonot-a-listin the body. - Prima:
grep -n "str(exc)" prima/app.py prima/api/v1.pyprints no line that hands it to a response or aPrimaError:prima/app.pyline 376 is aSystemExitat boot and the other five hits (app.py138;v1.py64, 246, 460, 852) are comments saying it never reaches the client.grep -rn "str(exc)" prima/ | grep -E 'PrimaError\(|invalid_request\(|errors\.'prints exactly one line,prima/runtime/jobs/composition.py:204. That grep counts sites; it says nothing about what the one message contains, which is check 12.grep -rn "resp.text" prima/upstream/*.pyprints three lines, each passing it toProviderAPIError.from_response, which keeps the byte count and drops the text. - Labs,
backend/:pytest tests/unit/test_primaapi_error_shape.py -qpasses 3 tests;sed -n 731,739p app_fastapi.pylists six statuses and none of 400, 402, 502, 503. - Labs,
backend/:grep -rn 'detail=str(e)\|detail=str(exc)' api/routers | wc -lprints89;grep -rln 'detail=str(e)\|detail=str(exc)' api/routers | wc -lprints24;grep -rn "RequestValidationError" app_fastapi.pyprints nothing. On a new app all three print0,0and one handler line. - Labs,
backend/:grep -n "add_middleware" app_fastapi.pyprints lines 446, 507, 511, 518, 523 and 528 in that order, andgrep -n '@app.middleware' app_fastapi.pyprints line 483 (public_endpoint_cors, the decorator-registered sixth layer; together the registration order behind Application logic step 6). - Labs, with the harness backend running (
tooling/render/labs-local.sh up, API on127.0.0.1:8020) and the seeded owner's token:curl -s -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8020/api/forms/templates/00000000-0000-0000-0000-000000000000returns404with keyserror(a string),codeequal toRES_001,request_id,timestamp,detailsequal tonull, and anX-Request-IDheader equal torequest_id;curl -s -i http://127.0.0.1:8020/api/prima/v1/modelsreturns401witherror.typeequal toauthentication_erroranderror.codeequal toAUTH_001(the Labs legacy values, Gotchas);curl -s -i -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"name": 12}' http://127.0.0.1:8020/api/forms/templatesreturns422with adetaillist whose first element carries aninputkey (the echo in Gotchas). On a new service, the same three calls against its own routes: the missing row is404in the envelope withrequest_idequal to theX-Request-IDheader; the bad body is400withdetails.paramand noinputecho; and a route made to raise (a test-only handler, or a monkeypatched service function) returns500 {"error": "Internal server error", "code": "SRV_001", "request_id": <the header's value>, "timestamp": ..., "details": null}with noerror_idkey, because no middleware wrapscall_nextin a bareexcept(S4 Application logic step 7). - prima-chat,
w5-billing-panelcheckout, repo root:npm test(tsx --test server/*.test.ts web/src/lib/*.test.ts) passes, including the case atweb/src/lib/billing.test.tslines 24 to 27 that readsno_card_on_fileout of a stringified dict inerror.message;grep -n "code === null || code === undefined" server/routes/billing.tsprints line 186. - Wiki d4f5b95:
grep -n "Errors are OpenAI-shaped" platform/primaapi.mdprints line 208;grep -c "empty body" platform/primaapi.mdprints1. npm run checkin this repo prints no line namingmodules/_shared/error-envelope.md(the problem count is repo-wide and moves with sibling recipes; it was0 problemsat 74766d0 on 2026-09-11).- Prima, with the app running as in check 2 and a key that may create layered jobs:
curl -s -X POST -H "Content-Type: application/json" -H "X-API-Key: $KEY" -d '{"task":"x","budget_tokens":1000,"pins":{"verifier":"zq7marker"}}' http://127.0.0.1:8080/api/prima/v1/jobsreturns400witherror.codeequal tounpinned_modelanderror.paramequal topins, and a body that does not contain the stringzq7marker; the same body with"pins":{"zq7marker":"prima-core-2026-08"}returns400,error.codeequal tounknown_model_role, and nozq7markerin the body.build_specruns before spend admission (router.pyline 240), so no budget is consumed. Known failure at cd2c248 (2026-09-11): both bodies contain the marker (Gotchas); the check stays red on the reference until the two sites incomposition.pydrop the caller's text, and it is green on a new service from its first route.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.