GraphQL · advanced
How to verify GraphQL errors preserve field paths
A verification playbook for confirming that a GraphQL service still surfaces `errors` with `path` and `extensions` arrays attributable to the specific field and input that caused each partial failure, rather than flattening failures into a top-level "request failed" message that loses field-level attribution. Focuses on the conformance behavior defined in the GraphQL October 2021 specification, Section 6.4.3, and on reproducible inspection of resolver, transport, and framework boundaries.
The symptoms
- •Production evidence shows a GraphQL operation succeeded with `data` populated alongside an `errors` array, but every entry has `path: []` or no `path` field, making it impossible to map the failure back to the selection that caused it.
- •Partial mutations return 200 OK with `data` set on sibling fields while the failing field reports the error, but downstream consumers cannot tell which input argument triggered the exception, so retries are performed against healthy subtrees.
- •Aggregated responses from batched or aliased queries lose field-level attribution: the same field name appears multiple times but only one instance failed, and the error does not distinguish which alias or argument list is responsible.
- •Observability dashboards record `extensions.code` but cannot correlate it with the originating selection, so root-cause analysis requires re-issuing the request with manually guessed field paths.
Likely causes
- •A custom error formatter or middleware wraps resolver exceptions and discards the original `GraphQLError` object before it reaches the response, leaving only a string `message` with no `path` or `locations`.
- •A transport layer (HTTP proxy, CDN, gateway, or framework handler) is rewriting or compressing the JSON body and stripping or truncating nested arrays such as `errors[].path` and `errors[].extensions`.
- •Resolvers throw generic `Error` or framework-specific exceptions instead of `GraphQLError`, causing the server to substitute a synthetic top-level error with an empty `path` array because no execution position is recorded.
- •DataLoader or batching helpers swallow per-resolver errors and rethrow a single aggregate error at the top of the execution tree, collapsing multiple attributable failures into one unattributable one.
- •Subscriptions and incremental delivery (`@stream`, `@defer`) are returning errors without the per-payload `path` required by the spec, because the server was upgraded for partial results but the error path was not propagated into subsequent payloads.
- •Federation or schema-stitching gateways are dropping or rewriting `errors[].path` when merging subgraphs, because the gateway lacks a source location map for subgraph fields and substitutes an empty array.
First ten minutes
- 01Capture the exact response body for a known partially-failing operation (one that returns both `data` and `errors`) and confirm whether `errors[].path` is present, non-empty, and numerically indexed for list elements, per GraphQL October 2021 Section 6.4.3.
- 02Locate the server-side error formatter, middleware chain, or framework handler (commonly named `formatError`, `willSendResponse`, `errorHandler`, or `errorTransformer`) and verify it returns the original `GraphQLError` shape rather than rewrapping it.
- 03Inspect the transport: confirm the response uses `Content-Type: application/json` (or `application/graphql-response+json` for incremental delivery) and that no intermediary is rewriting or compressing nested arrays.
- 04Reproduce against the canonical `graphql-js` reference executor in an isolated test to determine whether the loss of `path` originates in your server code or in an upstream transformation layer.
- 05Capture the request variables separately so the verification can correlate each `path` entry against the exact input that triggered it, including list indices and alias names.
Evidence to collect
- •An exact, byte-stable copy of the response body (or wire payload for subscriptions) for a query that intentionally fails on one field while returning `data` for sibling fields.
- •The corresponding request document and serialized variables, retained so `path` indices and argument values can be correlated against the original selection set.
- •Server-side stack traces or formatted error logs that show the original `GraphQLError` object as constructed by the resolver, before any custom formatter is applied.
- •Configuration of any middleware, proxy, CDN, or gateway in front of the GraphQL endpoint, specifically any rule that rewrites JSON, compresses bodies, or filters response fields.
- •For federated or stitched topologies, a record of which subgraph produced each error and whether the gateway preserved or rewrote `path` during response merging.
Where to look
- •The boundary between the GraphQL execution layer and the HTTP or WebSocket transport, where error formatters and middleware typically run; consult spec Section 6.4.3 for the required shape of `GraphQLError`.
- •The resolver boundary for the failing field, especially any try/catch that converts framework exceptions into `GraphQLError`; verify whether `path` from the resolver context is preserved when the error is constructed.
- •The transport boundary between the GraphQL server and the client, including reverse proxies, API gateways, content-encoders, and any JSON-schema validator that may strip unknown nested fields.
- •Federation or stitching gateways where subgraphs return errors that must be remapped into the combined response; verify that `path` is rewritten using the gateway's path-prefix map rather than dropped.
- •Client-side caches and normalization layers (Apollo, Relay, urql) that may rewrite the operation and lose `path` correlation if the operation is reconstructed locally without alias fidelity.
Diagnostic steps
- 01Send a known operation whose selection set is intentionally constructed to fail on one leaf while succeeding on another (for example: `mutation { ok: updateX ...; bad: updateY ... }` with input that triggers a validation error on `bad`).
- 02Inspect the response: confirm `data` is an object (not `null`), confirm `errors` is an array, and confirm each `errors[i].path` is a non-empty array whose elements match a segment of the selection path including aliases and numeric list indices.
- 03Cross-reference each `path` segment against the original request document: aliases must appear as strings, list indices as integers, and field names must reflect the response shape defined by the schema.
- 04Compare server-side logs that record the original `GraphQLError` against the wire payload to see whether `path`, `locations`, and `extensions` survive the trip through your formatter and transport.
- 05Run the same operation through a reference executor (`graphql.execute` in `graphql-js`) over your resolvers in a unit test, bypassing any HTTP or middleware layer; if `path` is correct here but absent on the wire, the loss is in the formatter or transport.
- 06For federated or stitched topologies, replay the failing operation against each subgraph in isolation and compare each subgraph's `path` with the gateway's reported `path`; discrepancies indicate rewriting or dropping during merging.
- 07For subscriptions or incremental delivery, replay the operation with `@stream`/`@defer` and confirm that each subsequent payload carrying a failure also includes a populated `path`, not just the initial payload.
Common mistakes
- •Assuming any `errors` array is sufficient for observability: the spec requires `path` to identify the field, and an error without `path` cannot be mapped back to a specific selection in a non-trivial query.
- •Treating `"path": []` as a valid "request-level" error: such entries have no execution position and force clients to retry the entire operation when only one subtree failed.
- •Logging only `error.message` and discarding `path`, `locations`, and `extensions`, which removes the evidence needed to verify field-level attribution.
- •Sanitizing error responses by stripping nested arrays under the assumption that they are "verbose"; this conflates redaction (which targets identifiers) with structural trimming (which destroys attribution).
- •Wrapping every resolver exception in a try/catch that constructs a fresh `Error` instead of forwarding the original `GraphQLError`, which causes the server to lose the recorded execution position.
Safe fixes
- •If a custom error formatter is stripping `path`, change the formatter to return the original error's `path`, `locations`, and `extensions` unchanged, and add a unit test that asserts these fields survive end-to-end for a known partially-failing operation.
- •If resolvers throw non-`GraphQLError` exceptions, wrap them at the resolver boundary into `GraphQLError` with `nodes`/`path`/`extensions` preserved from the original execution context; never construct a generic error without recording the execution position.
- •If a transport or proxy is rewriting JSON, exclude the GraphQL response from body-rewriting rules and validate end-to-end that nested arrays (`errors[].path`, `errors[].extensions`) are byte-stable between server and client.
- •If a DataLoader or batch helper aggregates failures, surface one `GraphQLError` per failed load with its original `path`, rather than a single collapsed error with `path: []`.
- •If a federation or stitching gateway drops `path`, implement explicit path-prefix rewriting during subgraph merge and assert in tests that each error's `path` resolves to a real field in the gateway's composed schema.
Prove the fix
- 01Replay the canonical partially-failing operation against the server and observe `errors[].path` populated as a non-empty array whose segments (strings for field/alias names, integers for list indices) match the selection path, alongside a `data` object that still contains values for sibling fields that did not fail.
- 02Run an automated check (unit test or contract test) that asserts (a) `errors` is non-empty, (b) each `errors[i].path` is a non-empty array, (c) each `errors[i].path[0]` corresponds to a top-level field in the selection set, and (d) `extensions` survives the formatter unchanged.
- 03For incremental delivery, confirm that subsequent payloads carrying deferred or streamed failures each include `path` reflective of their position in the original payload, not a residual or empty array.
- 04Compare a recorded wire payload from before and after the fix; the only allowed differences are message text and dynamic identifiers, while `path`, `locations`, and `extensions` structure must be byte-stable.
Prevention and next steps
- •Treat `path`, `locations`, and `extensions` as part of the public API contract: add conformance tests that assert these fields are populated for any operation that yields at least one error alongside non-null `data`.
- •Pin the custom error formatter behavior with snapshot tests covering at least one partial-failure, one top-level error, one list-indexed error, and one aliased-field error, so unintentional formatter changes are caught in CI.
- •Document for operators that reverse proxies and CDNs must not rewrite JSON bodies for the GraphQL endpoint, and exclude this path from any generic JSON-filtering or compression-with-truncation rule.
- •For federated or stitched deployments, maintain a path-prefix map test fixture per subgraph and verify that gateway response merging preserves `path` for every error returned by every subgraph.
- •For incremental delivery paths, include `path` fidelity in the rollout checklist so partial-result upgrades are not shipped without per-payload error attribution.
Safe commands and checks
echo 'Capture the response body and confirm Content-Type and that errors[].path is populated.'
cat response.json | python -c 'import json,sys; d=json.load(sys.stdin); assert "errors" in d and d["errors"]; [print(e.get("path"), e.get("extensions")) for e in d["errors"]]'
grep -n 'formatError\|willSendResponse\|errorHandler\|formatErrorFn' server.config.* resolver/middleware/*.* 2>/dev/null | head -n 20
grep -n 'new GraphQLError\|GraphQLError(' src/resolvers/**/*.* 2>/dev/null | head -n 20
node -e 'const {graphql, buildSchema} = require("graphql"); const schema = buildSchema("type Q { a: String, b: String }"); graphql({schema, source: "{ a bad: b }", rootValue: {a: () => "ok", b: () => { throw new Error("boom"); }}}).then(console.log)'
echo 'Replay a partially-failing operation; replace <endpoint> with your environment endpoint placeholder (no loopback).'
curl -sS -H 'Content-Type: application/json' -X POST <endpoint> --data-binary @query.json | jq '.errors[].path'
cat response.json | jq '.errors[] | {message: .message, path: .path, locations: .locations, extensions: .extensions}'