Class 3: GraphQL over HTTP: Status Codes and Media Types
Duration: 30 minutes | Difficulty: Intermediate | Prerequisites: Class 2 of this course; basic familiarity with HTTP semantics (methods, headers, status codes).
What You'll Learn
By the end of this class, you will:
- Know what the GraphQL-over-HTTP specification is, its current maturity, and how much weight its rules carry
- Understand the two media types,
application/graphql-response+jsonandapplication/json, and why the newer one exists - Apply the status-code rules: the
2xxrequirement,200, the new294 Partial Success, and the full4xx/5xxtable - Understand who status codes are actually for (intermediaries, not GraphQL clients) and what that means operationally
- See how the proposed
@deferand@streamdirectives split one response into several payloads, and where errors land when they do - Know which parts of this are normative, which are recommendations, and what changed as recently as September 2026
Why a transport chapter in an error-handling course
Class 1 ended with the observation that the GraphQL specification does not mention HTTP. That silence had a practical consequence: for years, many GraphQL servers answered every request with 200 OK and put all the information in the body. Servers did this because, with no rules at all, returning 200 and letting the client parse the body was the easiest option. The folklore version, "GraphQL always returns 200", then hardened into arguments that GraphQL is unobservable, uncacheable, and hostile to standard HTTP tooling.
The layer that reads status codes is the layer that cannot parse GraphQL. That layer includes:
- CDNs deciding whether to cache.
- Proxies and load balancers deciding whether to retry.
- API gateways enforcing policies.
- Log pipelines and dashboards computing error rates.
- Intrusion-detection systems watching for anomalies.
When every response is 200, that whole layer is blind. A deploy that breaks half your queries produces the same access-log signature as a healthy day. This is an error-handling problem in the strictest sense: the errors are all there in the bodies, and none of the infrastructure between server and client can see them.
The GraphQL-over-HTTP specification exists to fix this. It is developed by the GraphQL Foundation's HTTP working group in the graphql-over-http repository. As of September 2026 its own status note labels it "Stage 2: Draft", describes that stage as "a fully formed solution", and adds a warning:
Drafts may continue to evolve and change, occasionally dramatically, and are not guaranteed to be accepted.
The introduction sets out how the document relates to the GraphQL specification:
This specification does not override or replace the GraphQL specification; it extends it to cover the topic of serving GraphQL services over HTTP.
Its normative language follows RFC 2119 (MUST, SHOULD, MAY), and its notes and examples are declared non-normative, a distinction that will matter at the end of this class.
The two media types
The specification recognizes two media types: application/json for request bodies, and application/graphql-response+json for responses. The second one carries most of the specification's design. The Body section says a server "MUST support responses using the application/graphql-response+json media type", and it defines a legacy server as one that does not, and "thus does not conform to this specification".
Why responses have their own media type
Why invent a media type when the body is plain JSON either way? Because the Content-Type header is a promise about who produced the response. A 503 with Content-Type: application/json might be your GraphQL server shedding load, or it might be a cloud load balancer that never forwarded the request at all, and the two bodies look nothing alike. A response labeled application/graphql-response+json is guaranteed to be a well-formed GraphQL-over-HTTP response, whatever the status code says. A note in the Body section states the consequence for clients:
HTTP responses could originate from non-GraphQL intermediary servers and middleware handling failures (HTTP
4xxand5xx), so clients typically can only rely on a response to be from GraphQL either when it is successful (HTTP2xx) or when it explicitly declares it is a GraphQL-over-HTTP response (Content-Type: application/graphql-response+json).
The diagram shows how a client applies that note to a reply.
That unlocks the central design move of the specification, stated in the first line of its Status Codes section:
Clients should process a response using the
application/graphql-response+jsonmedia type as a well-formed GraphQL-over-HTTP response independent of the HTTP status code.
The sentence inverts the usual model. The status code is not aimed at the GraphQL client at all, because a client receiving this media type always reads the body regardless of the code. The note that follows says who the status code is for:
Intermediary servers and services may use the status code to determine the status of the GraphQL-over-HTTP response without needing to process the response body; this is useful in request logs, developer tooling, anomaly and intrusion detection, metrics and observability, API gateways, and more.
Once the client is reading the body regardless, servers become free to use accurate status codes without breaking anyone, a freedom servers did not have while clients inferred meaning from the status code.
Content negotiation
Clients participate through content negotiation. The Accept section sets the rule for a compliant client:
The client MUST include the media type
application/graphql-response+jsonin theAcceptheader.
The spec suggests Accept: application/graphql-response+json, application/json;q=0.9 when the server's support is unknown. The q=0.9 is a quality value, a weight between 0 and 1 that RFC 9110 defines for content negotiation. A media type without q has the default weight of 1, so this header asks for application/graphql-response+json first and accepts application/json as a fallback. For a legacy client that only accepts application/json, the Body section tells the server what to do:
If the
Acceptheader does not indicate support for one of the server's preferred media types but does indicate support forapplication/json, as may be the case for a legacy client, the request SHOULD be performed following the requirements of this specification as if the request had specifiedAccept: application/graphql-response+json, except any response that produces a2xxstatus code should replace theContent-Typeheader withContent-Type: application/json.
So even legacy clients get the full range of accurate status codes; only the label on successful responses changes.
Two more cases complete the rules, both in the Body section and its notes. If the Accept header lists neither application/graphql-response+json nor application/json, the server SHOULD reply with 406 Not Acceptable, although it may instead ignore the header and choose a media type itself. If the client sends no Accept header at all, the server may respond with an error or with any media type it chooses, which is one reason a compliant client always sends the header.
Earlier iterations of the draft kept two parallel rule sets for the two media types. PR #404, merged on 13 July 2026, removed the legacy rules and left this single scheme. The diagram follows it to the Content-Type a server sends.
The status-code rules
Every rule below depends on the two questions that separate the kinds of response in Class 1: does the GraphQL result contain data, and does it contain errors? Everything below quotes the Status Codes section as of September 2026. PR #423, merged on 3 September, changed its wording to speak of the "GraphQL result" and the "GraphQL-over-HTTP response", and the rules themselves stayed the same.
Execution happened and produced data. Two rules stack here. The first is the only unconditional MUST on the success side:
If the GraphQL result contains the
dataentry and it is notnull, then the server MUST reply with a2xxstatus code.
The second is a SHOULD:
If the GraphQL result contains the
dataentry and does not contain theerrorsentry, then the server SHOULD reply with a200status code.
Clean success is 200. A GraphQL result with non-null data can never legitimately be sent with a 4xx or 5xx, no matter how many execution errors it carries.
Execution happened and errors accompany the data. This is the partial-success case from Classes 1 and 2. Since April 2026 the draft has a dedicated answer:
If the GraphQL result contains both the
dataentry (even if it isnull) and theerrorsentry, then the server SHOULD reply with a294status code.
The accompanying note explains why:
Using
4xxand5xxstatus codes in this situation is not appropriate: since no GraphQL request error has occurred it is seen as a "partial response" or "partial success".
294 is not an IANA-registered status code, and the spec is open about that in its non-normative Partial success discussion. The working group examined the existing candidates:
203 Non-Authoritative Information, which "indicates the response has been transformed".206 Partial Content, which "requires theRangeheader".- WebDAV's
207 Multi-Status, which "provides status for multiple independent operations".
The discussion concludes:
None of those quite fit GraphQL's needs, so we recommend using the custom code
294 Partial Success. Since we are defining the code ourselves, rather than the IETF, we only recommend its usage alongside theapplication/graphql-response+jsonmedia type which makes the meaning explicit.
Being in the 2xx class, it satisfies the MUST above and reads as success to any HTTP infrastructure that does not know it.
The spec is also candid about its audience:
This status code is not to help clients, who should ignore the status code of a response when receiving the
application/graphql-response+jsonmedia type, but allows servers to indicate partial success such that intermediaries that do not implement this specification may still track the not-fully-successful request (for example, for observability).
Your dashboard can now chart clean successes against partial successes from access logs alone, which was the exact capability the always-200 world lacked. The change is recent: PR #346, "Use 294 as the partial status code", merged on April 2, 2026. Registering 294 with IANA is on the HTTP working group's agenda: PR #436 added the item on 17 September 2026, and PR #441 moved it, together with a release-status item, to the meeting on 29 October 2026.
Execution never happened. The rule for this case is also a MUST:
If the GraphQL result does not contain the
dataentry then it represents a GraphQL request error result and the server MUST reply with an appropriate4xxor5xxstatus code:
This is the request-error case from Class 1 (recall: a request error result must not contain data at all), plus every failure that precedes GraphQL entirely. The spec then divides by fault, in exactly the spirit of Class 1's "typically the fault of" sentences. When "the failure is due to an issue in the request itself", the 4xx list applies, and it is specific enough to quote as a table:
| Condition | Status |
|---|---|
| JSON body of the request could not be parsed | 400 |
| GraphQL document could not be parsed | 400 |
| Not a well-formed GraphQL-over-HTTP request (wrong parameter names or shapes) | 422 |
| Request fails GraphQL validation | 422 |
| Operation to execute cannot be unambiguously determined | 422 |
| Variable values do not match the types the operation declares | 422 |
| Client not permitted to issue the request | 401, 403, or similar |
Mutation attempted via GET, or unsupported HTTP method | 405 |
Unsupported Content-Type on the request | 415 |
No acceptable media type in Accept (and no application/json) | 406 |
| Client did not send the request within the time the server was prepared to wait | 408 |
URI too large (the draft suggests the client consider POST instead) | 414 |
| Request headers, or any one header, too large | 431 |
POST request body too large | 413 |
Every row is a SHOULD in the source, and each maps a request-error cause from the GraphQL spec to a conventional HTTP code. Parse failures are 400, because the bytes cannot be understood, whether they are the JSON body or the GraphQL document. Every other problem with the GraphQL request itself is 422 Unprocessable Content: a request that is not a well-formed GraphQL-over-HTTP request, a document that fails validation, an operation that cannot be determined, or variable values of the wrong type. The split gives HTTP-level observers real diagnostic signal: a spike in 422 means clients are sending queries the schema rejects (often a deploy skew between client and server), while a spike in 400 means something is mangling payloads.
For failures on the server side, the section says:
When the server is the reason for failure, the appropriate
5xxstatus code should be used. For example, if the server is not able to execute requests at this time due to maintenance or load-shedding, then status code503SHOULD be used.
When an error prevents a well-formed GraphQL-over-HTTP response entirely, the server "MUST NOT use the application/graphql-response+json media type". That keeps the media type's promise intact: the label only ever sits on a well-formed GraphQL-over-HTTP response.
Two boundary notes from the same section complete the picture. The first rules out a case:
There are no circumstances where the GraphQL specification allows a GraphQL result to have
dataasnullwithouterrorsbeing present.
So data: null alone is not a case you need a rule for. The second note reminds implementers that HTTP's own requirements still apply on top, for example that a 405 must carry an Allow header per RFC 9110.
The decision path, end to end
This section restates the rules in execution order, the order in which server middleware encounters them:
-
Could the HTTP request be read at all (headers, sizes, timeouts)? If not, the specific
4xxfrom the table, or5xxif the server is at fault, with a plain media type. -
Could the body be parsed as JSON, and is it a well-formed GraphQL-over-HTTP request? If not,
400for JSON that does not parse, and422for a request that is not well-formed. -
Did the document parse, pass validation, and identify the operation to run, with variable values of the right types? If not,
400for a document that does not parse, and422for the rest. These are the request errors of Class 1, and the body carries the request error result.A document can contain several operations, so the server has to decide which one to run. The request's
operationNameparameter names it. When the request leavesoperationNameout, the specification'sGetOperation()rule requires the document to contain exactly one operation. A missing name in a document with several operations is a request error, and so is a name the document does not define. The draft maps both to422, the case where "the operation to execute cannot be unambiguously determined". -
Did execution complete without raising any errors? Then
200, withdatain the body and theerrorsentry absent. -
Did execution complete with execution errors? Then
294, withdatapresent even if propagation nulled it entirely. The body carries both entries, and Class 2's blast-radius analysis tells you how much data survived. -
Did an error prevent a well-formed GraphQL-over-HTTP response? Then
5xx, without the GraphQL media type, and the client should treat the body as untrusted.
Notice how closely the HTTP layer now mirrors the GraphQL error model:
- Request errors map to
4xx. - Execution errors map to
294. - Clean success maps to
200. - Infrastructure failure maps to
5xx.
The transport now translates the error model for observers who cannot read GraphQL. That translation is precisely what Class 4 will do again one layer up, when extensions.code gives machine-readable classification to the errors inside the body.
When the response arrives in pieces: @defer and @stream
Everything above assumes one request produces one response body. A specification proposal removes that assumption. With the @defer directive a client marks a fragment as safe to deliver late, the server answers with the rest of the response first, and the deferred fields follow in later payloads. @stream does the same for the items of a list. The proposal began as the working group's DeferStream RFC, which notes that Facebook has used incremental delivery at scale since 2017. The specification text now lives in graphql-spec PR #1110, which is still open. The stable graphql-js v17 release ships @defer and @stream as experimental, and as of September 2026 all of it is still outside the ratified specification, so every claim in this section carries a date.
The error rules are the part this course cares about. Under the proposal, the response becomes an incremental stream. The pull request's text calls the first payload the initial incremental stream result and each later one an incremental stream update result, where the RFC spoke of initial and subsequent payloads. A payload lists newly delivered data under incremental, marks finished deferred work under completed, and carries hasNext: false when it is the last. Class 2's propagation rules still execute, and the RFC's payload format decides where a deferred failure is reported:
If the error did not bubble above an
incrementalobject boundary, it will be present on theincrementalobject. If an error bubbles above the boundary, causing the@deferor@streamto fail, it will be present on thecompletedobject.
A client consuming a deferred response therefore has to watch two places for errors, and data that arrived in an earlier payload has already been delivered, whatever a later payload reports.
The transport half is also in progress, as a separate incremental delivery RFC in the GraphQL-over-HTTP repository: the payloads travel as parts of a multipart/mixed response, each part a JSON document. That format has a consequence for everything this class said about status codes. The status line is sent once, before the later payloads exist, so it can only describe how the response started. A request that fails halfway through delivery does so behind a 2xx that is already on the wire. Intermediaries lose part of the signal the status-code rules restored, which is one reason to treat incremental delivery as its own adoption decision, separate from the media-type and status-code adoption below.
What to adopt today
Here is a reading for September 2026, given the document's own warning that "it is unwise to rely on a draft in a production GraphQL Service". At the GraphQL Working Group meeting on 6 August 2026 the group agreed that the final changes should land within days, to start a 45-day countdown toward a release. PR #416, a wording change from that meeting, merged the same day. Two more wording changes merged after that, on 27 August and 3 September, and two editorial changes followed on 17 September: PR #437 added the notes on 294 quoted below, and PR #439 tidied wording throughout. The release itself is being prepared: PR #434 names the planned edition "September2026" and was still open on 22 September, and the working group notes of 3 September leave room for a slip to November. As of 22 September 2026 the document is still labelled Stage 2: Draft. Check the stage note at the top of the document before deciding how much caution the word "draft" still warrants.
The parts that were always allowed carry the least risk, and the newest part carries the most:
- Accurate
4xxcodes for requests that did not execute were always allowed, and the graphql-http project ships an audit suite that checks a server against the specification. - The new media type, supported via content negotiation, is additive and safe, since legacy clients keep getting
application/jsonon success. - The
294code is the newest and least-settled piece: it entered the draft in April 2026, it is deliberately non-IANA, and the spec couples it to the new media type.
Adopting 294 means confirming that your own infrastructure (CDN rules, gateway policies, log parsers, uptime checks) treats unknown 2xx codes as the 2xx class, as RFC 9110 requires. Since 17 September 2026 the Partial success section says so itself, in two paragraphs that PR #437 added. The first states that HTTP clients which do not recognize 294 "must treat it as equivalent to 200 OK". The second warns:
Infrastructure may apply specific behaviors based on a fixed list of status codes; because
294is unlikely to appear in such lists by default,294might be handled differently from200.
The example that prompted the note is issue #435, closed the same day: nginx's add_header applies only to a fixed list of status codes unless the always parameter is set, so CORS and HSTS headers disappear from 294 responses.
One more detail shows why the difference between normative and non-normative text matters. The examples subsection of the section we have been quoting has an entry headed "Field errors encountered during execution". It still says a server SHOULD respond with 200, "even if a GraphQL execution error is raised". The example predates the 294 change, and its heading still uses the old "field error" term from Class 1. The normative rule and the example currently disagree, and the normative rule wins. When you cite a specification under active development, quote the normative text, date the claim, and expect the examples to lag behind occasionally.
Summary
The GraphQL-over-HTTP specification (Stage 2 Draft, GraphQL Foundation) maps the error model of Classes 1 and 2 onto HTTP. Its central move is the application/graphql-response+json media type: clients process such responses from the body alone, independent of status code, which frees the status code to carry accurate signal for intermediaries (CDNs, proxies, gateways, logs, monitoring). The rules:
| Response shape | Status |
|---|---|
non-null data | MUST be 2xx |
| data, no errors | SHOULD be 200 |
| data plus errors | SHOULD be 294 Partial Success, a deliberately custom code adopted in April 2026 and recommended only alongside the new media type |
no data entry | MUST be 4xx or 5xx |
- No
dataentry.400covers parse failures, and422covers a request that is not well-formed, validation failures, an operation that cannot be determined, and variable values of the wrong type.401and403cover permission, and405,415,406and the size and timeout family cover transport-level problems. - Server faults.
503is the canonical server-fault example. When an error prevents a well-formed GraphQL-over-HTTP response, the reply must not carry the GraphQL media type. - Incremental delivery. The proposed
@deferand@streamdirectives (working-draft stage, experimental in graphql-js v17) turn the response into an incremental stream, sent asmultipart/mixedparts. They report a deferred failure on theincrementalorcompletedentry, depending on how far the error propagated, and reduce the status code to a statement about how the response began. - Examples that lag. The draft's non-normative examples still lag the
294change, a reminder to cite normative text and date your claims.
Further Reading
- GraphQL-over-HTTP specification, draft: the primary source for this class; the Status Codes and Response sections are the parts to read in full, and the status-codes anchor the course keeps citing is the permanent reference.
- graphql-over-http repository: the working group's home; PR #346 (merged 2026-04-02) is the
294change and its discussion thread records the reasoning first-hand, and issue #435, closed on 17 September 2026, records how deployed proxies and CDNs treat the unregistered code and led to the notes that PR #437 added. - graphql.org, Serving over HTTP: the official learn page and a gentler introduction, covering endpoints, methods, headers, and where authentication middleware belongs relative to GraphQL execution. As of September 2026 its status-code section still reflects the older draft (
400for validation, and294is absent), so treat the draft itself as the authority. - IETF RFC 9110, HTTP Semantics: the HTTP specification the draft defers to; its definitions of the status-code classes explain why an unknown
294must be treated as generic2xxby conforming software. - IETF RFC 2119, Key words for use in RFCs: two pages defining MUST, SHOULD, and MAY; knowing these precisely is what separates quoting a spec from paraphrasing it.
- GraphQL working group, DeferStream RFC: the original proposal for
@deferand@stream, including the payload format and the error-placement rule quoted in this class. - graphql-spec PR #1110, Incremental Delivery spec draft: the specification text for
@deferand@stream, still open as of September 2026. - GraphQL-over-HTTP, incremental delivery RFC: the transport half of the proposal, delivering subsequent payloads as
multipart/mixedparts.
What's Next
The status code classifies the whole response; the errors inside the body still need classification of their own. Class 4 builds an error taxonomy and puts machine-readable codes in extensions.code, surveying the vocabularies Apollo Server and Netflix DGS ship so you can design one deliberately.