Why a GraphQL Publisher Must Inspect Errors on HTTP 200
Why this matters
An HTTP 200 proves that a server returned a successful HTTP response. It does
not prove that the GraphQL operation inside that response succeeded.
That distinction is easy to miss in a publishing pipeline. The transport can
work perfectly while the mutation is rejected because of permissions, invalid
input, or a resolver failure. If the client checks only response.ok, it may
read missing fields as if they were a successful post and persist a result that
never existed.
The inverse is also possible: a GraphQL response can contain both data and
errors. The
GraphQL response specification
allows partial data when field errors occur, and the current
GraphQL-over-HTTP working draft
explains why field errors can still travel in a successful HTTP response.
I traced this boundary in a TypeScript publisher and tested it with a mocked
fetch. The useful result is a three-gate rule:
- Check the HTTP status.
- Inspect the GraphQL
errorslist. - Require the expected mutation data before recording success.
What I built or tested
The publisher has a small Hashnode adapter. Its shared HTTP helper rejects
non-2xx responses and attaches the status to the thrown error. After that
transport gate passes, the adapter parses a GraphQL envelope shaped like this:
interface GraphqlResponse<T> {
data?: T;
errors?: Array<{ message: string }>;
}
I exercised the adapter through its public createPost() method with five
synthetic responses:
| Response | Observed result |
|---|---|
| HTTP 200 with the expected post data | Returned the post ID and URL |
| HTTP 200 with two GraphQL errors | Rejected with both messages |
HTTP 200 with {}
|
Rejected because data was missing |
| HTTP 200 with post data and one error | Rejected under the adapter's all-or-nothing policy |
| HTTP 403 with an error body | Rejected with status 403
|
The experiment replaced globalThis.fetch, used fake configuration, and made
no network request. It verified adapter behavior, not live Hashnode
availability or credentials.
Setup
The relevant environment is Node.js 22 or later with TypeScript. A minimal
mutation client needs a target endpoint and token, but the token belongs in an
environment variable rather than source code:
const response = await fetch("https://example.invalid/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: process.env.GRAPHQL_TOKEN!,
},
body: JSON.stringify({ query, variables }),
});
For Hashnode specifically, API writes currently require an eligible Pro
publication. Hashnode's official
API access announcement
and
GraphQL agent guidance
also make an operational point that matters here: access errors should stop
the workflow instead of entering a blind retry loop.
The experiment did not require that plan or a real token because it intercepted
the request locally.
Step-by-step walkthrough
1. Separate HTTP failure from GraphQL failure
The shared HTTP helper reads the response body, then rejects a non-success
status before returning parsed JSON:
const body = await response.text();
if (!response.ok) {
const error = new Error(
`${response.status} ${response.statusText}: ${body}`,
) as Error & { status?: number };
error.status = response.status;
throw error;
}
return JSON.parse(body);
Preserving the status is important. A definite 403 has different recovery
semantics from a timeout where the client cannot prove whether the operation
ran.
But this check is only the first gate. Calling JSON.parse() after a 200 does
not validate the GraphQL operation.
2. Treat a non-empty errors list as an operation result
The adapter checks the body immediately after the HTTP helper returns:
if (response.errors?.length) {
throw new Error(
response.errors.map((item) => item.message).join("; "),
);
}
In the isolated experiment, this HTTP 200 response:
{
"errors": [
{ "message": "permission denied" },
{ "message": "publication unavailable" }
]
}
rejected with both messages. No code attempted to read
data.publishPost.post.id.
Joining messages is a modest but useful diagnostic improvement over throwing a
generic “GraphQL failed.” A production client may also retain structured
fields such as path and safe values from extensions, subject to redaction.
3. Require the expected data boundary
An empty error list is still not proof of usable mutation data. The adapter
also checks:
if (!response.data) {
throw new Error("GraphQL mutation returned no data");
}
My mocked HTTP 200 response containing {} reached this branch. This guards
against a malformed envelope and prevents the caller from treating undefined
as a publication result.
For stronger runtime validation, the next step would be to validate the
operation-specific shape too:
function requirePublishedPost(
envelope: GraphqlResponse<{
publishPost?: { post?: { id?: string; url?: string } };
}>,
) {
if (envelope.errors?.length) {
throw new Error(envelope.errors.map(({ message }) => message).join("; "));
}
const post = envelope.data?.publishPost?.post;
if (!post?.id || !post.url) {
throw new Error("Mutation returned no post ID or URL");
}
return post;
}
The repository's current generic check proves that top-level data exists; this
operation-specific helper would make nested shape failures explicit rather
than allowing a property-access exception.
4. Persist success only after all three gates
The full response path is:
Transport, operation, and data-shape checks form separate success gates.
Only the final branch has enough evidence to store a confirmed publication.
This ordering also keeps a scheduler from mistaking “the HTTP request
completed” for “the article was published.”
What went wrong
The subtle failure was not in the GraphQL envelope check itself. It appeared
one layer later, when the publication state machine classified the thrown
error.
The adapter creates a plain Error for GraphQL messages. That error has no
HTTP status. The outer publication loop currently maps a missing status to
unknown, the same conservative state used for transport uncertainty.
That is safe for duplicate prevention, but imprecise for recovery. An explicit
permission rejection is not the same as “the request may have succeeded but
the response was lost.” Treating both as unknown means a deterministic
configuration problem can require reconciliation rather than a direct fix.
The current test suite reveals a second gap. It verifies Hashnode publication
action selection—for example, that an unknown result must be reconciled—but it
does not directly test HTTP 200 GraphQL error envelopes. The mocked experiment
covered the behavior for this article, yet turning those cases into permanent
unit tests would better protect the boundary.
There is one more important case: data and errors together. My experiment
returned a valid post plus secondary field failed. The adapter rejected it
because it checks errors first. For a mutation, that all-or-nothing policy is
conservative: a side effect may have happened, so the caller should reconcile
instead of assuming nothing changed.
Fix or mitigation
Keep the three gates, then make the error category explicit.
A reusable result type can preserve the distinction without coupling the state
machine to message text:
type PublicationFailure =
| { kind: "transport"; status?: number; message: string }
| { kind: "graphql"; codes: string[]; message: string }
| { kind: "shape"; message: string };
The GraphQL adapter can collect safe extensions.code values when the API
provides them. The publication layer can then apply deliberate recovery rules:
- a definite authorization or validation rejection becomes
failed; - a timeout or ambiguous server failure remains
unknown; - partial mutation data plus errors remains
unknownuntil the remote state is reconciled; - a malformed success envelope stops the run and raises an integration alert.
Do not classify by searching human-readable messages if structured codes are
available. Message wording can change, may be localized, and can contain
details that should not be persisted.
A compact regression matrix is also worth keeping:
it.each([
["data only", dataEnvelope, "success"],
["errors only", errorsEnvelope, "graphql-error"],
["data and errors", partialEnvelope, "graphql-error"],
["empty envelope", {}, "shape-error"],
])("%s", async (_name, body, expected) => {
mockFetch(200, body);
await expect(classifyMutation()).resolves.toBe(expected);
});
This pattern verifies the protocol boundary without a production publication
or credential.
Trade-offs
- Rejecting any GraphQL error is easy to reason about, but it discards usable partial query data. That may be appropriate for publication mutations and too strict for read-only dashboards.
- Preserving structured error codes adds types and mapping logic. It also makes retries and operator messages substantially safer.
- Requiring operation-specific data validation adds code for every mutation. A schema-generated client or runtime validator can reduce repetition, but neither removes the need for recovery policy.
- Mocked transport tests are deterministic and safe. They do not prove the current remote schema, account permissions, or service availability.
- Conservative reconciliation can delay automation after a partial mutation, but it is preferable to publishing a duplicate.
How I verified it
I used four layers of evidence:
- Source trace: followed the request from the HTTP helper through the Hashnode envelope check and into publication failure persistence.
- Isolated experiment: mocked five response combinations and asserted the returned ID, joined GraphQL messages, missing-data error, partial-response policy, and preserved HTTP status.
- Test audit: searched the repository for focused Hashnode adapter tests and ran the existing state-invariant tests. The focused run passed two tests, while confirming the envelope coverage gap.
- Release gates: ran the TypeScript check, complete test suite, Mermaid render, asset preparation, and publisher dry run before the authorized public write.
The experiment also captured the request method and endpoint, confirmed that
the mutation text was present, and verified that the synthetic token was not
placed in the JSON body.
These checks establish the local client's behavior. They do not claim that a
live Hashnode mutation succeeded.
Conclusion
GraphQL clients need a richer definition of success than response.ok.
Check the transport, inspect the GraphQL error envelope, and validate the
operation-specific data before persisting a remote ID. Then carry enough error
structure into the state machine to distinguish a definite rejection from an
ambiguous outcome.
That boundary is small, easy to test without network access, and reusable
across publishing, billing, provisioning, and every other automation where a
200 response can still contain a failed operation.
AI assistance disclosure
AI assisted with outlining and drafting. Every implementation claim was
checked against the repository, and all response cases were exercised with a
local mocked-fetch experiment before publication.

Top comments (0)