Error Handling and Partial Responses in Subgraphs

In a single-service GraphQL API an error is a local decision. In a federated graph it is a contract: what one subgraph throws determines how much of a response every other subgraph’s work survives, and a nullability choice made by one team decides whether another team’s outage nulls a field or empties a page. This guide, part of Subgraph Implementation & Entity Resolution, covers how errors travel through the router, how nullability governs their blast radius, and how to design a graph that degrades instead of failing.

Prerequisites

Concept Deep-Dive: Two Kinds of Error, Two Blast Radii

GraphQL distinguishes request errors from field errors, and federation makes the difference operationally enormous.

A request error happens before execution — a parse failure, a validation failure, an unknown persisted-query id. The response has no data at all. Nothing executes, so nothing partial survives.

A field error happens while resolving a specific field. The executor sets that field to null, records an entry in errors, and continues. If the field is non-null, the null cannot be stored there, so it propagates to the nearest nullable ancestor — and if there is none, all the way to data: null.

That propagation rule is the whole subject. One subgraph’s failure to resolve one field can, purely through nullability declarations made by unrelated teams, erase a response that four other subgraphs successfully produced. Nothing in the schema records this coupling and no check reports it.

How far one failure travels A failed non-null field nulls its parent, which is also non-null and nulls its parent in turn, until a nullable ancestor absorbs it. Making one link nullable stops the propagation at that point. pricing fails on one order line all non-null — whole response lost line.price: Money! ← throws order.lines: [Line!]! ← nulled query.orders: [Order!]! ← nulled data: null one nullable link — page still renders line.price: Money ← throws, nulls here order.lines: [Line!]! ← intact query.orders: [Order!]! ← intact data plus one error entry The only difference between these two columns is one exclamation mark, chosen by one team.

Error & Nullability Spec Table

Concept Where decided Effect on the response Federation-specific note
Request error router or subgraph, pre-execution data is absent entirely Nothing partial survives; no subgraph runs
Field error resolver throws field nulled, entry in errors Blast radius set by nullability above it
Non-null field (!) schema author null propagates to the parent A promise about another team’s availability
errors[].path executor locates the failed field The only reliable way to attribute a failure
errors[].extensions.code resolver machine-readable classification The only part clients should branch on
Subgraph timeout router.yaml field error at the fetch boundary Bounds one service’s ability to stall the graph
@key resolution failure __resolveReference entity null or error Choose deliberately — see the fallback guidance

Step-by-Step Implementation

Step 1 — Classify before you throw

import { GraphQLError } from "graphql";

// One helper, one vocabulary. Codes are API; messages are for humans.
export const notFound = (what: string) =>
  new GraphQLError(`${what} not found`, {
    extensions: { code: "NOT_FOUND", retryable: false },
  });

export const upstreamUnavailable = (service: string) =>
  new GraphQLError(`${service} is unavailable`, {
    extensions: { code: "UPSTREAM_UNAVAILABLE", retryable: true },
  });

Step 2 — Make cross-subgraph fields nullable by default

A non-null field is a promise that a value always exists. For a field resolved by another team’s service, that promise depends on their availability, their deploys, and their datastore. Very few such fields can honestly keep it.

type Order @key(fields: "id") {
  id: ID!
  # Owned here: non-null is honest.
  status: OrderStatus!
  # Resolved by the pricing subgraph: nullable, because their outage should
  # degrade this field rather than delete the order from the response.
  total: Money
}

Step 3 — Bound how long a failure can take

# router.yaml
traffic_shaping:
  all:
    timeout: 5s
  subgraphs:
    pricing:
      timeout: 2s     # below the client SLA, so a stall degrades rather than hangs

Step 4 — Distinguish absence from failure

Returning null for “this record does not exist” and for “the database is down” makes the two indistinguishable to clients and to your own dashboards. Return null for the first and throw for the second, with a code that says which.

Composition Pipeline Integration

Composition validates that nullability is compatible across subgraphs, not that it is wise. A field declared Money! in one subgraph and Money in another composes to the stricter form, which means one team can tighten a field for the whole graph without the other noticing. That is worth a lint rule rather than a review habit.

rover subgraph check my-graph@current --name orders --schema ./orders/schema.graphql

The check that pays for itself here is a schema lint asserting that any field whose value comes from another subgraph is nullable. It is mechanical, it catches the exact change that turns a degraded field into a deleted response, and it gives the reviewer something concrete to point at. Pair it with the usual pipeline described in federated schema validation in CI/CD pipelines.

Null or throw — four situations, four answers A missing record returns null. An unavailable upstream throws a retryable error. A forbidden field throws a forbidden error. A malformed key throws and should alert, because it indicates a bug rather than a condition. The response shape is a deliberate choice, not a default situation do code, and whether to alert record genuinely absent return null no error, no alert upstream unavailable throw UPSTREAM_UNAVAILABLE, alert caller may not read it throw FORBIDDEN, no alert malformed or missing key throw BAD_REFERENCE, alert loudly Rows one and two look identical to a client that only checks for null — which is why codes matter.

Performance & Scale Considerations

Error handling has a cost profile people rarely measure, and two parts of it matter.

The first is that a timeout is latency you pay in full. A subgraph with a five-second timeout that fails at four and a half seconds contributes four and a half seconds to the operation, and if the plan sequences another fetch behind it, that time is pure loss. Timeouts should be set from the subgraph’s real p99, not from a comfortable round number, precisely because their purpose is to bound the damage rather than to accommodate slowness.

The second is that retries multiply load exactly when a service is least able to take it. Bounded retries with a budget are useful; unbounded retries convert a degraded subgraph into an unavailable one. The router’s retry_percent exists to cap the additional load as a fraction of normal traffic, and setting it is a decision worth making explicitly rather than accepting a default.

There is also a subtler effect on caching. A field error means the router has no value to cache, so an operation that intermittently fails one field will keep missing the cache for the whole response even when the other fields are perfectly cacheable. Where a field is both optional and unreliable, letting it null out quickly is better for the cache hit rate than retrying it slowly.

Failure Modes & Debugging

A whole page goes blank when one service degrades. Root cause: a non-null chain from a cross-subgraph field to the root. Fix by making the cross-subgraph field nullable — one character, and it converts an outage into a gap.

Clients cannot distinguish “no data” from “broken”. Root cause: null returned for both. Fix by throwing with a code for failures and reserving null for genuine absence.

Error rate looks fine while users report failures. Root cause: errors counted at the router only, where a nulled field is a success. Count field errors by path and subgraph, not just HTTP status.

Retries make an incident worse. Root cause: retries configured without a budget, or clients retrying on top of router retries. Cap with retry_percent and forbid client retries on non-idempotent operations.

Designing a Graph That Degrades

Degradation is a design property, not a runtime feature. A graph either has places where a failure can stop, or it does not, and that is decided when the schema is written rather than when the incident happens.

The mental model worth adopting is the fuse. Every cross-subgraph field is a candidate fuse: made nullable, it absorbs a failure and the surrounding response survives; made non-null, it conducts the failure upward. A well-designed graph has fuses at every service boundary, so the maximum damage from any single service is the fields that service owns. A graph with no fuses has a single failure domain wearing the costume of a distributed system.

Placing fuses well takes one question per field: if this value were missing, would the parent object still be worth returning? An order without its computed shipping estimate is still an order — fuse. An order line without a product reference is arguably not a line at all — no fuse, and the null should propagate to the line. Answering that question honestly usually produces far more nullable fields than teams expect, and the discomfort is worth sitting with, because the alternative is discovering the answer during someone else’s outage.

The second design lever is selection granularity. A client that fetches everything it might need in one operation has one failure domain; a client that fetches the critical path in one operation and enrichment in another has two, and the second failing is invisible. This is why a “get the whole page in one query” instruction is good advice for latency and bad advice for resilience — the right split is by criticality, not by screen.

The third is where computation lives. A field computed from three subgraphs’ data fails if any of the three fails, and its blast radius is the union of their outages. Moving that computation to whichever subgraph already owns most of the inputs shrinks the set of services it depends on, which reduces failure probability without any resilience machinery at all. The related trade-offs are covered in using @external and @requires for field resolution.

Fuses at service boundaries bound the damage With a nullable field at each cross-subgraph boundary, a failure in one service nulls only the fields that service owns. Without them, the failure travels to the root and removes work every other service did successfully. Same outage, two graphs no fuses pricing down orders lost accounts work discarded blast radius: the whole response fuse at each boundary pricing down orders returned, total is null accounts data intact blast radius: one field Nothing about the outage differs between these columns — only where the schema allows a null.

Testing Failure Deliberately

The reason degradation is usually wrong in practice is that nobody exercises it. Success paths are tested constantly and failure paths are tested during incidents, which is the worst possible time to learn that a non-null field two services away deletes the page.

Three tests are worth the effort, and all are cheap.

The first asserts the blast radius of a single subgraph failure. Stub one subgraph to error, run your most important operation, and assert on the shape of the response: which fields are null, which are populated, and how many error entries appear. Written as a snapshot, this test fails the day someone tightens a nullability declaration anywhere in the chain — which is exactly the change you cannot otherwise see.

The second asserts error classification. Force a not-found and an upstream failure and assert the extension codes differ. This is the test that stops the two collapsing into an undifferentiated null over time, as resolvers get refactored by people who did not write the original distinction.

The third asserts timeout behaviour. Stub a subgraph to delay past its configured timeout and confirm the operation returns a partial response within the expected window rather than hanging to the client’s own limit. Teams routinely configure timeouts and never verify they engage, and a timeout that is longer than the client’s is not a timeout at all.

Run all three against the composed graph rather than a single subgraph, because the propagation behaviour they check only exists at that level. A subgraph tested in isolation cannot tell you what its errors do to somebody else’s response.

An Error Vocabulary Worth Agreeing On

The single highest-leverage artifact here is a short, written list of error codes that every subgraph uses and every client can rely on. It costs an afternoon and it removes an entire genre of cross-team confusion, because without it each service invents its own vocabulary and clients end up matching on message text — which then breaks the first time someone improves the wording.

A workable vocabulary is small. NOT_FOUND for a record that does not exist. FORBIDDEN for a caller who may not read this. BAD_USER_INPUT for a value that failed validation. UPSTREAM_UNAVAILABLE for a dependency that could not be reached. INTERNAL_ERROR for everything else, deliberately opaque. Five codes cover the overwhelming majority of real cases, and the discipline of squeezing a new situation into one of them is usually more valuable than the sixth code would be.

Two conventions make the list usable in practice. Codes are stable API and are versioned like any other part of the contract — renaming one is a breaking change even though no schema check will say so. And a boolean retryable extension alongside the code lets clients and gateways make the retry decision without hard-coding knowledge of which codes are transient, which keeps that knowledge in the service that actually knows.

What the vocabulary should not include is a code per domain condition. ORDER_ALREADY_SHIPPED looks helpful and pushes business logic into the error channel, where it is awkward to type, awkward to document, and invisible to the schema. Conditions that clients act on belong in the schema as fields or as result unions, where they are typed, discoverable through introspection, and covered by schema checks. The error channel is for things that went wrong, not for outcomes that are simply not the happy path.

Frequently Asked Questions

Should every cross-subgraph field be nullable?

As a default, yes, and the exceptions should be argued rather than assumed. A non-null field is a promise about the availability of a service you do not operate; the only fields that can honestly keep it are ones whose absence genuinely makes the parent meaningless. In practice that is a small set — an entity’s own key, and little else.

Where should error messages be made safe for clients?

At the router, for anything public. Subgraph error messages routinely name services, tables and internal identifiers, and redacting them at the router means one configuration change covers every subgraph rather than trusting each team to sanitise. Keep the full detail in traces, where your own team can still read it.

Do errors from one subgraph affect fetches to another?

Not directly — sibling fetches run independently and their results survive. What can be lost is anything downstream of the failed fetch in the plan, plus whatever null propagation removes. That is why a failure in a sequenced @requires chain is more expensive than the same failure in a parallel branch.

How should partial responses be surfaced in monitoring?

As their own signal. A response with data and errors is neither a success nor a failure and averaging it into either hides the thing you most want to see. Track the rate of partial responses separately, broken down by error path, and you will spot a degrading subgraph long before it shows up as an availability number.

Is it ever right to return an empty list instead of an error?

Only when empty genuinely means empty. Returning [] because a fetch failed tells the client “there are none”, which is a different and more damaging statement than “we could not find out” — it will be cached, rendered as an empty state, and believed. Prefer null with an error entry when the truthful answer is unknown.

Should a mutation ever return a partial response?

Almost never in the sense that matters. A mutation that half-succeeded and reports the fact through the error channel leaves the client unable to tell what state the system is in, and unable to retry safely. Model mutation outcomes in the schema instead — a result union with an explicit failure member — so the client branches on a typed value rather than inspecting an error list. Reserve the error channel for the cases where the mutation genuinely did not happen at all.

How do partial responses interact with client caches?

Badly, unless the client is told to expect them. A normalised cache that writes whatever it receives will happily store a null for a field that failed, and every subsequent read serves that null from cache as though it were data. Configure the client to skip writing fields that arrived alongside an error at their path, or the degradation outlives the outage by the length of your cache TTL — which is how a two-minute incident becomes an hour of confused users.