Error Handling, File Uploads, And Robust Clients
Three Kinds Of Failure, Three Channels
Sorting failures by who needs to act tells you where each belongs:
- Request-level failures — unparseable document, failed validation, unauthenticated transport. Nothing executed; the response has
errorsand nodata(possibly a non-200 status). Only developers can act; clients just show "something broke." - Field-level system errors — a resolver threw: database down, upstream timeout, bug. These land in the top-level
errorsarray with apath, and the field becomesnull(propagating per the nullability rules from the execution lesson). The user cannot fix these either. - Business outcomes — "rating must be 1–5", "album already published", "name taken". These are not errors to the system; they are results the user must see and respond to. They belong in the schema as data, not in the
errorsarray.
Most error-handling mess comes from pushing category 3 into channel 2 — stringly-typed business failures in errors that clients must parse by message text.
Making The errors Array Machine-Readable
For genuine errors, the spec gives each entry message, locations, path, and an open extensions map. Put a stable, documented code in extensions and make clients switch on that, never on message text:
{
"errors": [
{
"message": "Stats service timed out.",
"path": ["album", "averageRating"],
"extensions": { "code": "UPSTREAM_TIMEOUT", "retryable": true }
}
]
}
Keep the code vocabulary small (UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, INTERNAL, UPSTREAM_TIMEOUT, ...), and never leak internals — masking unexpected exception messages (the transport lesson's setting) stays on in production, with full details going to server logs correlated by a request id you can also echo in extensions.
Modeling Business Outcomes In The Schema
Two established patterns put expected failures into typed data. The payload + userErrors pattern from the mutations lesson:
type RenameArtistPayload {
artist: Artist
userErrors: [UserError!]!
}
Client logic is uniform: if userErrors is non-empty, render them (each with an optional field to attach messages to form inputs); otherwise use the data.
The stricter alternative is a result union, which makes ignoring failure impossible:
union RenameArtistResult = Artist | NameTakenError | ValidationError
type NameTakenError {
message: String!
suggestedAlternatives: [String!]!
}
The client must discriminate on __typename to get anything, and each failure type can carry rich, typed context (like suggestedAlternatives) that a generic error entry never could. Unions cost more schema surface; many teams use userErrors for routine validation and unions where failures are first-class outcomes worth designing (payments, publishing workflows).
Whichever you use, be consistent across the whole schema — per-mutation creativity is what makes clients fragile.
File Uploads
GraphQL has no bytes type, and JSON has no place for a 40 MB audio file. Two approaches:
- The multipart request convention (the de facto
graphql-multipart-requestspec, supported by many servers and clients): the operation is sent as one part of amultipart/form-databody with files as sibling parts and a map wiring each file into a variable of a customUploadscalar. Convenient, but it drags file-handling concerns into your GraphQL endpoint, and they bite: size and count limits must be enforced per part and in total; a file variable referenced in several places can be buffered repeatedly and exhaust memory; streams must be closed even when the operation fails part-way; multipart endpoints reopen the CSRF surface (browsers can send forms cross-site) so the custom-header defense from the transport lesson becomes mandatory; and client-supplied filenames and content types are untrusted input — never use them for storage paths or type decisions. - Signed upload URLs — usually the better architecture: a mutation like
prepareUpload(input: {filename, contentType})returns a short-lived signed URL for object storage; the client PUTs the bytes there directly; a second mutation attaches the resulting storage key. The graph moves only metadata, uploads scale independently of PHP workers, and your GraphQL endpoint keeps its small-JSON performance profile.
Reach for multipart only when clients cannot do two-step flows; default to signed URLs.
What Robust Clients Do
The server can be perfect and the app still fragile. Client-side discipline:
- Check
errorson every response, even alongside data — and decide per-screen whether partial data renders (a feed with one broken card) or fails the view (a checkout). - Handle
nullwhere the schema allows it. Generated types from the schema make this a compile-time obligation rather than a runtime surprise — one of the biggest practical wins of codegen. - Retry only what is safe: idempotent queries with backoff, mutations only with an idempotency key (the mutations lesson's design), and surface retryability from
extensionsrather than guessing. - Distinguish "loading", "error", and "empty". An empty connection and a failed connection must not render the same "no results" screen.
- Report GraphQL errors to your monitoring with operation name and path — remember that HTTP-level monitoring sees only 200s.
What To Check
Before moving on, make sure you can:
- classify a given failure into the three categories and name its correct channel
- design
extensionscodes clients can switch on, without leaking internals - implement both the
userErrorspattern and a result union, and argue when each fits - compare multipart uploads with signed-URL uploads and justify the default
- list the client behaviors that make partial success safe rather than surprising
What You Should Be Able To Do
After this lesson, you should be able to define an error strategy document for a team: which failures go where, the shared error-code vocabulary, the mutation payload convention, the upload architecture — and review a client codebase for the missing errors checks and conflated empty/error states that partial success punishes.