How-to › Make calls that survive failure

How to evolve an error contract without breaking clients#

Add codes, retire codes and reword messages while clients in production keep working, with a catalog diff that fails the build on the changes that break them.

Audience
API producer
Level
advanced
Topic
Handle and design API errors
Verified

A release splits one rate limit error into a per-key one and a per-tenant one, which is better for everybody reading the docs. Every client that switched on the old code now falls through to its default branch, retries a refusal it should have surrendered to, and burns its budget faster than before. The response body validates. The behavior is gone.

What you get

You will end up with rules about which parts of an error are contract and which are prose. You also get a diff between two published catalogs that fails a build on the changes clients notice. This is for you if your API has clients you cannot update.

Short answer

Treat the code and the type URI as the contract, and the detail text as prose you may rewrite at any time. New codes are additive and safe. An existing code never changes meaning or status, and a retired one goes through a window announced with Deprecation and Sunset headers. Diff the published catalog between releases, and fail the build on a removal.

You will need

A published error catalog with clients in production, and Node 22 or later. The members a client may rely on are defined in RFC 9457,1 and the retirement headers come from RFC 9745 and RFC 8594.2

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A catalog diff in CIYou publish a list of codes and want the rule enforced on every changeA file to keep current, and a gate somebody will want to overrideThe errors are described only inside the OpenAPI document
Additive-only codesYou value never breaking a client over ever tidying the catalogA catalog that grows and never shrinks, with dead codes in it foreverThe list has grown past what a reader can hold
oasdiff on the error schemasThe errors live in the description and you already diff it for breaking changesIt compares schemas, so a meaning rewritten under one code passesThe contract is the code list rather than the schema
Versioning errors with the APIA major version is already planned and clients expect migration workEvery client migrates on your schedule, whether or not they benefitYou cannot ask clients to move

The real division is what you are willing to give up. Additive-only never breaks anyone, and the catalog fills with codes nobody returns. Versioning lets you clean up, and it costs every client a migration. A diff in CI does not decide the policy, it makes the policy visible at the moment somebody departs from it, which is the point where the discussion is cheap.

Decide what is contract and what is prose

Two columns, and the line between them is the whole design.

"codes": {
  "meter_not_found": { "type": "https://example.com/problems/meter-not-found", "status": 404, "means": "no meter has that id" },
  "rate_limited": { "type": "https://example.com/problems/rate-limited", "status": 429, "means": "the caller exceeded a limit" },
  "invalid_query": { "type": "https://example.com/problems/invalid-query", "status": 400, "means": "a query parameter failed validation" },
  "meter_locked": { "type": "https://example.com/problems/meter-locked", "status": 409, "means": "the meter is being edited elsewhere" }
}

The code, the type URI and the status are contract. A client switches on them, and changing any of the three changes behavior in code you cannot see. The title and detail are prose, so you can reword them, translate them, or make them clearer without a sensibly written client noticing.

The means column exists for the diff. One line per code lets a tool report a meaning rewritten in place. That change is otherwise invisible. The code is the same, the status is the same, and the definition underneath has moved.

Diff the catalogs and grade the findings

The diff grades each change as breaking, deprecated, additive or review, and only a breaking change stops a release.

if (!now) {
  const plan = retired[code]
  findings.push(plan
    ? { level: 'deprecated', code, detail: `replaced by ${plan.replaced_by}, sunset ${plan.sunset}` }
    : { level: 'breaking', code, detail: 'removed with no deprecation entry' })
  continue
}

A removal is breaking unless the catalog carries a deprecation entry naming a replacement and a sunset date. That single rule turns retirement from a decision somebody makes in a pull request into a decision that leaves a record, and the record is what the Sunset header is generated from.

Splitting a code is a removal. It rarely feels like one, because the new codes are more precise and the docs read better. The diff sees the old name disappear and says so, which is the warning the reviewer needed.

Keep the old code returning alongside the new ones for the length of the window. Two codes for one condition is untidy and it is also what lets a client move on its own schedule. Remove the old one after the sunset date, in a release that announces it.

Check it worked

Diff the two published catalogs and read the grades.

node demo.mjs
1.4.0 -> 1.5.0
  breaking    invalid_query     status moved 400 to 422
  deprecated  meter_locked      replaced by meter_suspended, sunset 2027-03-01
  additive    meter_suspended   new code
  additive    quota_exhausted   new code
  review      rate_limited      meaning was rewritten in place
breaking findings: 1

Four of the five changes are fine and one is not. The status move is the interesting failure: the code is unchanged, the body still validates, and a client with a 400 branch and a 422 branch takes the wrong one. A schema diff would have passed this release.

The deprecated row is the one to copy into your own process. It names a replacement and a date, both machine readable, so the Sunset header and the migration note in the release can be generated rather than written by hand and forgotten.

node --test diff.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 130.155883

When it goes wrong

Clients break on a release the diff passed. The catalog is not the thing the service returns. Generate it from the code paths, or assert in tests that every code the service can emit appears in the file.

Calls still arrive on the old code after the deprecation window has passed. Nobody read the headers.3 Measure usage per code before removal, and treat the sunset date as the earliest removal rather than the scheduled one.

A code is reused for a different failure. Somebody found a name that fit and the old meaning was retired years ago. Never reuse a name, because a client written against the first meaning is still in production somewhere.

The catalog and the OpenAPI document disagree. Two files describe the same contract. Generate the description from the catalog, and the disagreement becomes impossible rather than merely unlikely.

When not to do this

Do not gate a release on the review findings. A reworded meaning is often a clarification, and a gate that fires on every editorial pass teaches people to skip the gate. Keep the block on removals and status moves.

Do not promise stability for detail. A client parsing free text is already broken, and saying so in the documentation costs nothing while protecting your ability to improve the message.

Do not retire a code because it looks untidy. The cost of a dead entry is a line in a file, and the cost of removal is spread across every client that still handles it. Retire when the code is wrong, not when the list is long. A catalog nobody can read is a documentation problem, and grouping by resource fixes it without touching a single client.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. RFC 9457 obsoleted RFC 7807 in July 2023. Its own list of what changed has three entries, the first of them a registry of common problem types, each named by a URI. The registry opened with one entry, about:blank, whose title is See HTTP Status Code and whose recommended status code is N/A. It is also the value a problem document takes when it names no type at all. The first registered problem type is the one that means nothing beyond the status code, so the absence of a type now has a URI, a title, and a reference. ↩︎ Back to text

  2. The two headers that announce one retirement carry their dates in different formats. RFC 9745 makes Deprecation a structured field whose value must be a Date, and its example is @1688169599, which the text translates as Friday, June 30, 2023 at 23:59:59 UTC. RFC 8594 makes Sunset an HTTP-date, and its example is Sat, 31 Dec 2018 23:59:59 GMT. Both examples land on the last second of a day, and a client that reads both headers parses two date formats to learn one thing. ↩︎ Back to text

  3. RFC 8594 is Informational rather than a standard. It says the Sunset value SHOULD name a time still to come, and that clients SHOULD treat it as a hint. A resource is not guaranteed to survive until the time it names or to vanish after it. The RFC then allows that, since the information comes from the resource itself, it does have some credibility. A header whose own specification rates it at some credibility is a fair description of a date nobody read. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.