Skip to main content

Class 7: Client-Side Error Handling

Duration: 25 minutes | Difficulty: Intermediate | Prerequisites: Class 4 of this course.

Familiarity with one JavaScript GraphQL client helps. Our frontend tutorial, coming soon, will include a hands-on Apollo error-handling class to go with this chapter.


What You'll Learn​

By the end of this class, you will:

  • Enumerate everything a client can receive, from transport failure to partial success, and map each to a reaction
  • Configure Apollo Client's errorPolicy deliberately, knowing exactly what each value does to data, error, and cache
  • Use the error link for cross-cutting reactions to Class 4's codes, including the re-authentication pattern
  • Read Relay's explicit error model (@required, @catch, @throwOnFieldError) as the strictest shipping answer to the ambiguous null
  • Map the same decisions onto the JVM clients, Spring's GraphQlClient and Apollo Kotlin
  • Render partial data honestly, and protect a normalized cache from error nulls

What actually arrives​

Assemble the server-side classes, and a client can face four situations:

  • Transport failure. No response, a timeout, or a non-GraphQL body from some intermediary. Class 3 explains how to tell these apart: a well-formed GraphQL-over-HTTP response is marked application/graphql-response+json, or arrived with a 2xx.
  • A request error result. A body with errors and without data. It comes under a 4xx when the request itself is wrong, where retrying it unchanged fails again, or under a 5xx when the server is at fault, where a later retry may succeed.
  • A partial or total execution failure. 200 or 294, data present, possibly with errors alongside, each error carrying a path and, from a Class 4-compliant server, a stable extensions.code.
  • A fully successful response, which the client uses as it is.

Every client library is, at bottom, a policy for routing these four situations to application code. The three major JavaScript clients route them very differently, so the next three sections examine each library in turn.

Apollo Client: choose your policy​

Apollo Client 4's unit of policy is the operation-level errorPolicy, and its three values, quoted from the documentation, are worth knowing exactly because the default surprises people.

With none, the default, the documentation says:

If the response includes errors, they are returned in the error field and the response data is set to undefined even if the server returns data in its response.

The documentation says that returned data "is cached" only under the other two policies, which implies that a result rejected under none stays out of the cache. Read that against Class 2: the server kept the failure local to one nullable field so that nine of ten sections survived, but under this default policy the client discards that surviving data as well. none turns every partial success into a total failure at the UI boundary. It is a defensible default (no component ever sees data accompanied by errors it did not expect), but for any screen that aggregates independent sections it discards exactly the resilience GraphQL's partial results exist to provide.

With ignore, the documentation says:

Errors are ignored (error is not populated), and any returned data is cached and rendered as if no errors occurred.

This is the opposite abdication, and the phrase "as if no errors occurred" should raise the alarm from Class 2: the error nulls are now in the normalized cache, indistinguishable from semantic nulls, with the correlating errors list discarded. The consequences of those cached nulls for other screens are covered in the cache section later in this class.

With all, the documentation says:

Both data and error are populated and any returned data is cached, enabling you to render both partial results and error information.

This is the policy that matches the server-side design this course has been building: the component receives the partial data and the error list, and can render nine sections plus one error notice. The cost is that the component must handle both fields: rendering logic for the partial data, and separate logic for the error list.

errorPolicydata in the componenterror in the componentResult written to the cache
none (the default)undefinedThe errorsNo
ignoreThe partial dataNot setYes
allThe partial dataThe errorsYes

In Apollo Client 4, error holds an instance of one of the client's error classes, such as CombinedGraphQLErrors for the errors in a GraphQL response, and CombinedGraphQLErrors.is(error) identifies that class.

For cross-cutting reactions Apollo provides ErrorLink, an interceptor in the link chain that sees every operation's errors. The canonical use is Class 4's identity quadrant. The example in the docs reacts to an UNAUTHENTICATED code by modifying the operation context with a new token, and retries by returning forward(operation). There is a built-in guard:

If your retried operation also results in errors, those errors are not passed to ErrorLink to prevent an infinite loop of operations.

One link that checks one code therefore implements re-login for every operation in the application. The transient quadrant belongs in the same ErrorLink, which can return forward(operation) on UNAVAILABLE. Apollo's RetryLink retries network errors only, and its documentation points to ErrorLink for GraphQL errors.

urql: one error object, both kinds​

's design collapses the transport/GraphQL distinction into a single CombinedError, which "can hold and abstract either": its networkError property carries "any error that stopped urql from making a network request", and graphQLErrors carries the response's errors list. The docs are explicit about partial results:

It's worth noting that an error can coexist and be returned in a successful request alongside data. This is because in GraphQL a query can have partially failed but still contain some data. In that case CombinedError will be passed to us with graphQLErrors, while data may still be set.

urql thus hands the component both halves by default, closer to Apollo's all than to its none, and leaves the correlation work to application code.

Relay: refusing the ambiguous null​

Relay's model deserves the most attention. Among the JavaScript clients it is the one that refuses the ambiguous null outright, and its directives are the direct ancestors of the specification work in Class 8. The idea has already travelled beyond JavaScript. Apollo Kotlin's experimental nullability support reads @semanticNonNull from the schema, offers the same @catch with to: RESULT, THROW or NULL, and wraps caught fields in a FieldResult type. Its client gained experimental support for the onError request property of Class 8 in version 5.0.1, released in June 2026.

Relay has three directives for this, and Class 2 introduced the first:

  • @required is the per-fragment answer to semantic nulls. NONE bubbles locally, LOG bubbles and reports, and THROW makes the reading component raise an exception into an error boundary.
  • @throwOnFieldError answers error nulls. On a fragment or query, it means "the Relay runtime will throw an exception if a field with a field error is encountered while reading the fragment or query". It has shipped since Relay 18, and Relay documents it as part of its experimental semantic nullability support. Errored fields stop pretending to be null and fail loudly at the exact component that reads them, contained by that component's error boundary.
  • @catch also answers error nulls, by turning an error into an explicit value at a chosen field. With to: RESULT, the default, the field's type becomes a result object, { ok: true, value } or { ok: false, errors }, so the component pattern-matches on it. With to: NULL it opts back into a plain null, for positions where that is genuinely fine.

The two error directives compose. The @throwOnFieldError guide says:

If you wish to handle a specific field error locally within your @throwOnFieldError fragment or query instead of having that error throw, you can catch the error with @catch.

Two details matter in practice. The first is scoping, which the @catch guide describes:

Both @catch and @throwOnFieldError only handle field errors in the query/fragment/mutation in which they are used. They do not handle errors related to fields in any spread fragments.

Each fragment therefore owns its own policy. This scoping is deliberate, because it makes error handling a local, reviewable property of each component. The second detail is typing: the @catch guide says fields it handles "will be typed using their Semantic Nullability", and the semantic nullability guide describes the same typing for @throwOnFieldError. A field the schema marks @semanticNonNull (null only on error) therefore generates a non-nullable type for components that have declared how they handle errors. That behavior anticipates the semantic nullability work in Class 8: a client that declares how it handles errors receives generated types that match the domain, and no longer needs null checks for nulls that could only have meant errors.

Comparing the two models shows that they ask different questions. Apollo asks: which policy routes data and errors to my callback? Relay asks: what does each component do when this specific field is in error? Apollo's question follows the shape of the transport, while Relay's follows the shape of the domain; the specification work in Class 8 builds on Relay's approach.

Clients on the JVM​

The three libraries above are JavaScript, but the decisions they embody are not JavaScript decisions, and this site's Java track meets the same ones in two clients. Spring for GraphQL's GraphQlClient builds both postures into its two retrieval styles, per the client reference. The shortcut, retrieveSync("movieById"), decodes one path and raises a FieldAccessException "if the given path is not present, or the field value is null and has an error". Its reactive form, retrieve, returns a Mono that completes with the same exception. That is the none posture expressed as an exception, though a caught FieldAccessException still exposes the full response for recovery. The full form, executeSync(), returns a ClientGraphQlResponse, and execute() returns it inside a Mono. isValid() separates Class 1's request error result (errors, no data) from everything else, and response.field("movieById") answers the ambiguous-null question of Class 2 field by field. A field whose value is null with an empty getErrors() list is a semantic null, while a null value with errors attached is an error null. The errors[].path correlation that Class 2 said hand-written code rarely performs is available here as a single method call.

Apollo Kotlin, whose semantic-nullability support the previous section already noted, separates the failure categories on the response object itself, per its error documentation. A fetch error, where the transport failed and a GraphQL response never arrived, populates response.exception and leaves data null. GraphQL errors populate response.errors alongside possibly partial data. By default, code inspects response.data, and dataOrThrow() exists for call sites that prefer an exception when data is missing. Both JVM clients deliver data and errors together and leave the policy to the caller, so the rendering and cache guidance in the rest of this lesson applies to them unchanged.

Rendering partial data honestly​

Whatever the library, the UI question is the same: the response holds nine good sections and one failure, and the screen must tell the truth about both. The pattern is boundaries at section granularity. Each independent section of the screen, the movie header, the ratings widget, the reviews list, renders from its own fragment behind its own error boundary or error check. The ratings widget then shows "Ratings are temporarily unavailable", sourced from the error's code per Class 4 and never from its message, while everything around it renders normally. The anti-patterns misreport the failure in opposite directions: a full-screen error overstates a one-widget failure (Apollo's none produces this by default), and a silently empty widget with no acknowledgment hides it (ignore produces this). A section whose error carries UNAVAILABLE with a retryAfterSeconds hint can even offer its own retry affordance, scoped to refetching just that fragment, which is the client-side reward for the server-side discipline of Class 6.

Error nulls and the normalized cache​

The normalized cache is where the semantic-null/error-null distinction from Class 2 has direct practical consequences, because a stored error null can affect screens far from the query that failed. A normalized cache stores entities by identity, and shares them across every query and screen. Now let an error null in. The ratings backend fails during one query, movie.rating arrives as null with a correlating error, and the client writes rating: null into the normalized Movie:1 entity. The error list was per-response; the cache entry is forever and application-wide. Every other screen that reads Movie:1, including screens whose own queries would have fetched the rating successfully, now renders "no rating", and will keep doing so until something refetches and overwrites the incorrect null. This is the failure mode Apollo's ignore policy invites, since data is "cached and rendered as if no errors occurred". all writes the same data to the cache, but at least hands the component the errors alongside it, so the component can show the errored section as errored.

The sequence below follows one error null from the failed query to a second screen that reads the same entity from the cache.

The principled fixes are all variations of one idea, keeping error nulls out of shared state:

  • Treat errored positions as unknown instead of absent, which is what Relay's directives do structurally, by making errored reads impossible to mistake for data.
  • Scope error-accompanied writes away from the shared cache.
  • Refetch on recovery, so that an incorrect cached null is corrected quickly.

The complete fix, where the wire format itself distinguishes the two nulls so caches can too, is one of the stated design goals of the proposal in the next class.

Summary​

This lesson covered how client libraries route GraphQL errors, and how the UI and the cache should treat them:

  • Four situations. A client faces transport failure, a request error result (4xx or 5xx), execution errors alongside data, and clean success; a client library is a routing policy for them.
  • Apollo Client. errorPolicy has three values. none is the default: partial data is discarded and nothing is cached. ignore caches data "as if no errors occurred" and discards the errors. all delivers both, and both can be rendered. The error link then handles cross-cutting reactions that depend on extensions.code, most often re-authentication on UNAUTHENTICATED.
  • urql delivers a CombinedError (transport and GraphQL errors unified) alongside whatever data arrived.
  • Relay refuses the ambiguous null. @required handles semantic nulls per fragment, @throwOnFieldError makes errored reads raise exceptions into error boundaries, and @catch turns errors into explicit result values. All three are scoped per fragment and rewarded with semantic-nullability-aware types.
  • JVM clients. Spring's GraphQlClient offers the same choice as two retrieval styles: retrieveSync raises FieldAccessException at a failed path, while executeSync exposes per-field values and errors, making the null correlation a method call. Apollo Kotlin separates transport failures (response.exception) from GraphQL errors (response.errors), and by default delivers partial data without raising an exception.
  • Rendering. UIs should put boundaries at section granularity and render failures as scoped, code-driven error messages instead of full-screen errors or silent gaps.
  • Caches. Error nulls written into a normalized cache outlive their response and poison unrelated screens, which is the strongest client-side argument for the wire-level fix of Class 8.

Further Reading​

What's Next​

Every class so far has managed a conflation the type system forced: one syntax answering both "can this be absent" and "how far do errors spread". Class 8 covers the specification work that separates them at last: semantic nullability, @semanticNonNull, and the onError request property that lets each client choose propagation, halting, or an error boundary at every response position.