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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. 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`).
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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}'