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.
titlestill resolved —errorsanddataship together, anddatais only null when the cascade reaches the top. pathlocates the failure as["post", "author"], so clients, logs, and alerts can point at a field instead of parsing prose.locationsrefers to the document, which is useless for variable-driven inputs —pathis 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
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
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?
2. What HTTP status accompanies a resolver that throws during execution?
3. A database error message reads "syntax error at or near \"usrs\"". What should the client receive?
4. Why does declaring every field non-null make failures worse?
Flashcards
Question
Field error shape
Click to reveal answer
Answer
message (human), path (which field), locations (position in the document), extensions.code (machine). Produced when a resolver throws.
Question
Null bubbling rule
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
datais 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