Skip to main content

Errors and limits

Two kinds of failure​

A GraphQL request can fail in two places, and they look nothing alike:

Transport failureQuery failure
WhenBefore the GraphQL engine runsWhile parsing, validating or executing the document
StatusHTTP 4xx / 5xxHTTP 200
BodyRFC 9457 problem documentGraphQL envelope with an errors array
ExamplesMissing or expired token, insufficient grant, malformed JSON, query over 10,000 charactersUnknown field, wrong argument type, a rejected filter, a limit exceeded

Never infer success from the status code. A query failure is a 200. Always check whether errors is present.

Response envelope​

{
"data": null,
"errors": [
{
"message": "Human-readable explanation.",
"locations": [{ "line": 1, "column": 3 }],
"path": ["equipment"],
"extensions": {
"code": "https://problems.tourfold.com/graphql/invalid-pagination",
"title": "Invalid pagination arguments",
"data": { "first": 5, "last": 5 },
"classification": "DataFetchingException"
}
}
],
"extensions": {
"cost": { "estimated_rows": 23, "limit": 100000 }
}
}

Later examples on this page omit classification for brevity; it is present on every error.

Field
messageHuman-readable text. Written for a developer, not a contract — do not match on it.
locationsWhere in the query document the problem is.
pathThe response path of the field that failed.
extensions.codeThe stable identifier. Branch on this.
extensions.titleShort label for the problem type.
extensions.dataPer-occurrence detail — the measured value, the rejected input.
extensions.errorsSub-errors, when one failure has several parts.
extensions.classificationThe GraphQL engine's own coarse category (ValidationError, DataFetchingException). Informational, not part of this API's contract — it belongs to the library and may change with an upgrade. Branch on code.

extensions.code is a Type URI, and it is the same string a REST error carries in type. One identifier covers both surfaces, so a client that already handles Tourfold's REST errors does not need a second vocabulary. There is deliberately no wrapping problem object; title and data sit flat beside code.

There is no status field. The response is always HTTP 200, so there is no status to report.

How far a failure propagates​

GraphQL replaces a failed field with null and reports the reason in errors. How much else survives depends on whether that null is allowed where it lands, and on this API the answer is usually "not much":

A failed root connection nulls the whole data. Root fields are non-null (equipmentConnection!), so a null there is not permitted and the failure propagates to the enclosing object — which is data itself. Every example on this page therefore shows "data": null, not "data": { "equipment": null }.

A failed nested relation nulls only that field. To-many and to-one relation fields are nullable, so a failure inside one stops there and its siblings keep their data. That is where you will genuinely see a response carrying both data and errors.

An invalid document executes nothing. An unknown field or a wrongly typed argument is caught before execution, and then the data key is absent altogether — not null. Nothing ran, and "no data" is a different claim from "the data is null". Every problem in the document is reported at once.

So do not write a client that assumes partial data is always available. Check errors first, and treat data as possibly null and possibly missing.

Not every error has a code​

Errors raised by the GraphQL engine before Tourfold code runs — syntax errors, unknown fields, type mismatches — carry no extensions.code. Their message and locations are all you get.

So the rule for clients is: branch on extensions.code when present, fall back to message when it is not, and never assume every entry in errors has a code.

{
"errors": [
{
"message": "Validation error (FieldUndefined@[equipment/cpu_processor]) : Field 'cpu_processor' in type 'equipment' is undefined",
"locations": [{ "line": 4, "column": 5 }],
"extensions": { "classification": "ValidationError" }
}
]
}

Note what is missing: no data key, and no code. classification is the engine's own category and is not a Tourfold problem type.

Check the schema endpoint for the fields your workspace actually has — and note that a missing field may be a name collision rather than a typo.

Error catalog​

Failures specific to GraphQL take the graphql/ namespace:

extensions.codeRaised whendata carries
graphql/query-too-deepThe document nests more Relay levels than alloweddepth, limit
graphql/query-too-complexThe query's estimated row count exceeds the budgetcost, limit
graphql/filter-too-deepA where argument nests too many levelsdepth, limit
graphql/invalid-cursorA cursor was not produced by this APIcursor
graphql/invalid-paginationPagination arguments conflict or are negativethe conflicting arguments

Everything else reuses a type from the general catalog:

extensions.codeRaised when
validation-failedA filter operand is rejected — too many _like wildcards, an oversized or malformed JSON operand. The offending field is named under extensions.errors.
unknown-errorAn unexpected server-side failure. The message is deliberately generic; the detail is logged server-side.

Sub-errors​

When one failure has several parts, they appear under extensions.errors. A sub-error carries only what varies per occurrence — code, message, path, data — and does not nest further. path is a field path, the way GraphQL addresses everything else, so a response never mixes field paths with JSON Pointers. Converting to a REST problem document renders it as an RFC 6901 pointer.

Limits​

LimitValueEnforced
Query document length10,000 charactersHTTP 422 with a problem document
Query depth12 Relay levelsgraphql/query-too-deep
Query cost100,000 estimated rowsgraphql/query-too-complex
Page size100 max; default 20 root, 10 nestedSilently capped
Filter (where) nesting4 levelsgraphql/filter-too-deep
Nested-object nesting (filter and order_by)3 levelsBounded by the definition itself — a deeper path is not expressible in the schema
_contains / _contained_in operand3,000 charactersvalidation-failed
% wildcards in _like / _ilike2validation-failed

Query depth and filter nesting are independent controls with different values: one bounds relation traversal, the other bounds a where tree. Raising one does not affect the other.

Counting query depth​

Depth is counted in selection levels, not relation hops — which is why 12 is smaller than it sounds:

  • A bare connection already costs 4: root field → edges → node → leaf.
  • Each further to-many hop costs 3: relation field → edges → node.
  • Each to-one hop costs 1.

So a one-hop query is depth 7 and a three-hop query is depth 13 — over the limit. Split a deep traversal into separate queries, or start from the other end of the relation.

{
"data": null,
"errors": [
{
"message": "Query depth 15 exceeds the maximum of 12. Each to-many hop costs three levels (field, edges, node); select fewer levels or split the traversal into separate queries.",
"extensions": {
"code": "https://problems.tourfold.com/graphql/query-too-deep",
"title": "Query too deep",
"data": { "depth": 15, "limit": 12 }
}
}
]
}

Query cost​

A response reports what the query cost whenever the request got far enough to be analysed:

"extensions": { "cost": { "estimated_rows": 23, "limit": 100000 } }

When it is present: on a successful execution, and on a query-too-complex refusal — which is the point, since it lets you see how far over budget you were.

When it is absent: when the request never reached cost analysis. A syntax error or a validation error is caught earlier, and there is no executable operation to price. A query-too-deep refusal is also earlier, because the depth guard runs first. Treat extensions.cost as optional and never assume it is there.

estimated_rows measures how far the response can expand — how many nodes the query can be asked to produce. It is not an upper bound on database rows read, statements issued, or time spent: some internal work (resolving a relation filter, or a nested relation once per parent row) is not proportional to the response and is not charged here. Use it to keep response sizes sane, not as a performance guarantee.

The score is a row count, not a field count:

  • A plain field costs 1 + (its children).
  • A connection costs 1 + pageSize × (its children), where pageSize is your first/last clamped to 100, or the applicable default when you gave neither.

The multiplication is the part to internalise: a connection pays for its whole subtree once per row. So this query —

cost-worked-example.graphql
{
equipment(first: 2, order_by: [{ name: asc }]) {
totalCount
pageInfo { hasNextPage endCursor }
edges { cursor node { id name asset_tag operational } }
}
}

— scores 23:

SelectionCost
totalCount1
pageInfo { hasNextPage endCursor }1 + 2 = 3
node { id name asset_tag operational }1 + 4 = 5
edges { cursor node }1 + 1 + 5 = 7
equipment connection1 + 2 × (1 + 3 + 7) = 23

Two consequences follow, and they are the opposite of what a field-count budget would suggest:

  • A wide page is cheap. Selecting 20 columns of a 25-row page is a few hundred rows.
  • A narrow deep traversal is expensive. One column, paginated 100 × 100 × 100, is a million rows and is refused — even though the document mentions barely a handful of fields.

If a query is refused, the cheapest fix is almost always a smaller first on the innermost connection, because that is the factor multiplied most times.

{
"data": null,
"errors": [
{
"message": "Query is estimated to materialise 3003001 rows, above the budget of 100000. A connection multiplies its subtree by its page size — request smaller pages or select fewer nested fields.",
"extensions": {
"code": "https://problems.tourfold.com/graphql/query-too-complex",
"title": "Query too complex",
"data": { "cost": 3003001, "limit": 100000 }
}
}
]
}

The cost limit applies per query. It is not a budget over time, so staying under it on each individual request is not on its own a reason to issue them as fast as possible — keep request rates proportionate to what your integration needs, and expect rate limiting on sustained heavy use.