Home » Designing API Errors That Clients Can Handle

Designing API Errors That Clients Can Handle

2 comments
Three geometric tokens are arranged beside and inside a service hatch, with arrows pointing toward a blank envelope.

An API error should tell the client what kind of failure occurred, what it can do next, and how to identify the occurrence for support. Keep the HTTP status meaningful, use a stable machine-readable problem type, and separate fields used by code from prose intended for people. Return only information the caller is entitled to see.

For a JSON HTTP API without an established error format, Problem Details is a useful starting point. RFC 9457 defines application/problem+json, identifies problems with type, and provides title, status, detail, and instance members. Extensions can carry application-specific data. It does not design the application’s recovery behavior for you.

Start with the client’s next decision

Imagine a client that lets someone cancel an order. Between reading the order and submitting the cancellation, another process ships it. The server rejects the operation because cancellation is no longer valid for that state.

The client needs to distinguish this from a malformed request or a temporary gateway failure. It should refresh the order and show the available actions, rather than silently retrying the same cancellation.

For this example, choose 409 Conflict. That choice follows the state-conflict meaning in HTTP Semantics, section 15.5.10. It is an API design decision for this scenario, not a rule that every failed business operation must return 409.

A synthetic response could be:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
  "type": "https://api.example.com/problems/order-state-conflict",
  "title": "Order state conflict",
  "status": 409,
  "detail": "Refresh the order before trying this action again.",
  "instance": "urn:uuid:8d517dca-6b1e-48df-9f78-4c2f658ecce0",
  "current_state": "shipped",
  "allowed_actions": ["view"]
}

The example domain and occurrence identifier are fictional. A real API should own its problem-type namespace and provide maintained documentation for client developers.

The server must establish that the caller may see the order before returning its state. This error is not an alternative to authorization.

Define a small, stable contract

For this problem, the API’s own contract can specify:

Element Meaning in this API Client use
HTTP 409 The operation conflicts with the current order state. Select a state-reconciliation path.
type Stable identity of this particular problem. Match a known recovery behavior.
current_state State the caller is authorized to observe. Update the explanation and refresh view.
allowed_actions Actions currently offered by this endpoint’s policy. Present supported choices, subject to fresh server checks.
instance Identifier for this occurrence. Include it in a support request.

allowed_actions is a hint about the observed state, not a capability token. Another transition may occur before the next request, so the server must validate the action again.

Keep type identity stable when rewriting explanatory text or localizing messages. RFC 9457 makes type the primary identifier, discourages parsing detail, and requires clients to ignore unrecognized extensions. Its status member is advisory; when a server emits it, the value must match the actual response status. These rules let the human explanation change without becoming a hidden API for string matching.

In this API, client code can act on the known combination of status and type:

def next_action(status, content_type, body):
    media_type = content_type.split(";", 1)[0].strip().lower()
    if media_type != "application/problem+json":
        return "generic_error"
    if not isinstance(body, dict):
        return "generic_error"
    if (
        status == 409
        and body.get("type")
        == "https://api.example.com/problems/order-state-conflict"
    ):
        return "refresh_order"
    return "generic_error"

The type and subtype are case-insensitive under HTTP Semantics, section 8.3.1, so Application/Problem+JSON must take the same path. The function lowercases only the type/subtype portion; parameter values can have different case rules.

This deliberately small function assumes the HTTP library has already parsed JSON successfully when appropriate. Production code also needs a body-size limit, JSON parsing failure handling, and a usable fallback message. An HTML error page from a proxy should not crash the error handler while it tries to recover from the original failure.

Describe recovery without promising success

“Refresh the order” is useful because it describes a safe next read. “Retry” is incomplete unless the API defines whether repeating the operation is safe and under what conditions. The error body cannot establish whether a previous timed-out request already took effect.

Use different handling for different failures. A validation response may highlight fields the user can correct. An authentication failure may trigger the application’s sign-in flow. A service failure may require waiting or showing an unavailable state. Avoid mapping every non-2xx result to the same automated retry.

For a validation problem, an extension can identify fields and stable validation codes. Design those codes around decisions the client actually makes. Do not expose an entire internal exception hierarchy merely because it exists in the server framework.

If an error provides a URL, decide who may follow it and what it exposes. The example uses an opaque occurrence URI, so it does not create a public incident page. A resolvable occurrence URL needs its own access controls and retention policy.

Keep debugging information behind the server boundary

Build error responses from an explicit set of public fields. Avoid serializing exception objects directly. Stack traces, database queries, filesystem paths, internal hostnames, tokens, and customer data can appear in exceptions even when the top-level message seems harmless.

RFC 9457’s security discussion warns that error details can disclose implementation or sensitive information. In practice, a support identifier can connect a safe client response to a restricted server-side event. The logs still require their own redaction and access policy; moving secrets from the response to an unrestricted log does not solve the problem.

Do not echo rejected input by default. If a field contains a secret or personal information, a message such as “the supplied value is invalid” plus a field identifier may be enough. The exact response should reflect what the caller already knows and needs to correct.

Validate the response and the consumer separately

The synthetic fixture executed ten test methods with Python 3.14.2:

  • A valid 409 response satisfied the example’s response contract.
  • The response check accepted mixed-case and uppercase media types, including a parameterized value.
  • The client selected the same recovery path for those equivalent media types.
  • A 200 HTTP status paired with body status 409 was rejected.
  • A string "409" in the body was rejected by the server-side contract check.
  • An unexpected stack field was rejected by the example’s public-field allowlist.
  • Changing detail did not change the client’s recovery choice.
  • An unknown extension was ignored by the client.
  • An unknown problem type used the generic fallback.
  • An HTML gateway response used the generic fallback.

All ten passed. The two media-type regression methods produced four failing subtests against the earlier case-sensitive implementation, then passed after the correction. The server-side allowlist is a policy of this example, while the client remains tolerant of new extensions. The checks validate that specific contract and handler; they are not a complete RFC conformance suite or an audit proving that arbitrary prose cannot leak sensitive data.

Review errors as part of the interface whenever adding an endpoint or changing a state transition. The useful test is whether a client can make the intended decision from stable fields, cope with an unfamiliar response, and obtain support without receiving internal details it should never have had.

Standards and example checked October 28, 2026. No live API was called; all responses and identifiers are synthetic.

You may also like