Error handling
How GraphQL reports failure: request errors against execution errors, the anatomy of an error, partial data, a stable extensions.code vocabulary, masking unexpected failures on both stacks, status codes and media types, and the choice between errors[], payload userErrors and result unions.
Chapter 4 followed a null upward through non-null fields, and Chapter 8 returned refused input as userErrors data. Both stopped short of the error itself: what it contains, which parts a client can rely on, and which parts the server must keep to itself. This chapter gives failures a contract. The tracking view fails in three ways, one outage and two unexpected failures, and both stacks answer them with the same shape.
The code is in examples/09-error-handling/. It starts from the Chapter 8 example, adds no schema, and restores three fault seeds: JKL567 sails on a voyage whose service is down, MNO901 loads at a location code the location service does not know, and PQR234 makes the handling service fail with an unexpected exception that carries an internal shard name. What is new is the error behaviour. An outage is an exposed error with a code, anything unexpected is masked as Internal server error., and the detail goes to the server log. verify.sh asserts every claim below on both stacks.
Two kinds of failure, one array
The GraphQL specification separates failures by when they happen. A request error occurs before execution begins: the document does not parse, it fails validation, a variable does not coerce, or the operation cannot be selected. No resolver runs, so there is no data entry at all (Python sends "data":null). An execution error occurs while a resolver is running. Execution continues, so the response carries data with a null where the field failed, and the error carries a path to that field. Older editions of the specification call these field errors. The current term is execution error, and this book uses it.
Both kinds arrive in the same top-level errors array, which is why the array alone does not tell a client what happened. The shape does. Errors with no data mean the request was rejected and nothing was fetched. Errors beside data mean the server did what it could and part of the answer is missing. A third outcome has no errors at all: Chapter 8’s userErrors are ordinary data in a successful response.
The origin matters for what a client does next. A request error is a defect in the client: retrying the same document fails the same way, and the fix is in the code that built it. An execution error may be transient, as an outage often is, so a retry can succeed. A domain failure is neither: the input was understood and refused, and the user can correct it.
Anatomy of an error
The specification’s “Response” section defines the error format, under “Errors” and “Error Result Format”. Each entry is a map with these keys:
messageis a string for a developer. Its wording belongs to the engine or the resolver, and clients must not parse it.locationsis a list of line and column pairs that point into the request document. Request errors and most execution errors have it.pathis the response path of the field that failed. It exists only for execution errors, because a request error has no field. Each step is a response key (the alias when the selection has one) or a list index, and an index is a JSON number:0, not"0".extensionsis a map the server may fill with anything. It is the only place for custom keys, and the specification advises against adding other keys to the error map, since a future edition may claim them.
A request error is the engine speaking, and its entries come from the validation rules of the specification’s “Validation” section or from coercion. An unknown field gives Cannot query field 'weight' on type 'Cargo'. on Strawberry and Validation error (FieldUndefined@[cargo/weight]) : Field 'weight' in type 'Cargo' is undefined on Quarkus. Same failure, same shape, two sentences. That is why the example asserts structure and codes and not request-error wording.
The order of the entries in errors carries no meaning, so a client looks up an error by its path and does not read it by position. The harness does the same: every assertion in verify.sh finds an error by its path when a response carries more than one.
Two details deserve a check on a live server. Aliases show in paths, so bug: cargo(...) that fails at delivery reports ["bug","delivery"]. And locations is optional in the specification. Strawberry and SmallRye both send it for resolver failures, while graphql-java’s own null-in-a-non-null-field error arrives with a path and no locations. A client treats the key as optional.
Partial data: errors beside data
The strongest argument for the execution-error model is a query that is half right. Three aliases ask for three cargos:
{
ok: cargo(trackingId: "ABC123") { trackingId }
outage: cargo(trackingId: "JKL567") { itinerary { legs { voyage { number } } } }
bug: cargo(trackingId: "PQR234") { delivery { transportStatus } }
}
The response has all three keys, in selection order. ok is intact. outage is a cargo whose itinerary has one leg and whose voyage is null, because the voyage service is down for V404X. bug is null, because the handling service failed under delivery. Two errors explain the two holes.
A client with one request per screen section can render what arrived and mark the missing parts. A client that treated any entry in errors as a failed request would throw away two usable answers here. That is the payoff of Chapter 4’s fuses: each alias sits under its own nullable root field, so one failure cannot take another alias with it.
Where the null lands
The error’s path and the null in data are not always at the same place. The path names the field that failed. The null goes where the type system allows it. Leg.voyage is nullable, so the failure and the hole coincide: ["cargo","itinerary","legs",0,"voyage"]. Cargo.delivery is non-null, so a null cannot be placed there, and the engine nulls the nearest nullable ancestor instead, which is cargo. The error still says ["bug","delivery"], and a client that looks for a null at that path finds the parent null instead.
MNO901 combines both rules. The location source answers null for XXBAD, Leg.loadLocation is non-null, legs is a non-null list of non-null legs, and Itinerary.legs itself is non-null. The null climbs from loadLocation through the leg, the list and the itinerary object to Cargo.itinerary, the first nullable position on the way up. One error, one hole, itinerary null, and trackingId still MNO901. The error count is one however far the null travels: it is recorded where the failure happened, and the propagation adds nothing.
A code vocabulary in extensions
A message is for a person, and a client that switches on it breaks whenever someone rewords it. A client that needs to act on a failure, say by retrying, needs something stable. That is extensions.code, a short string from a vocabulary the service owns and documents.
extensions.code |
Raised for | Message | Exposed |
|---|---|---|---|
SERVICE_UNAVAILABLE |
a source outage (V404X) |
voyage service unavailable |
yes |
| none | anything unexpected | Internal server error. |
masked |
The codes are contract and the messages are not, so the codes follow the same evolution rules as enum values: adding one is safe for a client that has a default branch and breaks one that does not. Chapter 11 returns to that. Chapter 12 adds BAD_USER_INPUT to the vocabulary for cursors that pass coercion and are still invalid.
A client that reads an error can follow a short order. First, is data absent? Then the request was rejected, nothing was fetched, and the fix is in the client. Otherwise, walk the errors entries, find the hole by path, and decide per entry: a SERVICE_UNAVAILABLE is worth a bounded retry or a degraded rendering, a masked error with no code is worth showing a generic apology while the server log keeps the exception, its stack and the time, and an unknown code falls to the same generic branch. That last rule is what makes it safe for the service to add a code later.
Request errors have no code. SmallRye and graphql-core generate them before any resolver runs, in their own words, and the example does not rewrite them. A client branches on their structure: errors present and no data.
Masking what clients must not see
An unexpected exception carries whatever its author put in the message: a table name, a shard, a host, a stack. In PQR234 it is handling projection corrupted on shard handling-primary-3. Showing that to a client exposes the topology and invites probing, and it does not help the client, which cannot fix a corrupted shard. The rule has two halves: the client gets a generic message, and the operator gets everything in the server log.
Both frameworks implement this as an allowlist. Nothing is shown unless the service listed it, so a new exception class added next year is masked by default.
@ErrorCode("SERVICE_UNAVAILABLE")
public class SourceUnavailableException extends RuntimeException {
public SourceUnavailableException(String message) {
super(message);
}
}
class ExposedError(Exception):
"""A failure whose message is safe to show a client."""
code: ClassVar[str]
def __init__(self, message: str) -> None:
super().__init__(message)
self.extensions = {"code": self.code}
class SourceUnavailable(ExposedError):
code = "SERVICE_UNAVAILABLE"
SmallRye hides the message of every unchecked exception by default. show-runtime-exception-message lists the classes whose messages are shown, which makes it the allowlist, and default-error-message sets the replacement. The @ErrorCode annotation sets extensions.code. The section below supplies the code for every other exception.
quarkus.smallrye-graphql.show-runtime-exception-message=com.patterncatalyst.shipping.tracking.SourceUnavailableException
quarkus.smallrye-graphql.default-error-message=Internal server error.
quarkus.smallrye-graphql.error-extension-fields=code
quarkus.smallrye-graphql.print-data-fetcher-exception=true
The same file lists ExceptionNameErrorExtensionProvider, so the exception field is gone too; DeclaredErrorCodeProvider is the only provider left.
Strawberry ships a schema extension, MaskErrors, that replaces the message of the errors a predicate selects. The predicate here inverts the usual reading: it masks every error whose original exception is not an ExposedError, so the allowlist is the class hierarchy.
def should_mask(error: GraphQLError) -> bool:
return error.original_error is not None and not isinstance(error.original_error, ExposedError)
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
config=StrawberryConfig(scalar_map=SCALAR_MAP),
extensions=[MaskErrors(should_mask_error=should_mask, error_message="Internal server error.")],
)
An error with no original exception came from the engine itself, a request error, and keeps its message. MaskErrors rebuilds a masked error without its extensions, so a masked error carries no code either. The predicate also decides what a resolver may raise: a GraphQLError raised directly is masked, because it is not an ExposedError, so a resolver raises a subclass of ExposedError for anything a client should read.
The log half needs the same attention as the response half. Strawberry logs the original exception, with its traceback, through the strawberry.execution logger before MaskErrors builds the replacement. SmallRye logs the exception when print-data-fetcher-exception is on. verify.sh reads each service’s log after the masked request and requires handling-primary-3 in it, and it requires the same string, the exception class names and the package name to be absent from the response.
The code is the one field the service decides for itself, and the example supplies it only for exceptions that declare one. DeclaredErrorCodeProvider expresses that rule. It is registered through SmallRye’s ErrorExtensionProvider SPI, found by ServiceLoader, and supplies extensions.code only for exceptions annotated @ErrorCode. The built-in provider file is removed with Quarkus quarkus.class-loading.removed-resources, so an unexpected failure carries "code": null:
public class DeclaredErrorCodeProvider implements ErrorExtensionProvider {
@Override
public String getKey() {
return "code";
}
@Override
public JsonValue mapValueFrom(Throwable exception) {
ErrorCode code = exception.getClass().getAnnotation(ErrorCode.class);
return code == null ? null : Json.createValue(code.value());
}
}
quarkus.class-loading.removed-resources."io.smallrye\:smallrye-graphql"=META-INF/services/io.smallrye.graphql.api.ErrorExtensionProvider
The class is listed in META-INF/services/io.smallrye.graphql.api.ErrorExtensionProvider. Quarkus now sends "code": null for a masked error, where Python sends no extensions, and a client reads both as no code. verify.sh asserts both values, which keeps the registration checked across upgrades, since the removal names a file inside the SmallRye jar.
The null in a non-null field splits the stacks the same way. On Python the TypeError behind a null loadLocation is an unexpected exception, so the client reads Internal server error.. On Quarkus the error is graphql-java’s own and is not a fetch exception, so it is shown: the message names the field and the path, and the extension is classification. Both stacks null the same itinerary.
Status codes and media types
Chapter 6 measured the request-error half of this against the GraphQL-over-HTTP working draft, which has one “Status Codes” section: a result with no data entry gets a 4xx or 5xx (400 for a parse failure, 422 for validation and coercion), and legacy application/json clients are handled the same way, with Content-Type: application/json on a 2xx. Both stacks answer 200 for a request error under either Accept header; the draft recommends a 4xx or 5xx for this case. Quarkus labels the response with the media type asked for; Strawberry always answers application/json, the media type the draft assigns to a legacy client. This chapter adds the execution error. When data is present beside errors, the draft says the status must be 2xx and recommends 294 and only alongside application/graphql-response+json, a custom “Partial Success” code outside the IANA registry that clients unaware of it treat as 200. The draft’s own example elsewhere shows 200 for an execution error, and both stacks send plain 200.
The example sends both Accept headers for a request error and an execution error, and every case answers 200. The media type differs: Quarkus echoes application/graphql-response+json when asked for it, and Strawberry answers application/json whatever the header says. A client must include application/graphql-response+json in Accept, and application/graphql-response+json, application/json;q=0.9 is the safe form when unsure.
The consequence for operations is the one Chapter 1 flagged. A request error and an execution error both answer 200 on both stacks, so a latency-and-status dashboard sees neither, and no stack here sends 294 to separate the partial answer from a clean one. A monitor has to count entries in errors, ideally split by extensions.code and by operation. Chapter 19 builds that count for execution errors.
Errors as data
Chapter 8 returned a domain failure as userErrors: an unknown voyage comes back as [{"code":"UNKNOWN_VOYAGE","field":["input","voyageNumber"]}] with no errors key, a null handlingEvent, and no write. A third envelope exists. A mutation can return a union of its outcomes, and the failure is a type:
union RegisterHandlingEventResult = RegisterHandlingEventSuccess | ValidationFailed
type ValidationFailed {
code: UserErrorCode!
field: [String!]!
message: String!
}
This is a sketch and the example does not serve it. The canonical contract keeps the payload shape, and a second write surface would only confuse it.
The trade-offs are concrete. A union is exhaustive: a client that selects ... on for every member sees a compile-time error from its code generator when the server adds one, which is useful. It is also the cost, because adding a member is a dangerous change, which Chapter 11 measures with the gate, and a client with no default branch breaks. A union also makes every outcome a __typename branch, where a payload keeps the common path flat: handlingEvent is there on success, userErrors is an empty list, and a client that ignores the list still renders. The payload shape also keeps a single place for a list of failures. A union member holds one failure unless the type wraps a list.
The book keeps payload userErrors for expected, user-correctable failures, the top-level errors for everything a user cannot fix, and a code vocabulary for the second kind. The choice follows Chapter 2’s axes, not taste. On the contract and evolution style axis, a payload that can grow is cheaper to evolve than a union that cannot gain a member safely. On the operational and security cost axis, keeping user mistakes out of errors keeps the failure count meaningful.
What a message should say
A message is read by a developer with a request in front of them and sometimes, carelessly, by an end user. It should say what happened to this request in terms of the domain: voyage service unavailable, No voyage has number V999X.. It should not carry internals: no host names, class names, SQL, shard names or stack. It should not instruct the client to parse it, and it should not change meaning between releases, because someone will match on it anyway. Everything a machine needs is in code, path and, for user errors, field.
The rewording rule has a corollary for tests. Assert the message only for errors the service wrote, as verify.sh does for voyage service unavailable, which is identical on both stacks because both are constructed in the example. Never assert the text of a request error: the two engines word them differently, as the Chapter 0 and Chapter 4 runs showed, and the example asserts their shape.
On the wire
Start the stack. Quarkus is on 8190 and Python on 8191.
docker compose -f examples/09-error-handling/compose.yaml up -d --build
Q() { curl -s localhost:${2:-8190}/graphql -H 'Content-Type: application/json' -d "$(python3 -I -c 'import json,sys; print(json.dumps({"query": sys.argv[1]}))' "$1")"; echo; }
An exposed error first. The voyage service is down for V404X:
Q '{ cargo(trackingId: "JKL567") { trackingId itinerary { legs { loadLocation { unLocode } voyage { number } } } } }'
{"errors":[{"message":"voyage service unavailable","locations":[{"line":1,"column":89}],"path":["cargo","itinerary","legs",0,"voyage"],"extensions":{"code":"SERVICE_UNAVAILABLE"}}],"data":{"cargo":{"trackingId":"JKL567","itinerary":{"legs":[{"loadLocation":{"unLocode":"USNYC"},"voyage":null}]}}}}
Python sends the same error with data first. A masked error:
Q '{ cargo(trackingId: "PQR234") { trackingId delivery { transportStatus } } }'
docker compose -f examples/09-error-handling/compose.yaml logs quarkus | grep handling-primary-3
{"errors":[{"message":"Internal server error.","locations":[{"line":1,"column":44}],"path":["cargo","delivery"],"extensions":{"code":null}}],"data":{"cargo":null}}
Python answers {"data":{"cargo":null},"errors":[{"message":"Internal server error.","locations":[{"line":1,"column":44}],"path":["cargo","delivery"]}]}: the same message and path, and no extensions key. The grep finds the exception in the Quarkus log; use the service name python for the other stack, where the line comes from the strawberry.execution logger. The partial-data query is the one above, and the request error and the status codes follow:
Q '{ cargo(trackingId: "ABC123") { weight } }' 8191
for a in application/json application/graphql-response+json; do
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" localhost:8190/graphql -H "Accept: $a" \
-H 'Content-Type: application/json' -d '{"query":"{ cargo(trackingId: \"ABC123\") { weight } }"}'
done
{"data":null,"errors":[{"message":"Cannot query field 'weight' on type 'Cargo'.","locations":[{"line":1,"column":33}]}]}
200 application/json
200 application/graphql-response+json
Port 8191 answers 200 application/json for both headers. Messages, timestamps and log lines differ on every run. The paths, codes, counts and statuses do not.
| Check | Expected |
|---|---|
JKL567 itinerary legs with loadLocation and voyage |
1 error, path ["cargo","itinerary","legs",0,"voyage"], message voyage service unavailable, extensions.code "SERVICE_UNAVAILABLE", with message, locations and path; voyage null, loadLocation USNYC |
PQR234 cargo { trackingId delivery { transportStatus } } |
data.cargo null; 1 error, path ["cargo","delivery"], message Internal server error.; no handling-primary-3, IllegalStateException, RuntimeError or com.patterncatalyst in the response; handling-primary-3 in the service log |
the same, extensions.code |
Quarkus JSON null; Python has no extensions key |
three aliases ok, outage, bug |
2 errors with paths ["outage","itinerary","legs",0,"voyage"] and ["bug","delivery"]; ok.trackingId ABC123; bug null; data keys ok,outage,bug |
MNO901 itinerary legs loadLocation |
itinerary null; 1 error, path ["cargo","itinerary","legs",0,"loadLocation"]; message contains loadLocation on Quarkus, equals Internal server error. on Python |
{ cargo(trackingId:"ABC123"){ weight } } |
errors with message and locations and no path; no data (Python sends "data":null); no source call |
| status and media type, request error and execution error (the draft asks for a 4xx and for 294) | 200 application/json for Accept: application/json; for Accept: application/graphql-response+json, Quarkus 200 application/graphql-response+json, Python 200 application/json |
registerHandlingEvent LOAD at DEHAM on V999X |
no errors; userErrors [{"code":"UNKNOWN_VOYAGE","field":["input","voyageNumber"]}]; handlingEvent null; no appendEvent call |
a: voyage("V404X"), b: voyage("V100S") |
1 error, path ["a"], code SERVICE_UNAVAILABLE; a null; b.number V100S |
| scalars and live schema | SCALAR and the specifiedByURL values from Chapter 3; conforms to contracts/schema/ |
bash examples/09-error-handling/verify.sh runs the whole sequence on both stacks and prints PASS only when both hold.
Decision
Error handling is where the aggregation across sources axis of Chapter 2 gets its operating rule: when one of several owners fails, the response keeps the rest and says which part is missing, with a path and a code a client can act on. The contract and evolution style axis decides the shape. Codes are contract and messages are not, so a new extensions.code value is a change to review like an enum value, and a payload userErrors list can grow where a result union cannot gain a member safely. The operational and security cost axis decides the masking and the monitoring: an allowlist hides every unexpected exception by default and keeps the detail in the log, and because both stacks answer 200 under both media types, where the GraphQL-over-HTTP draft recommends a 4xx, the monitor counts errors entries by code. The tracking view therefore exposes outages with SERVICE_UNAVAILABLE, masks everything else as Internal server error., keeps expected domain failures in userErrors, and treats the status code as no signal at all.
Where this goes next
This chapter gave failures a contract: the difference between request and execution errors, the parts of an error, partial data with a path to each hole, a code vocabulary, masking by allowlist on both stacks with the detail kept in the log, the status codes the GraphQL-over-HTTP draft asks for against the 200 the stacks send, and the choice between top-level errors, payload userErrors and a result union.
Chapter 10 turns to schema design patterns, including the Node interface and its nodes field. Chapter 11 treats a new extensions.code value or a new union member as an evolution decision, and runs the breaking-change gate. Chapter 12 reuses the exposed and masked machinery from this example: its connection arguments raise BAD_USER_INPUT for an invalid cursor and a page size outside 0..50, and its booking outage for GHI345 is a SERVICE_UNAVAILABLE. Chapter 19 turns the error count into a metric.
For the formal definitions, read “Response” with its “Errors” and “Error Result Format” sections, and “Handling Execution Errors”, in the GraphQL specification, and “Status Codes” with the response media types in the current working draft of the GraphQL-over-HTTP specification. The framework behaviour is in the Quarkus SmallRye GraphQL guide, where the error settings are documented, and in the Strawberry documentation under “Dealing with errors” and the MaskErrors extension. Artur Czemiel’s GraphQL Best Practices covers the same ground in its treatment of error descriptions, and Samer Buna’s GraphQL in Action gives the user-error context in “Implementing mutations”; both are listed on the Bibliography page.