skip to content

questions

3

Why does an exception thrown in a GraphQL resolver end up in the client's error message?

level: juniorimportance: must knowfreq 55%

answer

  1. The failure path is never tested
  2. Every error must carry a message
  3. Only one string is guaranteed to exist
  4. Debug extras ride under extensions
  5. Redaction is opt-in, never default

basics

~20 s

A GraphQL server must produce a message for every error it returns, and the simplest string available is the thrown exception's own. Nothing in the specification redacts it, so driver text, file paths and internal hostnames travel straight to the caller.

solid answer

~50 s

The specification requires every error entry to carry a `message`, and describes it as a description **intended for the developer**. It says nothing about which developer, and defines no redaction. So a server converting a failed resolver into a field error has one obvious string to hand — the exception's own message — and implementations use it verbatim unless configured otherwise. On a music catalogue graph, a failing `Album.tracks` resolver wrapping a timed-out data call becomes a client-visible message carrying the pool name, the internal host and a statement fragment. Servers commonly add more: a stack trace or cause chain under the error's `extensions` map, behind a debug switch whose default is convenient for local development and then follows the build into production. The leak is a default rather than a bug — masking is something you turn on, and nothing warns you when it is off.

code

json · 13 lines
json
{
  "errors": [
    {
      "message": "TimeoutException: pool 'catalogue-ro' exhausted after 2500 ms; host tracks-db-7.internal:5433; statement: SELECT ... FROM track WHERE album_id = $1",
      "extensions": {
        "stacktrace": [
          "catalogue.track.TrackStore.byAlbum(TrackStore:214)",
          "catalogue.graph.AlbumFields.tracks(AlbumFields:57)"
        ]
      }
    }
  ]
}

go deeper

for a junior

Be able to say that a resolver's exception message becomes the client-visible message unless the server is configured to replace it, and remember that a failing GraphQL request often still returns HTTP 200.

for a middle

Explain the translation step itself: the server needs a message string, the exception supplies one, and debug extras such as a stack trace ride under the error's extensions behind a flag whose default suits development rather than production.

for a senior

Show how you would find this in a running system — exercise failure paths deliberately, read real error payloads from outside the network, and alert on the errors list rather than on the HTTP status.

for a principal

Own it as a default-safety problem. Make the masked message the platform default that every new service inherits, instead of asking each team to remember a configuration flag that is convenient to leave unset.

## The specification asks for a message and stops there When a GraphQL server reports a failure, the response carries a list of errors, and the specification is explicit that each one must contain a `message` entry: a string description of the error, intended for the developer as a guide to understanding and correcting it. That sentence does a great deal of unnoticed work. It names an audience — the developer — and it quietly assumes that audience is the party reading the response. In a browser application, a public mobile client or a partner integration, the party reading that string is whoever sent the request. The specification does not distinguish the two, does not require redaction, and has no notion of an internal message versus an external one. Everything about what is safe to say is left to the server and, in practice, to its defaults. ## The exception message is the path of least resistance Executing a GraphQL document calls a resolver per field. When one of those resolvers raises, the server has to convert a language-level failure into a field error, and it needs a `message` string to do it. The only string guaranteed to exist is the exception's own. Copying it verbatim is what implementations do by default, because the alternative — inventing a message — makes the server useless to the person developing against it. The default is faithful, and faithful is exactly the problem: a driver's timeout text, a serializer's complaint naming a class, a filesystem error naming an absolute path, an HTTP client naming an internal hostname and port. None of it was written to be read by a stranger. It is worth naming the mechanism precisely, because candidates often misdescribe it. Nothing parses or sanitises the message on the way out. The transformation from exception to error entry is a copy with wrapping, and any redaction is a step someone deliberately added. ## `extensions` is where the worse leaks live The specification reserves `extensions` on an error as a map of additional information and deliberately says nothing about what goes in it. Server implementations use that freedom for debugging aid: a stack trace, a chain of causes, sometimes the exception's class name, sometimes a rendering of an underlying failure the server already chose not to put in `message`. A stack trace is a far richer disclosure than a message. It names internal package structure, framework layering, the shape of the service's internals, and often the exact line of the data-access code that failed. These extras usually sit behind a debug flag, and that flag is where the real danger is: its useful value during local development is on, and a configuration default travels with the build. A team that never explicitly sets it in the production configuration ships whatever the library chose. This is the single most common source of a genuinely serious GraphQL disclosure, and it is invisible in every happy-path test. ## What it looks like on a music catalogue graph A catalogue service resolves `Album.tracks` by calling a track store. A connection pool exhausts, the driver raises, and a client asking for an album's tracks receives a message reading something like `TimeoutException: pool 'catalogue-ro' exhausted after 2500 ms; host tracks-db-7.internal:5433; statement: SELECT ... FROM track WHERE album_id = $1`. That one string names an internal hostname, a non-standard port, a pool name, a timeout budget and the shape of the underlying table. If `extensions` also carries a stack trace, the caller now has the service's internal package layout as well. Nothing was breached. The server answered exactly as configured. ## Why it survives review Three reasons, and interviewers like hearing them stated. First, the leak lives on the failure path, so it never appears in a test that asserts a successful response. Second, developers want the detail — it is why the default exists — so the first person to notice it usually notices while being grateful for it. Third, a GraphQL server answering over HTTP commonly returns 200 with an errors list for a failed field, so monitoring that watches status codes never surfaces how often it is happening. The leak is quiet, useful to the team, and invisible to the usual alarms. ## The correct default The correct default inverts the choice. An unexpected failure produces a fixed, uninformative message; the detail goes to the server's own logs; and the two are joined by an identifier the caller can quote. Deliberate, client-actionable failures become the exception to that rule rather than the rule itself. Doing that without going blind in production, and without flattening the errors your clients genuinely need to act on, is where the actual engineering is — it is a design decision, not a switch.

  • Does the GraphQL specification say anything about what an error message may safely contain?
    No. It requires the entry and describes it as intended for the developer, which is an audience assumption rather than a rule. It defines `extensions` on an error as a free-form map and constrains nothing inside it. Every decision about redaction belongs to the server, so a team that never makes the decision ships whatever its library chose.
  • Why do alerts based on HTTP status codes usually miss this?
    A GraphQL server answering over HTTP commonly returns 200 with an errors list when a field fails, so a leak firing on thousands of requests raises no error-rate alarm. You need a metric derived from the errors list itself — count and classification, keyed by operation and field path — rather than from the transport status.
  • A team says they are safe because they catch exceptions in every resolver. Is that enough?
    Only if every catch also rewrites the message, and only until someone adds a resolver. Per-field handling is a convention nobody enforces and a reviewer cannot see missing. The reliable place is the single translation step every field error passes through, so the safe behaviour applies to code nobody remembered to guard.

It is the difference between a shop assistant saying "we cannot complete your order" and reading out the supplier's internal stock-system error, phone number and all — both are honest, only one was meant for you.

saying these in an interview costs you the question

  • Thinks the specification forbids internal detail in messages
  • Assumes the server sanitizes exception text automatically
  • Believes an HTTP 200 response means nothing failed
  • Leaves debug stack traces on because they are useful
  • Says catching inside each resolver is sufficient
  • Treats extensions as internal-only rather than client-visible

context

open as a page

How do you mask unexpected GraphQL errors without going blind in production?

level: middleimportance: should knowfreq 52%

basics

~20 s

Replace an unexpected failure's message with a fixed string, attach a correlation identifier the caller can quote, and log the full exception under that same identifier. The client learns nothing internal, and an engineer can still find the exact failure.

open as a page

How do you keep deliberate, client-actionable GraphQL errors legible while masking the rest?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Classify at the throw site, never by inspecting message text. A failure explicitly marked client-facing keeps its reviewed message and stable code; everything else is masked. Masking must be the fallback, so an unclassified failure is never legible by accident.

open as a page