Software Engineering · Documentation
Stable Error Codes as Documentation Identifiers
Human-readable messages change with wording and locale. A code that never changes, is never reused, and is documented once is what lets callers and support engineers agree on which failure occurred.
A failure has to be described twice: once to a person, who needs enough context to act, and once to a program, which needs a value it can branch on. Conflating the two produces the most common interface mistake in error design, where a client matches on the message string because no other identifier was offered, and the server’s next copy edit breaks the client.
The distinction is old and it is visible in C. A failing call sets errno to a symbolic constant and the program branches on that constant; strerror produces a message for a human, and that message is locale-dependent and free to change between library versions. Nothing in POSIX obliges the wording to be stable, and no correct program parses it. The same separation appears at the HTTP layer, where a status code is the coarse machine-readable signal and a body carries the explanation.
Message text carries context that the code cannot
The two artifacts answer different questions. A message answers “what happened here, in this request, and what should the operator look at first”, and its value comes from specifics: the field that failed validation, the limit that was exceeded, the host that could not be reached. Those specifics change per occurrence, which means the string is not a constant and cannot be matched reliably even within one version.
A code answers “which of the documented failures is this”, and its value comes from being a constant across occurrences, versions, and locales. Codes travel well: they appear in logs, in metrics labels, in support tickets, and in client code. Messages travel badly in all four, because aggregating logs by message produces a new bucket for every interpolated value and because a translated message is unrecognisable to the code that produced it.
There is a third property that only the code can carry: whether the failure is retryable. A message can say “try again later” but a program cannot act on prose. A boolean or a small enumerated class attached to the code — permanent, transient, and possibly a distinct “requires user action” — is what makes an automated retry policy decidable without a table of string comparisons.
What the registry entry has to state
An error code is only as useful as its documentation, and the entry has a small set of required elements. The cause: the condition that produces the code, stated in terms of the interface rather than the implementation. The remedy: what the caller or operator can change. The retry classification. The scope: whether the condition is specific to a request, to a credential, or to the whole service. The status mapping, when the interface is HTTP. The version in which the code was introduced, and, if applicable, the version in which it was deprecated.
The remedy is the element most often missing and the one that determines whether the registry reduces support load. The useful form is an action the reader can take: which header to supply, which permission to request, which quota to raise, which field to correct. “Contact support” is a valid remedy for a narrow class of conditions and a non-answer for everything else.
Scope is subtler than it looks. A code that means “this request was malformed” is per-request, and the caller fixes it by changing the request. A code that means “this account is suspended” persists across requests, and retrying with the same credentials is futile. A code that means “the upstream dependency is unavailable” is transient and service-wide. Three codes with the same retryable flag but different scope lead to different client behaviour, and the entry has to say which applies.
Namespacing and the no-reuse rule
Codes accumulate, and an unbounded namespace of short uppercase words collides eventually. Two services that both define NOT_FOUND are indistinguishable in a shared log, and a client library that aggregates errors from several backends cannot dispatch on them. Prefixing by domain — AUTH_, RATE_, STORAGE_ — keeps the namespace partitioned and makes the code self-describing at the point where it appears in a log line.
Allocation is the part that needs a rule rather than a convention. New codes are appended; a code is never deleted and its meaning is never changed; a withdrawn code is marked deprecated and reserved forever. The rule exists because codes leak into places that cannot be migrated: a dashboard alert, a runbook, a customer’s script, a mobile application that will not be updated for years. Reusing a retired code is indistinguishable, from the outside, from changing the meaning of a live one.
Within a namespace, a numeric or mnemonic suffix gives a stable allocation order without requiring a central authority for every new code. Blocks of numbers per subsystem are the usual arrangement; the block boundary is documented once and the subsystem owns the interior. The block is a coordination device, not a technical requirement, and its size should be chosen so that exhaustion is a distant problem rather than a yearly one.
The registry is a generated artifact, not a page
The failure mode of a hand-maintained error table is the same as any hand-maintained table: it lists codes that no longer exist and omits the ones added last week, and the omission is invisible because the page still renders. The arrangement that removes the failure is to make the registry the source of truth and generate everything else from it.
# errors.yaml
- code: AUTH_TOKEN_EXPIRED
status: 401
retryable: false
since: 2.3.0
cause: The access token's expiry time has passed.
remedy: Obtain a new token with the refresh credential and retry once.
- code: RATE_QUOTA_EXCEEDED
status: 429
retryable: true
since: 2.1.0
cause: The account's request quota for the current window is exhausted.
remedy: Wait for the interval named in the Retry-After header.
From one file of this shape, a build can emit the reference page, the constants in each client language, and a totality check. The check is the part that keeps the contract honest: every constant defined in the code must have an entry, and every entry must be reachable from at least one code path.
import yaml, myapp.errors
registry = {e['code'] for e in yaml.safe_load(open('errors.yaml'))}
emitted = {c.value for c in myapp.errors.ErrorCode}
assert emitted <= registry, f"undocumented: {sorted(emitted - registry)}"
assert registry <= emitted, f"unused entries: {sorted(registry - emitted)}"
The second assertion is the one that gets argued about, because an entry can be legitimately ahead of the code that raises it, or left behind by a deleted code path. It is still worth running, since a code documented but never raised is a promise the interface does not keep, and a reader who plans around it is planning around nothing.
HTTP problem types and the mapping problem
RFC 9457 defines a problem detail object for HTTP APIs: a type URI that identifies the problem type, a title that is stable for that type, a status that mirrors the HTTP status code, a detail that is specific to this occurrence, and an instance URI for this occurrence. The type URI is the stable identifier, and the specification expects it to resolve to human-readable documentation of the type; detail is the occurrence text.
The mapping from error code to HTTP status is lossy in one direction. Many codes map to 400, since most client errors are malformed requests, so the status cannot distinguish them; the code must. It is lossy in the other direction too: 429 can mean a rate limit, a concurrency limit, or a quota exhaustion, three conditions with different remedies. A registry that stores the status per code keeps the mapping explicit, and a client that needs to distinguish the three reads the code rather than the status.
Resist the temptation to mint a status code per condition. Status codes are a fixed, three-digit namespace defined by the HTTP specifications, and intermediaries — proxies, CDNs, client libraries — already interpret them. An unrecognised status is treated as a class by its first digit, which loses the distinction the new code was meant to create.
Two further details belong in the mapping table. Retry-After applies only to codes marked retryable, and its absence on a transient condition is a defect in the response. And authentication failures split: a missing credential and an expired one are both 401, but only the first is fixed by logging in, and clients that treat them identically loop through a refresh that cannot succeed.
Codes that fail in opposite directions
Two degenerate registries appear repeatedly. The first assigns a code per call site, so a service ends up with hundreds of codes that differ only in the function that produced them, most without documentation, and the code stops being a classification. The second assigns one code per category and lets the message carry the distinction, which returns the interface to string matching with extra steps.
The usable middle is a code per condition the caller can act on differently. That criterion is operational rather than taxonomic, and it settles most cases. If two failures have the same remedy and the same retry classification, they can share a code; if a caller would branch differently, they cannot.
The other recurring defect is the code that changes meaning. It usually happens during a refactor, when a code is repurposed because its old meaning seemed close enough. From the outside this is indistinguishable from a bug: a client that handled the old condition now handles a new one with the same handler. The no-reuse rule prevents the accidental version and a documented deprecation prevents the deliberate one, which is why the registry’s append-only discipline and the changelog’s Removed entries have to agree about the same event.
Codes are also regularly pasted into search boxes, which makes them an entry point into the documentation rather than a detail of it. Whether that search finds the entry depends on tokenisation and ranking, a problem distinct enough to be treated on its own in documentation search for error strings.
References
- RFC 9457: Problem Details for HTTP APIs — IETF
- POSIX.1-2017: errno.h — The Open Group
- Linux man-pages: errno(3) — Linux man-pages project