Skip to main content

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+json and application/json, and why the newer one exists
  • Apply the status-code rules: the 2xx requirement, 200, the new 294 Partial Success, and the full 4xx/5xx table
  • Understand who status codes are actually for (intermediaries, not GraphQL clients) and what that means operationally
  • See how the proposed @defer and @stream directives 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 4xx and 5xx), so clients typically can only rely on a response to be from GraphQL either when it is successful (HTTP 2xx) 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+json media 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+json in the Accept header.

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 Accept header does not indicate support for one of the server's preferred media types but does indicate support for application/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 specified Accept: application/graphql-response+json, except any response that produces a 2xx status code should replace the Content-Type header with Content-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 data entry and it is not null, then the server MUST reply with a 2xx status code.

The second is a SHOULD:

If the GraphQL result contains the data entry and does not contain the errors entry, then the server SHOULD reply with a 200 status 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 data entry (even if it is null) and the errors entry, then the server SHOULD reply with a 294 status code.

The accompanying note explains why:

Using 4xx and 5xx status 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 the Range header".
  • 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 the application/graphql-response+json media 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+json media 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 data entry then it represents a GraphQL request error result and the server MUST reply with an appropriate 4xx or 5xx status 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:

ConditionStatus
JSON body of the request could not be parsed400
GraphQL document could not be parsed400
Not a well-formed GraphQL-over-HTTP request (wrong parameter names or shapes)422
Request fails GraphQL validation422
Operation to execute cannot be unambiguously determined422
Variable values do not match the types the operation declares422
Client not permitted to issue the request401, 403, or similar
Mutation attempted via GET, or unsupported HTTP method405
Unsupported Content-Type on the request415
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 wait408
URI too large (the draft suggests the client consider POST instead)414
Request headers, or any one header, too large431
POST request body too large413

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 5xx status 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 code 503 SHOULD 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 data as null without errors being 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:

  1. Could the HTTP request be read at all (headers, sizes, timeouts)? If not, the specific 4xx from the table, or 5xx if the server is at fault, with a plain media type.

  2. Could the body be parsed as JSON, and is it a well-formed GraphQL-over-HTTP request? If not, 400 for JSON that does not parse, and 422 for a request that is not well-formed.

  3. Did the document parse, pass validation, and identify the operation to run, with variable values of the right types? If not, 400 for a document that does not parse, and 422 for 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 operationName parameter names it. When the request leaves operationName out, the specification's GetOperation() 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 to 422, the case where "the operation to execute cannot be unambiguously determined".

  4. Did execution complete without raising any errors? Then 200, with data in the body and the errors entry absent.

  5. Did execution complete with execution errors? Then 294, with data present even if propagation nulled it entirely. The body carries both entries, and Class 2's blast-radius analysis tells you how much data survived.

  6. 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 incremental object boundary, it will be present on the incremental object. If an error bubbles above the boundary, causing the @defer or @stream to fail, it will be present on the completed object.

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 4xx codes 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/json on success.
  • The 294 code 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 294 is unlikely to appear in such lists by default, 294 might be handled differently from 200.

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 shapeStatus
non-null dataMUST be 2xx
data, no errorsSHOULD be 200
data plus errorsSHOULD be 294 Partial Success, a deliberately custom code adopted in April 2026 and recommended only alongside the new media type
no data entryMUST be 4xx or 5xx
  • No data entry. 400 covers parse failures, and 422 covers a request that is not well-formed, validation failures, an operation that cannot be determined, and variable values of the wrong type. 401 and 403 cover permission, and 405, 415, 406 and the size and timeout family cover transport-level problems.
  • Server faults. 503 is 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 @defer and @stream directives (working-draft stage, experimental in graphql-js v17) turn the response into an incremental stream, sent as multipart/mixed parts. They report a deferred failure on the incremental or completed entry, 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 294 change, a reminder to cite normative text and date your claims.

Further Reading​

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.