Backend Systems · API Styles
Error Models Across REST, gRPC, and GraphQL
How each style encodes failure, why status-code categories are not enough, and how a client decides whether an error is safe to retry.
An error response has four audiences, and they need different things from it. The calling program needs a stable machine-readable classification and enough structure to decide whether to retry, fail, or ask the user to act. The developer integrating against the API needs a message that names the violated constraint. The operator needs a correlation identifier that ties the response to server-side traces. The end user needs a sentence that does not mention connection pools. Most error formats satisfy one or two of these and quietly fail the rest, and the failures show up as client code that branches on message strings.
Each API style provides a different amount of structure for this, and the mappings between them are lossy in ways that matter for retry logic. Understanding the losses is the difference between a client that recovers from a transient fault and one that either gives up too early or amplifies an outage.
What a caller has to know
Three facts determine what a client can do with an error. The first is whether the request reached the server and had an effect. A connection refused before the request was written is unambiguously safe to retry; a request that timed out after being written is not, unless the operation is idempotent or carries an idempotency key. The second is whether the condition is transient. Overload, leader election, and packet loss resolve on their own; a malformed payload does not. The third is whether the client is permitted to try again, which depends on its retry budget and not on the server at all.
A usable error model communicates the first two explicitly and leaves the third to the client. HTTP status codes communicate them coarsely: 4xx generally means the request is wrong and repeating it unchanged will fail again, 5xx means the server could not handle a request that may well have been valid. The coarse split is useful and insufficient. A 503 with Retry-After is transient; a 500 caused by a null pointer on a specific input is not; a 409 from optimistic concurrency is transient if the client re-reads and retries; a 404 is permanent for that URI but may be temporary for a resource that is still being created. The status code gives a category, and the body must give the diagnosis.
HTTP: status classes plus a machine-readable body
The status code remains the primary signal because intermediaries consume it: caches, load balancers, and retry middleware act on the class before any body is parsed. That constrains the design. An application error that a proxy should treat as permanent belongs in 4xx, even when the root cause is a server-side bug, and an error that a proxy should retry belongs in 5xx or 429. Putting a domain error in 200 with an error body breaks every intermediary that was doing its job.
RFC 9457 defines a body format that carries the diagnosis without inventing a vocabulary: type as a URI identifying the problem category, title as a short human-readable summary, status mirroring the code, detail for this occurrence, and instance identifying this occurrence. Extension members carry structured data, and the media type is application/problem+json, so a client can recognise the body even when it arrives from a proxy rather than the origin.
{
"type": "https://errors.example.com/insufficient-funds",
"title": "Insufficient funds",
"status": 409,
"detail": "The account balance is 1200 minor units; the transfer requires 5000.",
"instance": "/transfers/018f3a2c",
"balance": 1200,
"required": 5000
}
Branching on type rather than on detail is the point of the format. A type URI is stable and resolvable to documentation; detail is prose for a human and may change at any time. The status member duplicates the header for clients that log only the body, and it should agree with the header, because a mismatch is a bug that confuses every layer.
Validation failures need more structure than a single problem object can carry. A request with six bad fields should report six errors with their locations, not the first one encountered, or the client enters a fix-and-resubmit loop. The usual pattern is an extension member holding an array of field paths and codes, with the top-level type naming the general category. Field paths are part of the contract, so renaming a request field is a breaking change to the error body as well as to the success body.
Retry-After is the one standard mechanism for telling a client when to come back. It accepts either a number of seconds or an HTTP-date, and it is defined for 503 and for 429 in the core specification, though servers send it with other transient codes. Its absence on a 503 is an omission: the client must guess a backoff, and guesses tend to be either too short, amplifying the overload, or too long, stalling recovery.
gRPC: a closed enumeration with typed details
gRPC statuses are a fixed set of seventeen values, including OK. The non-OK values are CANCELLED, UNKNOWN, INVALID_ARGUMENT, DEADLINE_EXCEEDED, NOT_FOUND, ALREADY_EXISTS, PERMISSION_DENIED, RESOURCE_EXHAUSTED, FAILED_PRECONDITION, ABORTED, OUT_OF_RANGE, UNIMPLEMENTED, INTERNAL, UNAVAILABLE, DATA_LOSS, and UNAUTHENTICATED, numbered 1 through 16. The enumeration is small enough to reason about exhaustively, which is its main advantage: a generated client can switch over all of them and the compiler will complain when a new SDK version adds one.
The categories encode retry guidance more precisely than HTTP classes do. UNAVAILABLE and RESOURCE_EXHAUSTED are the overload signals. ABORTED means the operation was aborted by a concurrent conflict, which is retryable at a higher level after re-reading state. DEADLINE_EXCEEDED means the caller’s own deadline passed; retrying is pointless unless the caller extends the deadline, and it may duplicate an effect that was already committed. FAILED_PRECONDITION means the system is not in a state that permits the operation, which is different from INVALID_ARGUMENT, where the request itself is malformed; conflating them causes clients to retry a call that will never succeed until some external condition changes.
Structured detail rides alongside the status in google.rpc.Status, which carries a code, a message, and a list of Any-packed detail messages. The standard detail types cover error identifiers (ErrorInfo with a reason and domain), retry timing (RetryInfo with a duration), field violations (BadRequest with a list of field violations), and resource identifiers (ResourceInfo). A client that understands RetryInfo can honour a server-specified delay without parsing prose, and a client that does not can fall back to the status code.
The trouble is that many gRPC servers return only the code and a message, because populating details requires a dependency and some discipline. A client library that wants to distinguish “quota exceeded for this project” from “server is out of capacity” has only RESOURCE_EXHAUSTED in both cases. The detail message is where that distinction belongs, and its absence pushes clients toward string matching on the message, which breaks on the first localisation or rewording.
GraphQL: errors are data, partial success is normal
A GraphQL response is HTTP 200 in the common failure case, with an errors array and whatever data could be resolved. Each error has a message, optional locations, optional path identifying the field, and optional extensions for anything application-specific. Because the transport status does not change, a client cannot use HTTP-level retry middleware at all; it must parse the body to know whether anything failed.
The design follows from the query model. One document can touch many resolvers, and some can fail while others succeed. Reporting a single status code for a partially successful response is impossible without discarding the successful parts, so the errors travel in-band and the data is nulled where necessary. The cost is that retryability has no standard encoding. The GraphQL specification does not define error codes in extensions, so each server invents its own, and clients end up with per-server adapters.
Two conventions help. A stable extensions.code string mirroring the gRPC vocabulary gives clients something to switch on, and an extensions.retryable boolean removes the need to infer. Neither is standard, which means they must be documented and kept stable as part of the schema. There is a subtlety with null propagation: because a failing non-null field nulls its nearest nullable ancestor, an error deep in a tree can erase a large part of the response, and the path in the error is the only way to know which field caused it.
Transport-level failures still happen around GraphQL: a 400 for an invalid document, a 401 from an authentication proxy, a 502 from a load balancer. A client must handle both layers, and the layers use different formats. That dual model is the main operational argument for keeping a GraphQL endpoint behind an API gateway that normalises transport errors.
Mapping between the models is lossy
Teams that expose the same functionality over more than one style end up writing a mapping, and the mapping cannot be a bijection. The usual table runs: INVALID_ARGUMENT to 400, UNAUTHENTICATED to 401, PERMISSION_DENIED to 403, NOT_FOUND to 404, ALREADY_EXISTS to 409, ABORTED to 409, FAILED_PRECONDITION to 412 or 409, RESOURCE_EXHAUSTED to 429, UNIMPLEMENTED to 501, UNAVAILABLE to 503, DEADLINE_EXCEEDED to 504, INTERNAL to 500.
Every many-to-one arrow loses information. HTTP 409 must carry both “the resource already exists” and “the write conflicted with a concurrent one”, and those have different client behaviour: the first is permanent for that identifier, the second is retryable after a re-read. HTTP 403 covers both “the token lacks the required scope” and “the account is suspended”, which differ in whether obtaining a new token helps. The reverse direction loses less, since a 409 can map back to either code if the body says which, but only if the body says which.
A second loss is timing. gRPC’s RetryInfo expresses a delay precisely, and HTTP’s Retry-After has one-second granularity when given in seconds. A server that wants a 200-millisecond retry cannot say so in HTTP, and clients that use a fixed backoff ignore the header anyway.
What should never appear in an error
Internal detail is the most common leak: stack traces, SQL fragments, hostnames, and internal service names in a 500 body. These help an attacker map the system and help nobody else, because the client cannot act on them. The correlation identifier is the correct substitute: return an opaque value, log it with the exception server-side, and let support join the two.
A second leak is behavioural: an error that distinguishes “user exists” from “wrong password” enables account enumeration, and one that returns 403 for an existing resource and 404 for a missing one discloses existence. These are policy decisions, but the error model is where the policy is implemented, and it is easy to undo with a helpful validation message.
The third is not a leak but a design failure: an error with no stable code. If the only machine-readable part of the response is the HTTP status, every domain condition collapses into 400 or 409, and clients either treat them all as fatal or retry them all. Combining a small stable code set with a free-text message and a correlation identifier covers the four audiences, and the code set is the part that must not change between releases, as versioning treats it as part of the contract.
References
- RFC 9457: Problem Details for HTTP APIs — IETF
- RFC 9110: HTTP Semantics — IETF
- gRPC Status Codes — gRPC project
- Google API Design Guide: Errors — Google