Errorstaxonomyerror categories4xx5xxfault attribution

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.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
Which distinct kinds of failure can this API produce, and does the contract let a client tell them apart mechanically?
Consumers
The client's dispatch logic: a retry layer that must not retry validation errors, a login flow that must distinguish "bad token" from "no permission", a batch importer deciding which rows to fix and which to resubmit, an alerting rule deciding whose pager rings.
The promise
A designed taxonomy guarantees that every failure lands in exactly one documented category, and that the category alone tells the client three things: whose fault it was, whether retrying can help, and what kind of fix is needed.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

The taxonomy, with what each category tells the client
CategoryTypical statusWhose fixRetry same request?Client's correct move
validation400 / 422Caller (the input)Never — it will fail identicallyFix the field named in details, resubmit (see Validation Errors: Feedback, Not Verdicts)
authentication401Caller (the credential)After re-authRefresh the token / re-login, then retry once
authorization403Caller's admin (the grant)Not until permissions changeSurface "you need access" — do not loop
not_found404Caller (the reference)Never (usually)Drop or refresh the stale reference
conflict409 / 412Depends on stateAfter re-reading stateRe-fetch, reconcile, maybe done already (see Optimistic Concurrency: Versions and If-Match)
rate_limited429Caller (the pace)After the stated delayHonor Retry-After; slow down (see The Rate-Limit Contract)
dependency502 / 503 / 504Provider (capacity/downstream)Yes, with backoff + budgetBackoff, retry, then degrade (see Retryability: Telling Clients What To Do Next)
internal500Provider (a bug)Maybe once — then stopReport 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.

  • 401 means re-authenticate and retry; 403 means 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-403 for resource-existence privacy is fine as a documented, uniform policy.
  • Well-formed-but-unfulfillable requests deserve 409/422 with a specific code — never a reflexive 400 or leaked 500.

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.

Per-endpoint classification: three dialects of "no"
1GET /projects/9 (no access) → 403
2GET /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 the
7# caller's fault — retries never fire
One mapping boundary, uniform policy
1domain error category status code
2NotPermitted(hidden) → not_found 404 resource_not_found
3NotPermitted(known) → authorization 403 missing_permission
4ProjectArchivedconflict 409 project_archived
5DirectoryTimeoutdependency 503 upstream_unavailable
6 + Retry-After: 2
7# policy decided once, documented once,
8# enforced by the type system

The 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.
  • conflict is 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.

  1. 1
    Team → handlers: each endpoint picks status codes locally; no shared mapping exists.
  2. 2
    API → clients: the same logical failure surfaces as 400, 403, 404 and 500 depending on the endpoint.
  3. 3
    Clients → dispatch: give up on categories and branch on "2xx or not"; retry policy degenerates to "retry everything" or "retry nothing".
  4. 4
    "Retry everything" client → API: hammers validation failures thirty times each; "retry nothing" client → users: shows hard errors for blips.
  5. 5
    Provider → incident: a downstream outage reported as 4xx pages nobody; discovery arrives via a customer email hours later.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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]]).
What it costs
  • • 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.

Misconceptions

Claim
“Getting each endpoint's status code roughly right is enough — clients read the docs per endpoint.”
Reality
Clients write one dispatch table for the whole API (usually in a shared SDK layer). Any per-endpoint deviation either breaks that table or gets ignored by it; the taxonomy is only as strong as its uniformity.
Claim
“4xx means the client is wrong, so we should never see 4xx alerts.”
Reality
4xx *attribution* can itself be your bug. A dependency timeout returned as 400, a broken token validator returning 401 for valid tokens, or a deploy that tightens validation all produce 4xx storms that are entirely the provider's fault. Watch 4xx by category, not as someone-else's-problem.
Claim
“Conflict (409) is just another validation error — the request could not be processed.”
Reality
Validation says "this request is malformed and always will be"; conflict says "this request was fine but the world disagrees right now". One is dead on arrival, the other often succeeds after a re-read — or already succeeded, in the retry case. Idempotent clients depend on the distinction.