---
id: 58
title: "Errors are part of the contract"
kind: note
status: current
date: 2026-08-12
authors:
  - "Theo Zourzouvillys"
tags: [api, design, correctness, interop, http]
references:
  - id: rfc9457
    title: "RFC 9457 — Problem Details for HTTP APIs"
    url: https://www.rfc-editor.org/rfc/rfc9457.html
    abstract: "The 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."
  - id: aip193
    title: "AIP-193 — Errors (Google API Improvement Proposals)"
    url: https://google.aip.dev/193
    abstract: "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."
summary: "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."
supersedes: null
superseded_by: null
aliases: []
crossrefs:
  ZFN-14: "Errors belong in the same schema as the request and response types — designed, reviewed, versioned, and generated into clients, not improvised per handler."
  ZFN-13: "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."
  ZFN-19: "The endpoint annotations and the error taxonomy are two halves of one retry contract: what may be retried, and when."
---

## 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-14](/zfn/14-schema-first-apis-generate-clients/))
  — 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 details](ref:rfc9457), 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-193](ref:aip193)'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-14](/zfn/14-schema-first-apis-generate-clients/)). 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-13](/zfn/13-load-shedding-and-flow-control/)'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-51](/zfn/51-design-the-request-envelope-first/)) is the difference
  between a support ticket and a self-serve fix.

> [!aside]
>
> A good smell test for any API you're about to depend on: hit it with a malformed request and
> an expired credential, and look at the two responses. If they're the same shape, documented,
> and distinguishable by code rather than prose, the happy path will be fine too. The reverse
> inference is *far* more reliable.

## 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`, with
    `Retry-After` — [ZFN-18](/zfn/18-enforce-quotas-at-ingress/)), or
    retryable-after-you-change-something (`version_conflict`,
    [ZFN-25](/zfn/25-read-your-writes-version-token/)). This single bit, made explicit, is worth
    more than the rest of the design combined ([ZFN-13](/zfn/13-load-shedding-and-flow-control/),
    [ZFN-19](/zfn/19-annotate-readonly-idempotent-endpoints/)).
  - **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).

- **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 9457](ref:rfc9457)** — `application/problem+json`, a `type` URI 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-18](/zfn/18-enforce-quotas-at-ingress/) returns `429` because 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-51](/zfn/51-design-the-request-envelope-first/)) 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 `internal` with 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_exceeded` comes with
  `Retry-After` and a `429` — 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_1` through `error_94` and the exercise was pointless
  ([ZFN-47](/zfn/47-govern-the-contract-between-teams/) — 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 `internal` and 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-14](/zfn/14-schema-first-apis-generate-clients/) — the schema owns error types exactly as
  it owns everything else on the wire.
- [ZFN-13](/zfn/13-load-shedding-and-flow-control/) and
  [ZFN-19](/zfn/19-annotate-readonly-idempotent-endpoints/) — the retry machinery this taxonomy
  exists to inform.
- [ZFN-51](/zfn/51-design-the-request-envelope-first/) — the response envelope where the trace
  ID and retry metadata ride.
- [ZFN-18](/zfn/18-enforce-quotas-at-ingress/) — the quota errors that must be legible to every
  layer of infrastructure between you and the caller.
- [RFC 9457](ref:rfc9457) and [AIP-193](ref:aip193) — the standard wire shape, and the clearest
  statement of the codes-not-prose rule.

## Changelog

- **2026-08-12**: First published as a Field Note.
