A Boring API Error Model That Survives Five Years of Clients
Stable APIs usually fail on inconsistent error contracts. This post proposes a boring structure using status, problem code, human-safe detail, and request identifiers to reduce contract drift over years.
A Boring API Error Model That Survives Five Years of Clients
The first version of an API error response often looks reasonable:
json
{ "error": "email is invalid" }
Then clients begin depending on it.
One client compares the text. Another translates it. A third retries every error. Support asks for an occurrence ID. Security asks why database details appeared in a response. A new endpoint returns a different shape because a different developer wrote it.
Nothing dramatic happened. The API simply accumulated ambiguity.
The error model I prefer separates four concerns:
- HTTP status describes the broad protocol outcome.
- Problem code gives clients a stable machine-readable reason.
- Human detail explains this occurrence without becoming a contract.
- Request ID lets an operator find the evidence.
Everything else is an extension of that model.
Use HTTP semantics, but do not make clients guess
HTTP status codes are useful because generic infrastructure already understands them. A cache, proxy, browser, SDK, and monitoring system know that 404, 409, 429, and 503 are not interchangeable.
But a status code rarely tells an application enough.
409 Conflict could mean:
- an idempotency key was reused with a different payload
- an email address already exists
- a resource version is stale
- an order can no longer be cancelled
- a state transition is invalid
The client should not reverse-engineer which one happened from English prose.
That is what a stable problem code is for.
json
{ "type": "https://api.example.com/problems/order-not-cancellable", "title": "Order cannot be cancelled", "status": 409, "code": "order_not_cancellable", "detail": "Orders in the shipped state cannot be cancelled.", "request_id": "req_01JQ8M...", "retryable": false }
The structure follows RFC 9457 Problem Details for HTTP APIs, with a few deliberate extensions. RFC 9457 defines the standard type, title, status, detail, and instance members and allows problem-specific extension fields.
I use code because many clients find a short stable token convenient. The type URI remains the globally namespaced identity and can point to documentation.
The code is the contract; the wording is not
A client may safely branch on:
text
order_not_cancellable
It should not branch on:
text
Orders in the shipped state cannot be cancelled.
Titles and details change because wording improves, localization is added, or the product becomes more precise. A machine-readable code should change only when the semantic condition changes.
This gives me a useful compatibility rule:
Adding a new optional field is usually compatible. Changing what an existing problem code means is a breaking change.
I keep codes lowercase, explicit, and domain-shaped:
text
validation_failed resource_not_found version_conflict idempotency_key_reused payment_method_declined rate_limited dependency_unavailable
I avoid codes such as bad_request_17. They are stable in the same way a storage-unit key is stable: technically identifiable, operationally unhelpful.
Keep the public problem separate from the internal error
An internal error needs enough context for an engineer.
A public problem needs enough information for a client without exposing implementation details.
Those are different audiences.
Suppose a repository returns:
text
pq: duplicate key value violates unique constraint "users_email_key"
The service can wrap that error with context, classify it as email_already_registered, and return a safe problem:
json
{ "type": "https://api.example.com/problems/email-already-registered", "title": "Email is already registered", "status": 409, "code": "email_already_registered", "detail": "An account already uses this email address.", "request_id": "req_01JQ8M...", "retryable": false }
The database error belongs in restricted operational evidence, not in the response.
RFC 9457's security considerations make the same warning: problem details need scrutiny because they can leak information about the system or its users.
A small catalog in Go
I like a central problem catalog because it makes codes reviewable and stops handlers from inventing response contracts inline.
go
package problems import "net/http" type Code string type Definition struct { Code Code Status int Type string Title string Detail string Retryable bool } var Catalog = map[Code]Definition{ "validation_failed": { Code: "validation_failed", Status: http.StatusBadRequest, Type: "https://api.example.com/problems/validation-failed", Title: "Request validation failed", Detail: "One or more fields are invalid.", }, "version_conflict": { Code: "version_conflict", Status: http.StatusConflict, Type: "https://api.example.com/problems/version-conflict", Title: "Resource version conflict", Detail: "The resource changed after it was read.", }, "dependency_unavailable": { Code: "dependency_unavailable", Status: http.StatusServiceUnavailable, Type: "https://api.example.com/problems/dependency-unavailable", Title: "A required service is unavailable", Detail: "The request could not be completed at this time.", Retryable: true, }, }
The application returns a typed error rather than an HTTP response:
go
type Error struct { Code Code Detail string Err error } func (e *Error) Error() string { if e.Detail != "" { return e.Detail } if e.Err != nil { return e.Err.Error() } return string(e.Code) } func (e *Error) Unwrap() error { return e.Err }
The HTTP boundary owns the mapping:
go
type Problem struct { Type string `json:"type"` Title string `json:"title"` Status int `json:"status"` Code Code `json:"code"` Detail string `json:"detail"` RequestID string `json:"request_id,omitempty"` Retryable bool `json:"retryable"` }
This keeps transport semantics out of repositories and most domain code. A domain operation says what failed. The HTTP layer says how that failure appears on the wire.
The same pattern exists in `api-toolkit`. Its `ProblemCatalog` maps stable codes to status, type, title, default detail, retryability, documentation, and logging policy.
Validation needs a structured extension
A single detail string is not enough when several fields are wrong.
I use one top-level problem type plus a bounded list of field errors:
json
{ "type": "https://api.example.com/problems/validation-failed", "title": "Request validation failed", "status": 400, "code": "validation_failed", "detail": "One or more fields are invalid.", "request_id": "req_01JQ8M...", "retryable": false, "errors": [ { "pointer": "/email", "code": "invalid_format", "detail": "Enter a valid email address." }, { "pointer": "/display_name", "code": "too_long", "detail": "Use at most 80 characters." } ] }
The pointer identifies the field. The nested code lets a client choose behaviour. The detail remains human-readable.
I keep this list bounded. Returning ten thousand validation errors for a ten-thousand-row import is technically thorough and operationally strange. Batch workflows usually need a separate error-report model.
Retryability must be more precise than 5xx
Clients often implement this rule:
text
retry every 5xx
That is not terrible as a fallback, but it is not enough.
A 500 caused by an invariant violation may repeat forever. A 409 caused by optimistic concurrency may succeed after rereading. A 429 normally needs a delay. A 503 may represent a transient dependency failure or a maintenance window.
I include a conservative retryable field and use protocol headers where they apply. Retry-After is useful for rate limiting and temporary unavailability; its semantics are defined in HTTP Semantics, RFC 9110.
A client still needs a retry policy:
- cap attempts
- use exponential backoff with jitter
- respect
Retry-After - retry only idempotent operations, or use an idempotency key
- stop on a non-retryable problem code
- surface persistent failure instead of hiding it forever
retryable: true is permission to consider a retry, not a command to create an infinite loop.
The request ID should cross the support boundary
A good error response helps both the client and the operator.
json
{ "code": "dependency_unavailable", "request_id": "req_01JQ8M..." }
The client can show a calm message and retain the request ID. Support can paste that ID into a search. Engineering can connect the response to logs, traces, and deployment metadata.
I avoid exposing stack traces, database identifiers, internal hostnames, or raw exception text as a substitute for correlation. More detail is not the same as more diagnosable.
Log the problem code once
At the HTTP boundary, I log bounded fields such as:
json
{ "event": "http request", "http.route": "/v1/orders/{order_id}/cancel", "http.status_code": 409, "problem.code": "order_not_cancellable", "request_id": "req_01JQ8M...", "deployment": "orders-api@7d42c1a" }
This makes dashboards and alerts stable.
I do not use detail as a metric dimension. It may contain occurrence-specific text and can explode cardinality.
I also do not alert on every 4xx. A validation error is normally a client outcome, not an incident. A sudden change in a specific code's rate may still be useful, but that is a product or abuse signal rather than a generic server alarm.
Test the contract like an API, not an implementation
Error models drift when tests assert only the status code.
I add contract tests that verify:
- content type is
application/problem+json - the problem code is stable
- status in the body matches the HTTP status
- internal error text is absent
- request ID is present
- validation pointers are correct
- retryability is conservative
- unknown errors become a generic internal problem
- clients ignore added optional fields
A golden JSON response can be appropriate here. The point is not snapshot testing every comma. The point is noticing when someone changes a machine-readable contract.
I also test the mapping layer separately from handlers. One table-driven test over all registered definitions catches missing titles, invalid statuses, duplicate codes, and inconsistent type URIs cheaply.
Version problem types only when semantics change
I do not put v1 into every problem code by habit.
A problem type can evolve by gaining optional documentation or extension fields without becoming a new semantic condition. Clients are expected to ignore fields they do not understand.
I create a new code when the client should make a different decision.
For example:
text
payment_failed
may be too broad if clients need to distinguish:
text
payment_method_declined payment_provider_unavailable payment_requires_action
That is not merely more detail. Those conditions imply different next steps, so they deserve different identities.
The model is deliberately boring
A durable API error model does not need a clever hierarchy of exception classes or a new envelope for every endpoint.
It needs:
- correct HTTP semantics
- stable problem identities
- safe human details
- explicit retry guidance
- field-level validation where needed
- request correlation
- one mapping boundary
- contract tests
Boring error models age well because they separate semantics from wording.
Five years later, clients may be written in languages you did not predict by teams you have never met. They should still be able to answer the only question that matters after an error:
What happened, and what can I safely do next?
Need help with this in your own stack?
If reliability or delivery friction is slowing your team down, we can fix it in focused steps.
Related posts
Dependency Updates as Routine Maintenance, Not Emergency Work
Small, regular upgrades reduce security and stability risk. This post explains why a maintenance loop matters more than any one bot, and how update tooling must be paired with controlled delivery and rollback.
Before You Add a Queue: The Failure Modes You Are Agreeing to Own
Asynchronous processing introduces concrete operational risks like retries, ordering, poison failures, and visibility debt. This post argues for adding queues only when asynchronous ownership is part of the product, not a default optimization.