@error turns a Java exception into a typed GraphQL error appended to a service payload’s errors: list, instead of failing the resolver. The reference page covers signature and constraints. This recipe addresses three operational questions the directive raises: when to use a union of @error types vs a single error type for the carrier, how message: resolves between the handler’s description: and the exception’s own message, and what the synthesised path: slot actually contains at runtime.
Carrier shapes: union vs single type
The carrier is the errors: field on the payload type. Two shapes work:
# backed by com.example.FilmPayload via its @service producer's return type
type FilmPayload {
film: Film
errors: [FilmError]
}
union FilmError = YearOutOfRange | NotAllowed | DbError
vs:
# backed by com.example.FilmPayload via its @service producer's return type
type FilmPayload {
film: Film
errors: [SimpleError]
}
type SimpleError @error(handlers: [...]) {
path: [String!]!
message: String!
}
The classifier treats both the same way: ErrorChannel.mappedErrorTypes is a List<ErrorType>, populated with the union members in the union case and a one-element list in the single-type case. The runtime dispatch (ErrorRouterClassGenerator.buildDispatchMethod) walks that list once per thrown exception; cardinality of the list does not change the matching algorithm.
The choice is semantic, not structural:
-
Union fits when distinct error classes carry distinct fields.
YearOutOfRangemight want avalidRange: Stringslot;DbErrormight want aconstraint: String. Each member is its own object type with its own SDL field set, and graphql-java’s type resolver dispatches on the runtime instance. Use a union when clients should branch on__typenameto read shape-specific fields. -
Single type fits when every error has the same shape and only the
message:(andpath:) varies. The handler list on the single@errortype can still cover many distinct exception classes — the union pattern is for distinct result shapes, not distinct handlers.
The rewrite doesn’t pick a side: a class-backed payload whose canonical constructor declares a parameter typed List<? super FilmError> (or List<SimpleError>) is recognised either way. The classifier reflects on the canonical constructor and locates the unique parameterised List/Iterable/Collection slot whose element bound is compatible with every channel error type.
The Sakila example schema carries a worked end-to-end channel on Query.filmLookup: a union of @error types, one of them with a description: and one without, over a @service-returned payload. The classify-time fixtures live in graphitron/src/test/java/no/sikt/graphitron/rewrite/ErrorChannelClassificationTest.java (alongside the synthetic SakPayload record at no.sikt.graphitron.codereferences.dummyreferences.SakPayload). When you wire your first error channel, either is the right shape to copy.
Handler dispatch: source order, cause-chain unwrap
The router’s match loop is straightforward:
for (Mapping mapping : mappings) {
for (Throwable t = thrown; t != null; t = t.getCause()) {
if (mapping.match(t)) {
return DataFetcherResult.<P>newResult().data(payloadFactory.apply(List.of(t))).build();
}
}
}
return redact(thrown, env);
(ErrorRouterClassGenerator.buildDispatchMethod.)
Two facts to internalise:
-
Source order wins. Mappings are walked in the order the channel’s types appear in the SDL (and within a type, in
handlers:declaration order). The first mapping that returnstruefrommatch(t)is the answer. Order matters when one handler is more specific than another — declare the narrow one first. -
The cause chain is unwrapped per mapping. For each mapping, the loop walks the thrown exception, then
getCause(), then the cause’s cause, and so on. The result is "first (mapping, throwable-in-chain) pair that matches", not "first throwable matched against any mapping". ARuntimeExceptionwrapping aDataAccessExceptionwill match aDATABASEhandler attached to the inner exception even if the outer exception class isn’t itself in any handler. This is the intended shape: business exceptions surfaced through framework wrappers route to the typed channel.
The classifier rejects duplicate handler criteria within one variant across the channel (Rule 8). Two handlers with identical (handler, className, code, sqlState, matches) are unreachable for the second declaration; the build fails rather than silently picking one. Cross-variant overlap is allowed: a GENERIC handler for IllegalArgumentException and a DATABASE handler for sqlState: "23514" can coexist on the same channel because their match predicates inspect orthogonal fields.
VALIDATION is special-cased. There is at most one VALIDATION handler per channel (Rule 7); a Bean Validation failure runs as a pre-execution wrapper, never enters the dispatch loop, and the resulting GraphQLError is routed straight into the errors slot.
description: overrides message:, and nothing else
Set description: on a handler entry and the message: field of that entry’s @error type reads the authored string instead of the matched exception’s getMessage(). Leave it off and message: reads the source’s own message. Nothing else about the error changes.
That last sentence is the load-bearing one. Dispatch is source-direct: the matched Throwable itself is what goes into the payload’s errors list (payloadFactory.apply(List.of(t)) in the loop above), and the override is resolved on the read side, at the message: fetcher. So the matched exception is never wrapped or substituted, which is what keeps three other things pointed at the live object:
-
A
unioncarrier resolves__typenameby the source’s own class, so the override cannot move an error to a different type. -
Extra fields on the error type still read their accessors off the live exception. An
attempted: Int @field(name: "attemptedId")slot readsgetAttemptedId()from the throwable that was actually caught, alongside an authoredmessage:. -
The build-time check that every extra field resolves to a real accessor on each handler’s declared source class stays a build-time guarantee, rather than becoming a runtime property-fetcher miss.
The override is per handler, not per channel or per type. Two @error types on one channel, one with description: and one without, produce an authored message for the first and getMessage() for the second; two handler entries on one type behave the same way.
Since it is per handler, only entries that can match carry one. A {handler: VALIDATION} entry cannot: the build rejects description: there, because the validator emits one GraphQLError per constraint violation, each already carrying that violation’s own interpolated message, and collapsing them all to one authored string would discard exactly the per-violation detail Bean Validation exists to produce. Put the client-facing string on the constraint annotation’s own message attribute instead.
path:, message: field fetchers
Every @error type’s path: [String!]! and message: String! slots get synthesised data fetchers. The slots are matched by name; an @error type without one of them is rejected at classify time.
path: reads the GraphQL execution step path for non-validation sources (ErrorTypeFetcherClassGenerator.pathMethod; the schema class only wires the <ErrorType>Fetchers::path reference):
return env.getExecutionStepInfo().getPath().toList().stream().map(String::valueOf).toList();
For VALIDATION sources (which are GraphQLError instances pre-built by the Bean-Validation wrapper), path: reads from the error itself — ge.getPath().stream().map(String::valueOf).toList() — so the per-element constraint paths recorded by ConstraintViolations.toGraphQLError survive intact.
The path is the GraphQL response path, not the mutation argument path. For:
mutation { createFilm(input: {...}) { film { title } errors { path message } } }
an exception thrown by createFilm and matched into errors[0] produces path: ["createFilm"] (or ["createFilm", "<some-subfield>"] if the throw happened during nested resolution). To convey which input field triggered the error, encode that into the exception itself (the constraint or the validation-handler payload) and surface it through message: or a custom field on the error type.
message: resolves in three steps, in this order:
-
If the source is a
Throwable, the fetcher walks its own@errortype’s handler list in declaration order. The first handler that matches the source and carries adescription:resolvesmessage:to that string. -
Otherwise, if the source is a
GraphQLError,message:readsGraphQLError.getMessage(). This is the VALIDATION path:ConstraintViolations.toGraphQLErrorbuildsGraphQLErrorinstances that need not be `Throwable`s at all. -
Otherwise
message:readsThrowable.getMessage()from the source.
The walk runs ahead of the GraphQLError arm on purpose, because a source can be both shapes at once: graphql.GraphQLError is a plain interface, so an author’s own exception class may implement it (Graphitron’s own generated GraphitronClientException does, by extending GraphqlErrorException). For such a class the two arms would return different strings, and resolving the GraphQLError arm first would silently drop an authored description:.
The walk is per @error type rather than channel-wide, which matters when two types on one channel can both match one throwable through different variants: dispatch takes the channel’s first match while the union’s type resolver picks the type by source class, and resolving message: against the type the resolver already selected keeps it consistent with the __typename the client reads in the same selection set.
If you want a localised or sanitised message and description: is too static for it (a per-request locale, say), override getMessage() in the exception class and leave description: off.
Unmatched exceptions: redact and correlate
When no handler matches, the router runs the redact path (ErrorRouterClassGenerator.redactBody):
UUID correlationId = UUID.randomUUID();
LOGGER.error("Unmatched exception in fetcher; correlation id = {}", correlationId, thrown);
return DataFetcherResult.<P>newResult()
.data(null)
.error(GraphqlErrorBuilder.newError(env)
.message("An error occurred. Reference: " + correlationId + ".")
.build())
.build();
Two consequences:
-
Untyped exceptions don’t leak details to clients. The user-facing message is "An error occurred. Reference: <UUID>."; the original message stays in the server log. This is the fallback behaviour, intentional: only exceptions you’ve explicitly opted into via
@errorflow as data. -
The correlation ID joins the log line and the client response. Operations can grep the logs for the UUID a customer reports back, see the original stack, and decide whether to add a handler. This is the shape any new error category goes through before earning a typed channel slot.
Add a typed channel slot when an untyped fallthrough turns out to be a regular, expected condition (not a bug). Leave it as a fallthrough when it really is a bug: the redacted shape is exactly what you want for a 500-class condition.
Pitfalls
-
Source order is significant. Declare narrow handlers (with
matches:substrings, more specificclassName:subclasses) before broad ones. The first match wins, not the most specific. -
description:changesmessage:and nothing else. It does not move an error between union members, and it does not affect the extra fields, which keep reading the live exception. If you need the whole error shaped differently, that is a different@errortype, not a description. -
path:is the response path. If you need to point at an input argument, encode it in the exception or in a custom field on the error type. The synthesised fetcher readsenv.getExecutionStepInfo().getPath()and can’t distinguish argument positions. -
VALIDATIONruns pre-execution. The Bean-Validation handler intercepts before the service method runs; its results bypass the dispatch loop and route straight into the errors slot. There’s at most oneVALIDATIONhandler per channel. -
Cause-chain unwrap is per mapping, not per source-order step. If a wrapping exception declares a handler at the channel and the wrapped exception declares a different one, the wrapping exception wins for any mapping it matches first — even if the wrapped exception would match a later mapping more specifically. Keep handler tuples disjoint across the wrap boundary.
-
Unmatched flows redact. If a developer expects a specific exception class to surface to the client and it doesn’t, the most likely cause is "no
@errortype covers it"; check the redaction log for a correlation ID, then add the handler. -
No payload, no channel.
@erroronly takes effect when the type appears as (or in a union behind) anerrors:field on a payload one of the three producer families returns: a@servicefield, a payload-returning DML@mutation, or a payload-returning@routineMutation write. A standalone@errortype with no carrier is rejected at classify time.
See also
-
@erroris the directive surface and the handler-tuple specification. -
Three producer families sit upstream of an errors channel:
@servicepayloads, payload-returning DML@mutationcarriers, and payload-returning@routineMutation writes. -
How-to: Result-type variants covers the payload’s backing-class shape (reflected from its
@serviceproducer’s return type), which the channel classifier reflects on to find the errors slot.