GraphQL

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:

  1. Request-level failures — unparseable document, failed validation, unauthenticated transport. Nothing executed; the response has errors and no data (possibly a non-200 status). Only developers can act; clients just show "something broke."
  2. Field-level system errors — a resolver threw: database down, upstream timeout, bug. These land in the top-level errors array with a path, and the field becomes null (propagating per the nullability rules from the execution lesson). The user cannot fix these either.
  3. 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 errors array.

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-request spec, supported by many servers and clients): the operation is sent as one part of a multipart/form-data body with files as sibling parts and a map wiring each file into a variable of a custom Upload scalar. 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 errors on 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 null where 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 extensions rather 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 extensions codes clients can switch on, without leaking internals
  • implement both the userErrors pattern 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.