The Status Line That Never Complains
The Status Line That Never Complains
A team ships a mobile feed screen. The server answers HTTP 200, the client interceptor sees green, the screen renders blank. Twenty minutes later the logs show a resolver threw on a soft-deleted row. Nothing in the status line mentioned it.
REST habits die hard: check the status, branch on 4xx and 5xx, done. That reflex is wrong for GraphQL. The HTTP layer only carries the request to the server and the bytes back; the outcome of the operation lives entirely in a body whose shape the specification fixes:
{
"data": {
"feed": [
{ "id": "p1", "title": "Retry budgets", "author": { "name": "Sam" } },
{ "id": "p2", "title": "Rollback drills", "author": null }
]
},
"errors": [
{
"message": "Cannot return null for non-nullable field Author.name.",
"locations": [{ "line": 4, "column": 17 }],
"path": ["feed", 1, "author", "name"]
}
]
}
Three rules follow from the response format:
datapresent and non-null: execution ran at least partially.errorsmay accompany it and names the fields that failed.dataabsent: execution never started. The document failed to parse, failed validation, or its variables could not be coerced, so the response carries onlyerrors.datapresent and null: execution started and a failure propagated all the way to the root of the response.errorsmust be present.
HTTP has no status code for "half of your request worked", so a fully executed operation returns 200 even when resolvers threw. Servers do disagree about the earlier phases: Apollo Server answers 400 when a document cannot be parsed or validated, while many older stacks return 200 for the same failure because they still use the legacy application/json media type.
The client rule that survives every server: treat the status line as transport health and the body as the operation's result.
| Signal | What it means |
|---|---|
200, no errors |
Fully successful |
200, errors, non-null data |
Partial success: render what resolved, report the rest |
200, data: null |
Nothing usable came back; surface a failure |
| 400 / 422 / 500 | The request never executed, or the server crashed |
Two failure shapes, two different UI responses. A missing data key is a bug in the query or the caller; a populated data with errors is a runtime degradation that partial rendering can absorb.
Which Phase Rejected You: Parse, Validate, or Execute
Which Phase Rejected You: Parse, Validate, or Execute
An entry in errors is not a category. Where it was produced decides which fields it carries and who has to fix it: the developer who wrote the query, or the runtime that must keep rendering.
Parse errors come from the tokenizer — the document has a syntax error. They carry locations pointing into the query text, never path, and no data key, because execution was never scheduled. The fix belongs to whatever produced the string: a broken code generator or a hand-edited operation.
{
"errors": [
{
"message": "Syntax Error: Expected Name, found \"}\"",
"locations": [{ "line": 3, "column": 12 }]
}
]
}
Validation errors run after parsing and before execution: an unknown field, a missing required argument, a fragment spread on a leaf type. These also point at positions in the document and also have no path — nothing executed, so there is no response position to name.
Coercion errors abort before execution too: the supplied variables do not satisfy their declared types. No path again, and the extensions usually carry per-variable detail so a form can attach the message to the right input.
Field errors are the only ones born during execution: a resolver threw, or returned null for a non-null field. These are the entries with path, and they are the ones allowed to sit next to partial data.
| Phase | locations |
path |
data key |
Owner of the fix |
|---|---|---|---|---|
| Parse | into the document | absent | absent | Query author or codegen |
| Validate | into the document | absent | absent | Query author or codegen |
| Coerce variables | usually into the document | absent | absent | Caller |
| Execute | into the document | response path | partial or null | Server or data source |
path walks the response, not the schema. ["feed", 1, "author", "name"] reads: field feed, list index 1, field author, field name. Field names are strings, list indices are numbers, and that is exactly the key a logger needs (feed.1.author.name) to group failures by the UI element that broke.
A single response can carry several entries — each failed field contributes its own — and their order follows execution order, not importance. Servers do not deduplicate repeated failures of the same path, so client-side aggregation is worth doing before hitting the error tracker.
Working With Partial Data in the Client
Working With Partial Data in the Client
The two response shapes demand different reactions, and most client bugs come from treating them alike.
No data key: the operation never ran. Retrying the identical request reproduces the identical failure, so retry loops waste time. Show a global error, log the message, and stop.
Partial data: the rest of the response is real. Throw it away and you convert a cosmetic hole into a blank screen:
type GqlResult<T> = {
data?: T | null;
errors?: { message: string; path?: (string | number)[] }[];
};
function renderFeed(res: GqlResult<{ feed: Post[] }>) {
// data missing => the request never executed; there is nothing to render
if (res.data == null) return showGlobalError(res.errors?.[0]?.message);
const failedPaths = (res.errors ?? [])
.filter((e) => e.path) // only field errors have a path
.map((e) => e.path!.join('.')); // "feed.1.author.name"
for (const post of res.data.feed) {
render(post, post.author ? post.author.name : 'Author unavailable');
}
logFieldFailures(failedPaths); // grouped in the error tracker
}
Framework defaults often fight this. Apollo Client's errorPolicy: 'none' — the default — treats any GraphQL error as fatal and discards the data in the response; you must opt into errorPolicy: 'all' to receive data and errors together. Reading the raw response from a custom fetcher hides that trap, which is one reason teams standardize on one client.
Retry policy follows the same split:
- Field errors on idempotent reads: retry with backoff if the cause looks transient (a timeout, a rate limit).
- Validation and coercion errors: never retry; they are deterministic.
- Field errors on mutations: never retry automatically. The first attempt may have committed; re-run it only when the operation is declared idempotent.
Interviewers like the debugging version of this topic. The strong answer walks the chain: status line is 200, so read the body; data is null, so a non-null field failed; errors[0].path names it; the path tells you which resolver to open; the resolver shows the null the schema promised could not happen. That is the standard error shape doing its job — it converts "the app is broken" into a file to open.
Practice Problems
A client receives the response below for the query that follows. Determine, per errors entry, which phase produced it, whether the payload holds usable data, and write the client-side interpretation function that encodes those rules.
Example:
Input: query { feed(first: 2) { id title author { name } } } // author resolver for index 1 throws: "row deleted"
Output: data: partial (feed[0] complete, feed[1].author = null); errors[0] = { path: ["feed",1,"author"], execution phase }; interpretation = render + degrade, no full-page error
Execution started, so data is present; the entry has a response path, so it is a field error rather than a parse or validation failure.
Optimal Solution — O(e) time, O(e) space
Branch on the presence of the data key to separate request-level failures from execution, then split execution failures by whether a path exists: path-bearing entries are field errors that coexist with partial data, and root-null data means the whole payload is unusable.
type Entry = { message: string; path?: (string | number)[] };
type Body<T> = { data?: T | null; errors?: Entry[] };
function interpret<T>(body: Body<T>) {
// 1. no data key => parse/validation/coercion: deterministic, fix the operation
if (body.data === undefined) {
return { kind: 'request' as const, retry: false, message: body.errors?.[0]?.message ?? 'unknown' };
}
// 2. data null => a non-null failure bubbled to the root: nothing renderable
if (body.data === null) {
return { kind: 'root-null' as const, retry: false, degraded: [] as string[] };
}
// 3. partial data => render, and key the tracker by response path
const degraded = (body.errors ?? [])
.filter((e) => e.path)
.map((e) => e.path!.join('.'));
return { kind: 'partial' as const, data: body.data, degraded, retry: degraded.length > 0 };
} Edge Cases:
- errors present with data non-null but no path (server-side instrumentation entries)
- multiple entries sharing one ancestor path
- data null with an empty errors array (a non-conforming server: log and treat as a bug)
Quiz
1. A resolver throws for one field while nine siblings resolve normally. What does the HTTP layer typically report?
2. Which error carries a path field?
3. A response body is {"data": null, "errors": [...]}. What happened?
4. Why can errors be non-empty while data still contains usable values?
Flashcards
Question
Required keys in an errors entry
Click to reveal answer
Answer
message. locations, path, and extensions are optional and appear only when the phase that failed can supply them.
Question
When is data absent from a response?
Click to reveal answer
Answer
When execution never started: parse failure, validation failure, or variable coercion failure. The body then carries errors only.
Question
HTTP status for a successful execution with resolver errors
Click to reveal answer
Answer
200. HTTP has no partial-success code; clients must read the body to see errors.
Question
What path points at
Click to reveal answer
Answer
The response position of the failing field: field names as strings, list indices as numbers, e.g. feed[1].author.name.
Question
Validation error vs execution error signature
Click to reveal answer
Answer
Validation: locations into the document, no path, no data key. Execution: path present, data partial or null.
Question
Apollo Client errorPolicy none
Click to reveal answer
Answer
The default: any GraphQL error is fatal and the response data is discarded. Use errorPolicy all to keep partial data with errors.
Revision Notes
Key Takeaways
- 1. The status line reports transport health; data and errors together report the operation result
- 2. Absence of the data key means execution never started; a null data value means a non-null failure bubbled to the root
- 3. path walks the response (strings for fields, numbers for list indices) and is the key for logs and UI fallbacks
- 4. Discarding partial data turns a degraded screen into a blank one; frameworks often do it for you unless you change errorPolicy
Interview Tips
- • Answer "a client got 200 but no data" as a chain: read body, check data key, read errors[0].path, name the phase, open the resolver
- • State the three response shapes (full, partial, errors-only) and which UI behaviour each one should trigger
- • Be ready to explain why you would not retry a validation error but would retry a timeout-shaped field error
Cheat Sheet
GraphQL Response Triage
200 + data + no errors -> render
200 + data + errors -> render, degrade failed paths, log path
200 + data:null + errors -> non-null failure bubbled to root: global error
no data key + errors -> parse/validate/coerce failed: fix the query, do not retry
400/422/500 -> request never executed or server crashed
Field guide
| Phase | locations | path | data key |
|---|---|---|---|
| Parse | yes | no | absent |
| Validate | yes | no | absent |
| Coerce | usually | no | absent |
| Execute | yes | yes | partial or null |
path:["feed", 1, "author"]=feed[1].author- Apollo Client:
errorPolicy: 'none'(default) discards partial data;'all'returns data + errors - Never auto-retry mutations on field errors; never retry validation failures