A status code is part of the API contract
HTTP status codes are not decorative labels for logs. Clients use them to decide whether to display a validation message, refresh credentials, retry later, or treat an operation as complete. A service that returns 200 for every outcome forces each client to parse an application-specific error field and often causes retry loops or silent data loss. Choose the status code that describes the HTTP outcome, then provide a stable application error code for the domain detail.
Document response behavior next to the endpoint contract. State which codes are possible, whether the body is returned on errors, and whether a request can be retried safely. Keep the contract consistent across versions. Changing a 409 to a 400 may look minor in a server diff, but it can change a client's control flow and user experience.
Separate validation from resource state
Use 400 when the request cannot be understood as a valid input for the endpoint, such as malformed JSON or an invalid field shape. Use 422 when your API distinguishes syntactically valid input from domain validation failures, such as a date range that violates a business rule. The exact choice matters less than consistency and documentation. Return field-level details that a client can act on, but do not echo secrets or internal parser traces.
Use 404 when the requested resource is not available to the caller under the endpoint's semantics. In some security-sensitive APIs, returning 404 for an unauthorized resource avoids revealing whether it exists. Use 409 when the request conflicts with the current state, such as a duplicate idempotency key with different parameters or a version mismatch. A 409 tells the client that a retry without understanding state will not fix the problem.
Use 5xx responses for server responsibility
A 500 means the server failed in a way it did not classify. It should be rare in a mature API because expected dependency failures can be mapped to more useful categories. Use 502 when a gateway receives an invalid response from an upstream service, 503 when the service is temporarily unable to handle the request, and 504 when an upstream operation exceeded a gateway deadline. The distinction helps clients and operators choose a retry strategy.
Do not turn every exception into 503. A programming error should produce an internal alert and a generic 500 response, while a dependency outage can be retried with backoff if the operation is safe. Include a Retry-After header only when the server has a meaningful recovery estimate. Never include stack traces, SQL, secret names, or provider response bodies in a production error response.
Design a stable error envelope
A useful error body has a stable code, a human-readable message, optional field details, a request ID, and perhaps a documentation link. The code is for program logic; the message can be localized or improved. Keep field paths precise and avoid returning a different shape for every framework exception. A client should be able to handle an unknown code by showing the message and preserving the request ID for support.
Separate safe details from internal diagnostics. A code such as invalid_email is safe when paired with a field path. A code such as database_unique_constraint_users_email may reveal implementation details and should be translated into a product-level conflict. Use an allowlist for error details rather than serializing an exception object. Test that a nested error cannot accidentally include an authorization header or a raw query.
Make retries and idempotency explicit
Clients often retry 408, 429, 502, 503, and 504 when the operation is safe. A POST that creates a resource may not be safe unless the endpoint supports an idempotency key. Return a clear conflict when the same key is reused with different parameters. For a timeout, the client may not know whether the server completed the operation, so the response contract should provide a way to query the result.
Use consistent headers for rate limits and retry guidance if your API exposes them. A client should not infer that every 500 is retryable or that every 400 is permanent without reading the documented contract. Record the logical operation ID across retries so server logs show one user action rather than a confusing collection of attempts.
Test the contract at the edge
Write contract tests for each endpoint's success and failure statuses, error codes, headers, and redaction behavior. Include malformed JSON, missing fields, wrong types, expired credentials, forbidden actions, duplicate requests, dependency timeouts, and unknown routes. Verify that a reverse proxy does not rewrite a meaningful status or strip a retry header. Test content negotiation and an empty error body when a client does not accept JSON.
Review status choices during incident retrospectives. If clients repeatedly retry a permanent error, the contract may be misleading. If operators cannot distinguish a provider outage from an application bug, the mapping may be too broad. Good HTTP semantics reduce support work because clients can make safe decisions without reverse-engineering your server's intentions.
Document the client decision for every error
For each status family, state whether a client should retry, refresh credentials, correct input, wait for a quota window, or show a support path. Keep the response shape stable enough for machines while giving humans a request ID and useful summary. Exercise malformed input, dependency timeout, authorization failure, and rate limiting in contract tests. A clear error taxonomy prevents every failure from becoming an opaque 500 and keeps clients from retrying permanent errors into an outage.
Implementation example
Make the status code and error body agree on the client's next action. Include a stable machine-readable type, human-safe title, request ID, and field details only for validation failures. Do not turn authentication, permission, quota, conflict, and dependency failures into one generic 500; clients cannot recover correctly when the taxonomy is vague.
{"type":"https://api.example/errors/rate-limit","title":"Too many requests","status":429,"request_id":"req_123","retry_after":30}Verify and troubleshoot
Build a client decision table for success, validation, authentication, authorization, not found, conflict, rate limit, dependency timeout, and server failure. Test malformed input, expired credentials, duplicate writes, provider outages, and a request that times out after the side effect commits. Assert that retries stop for permanent errors and preserve the request ID for support.
Operations and recovery
Version error types deliberately and keep response shapes backward compatible. Monitor status classes, error types, retry amplification, and top user-impacting routes. When a new failure appears, add it to the contract and runbook rather than changing the code to hide it. Never return stack traces, SQL, provider tokens, or secret configuration in a client error.
References and further reading
Use RFC 9457 Problem Details, RFC 9110 HTTP Semantics, and the API provider's retry contract. Document which methods are safe, idempotent, or conditionally retryable.