Skip to main content

Errors

This API splits failure into two kinds and handles them in two different places. Getting the split right is most of what makes error handling here pleasant.

Where it arrivesExample
Expected business outcomeIn data, as a branch of a result unionThe campaign has already ended, so it cannot be paused
Something went wrongIn errors[]The token expired; a field does not exist; a database was unreachable

The rule of thumb: if a competent client could reasonably provoke it with a well-formed request against data it is allowed to see, it is data.

Expected failures are unions

Every mutation returns a union of a success payload and a failure payload, and you select on both:

mutation {
pauseCampaign(input: { campaignId: "Q2FtcGFpZ246..." }) {
__typename
... on CampaignPaused {
campaign { id status }
}
... on CampaignActionError {
code
message
field
}
}
}

code is a schema enum, so the compiler and the explorer both know every value it can take, and adding one is a visible schema change rather than a new string appearing in production. message is written to be shown to a merchant as-is. field names the input field at fault when exactly one is at fault, and is null when the refusal is about the state of the record rather than the request.

The same shape covers people data: requestLeave returns LeaveRequestCreated or LeaveValidationError. See the generated reference for the full list of codes on each — they are enums, so they are documented value by value.

A refused mutation changes nothing, and records nothing

Business refusals are not partial successes. Nothing was written, and no entry appears in the merchant's audit trail — an audit trail that recorded attempts would be describing a different thing than the one it claims to describe.

Everything else arrives in errors[]

Here is a real one, indented for reading and otherwise untouched: a page size over the ceiling, asked of the Ads module through the router.

{
"errors": [
{
"message": "A page may hold at most 50 items; 51 were requested.",
"path": ["campaigns"],
"extensions": {
"code": "PAGINATION_PAGE_SIZE_EXCEEDED",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"serviceName": "ads"
}
}
],
"data": null
}

Three things are always there, and one is sometimes:

FieldWhat it is
messageWritten for a human. It belongs to the module that raised it and is free to improve
extensions.codeMachine-readable, stable, and the only thing to branch on
extensions.traceIdThe 32-character W3C trace id of this request — see below
extensions.serviceNameWhich module raised it. Useful in a bug report, not something to branch on
pathWhere in your query it happened, in the schema you wrote against

So the reader is the obvious one:

function codesIn(response) {
return (response.errors ?? [])
.map((error) => error.extensions?.code)
.filter(Boolean);
}

Branch on code. Never on message: the wording belongs to the module that raised it, it is written for a human, and it is free to improve. code is machine-readable, stable and safe to branch on; an error that reaches a client with no code of its own is stamped UNEXPECTED_ERROR, which is itself a signal — something escaped the paths that classify failures, and it is worth reporting.

Inside each module the envelope is applied as a filter, in exactly one place, covering the errors the framework raises as well as the ones we raise deliberately. A per-call-site convention would have covered the deliberate ones only, and the failure mode worth preventing is precisely an unhandled fault arriving as a bare message with no extensions at all.

note
What is deliberately not in extensions

Only code, traceId and serviceName cross the edge. Everything else a module attaches stays inside it — including the field some frameworks add that echoes your own malformed input back at you. So an error is safe to log wholesale, and there is no key here whose meaning depends on which module answered.

Errors the router raises itself

Three classes never reach a module, so they do not carry the module envelope:

What happenedStatusWhat comes back
No token, or one that does not validate401{"errors":[{"message":"unauthorized"}]}, and nothing else — no data key, no extensions
A valid token missing a module scope200{"errors":[{"message":"Unauthorized"}]} with data: null, and the detail in a top-level extensions.authorization
A document that does not parse, does not validate, or coerces a variable badly200One errors[] entry with a message and usually path or locations — and no extensions at all

The status code is the only thing that separates the first two, and the lower-case m in "unauthorized" is not a distinction to build on. 401 means your token is the problem; 200 with errors means your request is.

The middle one is the one to wire up, because the useful part is not on the error:

{
"errors": [{ "message": "Unauthorized" }],
"data": null,
"extensions": {
"authorization": {
"missingScopes": [
{
"coordinate": { "typeName": "Query", "fieldName": "employees" },
"required": [["hr:view"]]
}
],
"actualScopes": ["ads:act", "ads:view", "sales:view"]
}
}
}

That is a complete answer: the coordinate that was refused, the alternatives that would have satisfied it, and what the token actually carried. Detect it by the presence of extensions.authorization, never by the string "Unauthorized".

The modules carry their own AUTH_NOT_AUTHENTICATED and AUTH_NOT_AUTHORIZED codes and they are real, but every field the router gates is refused at the router first — so a client calling :4000 sees the shape above instead. Build against that shape; treat the module codes as the layer behind it.

Document errors carry nothing to branch on, and that is the right size of problem: "you asked for a field that does not exist" is caught by the explorer, by codegen and by your build, not at runtime by a shipped client. (Asked of the People module directly, that one class carries a code but no traceId — its framework does not run the error filter over document validation. Through the router the question does not arise, because the router validates the document itself.)

What the trace id is for

Every error carries a traceId: the 32-character W3C trace id of the request, the same id across browser, router, module and database. The audit trail stores it too, so a change a merchant asks about can be tied back to the request that made it.

Quote it in bug reports. One id finds the whole waterfall — which module answered, what it asked the database, and how long each hop took. If your client sends its own traceparent header, that is the id you get back, so you can log it before the request leaves.

It is a one-way link. Nothing depends on the trace still existing; the audit trail is complete on its own, and a missing trace degrades the investigation without breaking the record.

Codes you will meet early

All of these arrive at errors[].extensions.code.

CodeModulesWhat happenedWhat to do
PAGINATION_PAGE_SIZE_INVALIDallfirst: 0, or a negative page sizeFix the caller — this is not an empty result
PAGINATION_INVALID_CURSORallA cursor this connection did not issueNever build, edit or persist a cursor
PAGINATION_BOUNDARIES_AMBIGUOUSallfirst and last togetherPage from one end
PAGINATION_PAGE_SIZE_EXCEEDEDAdsA page size above the ceiling of 50See Pagination
PAGINATION_BOUNDARIES_REQUIREDAdsNeither first nor lastSame table
HC0051 / HC0052People, SalesThe same two mistakes as the two rows aboveSame table — those two refusals happen inside the framework, before the module sees the request, so they carry its vocabulary
INVALID_GLOBAL_IDPeople, SalesAn id that is not one this platform issuedPass ids back unmodified
UNEXPECTED_ERRORanyAn error reached the envelope with no code of its ownReport it with the trace id — this one is ours

The last two rows of that table are one divergence, recorded rather than hidden: a page size that is missing or over the ceiling is refused by the framework underneath two of the modules, before any code of ours runs, so those two refusals alone are named in its vocabulary rather than the platform's. Every other pagination refusal agrees across every module. If the divergence closes, the platform names win and the change is additive for anything already switching on the current strings.

Note what is not in that table: authorization. Scope refusals are the router's, with the shape shown above, and they carry no code.

The one thing that is not an error

Rows belonging to another merchant. The tenant is a claim on your token, not an argument on the query, so there is no request that asks for someone else's data — employees means "the employees of whatever merchant this token is for", and it returns that merchant's complete answer. Row-level security in the database enforces it underneath the resolvers, so even a bug there returns nothing rather than someone else's rows.

Which also means an empty page is just an empty page: a merchant with no campaigns yet gets totalCount: 0 and data, not an error.