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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A catalog diff in CI | 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 | 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 | 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 | 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.
"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.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
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 -
The two headers that announce one retirement carry their dates in different formats. RFC 9745 makes
Deprecationa 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 makesSunsetan HTTP-date, and its example isSat, 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 -
RFC 8594 is Informational rather than a standard. It says the
Sunsetvalue 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