# 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.

Source: https://voxgig.com/howto/evolve-an-error-contract-without-breaking-clients

- Audience: api-producer
- Level: advanced
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://www.rfc-editor.org/rfc/rfc9457.html),[^1] and the
retirement headers come from [RFC 9745](https://www.rfc-editor.org/rfc/rfc9745.html) and
[RFC 8594](https://www.rfc-editor.org/rfc/rfc8594.html).[^2]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A catalog diff in CI](https://www.rfc-editor.org/rfc/rfc9457.html#name-type) | You publish a list of codes and want the rule enforced on every change | A file to keep current, and a gate somebody will want to override | The errors are described only inside the OpenAPI document |
| [Additive-only codes](https://docs.stripe.com/error-codes) | You value never breaking a client over ever tidying the catalog | A catalog that grows and never shrinks, with dead codes in it forever | The list has grown past what a reader can hold |
| [oasdiff on the error schemas](https://www.oasdiff.com/docs) | The errors live in the description and you already diff it for breaking changes | It compares schemas, so a meaning rewritten under one code passes | The contract is the code list rather than the schema |
| [Versioning errors with the API](https://spec.openapis.org/oas/v3.1.0.html#info-object) | A major version is already planned and clients expect migration work | Every client migrates on your schedule, whether or not they benefit | You 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.

```json title="catalog-1.4.json"
"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.

```js title="diff.mjs"
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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test diff.test.mjs
```

```text output
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.

## Related how-tos

- [Return validation errors a client can map to a form](/howto/field-level-validation-errors-clients-can-map)

- [Verify every error response matches one schema](/howto/verify-every-error-response-matches-one-schema)

## Last verified

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

[^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](https://www.rfc-editor.org/rfc/rfc9457.html#section-4.2.1) 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.

[^2]: The two headers that announce one retirement carry their dates in different formats. [RFC
9745](https://www.rfc-editor.org/rfc/rfc9745.html#section-2.1) 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](https://www.rfc-editor.org/rfc/rfc8594.html#section-3) 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.

[^3]: [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594.html#section-3) 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.