Skip to content
advanced Phase 4 · Resolvers and Data Source Integration

Error Propagation in Resolvers

How thrown errors become GraphQL errors and how nullability shapes their spread.

45m
2 problems
Topic Progress 0%

What the Client Sees When a Resolver Throws

What the Client Sees When a Resolver Throws

Post: {
  author: async (post, _a, ctx) => {
    const rows = await ctx.pool.query(
      'SELECT id, name FROM users WHERE id = $1', [post.authorId]);
    return rows[0];   // the connection drops mid-query and this throws
  },
}

The executor catches the throw — a resolver crash does not take down the HTTP handler — and converts it into a field error alongside whatever data could still be produced:

{
  "data": { "post": { "title": "Resolvers", "author": null } },
  "errors": [
    {
      "message": "connect ECONNREFUSED 10.4.2.9:5432",
      "path": ["post", "author"],
      "locations": [{ "line": 3, "column": 5 }],
      "extensions": { "code": "INTERNAL_SERVER_ERROR" }
    }
  ]
}

Four properties worth internalizing:

  • HTTP status stays 200. Execution errors are reported in the body; only malformed requests or failures before execution tend to change the status.
  • Partial responses are valid results. title still resolved — errors and data ship together, and data is only null when the cascade reaches the top.
  • path locates the failure as ["post", "author"], so clients, logs, and alerts can point at a field instead of parsing prose.
  • locations refers to the document, which is useless for variable-driven inputs — path is what to log.

The item no bullet mentions is the one that bites: message came straight from the thrown error. The client receives whatever text the exception carried — SQL fragments, hostnames, connection strings — while extensions.code falls back to the generic INTERNAL_SERVER_ERROR because nothing classified it. Sanitizing and classifying that message is the third chapter.

Two more habits worth forming while reading responses. A single document can produce several entries: each failing field contributes its own, so three paths in errors means three distinct failures at three depths, never one summary. And this shape only exists once execution has started — a document that fails validation arrives with data absent entirely and no path on the error, because no field was ever reached.

Nullability Is the Bubbling Rule

Nullability Is the Bubbling Rule

Whether an error dies at one field or empties half the response is decided entirely by nullability.

The rule: a non-null field cannot represent failure as null, so the null propagates to the nearest nullable ancestor — and keeps climbing if that ancestor is non-null too.

Query.post: Post          nullable   ← stops the cascade
Post.author: Author       non-null
Author.email: String!     non-null   ← resolver fails here
error at ["post", "author", "email"]
  email  is String!  → cannot be null here, bubble up
  author is Author!  → non-null, keep bubbling
  post   is Post     → nullable, absorbs it
{
  "data": { "post": null },
  "errors": [
    { "message": "Internal", "path": ["post", "author", "email"] }
  ]
}

One leaf failure removed the entire post — including title, which had already resolved successfully. Lists follow the same logic: if a non-null subfield fails inside [Author!]!, the whole list goes null rather than growing a hole.

Failing field Effect on the response
nullable leaf (email: String) that field alone becomes null
non-null leaf (email: String!) null bubbles to the nearest nullable ancestor
non-null item inside a nullable list the list becomes null
cascade reaching a non-null root field data itself becomes null

The design consequence is easy to state and hard to follow: non-null is a promise that the field always produces a value. Marking everything ! looks strict, but one failed dependency then blanks the response; leaving a leaf nullable preserves the data that did resolve. Deeper semantics live in null propagation semantics.

Sanitize, Classify, Control the Blast Radius

Sanitize, Classify, Control the Blast Radius

Sanitize. Never let raw exception text out:

// WRONG — SQL and infrastructure details become public
throw new Error('SELECT failed: ' + err.message);

// BETTER — detail stays server-side, clients get a stable message and code
ctx.logger.error({ err, requestId: ctx.requestId, path: info.path }, 'author lookup failed');
throw new PublicError('Author could not be loaded', { code: 'UPSTREAM_UNAVAILABLE' });

The contract to implement: message for humans, extensions.code for machines, everything else in logs keyed by the request id built in resolver context. Clients branch on codes, never on message strings.

Classify. Where an error belongs follows from who can act on it:

Failure Destination
input the caller can fix payload userErrors with a field path (mutation return payloads)
unauthenticated / forbidden top-level error, code UNAUTHENTICATED / FORBIDDEN
record not found null if null is meaningful, otherwise a not-found error
dependency unavailable top-level error, code UPSTREAM_UNAVAILABLE, marked retryable

Control the blast radius. Error handling and schema design meet at nullability. If Post.author were declared non-null, the failed lookup in chapter one would have nulled the whole post; with Post.author: Author nullable, clients still render the title and show an author placeholder. Mark a field non-null only where a null value would be useless to the caller — every ! you write is a decision about how much of the response one failure may destroy.

Interview angle: an interviewer will hand you a schema with a failing deep resolver and ask for the response body. Walk the tree bottom-up applying the bubbling rule, state the resulting path, note that HTTP stays 200, then say what you logged and what the client was allowed to see.

Practice Problems

0 / 2 solved
Predict the Exact Response for a Deep Null Failure
Error Propagation

Given a schema and a resolver that throws at the deepest field, write the response body the client receives. Query.post is nullable, Post.author is non-null, Author.email is String!, and Post.title resolves before the failure.

Example:

Input: { post(id: "p1") { title author { name email } } } — the email lookup throws

Output: { "data": { "post": null }, "errors": [ { "message": "Upstream unavailable", "path": ["post","author","email"], "extensions": { "code": "UPSTREAM_UNAVAILABLE" } } ] }

email is String! so the null moves to author (non-null), then to post (nullable) — one error entry, title lost with the cascade.

Optimal Solution — O(1) time, O(1) space

Apply the bubbling rule bottom-up: stop at the first nullable ancestor, keep everything resolved beneath it discarded, and attach one error at the throwing field.

// schema
type Query { post(id: ID!): Post }        # nullable
type Post  { title: String! author: Author! }
type Author { name: String! email: String! }

// failing resolver
Author: { email: async () => { throw new PublicError('Upstream unavailable', { code: 'UPSTREAM_UNAVAILABLE' }); } }

// response
{
  "data": { "post": null },
  "errors": [
    {
      "message": "Upstream unavailable",
      "path": ["post", "author", "email"],
      "locations": [{ "line": 4, "column": 7 }],
      "extensions": { "code": "UPSTREAM_UNAVAILABLE" }
    }
  ]
}

Edge Cases:

  • If Query.post were non-null too, data itself would be null
  • A second independent failing sibling adds a second entry rather than replacing the first
  • Making email nullable converts the whole-post blackout into one null leaf
Sanitize a Resolver That Leaks Database Errors
Error Hardening

A resolver rethrows whatever the database driver raises, so clients receive SQL text and hostnames. Rewrite it so the response carries a stable message and an extensions.code, the full detail is logged with the request id, and expected failures (no rows) are not errors at all.

Example:

Input: A query with a typo hits production: error 42601 from Postgres

Output: Client: { "message": "Author could not be loaded", "extensions": { "code": "INTERNAL" } } — log line contains the SQL, driver message, requestId, and path

Operators reproduce from the log using the request id; attackers learn nothing about the schema.

Optimal Solution — O(1) time, O(1) space

Log the original error with request context, then throw a sanitized error carrying a machine-readable code; treat empty results as null.

class PublicError extends Error {
  constructor(message, extensions) { super(message); this.extensions = extensions; }
}

async author(post, _args, ctx) {
  try {
    const rows = await ctx.pool.query(
      'SELECT id, name FROM users WHERE id = $1', [post.authorId]);
    return rows[0] ?? null;                  // no row = null, not an error
  } catch (err) {
    ctx.logger.error(
      { err, requestId: ctx.requestId, path: ['post', 'author'] },
      'author lookup failed');
    throw new PublicError('Author could not be loaded', { code: 'INTERNAL' });
  }
}

Edge Cases:

  • Connection-pool timeouts deserve a retryable code so clients know a retry may help
  • Logging must not capture the whole args object when it carries tokens or personal data
  • Never swallow the error: returning null after a failure hides the incident from alerts

Quiz

1. A resolver throws on Author.email (String!), Post.author is non-null, and Query.post is nullable. What does the response contain?

Question 1 options

2. What HTTP status accompanies a resolver that throws during execution?

Question 2 options

3. A database error message reads "syntax error at or near \"usrs\"". What should the client receive?

Question 3 options

4. Why does declaring every field non-null make failures worse?

Question 4 options

Flashcards

Question

Field error shape

Answer

message (human), path (which field), locations (position in the document), extensions.code (machine). Produced when a resolver throws.

Question

Null bubbling rule

Answer

An error on a non-null field cannot be represented there, so null propagates to the nearest nullable ancestor — lists null entirely if a non-null item fails.

Question

Partial response

Answer

errors[] ships alongside data; fields that resolved before the failure remain. data becomes null only when the cascade reaches the top.

Question

Sanitization contract

Answer

message for humans, extensions.code for machines, stack traces and SQL in logs keyed by request id — never in the response.

Question

Non-null as a failure promise

Answer

Every ! says "this always produces a value"; one failed dependency then blanks the ancestor. Leave leaves nullable to keep partial data.

Revision Notes

Key Takeaways

  • 1. A throw becomes a field error with path and extensions while sibling fields keep resolving
  • 2. Nullability decides blast radius: non-null errors bubble to the nearest nullable ancestor
  • 3. HTTP stays 200 for execution errors — clients read data and errors, not status codes
  • 4. Sanitize messages, put stable codes in extensions, and keep detail in logs tied to the request id

Interview Tips

  • • Given a schema and a failing deep field, produce the response JSON: walk bottom-up through the nullability, write the path, and state what the sibling fields return
  • • Discuss the tradeoff openly — "every ! I write is a decision about how much one failure may destroy" — interviewers are listening for design judgment, not just the rule

Cheat Sheet

Error Propagation Cheat Sheet

{
  "data": { "post": null },
  "errors": [
    { "message": "Author could not be loaded",
      "path": ["post", "author"],
      "locations": [{ "line": 3, "column": 5 }],
      "extensions": { "code": "UPSTREAM_UNAVAILABLE" } }
  ]
}
  • Throw in a resolver → field error; HTTP stays 200; siblings still resolve
  • Bubbles: failing field non-null → nearest nullable ancestor; list fails as a whole if an item is non-null
  • data is null only when the cascade reaches a non-null root field
  • message = human, extensions.code = machine, stack/SQL = logs with request id
  • Caller-fixable input → payload userErrors; auth/infra → top-level errors with codes
  • Every ! is a promise: fewer of them means more partial data survives

Continue Learning