Skip to main content

Class 4: An Error Taxonomy and extensions.code

Duration: 20 minutes | Difficulty: Intermediate | Prerequisites: Class 3 of this course.


What You'll Learn​

By the end of this class, you will:

  • Explain why message strings must never be the thing clients branch on
  • Classify any error along two axes: whose fault it is, and whether retrying can help
  • Design a stable extensions.code vocabulary and know the rules for evolving one
  • Know the built-in code vocabularies of Apollo Server and Netflix DGS, and what each got right
  • Decide what else belongs in extensions, and what must never appear there

A message is not an API​

Class 1 established that message is the only required key of an error, and quoted its purpose: "a string description of the error intended for the developer as a guide to understand and correct the error". That purpose is harmless while developers reading logs are the only readers. Problems start when a client treats the string as data, which clients tend to do when the API offers only the message. Client code appears that says if (error.message.includes("not found")), and from that moment the exact wording of the message is part of the contract. Rewording "Movie not found" to "No movie exists with that ID" is now a breaking change that no schema-diff tool will catch, because the schema never mentioned it. Messages are also routinely localized, enriched with IDs and timestamps, and rewritten for clarity. Each of those is a normal maintenance change, and each one breaks any client that matches on the string.

The same section of the specification defines extensions, the map "reserved for implementers to add additional information to errors however they see fit". A machine-readable code in extensions.code gives clients something stable to branch on, so the server can reword message, localize it, or add IDs and timestamps to it, and clients keep working. Most of the ecosystem uses this split: message for people reading logs, and a machine-readable code for client code that makes decisions. The name and location of that code differ between implementations. Apollo Server uses extensions.code, Netflix DGS uses extensions.errorType, and Class 10 shows GitHub placing its code outside extensions entirely. GitLab relies on whatever graphql-ruby produces, so a validation failure arrives as extensions.code: "undefinedField" and a business failure arrives with only a message.

Two axes: fault and retriability​

A useful error taxonomy answers the two questions that consumers of an error ask. The client asks: is this my fault, meaning should I fix the request, or the system's fault, meaning my request was fine? And whoever is deciding what to do next asks: would retrying help? A well-designed error code answers those two questions, and clients branch on that code. The finer-grained fields discussed later in this class add detail for humans and tooling.

The fault axis comes straight from the specification, as Class 1 noted: "A request error is typically the fault of the requesting client", while "an execution error is typically the fault of a GraphQL service". But the spec's split is coarse, because execution errors cover several situations that the client must treat differently. The four cases below use the Movie Database as the example.

A client mistake that no retry will fix: the query passes movieById(id: 4.5), and 4.5 is not a valid ID. The specification requires a request error for ID input that is neither a string nor an integer. A request for a field the schema does not define lands in the same place. Retrying the identical request produces the identical failure. The client code or the calling user must change something.

A caller-identity problem: the request lacks valid credentials, or carries valid credentials that lack permission for a field. This is a client-side problem, but of a special kind: retrying with the same identity is pointless, while retrying after re-authenticating may succeed. Nearly every vocabulary you will meet separates the two cases (unauthenticated vs forbidden), because clients handle them with different UI: a prompt to log in for the first, and a message that access is denied for the second.

A transient system failure: the ratings backend timed out, a connection pool was exhausted, a downstream returned 503. The client is not at fault, and a retry with backoff has a real chance of succeeding. This is the one quadrant where automated retry is the right reaction, which is why it deserves its own code and must never share one with the next quadrant. Rate limiting sits close to it: a throttled client should also retry, but only after the delay the server names. Shopify, for example, reports throttling as extensions.code: THROTTLED.

A persistent system failure: a bug, a corrupted record, an invariant broken. The client is not at fault and retrying will not help; something needs a Slack alert to fire on the owning team's channel. From the outside this quadrant is indistinguishable from the transient one at first occurrence, which is why the server, which can often tell the difference, should say so in the code.

The example codes in the grid are the Netflix DGS values surveyed later in this class.

Retrying the same request cannot helpA retry can help
The client is at faultClient mistake: fix the request.
BAD_REQUEST, NOT_FOUND, FAILED_PRECONDITION
Identity problem: retry only with different credentials.
UNAUTHENTICATED, PERMISSION_DENIED
The system is at faultPersistent failure: alert the owning team.
INTERNAL
Transient failure: back off and retry.
UNAVAILABLE

Expected business outcomes, such as "review text too long", "username already taken" and "insufficient inventory", are left out of this error taxonomy, because Class 5 argues that they belong in the schema as results, outside the errors list. The error taxonomy above covers failures, things that interrupt normal operation. Keeping business outcomes out of the error vocabulary also keeps that vocabulary small and stable.

Apollo Server's error codes​

Apollo Server documents its vocabulary in its error handling guide. The current major version is Apollo Server 5, since Apollo Server 4 reached end of life in January 2026. Every error response carries extensions.code. The built-in values, per the docs as of September 2026:

CodeWhat it means, in Apollo's words
GRAPHQL_PARSE_FAILED"the GraphQL operation string contains a syntax error"
GRAPHQL_VALIDATION_FAILED"the GraphQL operation is not valid against the server's schema"
BAD_USER_INPUT"the GraphQL operation includes an invalid value for a field argument"
PERSISTED_QUERY_NOT_FOUNDan automatic persisted queries cache miss
PERSISTED_QUERY_NOT_SUPPORTEDAPQ disabled on the server
OPERATION_RESOLUTION_FAILUREparsed and valid, "but the server couldn't resolve which operation to run"
BAD_REQUEST"an error occurred before your server could attempt to parse the given GraphQL operation"
INTERNAL_SERVER_ERROR"an unspecified error occurred"; the fallback, set "if no other code is set"

Most of these are request-error codes, and they match the 400 and 422 rows of Class 3's status-code table almost one for one. Apollo also leaves auth codes out of the built-in set on purpose. The docs show custom errors for those cases instead:

We recommend making clear errors for common cases, for example, when a user isn't logged in (UNAUTHENTICATED), or someone is forbidden from performing an action:

The example that follows builds a GraphQLError with FORBIDDEN in extensions, and the surrounding text names UNAUTHENTICATED for the logged-out case. Apollo Client's own re-authentication example branches on UNAUTHENTICATED, and Class 7 shows that pattern.

Apollo's masking defaults are also worth copying, and Class 6 covers them again. Unexpected errors thrown from resolvers become INTERNAL_SERVER_ERROR, and the stacktrace that development mode includes under extensions is omitted "if the NODE_ENV environment variable is set to either production or test".

Netflix DGS and ErrorType​

Netflix's DGS framework documents its convention in its error handling guide. Its codes follow a classification of failures, like the one above. Several of its value names and descriptions match the gRPC status codes almost word for word (Netflix runs a large part of its internal service-to-service communication on gRPC). Every error carries an errorType, and the enum has exactly eight values, each documented with its closest HTTP analog:

errorTypeMeaningHTTP analog
BAD_REQUESTmalformed or invalid request; retrying will not help400
UNAUTHENTICATEDmissing valid credentials401
PERMISSION_DENIEDauthenticated, but not allowed403
NOT_FOUNDa resource that never existed or no longer exists404
FAILED_PRECONDITIONthe system is not in the state the operation requires400 or 500
UNAVAILABLEtemporarily unavailable, and the designated transient value503
INTERNALan invariant broke500
UNKNOWNnothing better is known520

Look at the set through the two axes and you can see the design:

  • Client fault, no retry: BAD_REQUEST, NOT_FOUND, FAILED_PRECONDITION.
  • Identity: UNAUTHENTICATED and PERMISSION_DENIED, kept separate.
  • System fault, worth retrying: UNAVAILABLE.
  • System fault, worth an alert: INTERNAL.
  • Cause unknown: UNKNOWN.

Those eight values cover both axes of the error taxonomy without any two of them overlapping in meaning. The DGS structure also layers detail under the coarse type. Alongside errorType, the extensions may carry:

  • errorDetail, a finer-grained cause.
  • origin, the name of the service or library that produced the error.
  • debugInfo, diagnostic detail included when the request asks for it.
  • debugUri, a link to a page with more information for debugging the error.

The coarse type is what clients branch on; the detail fields are for humans and tooling. That layering, a coarse closed enum for clients to branch on with open detail fields underneath it, is worth borrowing on its own.

The two vocabularies were designed in different ways. Apollo's codes follow the points where the request pipeline can fail, so they give detail on request errors and leave auth to custom errors. DGS's codes come from a classification, apply the same way to request and execution errors, and include auth. For a new API this course recommends the DGS shape (a small closed enum designed around the two axes) combined with Apollo's naming where the ecosystem already agrees, in particular UNAUTHENTICATED, since so much client code already understands it.

Designing your own vocabulary​

The rules below come from the Apollo Server and Netflix DGS conventions above, and from mistakes that teams repeat when they design a vocabulary without these rules.

Keep it small and closed. Clients keep branching on a code for as long as the API exists. The DGS enum shows that eight values can cover both axes; every additional value is another case each client must handle or ignore. Publish the set, document each value's meaning and the expected client reaction, and treat additions the way you treat schema changes: deliberately, and announce them. Removing or renaming a code is always a breaking change, even when no schema type changes, because clients branch on the value itself.

Encode the two axes in the code. UNAVAILABLE is a good code because it tells the client what to do (back off, retry). REDIS_TIMEOUT is a bad code because it names your infrastructure, exposes your architecture to outsiders, and becomes wrong when you replace Redis. If humans need the detail, put it in a DGS-style errorDetail or origin field, or in logs that record a request ID with every entry.

Make the retry-safe codes unmistakable. The retry-safe codes let a client retry automatically: UNAVAILABLE, and a rate-limit code if the API has one. If UNAVAILABLE is ever emitted for a non-transient condition, automated retries keep repeating a request that cannot succeed. The operators dealing with the original incident then absorb the extra retry traffic too, which can amplify a single failing backend into a much larger outage.

Give the vocabulary to your monitoring. Class 3 gave HTTP intermediaries their signal; extensions.code is the equivalent signal one layer up, and your monitoring should record it. Each code then tells the monitoring system something different:

  • A spike in BAD_USER_INPUT after a deploy points at a client release.
  • A spike in INTERNAL fires an alert to the owning team.
  • UNAVAILABLE trends feed capacity planning.
  • UNAUTHENTICATED spikes can indicate credential-stuffing.

Those distinctions disappear when every failure reaches the monitoring system as an undifferentiated message string.

Decide what else goes in extensions. These belong there:

  • The code.
  • A correlation or request ID, so a user report can be joined to server logs.
  • A retryAfterSeconds hint on UNAVAILABLE.
  • DGS-style errorDetail and origin.

Attackers who probe an API read its error responses, so these stay out of extensions in any environment a stranger can reach:

  • Stack traces.
  • Exception class names.
  • SQL fragments.
  • Internal hostnames.
  • Anything else that describes your implementation instead of the request.

Validation messages can describe the schema too. Some servers add "did you mean" field suggestions to them, and Apollo Server's hideSchemaDetailsFromClientErrors option removes those suggestions, which Apollo recommends for production. graphql-java does not add such suggestions, so Spring for GraphQL servers do not need this step. Class 6 shows where the enforcement point for these rules lives in a Spring server.

Summary​

This lesson covered how to design an error taxonomy, using the vocabularies of Apollo Server and Netflix DGS as examples:

  • Machine-readable codes. Clients must branch on machine-readable codes, never on message strings, and this course puts them in extensions.code.
  • Two axes. A good error taxonomy answers two questions: whose fault, and whether a retry helps. That gives four failure quadrants: client mistake, identity problem, transient system failure, and persistent system failure. It deliberately leaves out expected business outcomes, which belong in the schema (Class 5).
  • Apollo Server ships a widely used vocabulary (GRAPHQL_PARSE_FAILED through INTERNAL_SERVER_ERROR, with UNAUTHENTICATED/FORBIDDEN as recommended custom codes).
  • Netflix DGS ships the more systematic one: eight errorType values that fit the two axes, each with an HTTP analog, layered over errorDetail, origin, and debug fields.
  • Your own vocabulary. Design it small and closed, name codes after the client's reaction, keep the retry-safe codes accurate, feed the codes to monitoring, and keep implementation details out of extensions entirely.

Further Reading​

  • Apollo Server, Error handling: the complete built-in code list, custom-error construction with GraphQLError, and the masking and stacktrace-omission defaults this class summarized.
  • Netflix DGS, Error handling: the eight-value ErrorType enum with HTTP analogs and the TypedGraphQLError builder API; a published convention that you can adopt directly.
  • GraphQL Specification (draft), Errors: the extensions rules this class relies on, including the counter-example that places keys directly on an error, outside extensions.
  • graphql.org, Error Handling: the official learn page; its table of which situations warrant top-level errors vs errors-as-data is a compact preview of Class 5's argument.
  • Apollo Server, Masking and logging errors: the formatError hook and the reporting-side masking behavior, a useful model for the logging pipeline Class 6 builds.

What's Next​

The error taxonomy deliberately excluded "username already taken" and its relatives. Class 5 puts those expected business outcomes in the schema as typed results, which a client has to handle, and sets out the design rule the rest of the course follows.