An API error is part of the product interface. A client must decide whether to fix input, refresh state, request permission, wait and retry, show a message, or contact support. A response containing only 400, 500, or an English sentence forces that client to guess. Parsing prose works until punctuation changes, localization arrives, or two different failures share the same wording.
A usable error contract separates concerns. HTTP status communicates the broad outcome to generic infrastructure. A stable problem type and application code identify what software can do. Structured fields locate invalid input. Safe human text explains the current occurrence. Retry metadata and an observability identifier support recovery without exposing internal state.
The thesis is simple: errors should be modeled as versioned data, not serialized exceptions. The server may have thousands of internal failure paths, but the public API should expose a deliberate, finite vocabulary tied to client decisions.
Design for Decisions, Not Exception Classes
Begin by listing the decisions a client can reasonably make. A form can highlight a field. An optimistic editor can reload a stale resource. A batch worker can delay after throttling. An administrator can present a support reference. If two server failures lead to the same safe client action, they may share a public code even when their internal exception classes differ.
Conversely, one HTTP status can cover several actionable problems. 409 Conflict might mean an idempotency key was reused with different content, a resource version is stale, or a unique business name is taken. Those situations require different remedies, so status alone is insufficient.
A public taxonomy should be:
- Stable: codes do not change when internal classes, databases, or messages change.
- Finite: clients can document and test meaningful branches instead of handling arbitrary strings.
- Action-oriented: distinctions correspond to different safe behavior.
- Non-sensitive: codes and details do not reveal secrets, account existence, query text, or stack frames.
- Extensible: new optional metadata and new codes can be introduced without breaking tolerant clients.
Do not expose a generic INTERNAL_ERROR for every failure and call the contract complete. A generic code is appropriate when the caller cannot act beyond retrying according to policy or reporting the incident, but expected domain rejections deserve specific identities. At the other extreme, avoid one code per validation rule implementation. POSTAL_CODE_NOT_SUPPORTED_IN_PROMOTION_V2 may hard-code temporary policy into a permanent public API.
Keep the internal cause chain in protected telemetry. Map it at a boundary to the public problem. This mapping is also a security control: an ORM error, filesystem path, or upstream body never reaches the client merely because an exception was not anticipated.
Use a Problem Details Envelope Consistently
An RFC 9457-style Problem Details document provides a recognizable JSON envelope with standard members: type, title, status, detail, and instance. Use the application/problem+json media type and add namespaced or clearly documented extension members for application needs.
{
"type": "https://api.example.test/problems/validation-failed",
"title": "Request validation failed",
"status": 422,
"detail": "Two request fields need attention.",
"instance": "/problems/01J8JTY9CN9X",
"code": "VALIDATION_FAILED",
"errors": [
{
"code": "REQUIRED",
"pointer": "/shippingAddress/postalCode",
"message": "Enter a postal code."
},
{
"code": "MINIMUM",
"pointer": "/items/0/quantity",
"message": "Quantity must be at least 1.",
"minimum": 1
}
],
"retryable": false,
"traceId": "4f2c7a0d2a8b49ab"
}
type identifies the class of problem and should be a stable URI. It can resolve to documentation, but clients must not require a network fetch to interpret the response. title is a short human summary associated with the type. status repeats the HTTP status to make stored bodies self-describing. detail describes this occurrence safely. instance identifies the occurrence as a URI reference; it need not reveal an internal log key.
The extension code is a compact stable identifier convenient for generated SDKs and switch statements. If both type and code are present, document their relationship and keep it one-to-one unless there is a clear hierarchy. Otherwise clients will wonder which one is authoritative.
Return the same envelope across endpoints, gateway-generated rejections, and expected dependency failures where possible. A proxy’s HTML 502 page violates the application contract even if the status is correct. Gateways can emit a minimal problem document and preserve a trace header, while application-specific details remain unavailable.
Successful responses and errors should not share a 200 OK envelope such as { success: false }. Doing so defeats caches, monitoring, browser behavior, and generic HTTP clients. Use HTTP semantics honestly, then add domain precision in the body.
Align Status, Code, and Retry Semantics
HTTP status describes a category, not the entire recovery algorithm. Choose it according to what happened at this HTTP interaction, while a stable code supplies domain meaning.
| Situation | Typical status | Example code | Likely client action |
|---|---|---|---|
| Malformed JSON or invalid syntax | 400 |
MALFORMED_REQUEST |
Fix serializer or request construction |
| Well-formed input violates field rules | 422 |
VALIDATION_FAILED |
Show field guidance |
| Missing or invalid authentication | 401 |
AUTHENTICATION_REQUIRED |
Establish or refresh credentials |
| Authenticated but not permitted | 403 |
ACTION_FORBIDDEN |
Disable action or request access |
| Resource absent or intentionally concealed | 404 |
RESOURCE_NOT_FOUND |
Stop or correct identifier |
| Current resource state conflicts | 409 |
VERSION_CONFLICT |
Reload, merge, and resubmit |
| Preconditions fail | 412 |
PRECONDITION_FAILED |
Refresh representation or condition |
| Rate limit reached | 429 |
RATE_LIMITED |
Wait according to bounded guidance |
| Temporary service inability | 503 |
DEPENDENCY_UNAVAILABLE |
Retry only when operation permits |
401 should include the appropriate authentication challenge when the scheme requires it. 405 should advertise allowed methods. 429 and 503 may include Retry-After. These headers are part of the contract; do not bury every protocol fact in JSON.
Retryability is not a universal property of a status. A 503 response to a safe read is usually a retry candidate. The same response after a non-idempotent operation may leave the outcome ambiguous. A body member such as retryable should mean “the server believes repetition can be useful,” not “repeat immediately regardless of method, deadline, or idempotency.” Document whether it applies to the identical request and whether an idempotency key is required.
Prefer machine-readable delay guidance when clients need more than Retry-After, for example retryAfterSeconds, bounded to a documented range. Do not expose an internal queue estimate as a promise. Clients should still apply their own deadline and jitter. Permanent failures should explicitly report retryable: false or omit retry guidance according to one consistent convention.
Locate Field Errors Precisely
Validation errors are most useful when they identify every independently correctable issue in one response. A top-level “invalid input” forces a user through repeated submit-and-fail cycles. Return a bounded array of field problems when evaluation is safe and deterministic.
Use a standard location syntax. JSON Pointer is a strong choice because /items/0/quantity unambiguously identifies a request-body location and escapes special characters by defined rules. If errors can refer to query parameters, headers, or path variables, include a source member rather than overloading body pointers:
{
"code": "INVALID_DATE_RANGE",
"source": "query",
"parameter": "through",
"message": "The end date must not be before the start date."
}
Each field item needs its own stable code. Presentation clients can map REQUIRED, INVALID_FORMAT, MINIMUM, or domain codes to controls without parsing message. Optional constraint metadata such as minimum, maximum, allowedValues, or expectedFormat should be typed and documented. Never echo a rejected secret or an arbitrarily large value.
Cross-field failures may identify several pointers or use a form-level location. For example, a date range rule can list both /from and /through, while a promotion eligibility failure may have no single field. Do not attach a global business rejection to an unrelated control merely to satisfy a UI shape.
Cap the number of returned errors and indicate truncation. An attacker can otherwise submit a huge array designed to produce a huge response and expensive validation traversal. Validation order should be deterministic so clients and snapshot tests do not observe random field ordering. Distinguish malformed input, where field traversal may be impossible, from well-formed content that violates constraints.
The server remains authoritative. Publishing field constraints improves experience but does not transfer enforcement to clients, and a code such as INVALID_FORMAT should not promise that passing syntax establishes business validity.
Work Through a Checkout Failure Contract
Consider POST /v1/checkouts, which validates a request, verifies inventory, and creates a checkout under an idempotency key. Several failures can occur, and each should drive a different client path.
First, invalid quantities produce 422 VALIDATION_FAILED with pointers. The user can edit the request. No retry is useful without changed input.
Second, inventory can change between page display and checkout. Return 409 INVENTORY_CHANGED with safe structured context:
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
Traceparent: 00-4f2c7a0d2a8b49ab13d9e62a91b1d230-1fd2a4aa42ea4c11-01
{
"type": "https://api.example.test/problems/inventory-changed",
"title": "Inventory changed",
"status": 409,
"detail": "One item is no longer available in the requested quantity.",
"instance": "/problems/01J8JTY9CN9X",
"code": "INVENTORY_CHANGED",
"items": [
{
"lineId": "line-2",
"availableQuantity": 1
}
],
"retryable": false,
"traceId": "4f2c7a0d2a8b49ab"
}
The client can update that line and ask the user to confirm. Marking this response retryable would encourage an identical request that cannot succeed.
Third, reusing an idempotency key with different canonical request content produces 409 IDEMPOTENCY_KEY_REUSED. The remedy is to fix client key management, not invent a new key automatically and risk an unintended second checkout. A duplicate with identical content should return the original outcome rather than an error.
Fourth, a temporary inventory dependency failure may produce 503 DEPENDENCY_UNAVAILABLE, a bounded Retry-After, and retryable: true. The client may repeat only if its idempotency contract and remaining deadline permit. It should preserve the same key across attempts.
This worked set demonstrates why a single CHECKOUT_FAILED code is insufficient. Validation, changed business state, key misuse, and temporary unavailability all prevent checkout, but the usable recovery differs. It also demonstrates the opposite limit: internal DNS failures and upstream socket resets can map to the same public availability problem when the client action is identical.
Separate Stable Meaning from Localized Presentation
Codes, pointers, numeric constraints, and retry fields are language-neutral. title, detail, and field message are presentation. A client must never branch on localized text or assume the text remains byte-for-byte stable.
There are two valid localization ownership models. A server can localize messages using a negotiated locale and include Content-Language; this is useful for thin clients and server-rendered experiences. Alternatively, a client can localize stable codes using its own catalog, which keeps UI voice and release timing under client control. APIs can support both by supplying safe default text while declaring codes authoritative.
Locale selection should follow a documented mechanism such as user preference or Accept-Language, with a deterministic fallback. Do not accept arbitrary locale values and pass them directly into file paths or template loaders. Cache behavior must vary correctly when representations differ by language.
Message templates need controlled parameters. Return typed values separately when a client needs them:
{
"code": "MAXIMUM",
"pointer": "/items/0/quantity",
"message": "Quantity must be no more than 25.",
"maximum": 25
}
Avoid sending a format string plus arbitrary interpolation arguments that clients execute. Keep personally identifiable data, credentials, raw database values, and upstream messages out of localized detail. A support-facing explanation can live in protected logs linked by traceId.
Changing wording is normally backward compatible; changing a code’s meaning is not. Documentation should say that prose can improve without versioning so consumers do not accidentally freeze it through snapshots.
Correlate Safely and Observe the Taxonomy
Every error occurrence should connect to server telemetry. A trace context header is ideal when distributed tracing is supported; a compact traceId extension is convenient for support interfaces. Generate or validate identifiers at a trusted edge, propagate them to dependencies, and return a safe form to the caller.
Do not let callers choose an identifier that is interpolated into logs without validation, and do not expose sequential database keys or log storage paths as instance. The public occurrence URI may be opaque and non-resolvable. If it resolves for authorized operators, enforce access independently from possession of the URL.
Log the public problem type, code, HTTP status, route template, deployment version, trace ID, and internal cause classification. Do not log full bodies or high-cardinality user values by default. Metrics can count code and status when the code vocabulary is bounded; trace and logs carry occurrence detail.
Dashboards should reveal changes in error mix. A stable total 4xx rate can hide a surge in VERSION_CONFLICT; a stable 5xx rate can hide one dependency replacing another as the cause. Track mapping failures too: if an internal exception falls through to a generic problem, operators need to know without exposing it publicly.
Alerting should correspond to ownership. Expected validation errors may inform product quality but should not page an infrastructure team individually. A sudden generic internal-error rate, sustained throttling, or a contract serializer failure can justify operational action. Error contracts improve observability precisely because failures are categorized before they reach dashboards.
Evolve and Test the Contract
Treat problem schemas like successful response schemas. Publish them in the API description, generate representative examples, and run consumer-oriented compatibility checks. Adding an optional extension is usually safe for clients that ignore unknown fields. Removing a field, changing its type, reassigning a code, or changing retry semantics can break behavior even if JSON still parses.
Clients need a documented unknown-code policy. A robust client branches on codes it understands and falls back by HTTP status plus retryable for unknown ones. The server should not require an exhaustive switch over a vocabulary that can only grow through a full API version. Conversely, never reuse an old code for a new action merely to avoid adding one.
Automated tests should verify:
- Every non-success response uses the promised media type and required envelope.
- Body
statusmatches the HTTP status. - Each public code maps to one documented type, status range, retry meaning, and owning team.
- Validation pointers resolve against the submitted document or use a documented non-body source.
- Error counts, strings, and extension collections obey size limits.
- Sensitive fixtures never appear in
detail, field messages, headers, or serialized causes. - Locale changes alter presentation but not codes, pointers, or numeric metadata.
- Gateways and authentication middleware emit compatible minimal problems.
- SDKs preserve unknown extension fields or at least ignore them without failing.
Keep golden examples for semantic review, but avoid snapshots that make harmless prose edits look breaking. Schema tests catch shape; table-driven mapping tests catch meaning. A mapping test should feed representative internal outcomes into the boundary and assert the public problem without depending on a real database or network.
Failure injection is valuable at integration boundaries. Make a dependency time out, return malformed content, or reject a request, then verify the API maps it to the intended finite vocabulary and retains trace linkage. Fuzz malformed inputs to ensure the error serializer itself never throws or creates an unbounded response.
The tradeoff of a deliberate taxonomy is governance. Someone must own codes, review additions, document client action, and retire obsolete meanings carefully. That work prevents each endpoint from inventing an incompatible dialect. The result is an API where people receive useful safe explanations, programs receive stable decisions, and operators receive correlation without turning implementation failures into a public interface.