An Error Taxonomy Clients Can Branch On
Validation, authentication, authorization, not-found, conflict, rate-limit, dependency, internal: eight categories with different owners, different fixes and different retry rules. Collapse them and every client guesses; distinguish them and clients can be correct.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Eight failures that must not look alike
Consider the same POST /projects/{id}/members failing eight ways: the email is malformed (fix the input), the token expired (re-authenticate), the caller lacks permission (ask an admin), the project does not exist (stale reference), the member is already in the project (conflict — maybe fine!), the caller is over its rate limit (wait), the downstream directory service is down (retry later), and a null-pointer bug fired (provider's problem entirely). Eight failures, eight different correct client reactions.
A taxonomy is not bureaucracy — it is the minimum structure that lets those eight reactions be written as code instead of guesses. Each category answers three questions mechanically: who owns the fix (caller, caller's admin, provider), is retrying useful (never, after re-auth, after waiting, immediately with backoff), and is the request itself wrong or just the timing. Notice that the categories are about *recovery*, not about blame: conflict is often a success in disguise ("already a member" during a retry), which is why it must never be lumped with validation failures.
| Category | Typical status | Whose fix | Retry same request? | Client's correct move |
|---|---|---|---|---|
validation | 400 / 422 | Caller (the input) | Never — it will fail identically | Fix the field named in details, resubmit (see Validation Errors: Feedback, Not Verdicts) |
authentication | 401 | Caller (the credential) | After re-auth | Refresh the token / re-login, then retry once |
authorization | 403 | Caller's admin (the grant) | Not until permissions change | Surface "you need access" — do not loop |
not_found | 404 | Caller (the reference) | Never (usually) | Drop or refresh the stale reference |
conflict | 409 / 412 | Depends on state | After re-reading state | Re-fetch, reconcile, maybe done already (see Optimistic Concurrency: Versions and If-Match) |
rate_limited | 429 | Caller (the pace) | After the stated delay | Honor Retry-After; slow down (see The Rate-Limit Contract) |
dependency | 502 / 503 / 504 | Provider (capacity/downstream) | Yes, with backoff + budget | Backoff, retry, then degrade (see Retryability: Telling Clients What To Do Next) |
internal | 500 | Provider (a bug) | Maybe once — then stop | Report with request_id; do not hammer |
The load-bearing boundaries
Two boundaries in the taxonomy do the most work, and both are routinely blurred. The first is 401 vs 403 — "I do not know who you are" versus "I know exactly who you are, and no". Clients react oppositely: a 401 triggers token refresh and a silent retry; a 403 must *not* (the refreshed token will have the same permissions — retrying turns one denial into a refresh-loop). Providers sometimes deliberately return 404 instead of 403 to avoid confirming a resource exists to unauthorized callers; that is a legitimate choice, but it must be a documented policy, not endpoint-by-endpoint mood (see Authorization Design in the Contract).
The second is 4xx vs 5xx as fault attribution, because automation branches on the class. Retry layers retry 5xx and not 4xx; alerting pages on 5xx rates and dashboards 4xx rates; API gateways count 5xx against *your* SLO. Every misclassification therefore misroutes a machine decision: return 500 for a malformed request and your on-call gets paged for the caller's typo — and their retry layer re-sends the garbage with backoff, thirty times. Return 400 for your own database timeout and the client's code tells the user *they* did something wrong, retries never fire, and your error budget looks clean while users suffer.
When a request is well-formed but cannot be honored — valid JSON asking to add a member to a project that is archived — the taxonomy needs a considered answer, not reflexes. A 409 (state conflict) or a 422 with a specific code both work; 400 ("your syntax is wrong") and 500 ("we broke") are both lies, and each misroutes a different machine.
401means re-authenticate and retry;403means stop — collapsing them creates refresh-loops or dead-end login prompts.- The 4xx/5xx boundary routes retries, pages and SLO accounting; misclassification misroutes all three at once.
- Deliberate
404-instead-of-403for resource-existence privacy is fine as a documented, uniform policy. - Well-formed-but-unfulfillable requests deserve
409/422with a specific code — never a reflexive400or leaked500.
Design the taxonomy once, then map — do not invent per endpoint
The taxonomy fails in practice not because teams cannot list eight categories, but because each endpoint classifies independently. One handler returns 403 for a missing project ("you cannot see it"), another returns 404 for a permission failure ("pretend it is not there"), a third returns 400 for both. Each choice is defensible in isolation; together they mean the client's dispatch table needs a per-endpoint appendix — which no client will write, so they collapse everything into "did it 2xx or not".
The fix is structural: the taxonomy is a shared library concern, not a handler concern. Handlers throw typed domain errors (ProjectArchived, QuotaExceeded); one boundary maps types to categories, categories to status codes, and attaches the envelope from The Error Model: Structure Over Apology. New failure modes force a conscious classification decision at review time — "which category is this?" — instead of a reflexive res.status(500) at 6pm.
1GET /projects/9 (no access) → 4032GET /invoices/12 (no access) → 404 "not found"3POST /projects/9/run (archived) → 400 "bad request"4POST /members (dir. svc down) → 400 "invalid user"5# the client cannot write a correct dispatch table;6# a *dependency outage* is being reported as the7# caller's fault — retries never fire1domain error category status code2NotPermitted(hidden) → not_found 404 resource_not_found3NotPermitted(known) → authorization 403 missing_permission4ProjectArchived → conflict 409 project_archived5DirectoryTimeout → dependency 503 upstream_unavailable6 + Retry-After: 27# policy decided once, documented once,8# enforced by the type systemThe bad side's worst bug is the quiet one: a downstream outage classified as 400 tells every client "you sent garbage" — retry layers stand down and the outage is prolonged by correct client behavior. Classification is fault attribution, and automation believes it.
Key points
- A taxonomy exists so clients can branch mechanically: each category fixes who owns the fix, whether retrying helps, and what recovery looks like.
- 401 (re-auth and retry) and 403 (stop) demand opposite client behavior; collapsing them produces refresh-loops or dead ends.
- The 4xx/5xx boundary is fault attribution that machines act on: retry layers, pagers and SLO math all branch on the class.
- Misclassifying a dependency failure as 4xx suppresses client retries and hides the outage from your own alerting simultaneously.
- Classify in one shared boundary via typed domain errors — per-endpoint classification always diverges into dialects.
conflictis often "already done" during a retry; it must stay distinguishable from validation failure or idempotent clients break.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → handlers: each endpoint picks status codes locally; no shared mapping exists.
- 2API → clients: the same logical failure surfaces as 400, 403, 404 and 500 depending on the endpoint.
- 3Clients → dispatch: give up on categories and branch on "2xx or not"; retry policy degenerates to "retry everything" or "retry nothing".
- 4"Retry everything" client → API: hammers validation failures thirty times each; "retry nothing" client → users: shows hard errors for blips.
- 5Provider → incident: a downstream outage reported as 4xx pages nobody; discovery arrives via a customer email hours later.
- Retry behavior inverts: permanent failures get hammered, transient ones surface to users — the worst of both directions at once.
- Alerting and SLOs go blind: misclassified 5xx-as-4xx hides real outages, 4xx-as-5xx burns error budget on caller typos.
- Every client accumulates a private, partially wrong map of "what this API's codes really mean", which hardens into load-bearing folklore.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Adopt the eight-category taxonomy (or a documented variant) API-wide, and publish the category → status → retry table in the docs as a contract clause.
- • Route all classification through one boundary that maps typed domain errors to categories; make handlers unable to emit raw statuses.
- • Decide privacy-motivated deviations (404-for-403) once, as uniform documented policy — never per endpoint.
- • Pair each category with its recovery affordance: `Retry-After` on 429/503, the offending field on validation, the current state on conflict.
- • Dashboard error rates *by category*, not just by status: `dependency` rising is capacity or a downstream incident; `validation` rising after a client release is their bug shipping.
- • Alert on classification anomalies — 4xx spikes correlated with downstream latency usually mean a dependency failure is being misfiled as a client error.
- • Sample the `internal` category weekly: it should be near-empty, and each entry is either a bug or an unclassified failure mode waiting to be named.
- • New failure modes get new `code` values inside existing categories — additive and safe if clients fall back on the category/status class for unknown codes.
- • Adding a whole new category is a bigger event: old clients will bucket it by status class, so choose the status class as the safe fallback meaning.
- • Reclassifying an existing failure (a 400 that should have been 503) is a behavioral breaking change — clients built retry logic on the old class — and deserves a deprecation window like any other (see [[deprecation]]).
- • A shared classification boundary is friction: new failure modes require a mapping decision and review instead of a one-line status write.
- • Eight categories cost documentation and testing; the alternative — two categories, "worked" and "did not" — costs every client its correctness.
- • Uniform policies (like 404-for-403) trade debuggability for privacy; integrators will file "bug: resource missing" tickets for permission problems.