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 arrives | Example | |
|---|---|---|
| Expected business outcome | In data, as a branch of a result union | The campaign has already ended, so it cannot be paused |
| Something went wrong | In 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.
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:
| Field | What it is |
|---|---|
message | Written for a human. It belongs to the module that raised it and is free to improve |
extensions.code | Machine-readable, stable, and the only thing to branch on |
extensions.traceId | The 32-character W3C trace id of this request — see below |
extensions.serviceName | Which module raised it. Useful in a bug report, not something to branch on |
path | Where 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.
extensionsOnly 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 happened | Status | What comes back |
|---|---|---|
| No token, or one that does not validate | 401 | {"errors":[{"message":"unauthorized"}]}, and nothing else — no data key, no extensions |
| A valid token missing a module scope | 200 | {"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 badly | 200 | One 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.
| Code | Modules | What happened | What to do |
|---|---|---|---|
PAGINATION_PAGE_SIZE_INVALID | all | first: 0, or a negative page size | Fix the caller — this is not an empty result |
PAGINATION_INVALID_CURSOR | all | A cursor this connection did not issue | Never build, edit or persist a cursor |
PAGINATION_BOUNDARIES_AMBIGUOUS | all | first and last together | Page from one end |
PAGINATION_PAGE_SIZE_EXCEEDED | Ads | A page size above the ceiling of 50 | See Pagination |
PAGINATION_BOUNDARIES_REQUIRED | Ads | Neither first nor last | Same table |
HC0051 / HC0052 | People, Sales | The same two mistakes as the two rows above | Same table — those two refusals happen inside the framework, before the module sees the request, so they carry its vocabulary |
INVALID_GLOBAL_ID | People, Sales | An id that is not one this platform issued | Pass ids back unmodified |
UNEXPECTED_ERROR | any | An error reached the envelope with no code of its own | Report 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.