Propagating Subgraph Errors Through the Router
An error thrown in a subgraph is not the error a client receives. Between the two sits the router, which rewrites the path, decides how much detail survives, and merges entries from several subgraphs into one list. Knowing exactly what happens in that gap is the difference between an error channel clients can act on and one they learn to ignore. This page sits under Error Handling and Partial Responses in Subgraphs.
When to use this pattern
- Use it whenever more than one subgraph can fail in the same operation, which in a real graph means always.
- Use it before exposing a graph publicly, because subgraph error messages routinely describe internal topology and will reach clients unmodified by default.
- Skip the detail only for a single-subgraph graph, where the router has nothing to merge and nothing to rewrite.
Prerequisites
What the router does to an error
Three transformations happen between a subgraph throwing and a client reading, and each one is a place where information is added or lost.
Path rewriting. A subgraph resolving an _entities fetch reports a path relative to its query — something like _entities.0.total. That path is meaningless to a client, so the router maps it back onto the client’s own selection: orders.3.total. This is genuinely useful and it is why path is the most reliable attribution signal in a federated error.
Merging. Several subgraphs can fail in one operation, each contributing entries. The router concatenates them into a single errors array, in no guaranteed order. Clients must treat it as a set keyed by path rather than as a sequence.
Redaction, if configured. By default, subgraph error messages and extensions pass through. That default is right for an internal graph and wrong for a public one, since those messages frequently name services, tables and identifiers.
Implementation walkthrough
The subgraph side is about attaching enough structure that the router and your own dashboards can use it, without attaching anything a client should not see.
import { GraphQLError } from "graphql";
export async function resolveTotal(order: { id: string }, ctx: Context) {
try {
return await ctx.pricing.total(order.id);
} catch (cause) {
throw new GraphQLError("Pricing is temporarily unavailable", {
extensions: {
// Machine-readable and stable — this is what clients branch on.
code: "UPSTREAM_UNAVAILABLE",
retryable: true,
// Useful internally, and exactly the kind of thing to redact publicly.
service: "pricing",
orderId: order.id,
},
// Keeps the stack for logs without putting it in the response.
originalError: cause as Error,
});
}
}
The router side decides what survives. For an internal graph, pass everything through; for anything public, include the codes and drop the rest:
# router.yaml
include_subgraph_errors:
all: false # default: clients see a generic message
subgraphs:
orders: true # this one is internal-only, full detail is fine
telemetry:
apollo:
errors:
subgraph:
all:
send: true # full detail still reaches your traces
redact: false
That pairing is the important part: include_subgraph_errors: false with telemetry redaction off means clients get a safe message while your own traces keep everything. Turning both off is the common misconfiguration — it produces a public API that leaks nothing and an incident you cannot debug.
Clients then branch on codes and paths rather than on text:
for (const err of response.errors ?? []) {
const code = err.extensions?.code;
if (code === "UPSTREAM_UNAVAILABLE") {
// path tells us WHICH item degraded, so only that row shows a placeholder.
markDegraded(err.path);
} else if (code === "FORBIDDEN") {
hideField(err.path);
}
}
Verification steps
Confirm the three behaviours separately, because they fail independently.
# 1. Path rewriting: force a failure on one item in a list and read the path.
# It should name the client's field, not "_entities".
curl -s localhost:4000/graphql -H 'content-type: application/json' \
-d '{"query":"{ orders(first:5){ id total } }"}' | jq '.errors[].path'
# expect: ["orders",3,"total"] — never ["_entities",0,"total"]
Then verify merging by failing two subgraphs in the same operation and asserting both entries appear, each with its own path. A client that assumes one error per response will silently handle only the first.
Finally verify redaction from the outside, not from configuration. Query the public endpoint, force an internal failure, and read the message: it must not name a service, a table, or an identifier. Then check the same request in your tracing backend and confirm the full detail is there. Both halves must hold; either one alone is a failure of a different kind.
One further detail is worth internalising before configuring anything: the router’s default is to hide subgraph error detail from clients while still surfacing a generic error, and teams frequently flip that default without deciding what they are trading. Exposing subgraph errors makes an internal graph vastly easier to work with, because the developer reading the response is the same person who can fix it. Exposing them on a public endpoint hands external callers a description of your service topology, gathered one malformed request at a time.
The right resolution is almost never a single global setting. Configure it per subgraph and per deployment: full detail on the internal router, nothing on the public one, and the same subgraph code serving both. Because the setting lives in router configuration rather than in the subgraph, no service has to know which audience it is currently answering, which keeps the decision in one reviewable place rather than distributed across every team’s error handling.
Common mistakes & gotchas
Matching on message text in a client. The message is the one part guaranteed to change — through rewording, translation, or redaction. Every such client breaks silently the first time someone improves an error message. Branch on the code.
Redacting in traces as well as in responses. Clients are protected and so is the incident, from you. Redact outbound and keep full fidelity inbound.
Assuming one error per response. Two subgraphs failing produces two entries, and a client reading errors[0] handles one of them. Iterate, and key on path.
The posture differs per deployment rather than per subgraph, and the last row of the table below is the configuration mistake worth naming explicitly: redacting in both directions protects clients and blinds you at the same time.
Frequently Asked Questions
Why does the path sometimes still mention _entities?
Usually because the error was raised outside the resolver the router can map — in a plugin, in the context factory, or during serialisation. Those errors have no client-facing path to rewrite to. If you see this regularly, look at where the throw happens rather than at the router; moving it inside the field resolver restores the mapping.
Should errors carry a trace id?
Yes, and it is one of the highest-value additions available. An extension carrying the trace id lets a user or a support engineer hand you a single string that leads directly to the full waterfall, which is far more useful than a redacted message. It is safe to expose because it identifies a trace rather than describing your internals.
Can the router add its own errors?
It does, for anything it handles itself: validation failures, rate limiting, authorization filtering, and subgraph timeouts. Those carry router-generated codes and no subgraph attribution, which is worth knowing when you are trying to work out which service failed — an error with no subgraph in its extensions usually did not come from one.
Does an error stop the rest of the operation?
No. Sibling fetches continue, and their data appears in the response alongside the error. Only work that was downstream of the failed fetch is lost, plus whatever null propagation removes. This is what makes partial responses the normal case rather than an exception in a federated graph.
How should error rate be measured?
Per field path and per subgraph, not per HTTP status. An operation returning a partial response is a 200, so a status-based error rate reports perfect health while a field fails on every request. Counting errors[] entries grouped by path is the metric that actually tracks user-visible degradation.
Related
- Error Handling and Partial Responses in Subgraphs — parent guide
- Handling Nullability and Partial Data in Federated Queries — what the null does after the error
- Retrying Failed Subgraph Fetches in Apollo Router — before the error reaches a client
- Observability and Distributed Tracing in Federation — where redacted detail should still live
- Securing the Federated Gateway — error redaction as a security control