Errors
Errors use the envelope’s error variant:
{ "error": { "code": "bad_request", "message": "...", "details": { ... } }, "meta": { "api_version": "v1", "request_id": "req_01H..." }}There is no data field on error responses. HTTP status follows RFC
9110.
Status conventions
| Status | When |
|---|---|
400 Bad Request | Malformed request, missing required field, bound exceeds hard maximum, missing confirm: true on DELETE. |
401 Unauthorized | Missing or invalid bearer token on a token-required endpoint. |
403 Forbidden | Token supplied via query parameter (always rejected); internal-metrics call without internal bearer. |
404 Not Found | Resource id (course, credential, source reference) does not exist in current index. |
409 Conflict | expected_state_version mismatch on a state mutation. |
415 Unsupported Media Type | Content-Type is not application/json on a POST/PATCH/PUT/DELETE. |
422 Unprocessable Entity | Body shape valid but semantically invalid (catalog version mismatch, unknown course id, etc.). |
429 Too Many Requests | Per-IP rate limit exceeded. Retry-After header set. |
500 Internal Server Error | Unhandled server fault. Includes request_id for debugging; never leaks stack traces. |
503 Service Unavailable | Server is restarting (index publication swap) or shutting down. Retry-After set. |
Canonical error codes
These are stable enough that frontends can switch on them.
General
bad_request— generic 400.unsupported_media_type— wrong Content-Type.payload_too_large— request body exceeds limit.internal_error— unhandled fault.service_unavailable— restart in progress.
Authentication
missing_token— Authorization header absent on a token-required endpoint.unauthorized— bearer doesn’t match any stored verifier.token_in_query— token supplied via?token=(always rejected).
Catalog
course_not_found,credential_not_found,source_reference_not_found.
Query and Advisory
unknown_target— the requested target is not in the current index.evaluation_problem_invalid—what-ifbody’s hypothetical changes are malformed.engine_native_status_leaked— internal sanity check; should never appear externally. Filing this as a bug helps.
Graph views
bound_exceeds_hard_max— request bound is over the hard maximum for that endpoint.unknown_view—/graph/views/<name>is not one of the seven supported views.
State
missing_confirm—DELETE /state/currentwithout{"confirm": true}.state_version_conflict—expected_state_versionmismatch.catalog_version_mismatch— body’scatalog_version_iddoesn’t match persisted state.
Rate limiting
rate_limit_exceeded— 429. Response includesRetry-Afterand hints which limiter fired.
What error responses never contain
- The raw bearer token (even if the request supplied a malformed one).
- Stack traces or internal exception messages.
- File paths from the server filesystem.
- Engine-native debug logs.
Per ADR 0018,
engine-native statuses (UNKNOWN, UNSAT, infeasible, timeout) never
appear as HTTP errors — they show up as academic unknown or conflict
statuses inside successful responses.
Distinguishing transport failures from academic unknowns
This split matters a lot in client code:
| If the client sees… | It’s… |
|---|---|
HTTP 4xx/5xx with error.code | A transport / contract error. Bug or load. |
HTTP 200 with unknowns in the envelope | Academic uncertainty. A feature, not a bug. |
HTTP 200 with warnings[].code == "graph_view_truncated" | A bounded response. Caller asked for too much; server gave back a partial answer. |
The frontend’s EnvelopeIssues surface renders the first kind; the
status badges render the second; the inspector’s truncation banner
renders the third.
Want more?
- Envelope — full envelope shape.
- Rate limits — per-IP limits and tuning.
- Backend API spec — every endpoint’s specific errors.