Field Note 58current
Errors are part of the contract
Error paths are the half of your API clients depend on most, and usually the half nobody designed. Enumerate error codes in the schema like any other type: stable code, retryable-or-not, whose fault, structured params. Machines branch on codes — anyone parsing prose is broken.
TL;DR
Your API’s error responses are read by three audiences, and most APIs design for none of them. Machines need a stable code and a retryable/terminal classification to branch on. Developers integrating with you need enough structured context to fix their bug. Humans downstream need a safe message that leaks nothing. Serve all three deliberately:
- Enumerate error codes in the schema (ZFN-14Field Note · currentZFN-14 — Define every API with a schema, and generate the clientsDefine every API with a machine-readable schema (OpenAPI, Protobuf, GraphQL) as the source of truth, and generate clients and server stubs from it — never hand-roll request-building and JSON parsing. Hand-written clients drift and break silently; check schema compatibility in CI.Why it's cited here: Errors belong in the same schema as the request and response types — designed, reviewed, versioned, and generated into clients, not improvised per handler.Open ZFN-14 →) — a closed, documented, versioned set per endpoint, not whatever strings handlers improvise.
- Every error carries: the stable code; whether retrying can help (and after how long); whose fault it is (caller’s request, your system, or a dependency); and structured parameters (which field, which limit, which resource) — not facts embedded in prose.
- On HTTP, wear the standard shape: RFC 9457 problem detailsRFC 9457 — Problem Details for HTTP APIsThe standard shape for machine-readable HTTP error responses: a `type` URI identifying the error class, `title`, `status`, `detail`, `instance`, and extension members. Obsoletes RFC 7807; the answer to every hand-rolled { "error": "..." } envelope.rfc-editor.org ↗, with the status code agreeing with the body.
- Prose is for humans only. The moment a client greps a message string, that string is frozen forever — AIP-193AIP-193 — Errors (Google API Improvement Proposals)Google's API design guidance for errors: a stable canonical code for programmatic handling, a developer-facing message, and structured details — with the explicit rule that clients must be able to rely on the code and structured details, never on parsing message text.google.aip.dev ↗’s core rule. Machine-readable means codes and fields.
- Unknown codes must have defined client behaviour (treat as terminal, surface, don’t retry blindly) — because you will add codes, and that must not break anyone.
Context
The happy path of an API gets designed: reviewed schemas, typed responses, generated clients
(ZFN-14Field Note · currentZFN-14 — Define every API with a schema, and generate the clientsDefine every API with a machine-readable schema (OpenAPI, Protobuf, GraphQL) as the source of truth, and generate clients and server stubs from it — never hand-roll request-building and JSON parsing. Hand-written clients drift and break silently; check schema compatibility in CI.Why it's cited here: Errors belong in the same schema as the request and response types — designed, reviewed, versioned, and generated into clients, not improvised per handler.Open ZFN-14 →). The error paths — the half the client
author actually spends their time on — usually just accrue. One handler returns
{"error": "invalid input"}, another {"message": "..."}; the framework contributes an HTML 500
page; somewhere a stack trace escapes. The resulting contract exists, but nobody wrote it, and
you discover its terms when clients start depending on them.
What that costs, concretely:
- Retry behaviour becomes folklore. The client can’t tell a “this request will never work”
from a “try again in a second,” so it retries everything or nothing.
ZFN-13Field Note · currentZFN-13 — Fail fast and push back: retries, load shedding, and flow controlBuild client retries (backoff, jitter, Retry-After) from day one. Under overload, shed fast and push the failure back to the source to retry — don't retry internally and amplify it. Flow-control everywhere, bound every queue, and don't take more work than you can finish in time.Why it's cited here: Retry behaviour is only as good as the error classification driving it: a client can't back off correctly if the API won't say whether the failure is retryable.Open ZFN-13 →’s whole retry discipline — back off, respect
Retry-After, give up on terminal failures — is only implementable if the error says which kind it is. An API without error classification exports its outages: every incident is amplified by clients hammering non-retryable failures. - Clients parse prose. If the only way to distinguish “insufficient funds” from “card expired” is the message text, integrators will regex the message text — and now a copyedit is a breaking change. You’ve created an API surface out of your typos.
- Internals leak. Unhandled errors carry stack traces, SQL fragments, internal hostnames, other tenants’ resource names — reconnaissance served on request. The error path is an attack surface and gets none of the review the happy path gets.
- Debugging round-trips multiply. “Something went wrong” with no correlation ID means the integrator emails you a timestamp and a prayer; the trace ID that should have been in the response envelope (ZFN-51Field Note · currentZFN-51 — Design the request envelope before the first endpointAuth, idempotency keys, trace IDs, vector clocks: context about a request, not part of it. Define an envelope alongside the payload in the schema on day one — transport-native, per-hop vs propagated, owned by generated SDKs and middleware. The retrofit is what costs you.Open ZFN-51 →) is the difference between a support ticket and a self-serve fix.
Recommendation
Design the error surface with the same ceremony as the success surface — in the schema, as types.
-
A closed, named code set. Each code is a stable identifier (
quota_exceeded,resource_not_found,version_conflict), documented with meaning, and enumerated per endpoint in the schema so generated clients can switch on it exhaustively. Adding a code is a contract change — cheap and additive, but visible, and covered by the unknown-code rule below. -
Classification travels with the code, explicitly:
- Retryable? Terminal (
invalid_argument), retryable-after-backoff (overloaded, withRetry-After— ZFN-18Field Note · currentZFN-18 — Enforce a quota at ingress on every endpoint — even unabused onesPut a quota on every endpoint and enforce it at ingress from day one — per tenant, principal, IP — even for endpoints nobody abuses yet. Unlimited-by-default means the first runaway client or compromised key is an outage. Return 429 + Retry-After; retrofitting limits is painful.Open ZFN-18 →), or retryable-after-you-change-something (version_conflict, ZFN-25Field Note · currentZFN-25 — Track the version a client has seen for read-your-writesFor read-your-writes across backends, track the latest version a client has seen — a token or vector clock. Return it on write; reads then go to a backend at least that fresh. Hold it client-side (a token they present) or server-side (a gateway tracks the session and routes).Open ZFN-25 →). This single bit, made explicit, is worth more than the rest of the design combined (ZFN-13Field Note · currentZFN-13 — Fail fast and push back: retries, load shedding, and flow controlBuild client retries (backoff, jitter, Retry-After) from day one. Under overload, shed fast and push the failure back to the source to retry — don't retry internally and amplify it. Flow-control everywhere, bound every queue, and don't take more work than you can finish in time.Why it's cited here: Retry behaviour is only as good as the error classification driving it: a client can't back off correctly if the API won't say whether the failure is retryable.Open ZFN-13 →, ZFN-19Field Note · currentZFN-19 — Annotate read-only and idempotent endpoints; make every mutation idempotentAnnotate every endpoint as read-only (safe) or idempotent, in the schema, so infrastructure can retry, route to replicas, and cache safely. Make every state-changing endpoint idempotent (idempotency keys for create/charge/send); a non-idempotent retry double-applies.Why it's cited here: The endpoint annotations and the error taxonomy are two halves of one retry contract: what may be retried, and when.Open ZFN-19 →). - Whose move? The caller’s request is wrong (fix and resend), your system failed (their retry policy’s business), or an upstream dependency did (be honest — don’t wear a vendor’s outage as your 500 without saying so).
- Retryable? Terminal (
-
Structured parameters, not interpolated prose.
{"code": "field_invalid", "field": "email", "reason": "format"}— the field name is data. The human message is derived from the structure, never the other way round. This is also what makes errors localisable and lets a UI attach the failure to the right form field. -
On HTTP, use RFC 9457RFC 9457 — Problem Details for HTTP APIsThe standard shape for machine-readable HTTP error responses: a `type` URI identifying the error class, `title`, `status`, `detail`, `instance`, and extension members. Obsoletes RFC 7807; the answer to every hand-rolled { "error": "..." } envelope.rfc-editor.org ↗ —
application/problem+json, atypeURI per code (which gives you a natural home for per-error documentation), extensions for your structured params. Status code and body must agree; infrastructure branches on the status (ZFN-18Field Note · currentZFN-18 — Enforce a quota at ingress on every endpoint — even unabused onesPut a quota on every endpoint and enforce it at ingress from day one — per tenant, principal, IP — even for endpoints nobody abuses yet. Unlimited-by-default means the first runaway client or compromised key is an outage. Return 429 + Retry-After; retrofitting limits is painful.Open ZFN-18 → returns429because every proxy and SDK on earth understands it), clients branch on the code. -
Two renderings, one boundary. Internal detail — stack traces, query text, dependency hostnames — goes to your telemetry, keyed by trace ID. The response carries the code, the structured params, the safe message, and that same trace ID (ZFN-51Field Note · currentZFN-51 — Design the request envelope before the first endpointAuth, idempotency keys, trace IDs, vector clocks: context about a request, not part of it. Define an envelope alongside the payload in the schema on day one — transport-native, per-hop vs propagated, owned by generated SDKs and middleware. The retrofit is what costs you.Open ZFN-51 →) so support can join the two. Enforce the split in middleware at the edge, not by per-handler discipline: unrecognised errors render as an opaque
internalwith a trace ID, full stop. -
Define the unknown-code behaviour in the client contract: treat unrecognised codes as terminal, surface them with the trace ID, never blind-retry. Generated SDKs implement this once; hand-rolled callers get it in the docs. This is the forward-compatibility hinge — it’s what makes adding codes additive instead of breaking.
-
Test the error paths as contract tests. The schema says
quota_exceededcomes withRetry-Afterand a429— prove it in CI, per endpoint, the way you’d prove a response shape. Error contracts rot faster than success contracts precisely because nothing exercises them until an incident does.
Consequences
Easier:
- Client retry logic becomes correct and boring — and your outages stop being amplified by well-meaning hammering.
- Integration debugging collapses from email-the-vendor to read-the-code-and-params; support tickets arrive with trace IDs on them.
- The security review of the error path is a middleware review, not an audit of every handler’s sense of discretion.
- Copy changes are copy changes. Nobody’s integration depends on your prose.
Harder:
- The taxonomy is a real design artefact — codes need naming discipline, ownership, and
review, or you get
error_1througherror_94and the exercise was pointless (ZFN-47Field Note · currentZFN-47 — Govern the contract between teams, not the code inside themTeams own services end to end; one team owns the gateway that dispatches to them. Govern exactly one thing centrally — the contract at the boundary (schema, identity, errors, idempotency) — and enforce it at runtime. Don't mandate libraries; ship them as an opt-in blueprint.Open ZFN-47 → — this is exactly the kind of boundary contract worth governing). - Honest classification is work. Deciding whether a failure is retryable forces you to
understand your own failure modes; the temptation to mark everything
internaland move on is constant, and each such code is a small default on the contract. - Migration from an accreted error surface is slow — existing clients depend on the accidents (that HTML 500 page, that message string), so the old shapes linger behind the new ones for a deprecation cycle.
References
- ZFN-14Field Note · currentZFN-14 — Define every API with a schema, and generate the clientsDefine every API with a machine-readable schema (OpenAPI, Protobuf, GraphQL) as the source of truth, and generate clients and server stubs from it — never hand-roll request-building and JSON parsing. Hand-written clients drift and break silently; check schema compatibility in CI.Why it's cited here: Errors belong in the same schema as the request and response types — designed, reviewed, versioned, and generated into clients, not improvised per handler.Open ZFN-14 → — the schema owns error types exactly as it owns everything else on the wire.
- ZFN-13Field Note · currentZFN-13 — Fail fast and push back: retries, load shedding, and flow controlBuild client retries (backoff, jitter, Retry-After) from day one. Under overload, shed fast and push the failure back to the source to retry — don't retry internally and amplify it. Flow-control everywhere, bound every queue, and don't take more work than you can finish in time.Why it's cited here: Retry behaviour is only as good as the error classification driving it: a client can't back off correctly if the API won't say whether the failure is retryable.Open ZFN-13 → and ZFN-19Field Note · currentZFN-19 — Annotate read-only and idempotent endpoints; make every mutation idempotentAnnotate every endpoint as read-only (safe) or idempotent, in the schema, so infrastructure can retry, route to replicas, and cache safely. Make every state-changing endpoint idempotent (idempotency keys for create/charge/send); a non-idempotent retry double-applies.Why it's cited here: The endpoint annotations and the error taxonomy are two halves of one retry contract: what may be retried, and when.Open ZFN-19 → — the retry machinery this taxonomy exists to inform.
- ZFN-51Field Note · currentZFN-51 — Design the request envelope before the first endpointAuth, idempotency keys, trace IDs, vector clocks: context about a request, not part of it. Define an envelope alongside the payload in the schema on day one — transport-native, per-hop vs propagated, owned by generated SDKs and middleware. The retrofit is what costs you.Open ZFN-51 → — the response envelope where the trace ID and retry metadata ride.
- ZFN-18Field Note · currentZFN-18 — Enforce a quota at ingress on every endpoint — even unabused onesPut a quota on every endpoint and enforce it at ingress from day one — per tenant, principal, IP — even for endpoints nobody abuses yet. Unlimited-by-default means the first runaway client or compromised key is an outage. Return 429 + Retry-After; retrofitting limits is painful.Open ZFN-18 → — the quota errors that must be legible to every layer of infrastructure between you and the caller.
- RFC 9457RFC 9457 — Problem Details for HTTP APIsThe standard shape for machine-readable HTTP error responses: a `type` URI identifying the error class, `title`, `status`, `detail`, `instance`, and extension members. Obsoletes RFC 7807; the answer to every hand-rolled { "error": "..." } envelope.rfc-editor.org ↗ and AIP-193AIP-193 — Errors (Google API Improvement Proposals)Google's API design guidance for errors: a stable canonical code for programmatic handling, a developer-facing message, and structured details — with the explicit rule that clients must be able to rely on the code and structured details, never on parsing message text.google.aip.dev ↗ — the standard wire shape, and the clearest statement of the codes-not-prose rule.
Changelog
- 2026-08-12: First published as a Field Note.