Building a Machine-Readable Error Registry
This guide is part of the RFC 7807 Problem+JSON Implementation reference within Error Contracts & Resilience Mapping. It shows how to collapse a scattered set of error strings and status codes into one machine-readable registry — then generate server constants and client exception classes from it, and gate CI so the two can never drift apart.
Once you have settled on stable type URIs and a set of extension members, a new problem appears: keeping every service and every SDK agreeing on the same taxonomy. When each team hard-codes type strings, the taxonomy fragments — one service ships validation-failed, another validation_error, and consumers cannot key on either reliably. A registry makes the taxonomy a single reviewable artifact and the source that all code is generated from.
Decision trigger
Introduce a registry when any of these hold:
- Two services return the same failure under different
typeURIs, or the same URI with differenttitle/status. - A client throws a generic error because the
typeit received is not in its (hand-maintained) list. - A code review cannot answer “what is the full set of errors this API can return?” from one place.
The fix is one registry file, code generation for both sides, and a CI drift check — the same governance mindset applied to specs in Contract Linting & Governance and to shared envelopes in Standardizing Error Responses Across Microservices.
The registry file
One file, reviewed like any other contract. YAML is convenient; JSON works equally well:
# errors/registry.yaml — the single source of truth
version: 1
namespace: "https://errors.api-contract.com/v1"
problems:
validation_failed:
type: "https://errors.api-contract.com/v1/validation-failed"
title: "Your request parameters did not validate"
status: 422
docs: "https://errors.api-contract.com/v1/validation-failed"
insufficient_funds:
type: "https://errors.api-contract.com/v1/insufficient-funds"
title: "The account balance is too low for this operation"
status: 409
docs: "https://errors.api-contract.com/v1/insufficient-funds"
rate_limited:
type: "https://errors.api-contract.com/v1/rate-limited"
title: "Rate limit exceeded"
status: 429
docs: "https://errors.api-contract.com/v1/rate-limited"
Validate the registry itself against a schema so entries stay well-formed:
# errors/registry.schema.yaml (JSON Schema 2020-12)
$schema: "https://json-schema.org/draft/2020-12/schema"
type: object
required: [version, namespace, problems]
properties:
version: { type: integer }
namespace: { type: string, format: uri }
problems:
type: object
additionalProperties:
type: object
required: [type, title, status, docs]
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer, minimum: 400, maximum: 599 }
docs: { type: string, format: uri }
Numbered implementation steps
Step 1 — Generate server constants and a factory (Node)
Emit constants plus a factory so a service physically cannot construct a problem that is not in the registry:
// scripts/gen-server.mjs
import { readFileSync, writeFileSync } from 'node:fs';
import { parse } from 'yaml';
const reg = parse(readFileSync('errors/registry.yaml', 'utf8'));
const entries = Object.entries(reg.problems)
.map(([key, p]) =>
` ${key}: { type: ${JSON.stringify(p.type)}, ` +
`title: ${JSON.stringify(p.title)}, status: ${p.status} },`)
.join('\n');
writeFileSync('src/generated/problems.ts',
`// AUTO-GENERATED from errors/registry.yaml — do not edit.
export const PROBLEMS = {
${entries}
} as const;
export type ProblemKey = keyof typeof PROBLEMS;
export function problem(key: ProblemKey, detail?: string) {
const p = PROBLEMS[key];
return { type: p.type, title: p.title, status: p.status, detail };
}
`);
A handler now writes res.status(p.status).json(problem('insufficient_funds', 'balance 3.20 < 9.99')) — the type, title, and status are guaranteed to match the registry.
Step 2 — Generate client exception classes (Python)
Emit one exception class per entry and a lookup keyed on the type URI:
# scripts/gen_client.py
import yaml, pathlib
reg = yaml.safe_load(open("errors/registry.yaml"))
lines = [
"# AUTO-GENERATED from errors/registry.yaml - do not edit.",
"class ApiProblem(Exception):",
" def __init__(self, detail=None): self.detail = detail",
"",
]
registry_map = []
for key, p in reg["problems"].items():
cls = "".join(part.capitalize() for part in key.split("_")) + "Error"
lines += [f"class {cls}(ApiProblem):",
f" TYPE = {p['type']!r}",
f" STATUS = {p['status']}",
""]
registry_map.append(f" {p['type']!r}: {cls},")
lines += ["BY_TYPE = {"] + registry_map + ["}"]
pathlib.Path("client/generated_errors.py").write_text("\n".join(lines) + "\n")
Consumers resolve a payload with BY_TYPE.get(problem["type"], ApiProblem)(problem.get("detail")) — the typed routing described in Type URI Conventions for Problem Details, now generated rather than hand-maintained.
Step 3 — CI check: every thrown type exists in the registry
Grep the server source for emitted type URIs and assert each one is registered:
#!/usr/bin/env bash
# ci/check-thrown-types.sh
set -euo pipefail
# All type URIs the registry knows about
known=$(yq -r '.problems[].type' errors/registry.yaml | sort -u)
# All type URIs actually emitted in source
thrown=$(grep -rhoE 'https://errors\.api-contract\.com/v1/[a-z-]+' src/ | sort -u)
missing=$(comm -23 <(echo "$thrown") <(echo "$known"))
if [ -n "$missing" ]; then
echo "Thrown type(s) not in registry:"; echo "$missing"; exit 1
fi
Step 4 — CI check: registry matches the OpenAPI discriminator
Assert the registry’s type set equals the OpenAPI discriminator mapping so the spec and the code never diverge:
# ci/check_discriminator_drift.py
import sys, yaml
reg = yaml.safe_load(open("errors/registry.yaml"))
spec = yaml.safe_load(open("openapi/openapi.yaml"))
reg_types = {p["type"] for p in reg["problems"].values()}
mapping = spec["components"]["schemas"]["Problem"]["discriminator"]["mapping"]
spec_types = set(mapping.keys())
if reg_types != spec_types:
print("Registry vs OpenAPI discriminator drift:")
print(" only in registry:", reg_types - spec_types)
print(" only in spec: ", spec_types - reg_types)
sys.exit(1)
print("registry and discriminator are in sync")
RFC 9457 alignment
RFC 9457 obsoletes RFC 7807 but keeps the application/problem+json media type; the registry operationalizes its intent.
| Requirement | RFC 9457 clause | How the registry satisfies it |
|---|---|---|
type is a stable URI |
§3.1.1 | One canonical URI per entry, reviewed centrally |
| Types SHOULD resolve to docs | §3.1.1 | Each entry carries a docs link |
| A registry of problem types is encouraged | §5 | Standalone registry file is the source of truth |
title is a stable human summary |
§3.1.2 | title generated from the registry, not per service |
| Media type unchanged | §6.1 | Factory always emits application/problem+json |
Safety and caching implications
Because server and client are generated from the same file, a taxonomy change is a single reviewable diff — you can see exactly which errors were added, retitled, or removed, and gate the change like any other contract update. Generated artifacts must be committed or built in CI, never hand-edited, or the drift checks lose meaning. Removing a type from the registry is a breaking change for any deployed client that still routes on it, so treat deletions as a versioned deprecation and keep the entry (with its docs) alive through the window. The registry file itself is a build-time input, not a runtime response, so it has no caching concern; the per-request problem responses it generates stay no-store.
SDK and codegen downstream effect
The registry becomes the join point between the spec and every SDK:
- // hand-maintained, drifts silently between server and client
- const TYPES = { insufficient: 'https://errors.../insufficient-funds' };
- class InsufficientFundsError extends Error {}
+ // generated from errors/registry.yaml — server, spec, and client agree
+ import { PROBLEMS } from './generated/problems';
+ import { BY_TYPE } from './generated/errors';
+ // adding a new problem regenerates constants, exceptions, and the
+ // discriminator mapping in one step; CI blocks any partial change
When a new problem is added, one edit regenerates server constants, client exceptions, and the discriminator mapping together, and the CI checks refuse a change that updates only one side.
Common mistakes
| Mistake | Correct approach |
|---|---|
Hard-coding type strings in each service |
Generate constants from one registry file |
| Hand-maintaining client exception classes | Generate them from the same registry |
| Registry and OpenAPI discriminator maintained separately | Add a CI drift check asserting the two type sets match |
| Deleting a registry entry to “clean up” | Deprecate with a version window; deletions break deployed clients |
| Editing generated files directly | Regenerate from the registry; commit or build artifacts in CI |
FAQ
Why keep errors in a registry instead of scattering constants across services?
A single registry is the one source of truth for every type URI, title, and status. Server constants and client exception classes are generated from it, so a service cannot invent an undocumented type and a consumer cannot fall behind the server. Scattered constants drift the moment two teams change them independently, which is exactly what produces mismatched type URIs and clients that fall back to generic errors.
How do I detect drift between the registry and the OpenAPI spec?
Add a CI job that loads the registry and the OpenAPI discriminator mapping and asserts the two type sets are identical. If the spec lists a type the registry lacks, or the registry defines a type the discriminator omits, the job fails the build before the mismatch reaches a generated SDK. This keeps the contract, the server, and the clients provably in sync on every pull request.
Can the registry live inside the OpenAPI file instead of a separate file?
It can, but a standalone registry is easier to consume from non-OpenAPI tools, CI scripts, and multiple services, and it keeps the error taxonomy reviewable on its own. The practical pattern is a standalone registry that a build step projects into the OpenAPI discriminator mapping, so the two never diverge while each stays independently usable.
Related
- RFC 7807 Problem+JSON Implementation — up-link: the end-to-end reference this registry industrializes
- Error Contracts & Resilience Mapping — up-link: the parent reference for error envelopes and resilience mapping
- Type URI Conventions for Problem Details — the
typeURI scheme the registry enforces - Adding Extension Members to Problem Details — the extension members each registry entry can carry
- Contract Linting & Governance — the wider governance pipeline these drift checks plug into