Prima Get an API key

Module kit / Foundations

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
  • prima/prima/errors.py
  • prima/prima/app.py
  • prima/prima/streaming.py
  • prima/prima/upstream/provider.py
  • alchemy_labs/backend/app_fastapi.py
  • alchemy_labs/backend/schemas/error_schemas.py
  • alchemy_labs/backend/core/exceptions.py
  • alchemy_labs/backend/api/middleware/unified_middleware.py
  • alchemy_labs/backend/middleware/error_logging_middleware.py
  • alchemy_labs/backend/middleware/workspace_middleware.py
  • alchemy_labs/frontend/src/services/serviceDeskService.ts
  • alchemy_labs/frontend/src/services/commerceService.ts
  • prima-chat/web/src/lib/billing.ts
  • prima-chat/server/routes/billing.ts
Measured on prima feature/per-model-pricing cd2c248 2026-09-11
Also measured on
  • alchemy_labs d1a-token-source fd8558f 2026-09-11
  • alchemy_labs w1-auto-topup 8b5361c 2026-09-11
  • prima-chat w5-billing-panel 743b840 2026-09-11
  • philosophers_stone main d4f5b95 2026-09-11
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:

ProducerBodyWhen
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-Afterthe 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, typecodeWhere raised at cd2c248What the client does
400 invalid_request_errorinvalid_request (param = first failing field)prima/app.py 121 to 127fix the body
400model_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
400unknown_model_role, unpinned_model (param: pins; both messages quote the caller's own text, Gotchas)prima/runtime/jobs/composition.py 189 to 194, 204fix the pins
400, 403, 409workspace_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 constructionsS6
401 invalid_request_errorinvalid_api_key, one message for every rejected API keyprima/errors.py 108 to 111, raised by check_key (prima/auth/keys.py 159 to 213)fix the credential
401token_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 157refresh the session
401the 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_unexpectedprima/auth/claims.py 103 to 180a signed token with a bad claim set; the issuer's bug, not the caller's
402 insufficient_quotainsufficient_credit (the top-up URL, never the balance)prima/errors.py 146 to 158top up and retry
402 invalid_request_errormonthly_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_errorinsufficient_permissionsprima/errors.py 114 to 115you may not
403insufficient_scope, scope_not_recognised, key_exhaustedprima/auth/keys.py 184 to 206; the first two again in prima/seams/identity.py 507 to 519a legitimate holder acts: mint a key with the scope, or a new key
403surface_not_granted, insufficient_surface, aud_missing, aud_ambiguous, aud_unknown, aud_not_issuableprima/auth/surfaces.py 112 to 134; surface_not_granted again in prima/seams/identity.py 525 to 530a session from the wrong door (S5)
404 invalid_request_errornot_found; job_not_foundprima/errors.py 118 to 119; prima/runtime/jobs/router.py 205 to 209, 355 to 359no such thing under your tenant
404nullprima/app.py 97 to 108 (a route or method miss; 405 for a method)this host does not serve that path
410 invalid_request_errorjob_id_not_routable, job_owner_retiredprima/runtime/jobs/router.py 367 to 372, 381 to 386the job is gone; do not poll again
429 rate_limit_errorrate_limit_exceededprima/errors.py 122 to 123wait
429spend_cap_exceeded (unit, used, cap in the message)prima/errors.py 126 to 143raise the cap or wait for the period
429 rate_limit_error; 400 and 503 invalid_request_errorrate_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 249the 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_errorinternal_errorprima/errors.py 177 to 183quote the request_id
502 api_errorupstream_error; cluster_misroutedprima/errors.py 161 to 162; prima/runtime/jobs/router.py 402 to 407retry may help
503 api_errorprovider_not_configured, credits_unavailable (the money path failing closed); identity_provider_unavailable (with Retry-After); cluster_unconfigured, job_owner_unreachableprima/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 465our side; back off, honour Retry-After
504 api_errorprovenance_may_be_lostprima/runtime/jobs/router.py 450 to 457poll 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":

PathLayer that answersShaperequest_idRead by
/api/prima/v1/** on PrimaPrimaError handler, Starlette miss handler, validation handler, unhandled handlerAin the object and in X-Request-IdSDKs, the chat server, the developer portal
/api/prima/v1/** on Labs (legacy)http_exception_handler gateway branchA (code from ErrorCode, message = str(detail))in the object; X-Request-ID from UnifiedMiddlewarethe same clients while the Labs hosts still answer
any other Prima path_render non-gateway branchEin the body and the headerhealth probes, the internal seams
any Labs route, HTTPException or a core.exceptions classhttp_exception_handler, the nine domain handlers (eight typed plus the AlchemyException catch-all, lines 551 to 688)Bin the body and in X-Request-IDthe SPA (api singleton and the two extractErrors), the desk portal
a Labs route whose body fails pydanticFastAPI defaultD (422 list)header only (X-Request-ID, unified_middleware.py line 103); not in the bodythe SPA's detail readers
a Labs route that crashesErrorLoggingMiddlewareC (500 {detail, error_id, timestamp})header only; the body carries error_id insteadthe SPA's detail readers
a path no Labs route matchesFastAPI 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 onlyrouteMiss (a missing code reads as a miss)
before the router on Labs (workspace context, rate limit)the middleware itselfCno, 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 headersthe streaming generatorFin the frameSDKs

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:

  1. 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 in prima/errors.py (raise errors.not_found("...") and friends) or constructed directly where the factory vocabulary does not fit (21 direct constructions outside errors.py at 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 hands str(exc) to a PrimaError. 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 the core.exceptions classes (NotFoundError, ValidationError, ForbiddenError, UnauthorizedError, ConflictError, RateLimitExceededError, ServiceUnavailableError, ServiceConfigurationError, core/exceptions.py lines 24 to 230) that a service raises without knowing HTTP.
  2. The request id is read off request.state.request_id, minted earlier by the request-context middleware (Prima prima/app.py line 169; Labs unified_middleware.py line 59) and 'unknown' when the middleware did not run.
  3. Prima renders through one function, _render (prima/app.py lines 60 to 81): gateway path, Shape A via exc.body(request_id); other path, Shape E. X-Request-Id is set last so a raised error can never overwrite it; the error's own headers (Retry-After) are added with setdefault. Every handler calls _render; there is no second renderer.
  4. Labs renders through http_exception_handler (app_fastapi.py lines 724 to 788): the status is mapped to an ErrorCode (error_code_map, lines 731 to 739: 401, 403, 404, 409, 422, 429; every other status becomes INTERNAL_ERROR, SRV_001); a gateway path gets Shape A with message = str(exc.detail) (line 758); any other path gets Shape B, where a dict detail becomes error = detail["error"] or detail["message"] and details = <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 the AlchemyException catch-all at line 672, lines 551 to 688) render Shape B directly with a fixed ErrorCode each and error_message=str(exc), the domain exception's own authored message (measured 2026-09-11: no raise site of those classes outside tests/ 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); ServiceConfigurationError adds details: {service, missing_env, hint} so a missing configuration key is a 503 with a name, not an opaque 500.
  5. An exception nobody raised on purpose. Prima: _unhandled logs exception_class and renders internal_error(); "str(exc) never reaches the client. request_id is the join key" (prima/app.py lines 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.
  6. On Labs, step 5's handler is not what a crashing route reaches. ErrorLoggingMiddleware.dispatch wraps call_next in try/except Exception (error_logging_middleware.py lines 151 to 180), logs the crash once, and returns Shape C's 500 {detail, error_id, timestamp}; because the Exception handler lives on the outermost ServerErrorMiddleware, it answers only exceptions raised above the logging middleware (measured by reading the stack, 2026-09-11: registration order CORSMiddleware 446, the @app.middleware("http") decorator public_endpoint_cors 483 (a plain await call_next(request) with no try/except, so it adds no exception handling), SecurityHeadersMiddleware 507, TrustedHostMiddleware 511 in production only, ErrorLoggingMiddleware 518, UnifiedMiddleware 523, WorkspaceMiddleware 528, so WorkspaceMiddleware is outermost and CORSMiddleware innermost). A Labs crash therefore has error_id in the body and no request_id there; the X-Request-ID header is still present, because the 500 travels back out through UnifiedMiddleware.dispatch, which sets it after call_next returns (unified_middleware.py lines 93 and 103). The same holds for a 422. A WorkspaceMiddleware refusal, 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.
  7. A refusal before the router. WorkspaceMiddleware answers its own 400, 403 and 500 with Shape C (S6, steps 1 to 4); UnifiedMiddleware answers 429 with Shape C and Retry-After; the surface gate refuses with HTTPException(403, "<sentence>") (api/surface_gate.py lines 82 to 91), which the router's handler renders as Shape B or A by path.
  8. Validation on Labs: pydantic's RequestValidationError is not handled, so FastAPI's default 422 answers with loc, msg, type and the offending input echoed 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.
  9. Validation on Prima: _validation_error (prima/app.py lines 110 to 128) reduces pydantic's errors to field locations, drops input, ctx and msg, and renders 400 invalid_request_error / invalid_request with param = 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 with code: 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 on starlette.exceptions.HTTPException (prima/app.py lines 28 and 97), the class the router raises; Labs registers on fastapi.HTTPException (app_fastapi.py 724), 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.
  10. A provider failure. ProviderAPIError.from_response (prima/upstream/provider.py lines 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 raises errors.upstream_error() with its fixed message and never str(exc) (prima/api/v1.py lines 455 to 462: "the rule here is stronger than 'this particular one happens to be safe'").
  11. 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.py lines 791 to 793 for a PrimaError raised 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 in finally (the ai-gateway recipes).
  12. 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/v1 and 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.py 138b054, 2026-08-03; tests/test_app.py::test_non_gateway_errors_keep_the_plain_envelope).
  • error.message on the gateway is a non-empty string on an object; error on 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 at prima/app.py lines 130 to 139 and prima/api/v1.py lines 455 to 462 render fixed messages; Labs' general_exception_handler renders a fixed message, and its AlchemyException catch-all renders str(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 one str(exc)-to-PrimaError site 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_body line 460, ::test_an_internal_error_frame_carries_no_exception_text line 471, tests/test_app.py::test_unhandled_exception_does_not_leak_its_message; the Labs equivalents are in backend/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_shaped line 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.py lines 108 to 111; check_key, prima/auth/keys.py lines 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_wrong line 500 asserts no-credentials and junk-bearer return the same code). A rejected session token is not uniform on purpose: token_expired and credential_revoked (prima/auth/sessions.py lines 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_credit means exactly "top up and retry", 402 with monthly_ceiling_reached means exactly "raise the cap" (the layered-jobs route, Data model), and 429 with spend_cap_exceeded means exactly "not a wait"; a client reads code and never parses a message to tell them apart.
  • A PrimaError can be raised through an ASGI stack: the frozen dataclass delegates dunder writes to object.__setattr__ (prima/errors.py lines 83 to 101) and tests/auth/test_error_transport.py::test_every_error_renders_over_a_real_asgi_round_trip proves it per factory over a real round trip, because pytest.raises never 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_repr line 437, ::test_upstream_error_is_a_terminal_json_error_frame_then_done line 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-chat w5-billing-panel 743b840, web/src/lib/billing.ts readFailure, lines 215 to 249, pure and unit-tested in billing.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, where code is the status class and the token is found by regex inside error.message (/'error':\s*'([a-z_]+)'/, because Labs stringifies a dict detail on that path) or, for Prima's envelope, read from error.code; and FastAPI's {detail: [...]}, which becomes invalid_request. A sentence the server wrote is shown as is; a token with no sentence is looked up in REFUSALS (lines 169 to 192); an unknown token renders "That did not work (<code>)". Never the raw Python repr.
  • The chat server (server/routes/billing.ts lines 172 to 187, routeMiss) turns a gateway 404 whose error.code is null or missing into 503 {"error": "billing_not_enabled", "message": "Top-ups are not enabled on this installation yet. ..."} (lines 248 to 252), and passes a Labs 404 (a string error, or an object with a code) through untouched, because "that tier does not exist" is a real answer. server/billing.test.ts::a Prima route-miss 404 becomes billing_not_enabled pins it. This is the rule every frontend inherits: code: null under the gateway prefix means "not enabled here", rendered as absence, never as a red banner.
  • The Labs SPA (alchemy_labs fd8558f): the desk services' extractError (frontend/src/services/serviceDeskService.ts lines 262 to 283) reads body.detail.error (a structured 409 or 403) as the code, a slug-shaped body.error (the middleware's rate_limit_exceeded, desk_only_role) as the code, and otherwise detail ?? error ?? message as the message; the code is what matters "because a code has two languages, a string has one", and errorText(e, surface) picks the German or English sentence (lines 123 and 180 are the desk_only_role pair). The commerce service's extractError (commerceService.ts lines 30 to 42) reads detail ?? error ?? message as 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)" until error was read). WorkspaceContext.tsx lines 203 to 206 read data.detail?.error ?? data.error for desk_only_role.
  • The S2 skeleton's api singleton retries a 401 once through the shared refresh and is meant to bootstrap on a 409 no_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 of error.code, the Shape E read of detail, and the Shape D read of {detail: [...]} as invalid_request. The regex branch that finds a token inside error.message is 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, message non-empty, request_id equals 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.py lines 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/models without credentials: error is a dict, message non-empty, type == "authentication_error", request_id present), test_labs_routes_keep_the_old_envelope, test_type_varies_with_status; tests/test_primaapi_streaming.py lines 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 (readFailure over 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: null on a route miss; 400 invalid_request with param on a bad body; the fixed 500 message with a request id; one uniform 401 for two different bad API keys; the streaming frame with choices: [] 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 reads error.message off an object, so a stock client showed the developer an empty message on the error path. Fixed by the gateway-prefix branch in http_exception_handler and pinned by tests/unit/test_primaapi_error_shape.py. Changing the envelope globally was the easy wrong fix; the Labs SPA reads error as a string.
  • 2026-08-03 (Prima, prima/errors.py docstring lines 28 to 54): a frozen dataclass that subclasses Exception cannot 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 with code: internal_error and no unit test could see it. Fixed by _allow_exception_dunders and the round-trip test in tests/auth/test_error_transport.py.
  • 2026-09-04 (Labs a360b2c): the Labs branch of http_exception_handler stopped flattening a dict detail with str(); the token is now error and the dict rides in details. 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 on w1-auto-topup, topup_not_configured at primaapi_router.py line 873 on fd8558f) reaches the SDK as the Python repr "{'error': 'owner_required', 'hint': '...'}" with code: AUTHZ_001. The chat's readFailure regex and the W1 docstring ("the token in error.message", w1-auto-topup primaapi_router.py lines 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 a PrimaError carries its code in code. D3 makes Prima the reference, so the recipe's rule is Prima's: the token is in code, never inside message.
  • 2026-09-11 (measured at fd8558f): error_code_map (app_fastapi.py lines 731 to 739) has no entry for 400, 402, 502 or 503, so those statuses carry code: SRV_001 (INTERNAL_ERROR) in Shape B, and on the gateway a 402 or 503 is type: api_error. A client that reads code on a Labs 400 learns nothing; it reads the status and the error string.
  • 2026-09-11 (measured at fd8558f): 89 detail=str(e) or detail=str(exc) sites in 24 router files under api/routers/ (and 27 more f-strings interpolating the exception), each a path from an exception message to a client; prima_router.py line 1197 is one (500 detail=str(e) on /conversation/history). The rule "no str(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.py and both 400 invalid_request_error with param: 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 of JobRequest.pins, a dict[str, str] unbounded in length at line 65). Second, unpinned_model (line 204) raises invalid_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, into ModelPins.resolve (prima/runtime/types.py lines 374 to 376), which calls PinnedModel.pin(alias) with the raw string, and every message raised on that path interpolates it: f"{alias!r} is a FLOATING alias..." (types.py 289 to 293), UnpinnedModelError(str(exc)) at 297 wrapping f"Unknown PrimaAPI model alias {alias!r}..." (prima/aliases.py 126 to 128), f"{alias!r} has no floating parent..." (301 to 303), and f"{self.alias!r} is not a pinned chat alias..." (271 to 275). A POST to the layered-jobs route with pins: {"verifier": "<any text>"} therefore answers a 400 whose error.message contains that text, the leak model_not_found at prima/api/v1.py lines 245 to 253 was written to avoid ("NOT str(exc): Labs' message interpolates the alias the caller sent"). Acceptance check 4 counts the str(exc)-to-PrimaError sites (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 the UnpinnedModelError messages already carry) and drop the caller's key and alias, exactly as model_not_found does; the comment at lines 200 to 203 goes with them.
  • 2026-09-11 (measured at fd8558f): no RequestValidationError handler on Labs; a 422 echoes the offending input. Prima answers 400 with param only (prima/app.py lines 110 to 128). A new service copies Prima's reduction on day one (locations only, input, ctx and msg dropped, 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 and param in details).
  • 2026-09-11 (measured at fd8558f): three middleware envelopes precede the handlers and none carries code; the crash body a Labs route actually produces is ErrorLoggingMiddleware's {detail, error_id, timestamp}, not general_exception_handler's Shape B, because the logging middleware catches first (Application logic step 6). A client that searches a Labs 500 for request_id finds error_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_001 from Labs (_PRIMA_ERROR_TYPES) and type: invalid_request_error, code: invalid_api_key from Prima (prima/errors.py lines 108 to 111, the shape OpenAI itself uses). Same status, different type and code. D3: Prima wins. A client switches on the status and, where it must, on Prima's code; never on type.
  • 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 as 502 upstream_error, indistinguishable from an outage, so retry logic retries what can never succeed; OpenAI returns 400 context_length_exceeded with 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.py lines 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_envelope skips when the probed route does not error and asserts only when error is 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): readFailure and routeMiss exist only on prima-chat branch w5-billing-panel (743b840), unmerged, and they parse the W1 credits routes, which exist only on alchemy_labs w1-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 extractError helpers with different return types (DeskApiError with a code; a plain string) and many ad hoc reads beside them: in frontend/src, grep -rnE 'data\??\.error\b' . | wc -l prints 28 and grep -rnE 'data\??\.detail\b' . | wc -l prints 148 (the optional-chained reads included; the strict data.error and data.detail reads alone, 11 and 2). A new app has one parser, the four-shape readFailure, 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-chat web/src/lib/api.ts line 9 imports httpTransport and PrimaClient), and PrimaClient.streamChat (prima packages/ui/src/core/client.ts lines 53 to 75) throws PrimaError on a non-2xx before the stream opens and then yields every SSE frame as a ChatCompletion without looking for a top-level error; accumulator.ts has no read of an error key and conversation.ts lines 210 to 231 only catch what the generator throws. A Shape F frame is therefore absorbed as a chunk with choices: [] and the turn ends at [DONE] with no sentence; the sentence the chat shows (web/src/App.tsx lines 1010 to 1013, state.error.message) comes only from the thrown PrimaError. The client's own docstring (lines 49 to 51) still describes the pre-stream case as the only error path. Reading the frame is the ai-gateway/ui/chat-conversation-ui recipe'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 PrimaError constructions outside errors.py, ClaimError as 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-pricing at cd2c248, the reference per D3):
    • prima/errors.py: GATEWAY_PREFIX (23), PrimaError and body (26 to 80, the headers comment 62 to 69), _allow_exception_dunders (83 to 101), the factories invalid_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_error 97 to 108, _validation_error 110 to 128, _unhandled 130 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), the upstream_error raise (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 (ClaimError 75, _reject 79, the claim codes 103 to 180, the workspace codes 214 to 252); prima/auth/keys.py (check_key 159 to 213: the uniform 401 and the three 403 codes); prima/auth/sessions.py (_unavailable 223 to 231, _revoked 234 to 239, token_expired 304 to 306); prima/auth/surfaces.py (_deny 89 to 90, the six audience codes 112 to 134); prima/seams/identity.py 500 to 531; prima/runtime/jobs/router.py 171 to 176, 192 to 195, 205 to 209, 244 to 249, 262 to 273, 355 to 407, 450 to 465; prima/runtime/jobs/composition.py 186 to 204; prima/runtime/spend.py RefusalReason 220 to 226; prima/api/keys_sync.py 181 (the internal prefix) and 240 to 446; prima/api/internal.py 79 and 131 to 459.
    • prima/api/health.py lines 110 to 118 (the readiness report a failing /readyz returns with 503).
    • prima/logging.py: event (276), new_request_id (307 to 308).
    • packages/ui/src/core/client.ts streamChat (53 to 75) and conversation.ts (210 to 231): the stream consumer that does not read Shape F.
  • alchemy_labs (d1a-token-source at fd8558f, backend tree unchanged from feat/service-desk b1089b6; legacy for the gateway, current for every Labs route):
    • backend/app_fastapi.py: CORS expose_headers (446 to 453), middleware registration (507 to 533), the domain handlers (551 to 688), _error_cors_headers (691 to 708), PRIMA_GATEWAY_PREFIX and _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.py lines 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.py lines 458 to 512 (the Labs streaming tests; the Prima equivalents are tests/gateway/test_streaming.py 420 to 488 above).
    • frontend/src/services/serviceDeskService.ts lines 255 to 283 and 123, 180; frontend/src/services/commerceService.ts lines 30 to 42; frontend/src/contexts/WorkspaceContext.tsx lines 197 to 209.
  • alchemy_labs (w1-auto-topup at 8b5361c, unmerged): backend/api/routers/primaapi_router.py lines 845 to 869 (the owner_required dict detail and the docstring that describes it landing in error.message).
  • prima-chat (w5-billing-panel at 743b840, unmerged): web/src/lib/billing.ts (BillingFailure 26 to 38, call 131 to 158, REFUSALS 169 to 192, PLAIN_DETAILS 195 to 198, readFailure 215 to 249), web/src/lib/billing.test.ts, web/src/lib/api.ts line 9 (the @prima/ui import), web/src/App.tsx lines 1010 to 1013 (the error banner), server/routes/billing.ts (routeMiss 172 to 187, NOT_ENABLED 248 to 252), server/billing.test.ts line 225.
  • Wiki philosophers_stone (d4f5b95): platform/primaapi.md "Errors are OpenAI-shaped on /api/prima/v1 only" (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:null as "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.

  1. Prima, repo root: pytest tests/test_app.py tests/auth/test_error_transport.py -q passes 36 tests; grep -c "^def " prima/errors.py prints 11 (ten factories plus _allow_exception_dunders); grep -rn 'PrimaError(' prima/ | grep -v '^prima/errors.py' | wc -l prints 21 (the direct constructions, Data model); grep -rhoE 'code="[a-z_]+"' prima/ | sort -u | wc -l prints 42. A different number on a later commit means the vocabulary table has moved, not that the check is wrong.
  2. Prima, with the app running locally (python -m prima, PRIMA_PORT default 8080, _shared/fastapi-project-layout): curl -s -i http://127.0.0.1:8080/api/prima/v1/models (no credentials) returns 401, a body whose error object has exactly the keys message, type, param, code, request_id, with code equal to invalid_api_key and type equal to invalid_request_error, and an X-Request-Id header equal to error.request_id; curl -s http://127.0.0.1:8080/api/prima/v1/no-such-route returns 404 with error.code equal to null; curl -s http://127.0.0.1:8080/no-such-route returns 404 with the keys detail and request_id and no error key. On a new Shape B service: the no-credential call returns 401 with exactly the keys error, code, request_id, timestamp, details and an X-Request-ID header equal to request_id; an unmatched path returns 404 in the same envelope with the same equality (the handler is on the Starlette class).
  3. 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/completions returns 400 with error.code equal to invalid_request, error.param equal to messages, and a body that does not contain the string not-a-list. On a new Shape B service the same request against any body-taking route returns 400, code equal to VAL_001, details.error equal to invalid_request, details.param naming the field, and no not-a-list in the body.
  4. Prima: grep -n "str(exc)" prima/app.py prima/api/v1.py prints no line that hands it to a response or a PrimaError: prima/app.py line 376 is a SystemExit at boot and the other five hits (app.py 138; v1.py 64, 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/*.py prints three lines, each passing it to ProviderAPIError.from_response, which keeps the byte count and drops the text.
  5. Labs, backend/: pytest tests/unit/test_primaapi_error_shape.py -q passes 3 tests; sed -n 731,739p app_fastapi.py lists six statuses and none of 400, 402, 502, 503.
  6. Labs, backend/: grep -rn 'detail=str(e)\|detail=str(exc)' api/routers | wc -l prints 89; grep -rln 'detail=str(e)\|detail=str(exc)' api/routers | wc -l prints 24; grep -rn "RequestValidationError" app_fastapi.py prints nothing. On a new app all three print 0, 0 and one handler line.
  7. Labs, backend/: grep -n "add_middleware" app_fastapi.py prints lines 446, 507, 511, 518, 523 and 528 in that order, and grep -n '@app.middleware' app_fastapi.py prints line 483 (public_endpoint_cors, the decorator-registered sixth layer; together the registration order behind Application logic step 6).
  8. Labs, with the harness backend running (tooling/render/labs-local.sh up, API on 127.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-000000000000 returns 404 with keys error (a string), code equal to RES_001, request_id, timestamp, details equal to null, and an X-Request-ID header equal to request_id; curl -s -i http://127.0.0.1:8020/api/prima/v1/models returns 401 with error.type equal to authentication_error and error.code equal to AUTH_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/templates returns 422 with a detail list whose first element carries an input key (the echo in Gotchas). On a new service, the same three calls against its own routes: the missing row is 404 in the envelope with request_id equal to the X-Request-ID header; the bad body is 400 with details.param and no input echo; and a route made to raise (a test-only handler, or a monkeypatched service function) returns 500 {"error": "Internal server error", "code": "SRV_001", "request_id": <the header's value>, "timestamp": ..., "details": null} with no error_id key, because no middleware wraps call_next in a bare except (S4 Application logic step 7).
  9. prima-chat, w5-billing-panel checkout, repo root: npm test (tsx --test server/*.test.ts web/src/lib/*.test.ts) passes, including the case at web/src/lib/billing.test.ts lines 24 to 27 that reads no_card_on_file out of a stringified dict in error.message; grep -n "code === null || code === undefined" server/routes/billing.ts prints line 186.
  10. Wiki d4f5b95: grep -n "Errors are OpenAI-shaped" platform/primaapi.md prints line 208; grep -c "empty body" platform/primaapi.md prints 1.
  11. npm run check in this repo prints no line naming modules/_shared/error-envelope.md (the problem count is repo-wide and moves with sibling recipes; it was 0 problems at 74766d0 on 2026-09-11).
  12. 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/jobs returns 400 with error.code equal to unpinned_model and error.param equal to pins, and a body that does not contain the string zq7marker; the same body with "pins":{"zq7marker":"prima-core-2026-08"} returns 400, error.code equal to unknown_model_role, and no zq7marker in the body. build_spec runs before spend admission (router.py line 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 in composition.py drop 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.