Errors and limits
Two kinds of failure​
A GraphQL request can fail in two places, and they look nothing alike:
| Transport failure | Query failure | |
|---|---|---|
| When | Before the GraphQL engine runs | While parsing, validating or executing the document |
| Status | HTTP 4xx / 5xx | HTTP 200 |
| Body | RFC 9457 problem document | GraphQL envelope with an errors array |
| Examples | Missing or expired token, insufficient grant, malformed JSON, query over 10,000 characters | Unknown 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 | |
|---|---|
message | Human-readable text. Written for a developer, not a contract — do not match on it. |
locations | Where in the query document the problem is. |
path | The response path of the field that failed. |
extensions.code | The stable identifier. Branch on this. |
extensions.title | Short label for the problem type. |
extensions.data | Per-occurrence detail — the measured value, the rejected input. |
extensions.errors | Sub-errors, when one failure has several parts. |
extensions.classification | The 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.code | Raised when | data carries |
|---|---|---|
graphql/query-too-deep | The document nests more Relay levels than allowed | depth, limit |
graphql/query-too-complex | The query's estimated row count exceeds the budget | cost, limit |
graphql/filter-too-deep | A where argument nests too many levels | depth, limit |
graphql/invalid-cursor | A cursor was not produced by this API | cursor |
graphql/invalid-pagination | Pagination arguments conflict or are negative | the conflicting arguments |
Everything else reuses a type from the general catalog:
extensions.code | Raised when |
|---|---|
validation-failed | A filter operand is rejected — too many _like wildcards, an oversized or malformed JSON operand. The offending field is named under extensions.errors. |
unknown-error | An 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​
| Limit | Value | Enforced |
|---|---|---|
| Query document length | 10,000 characters | HTTP 422 with a problem document |
| Query depth | 12 Relay levels | graphql/query-too-deep |
| Query cost | 100,000 estimated rows | graphql/query-too-complex |
| Page size | 100 max; default 20 root, 10 nested | Silently capped |
Filter (where) nesting | 4 levels | graphql/filter-too-deep |
Nested-object nesting (filter and order_by) | 3 levels | Bounded by the definition itself — a deeper path is not expressible in the schema |
_contains / _contained_in operand | 3,000 characters | validation-failed |
% wildcards in _like / _ilike | 2 | validation-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), wherepageSizeis yourfirst/lastclamped 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 —
{
equipment(first: 2, order_by: [{ name: asc }]) {
totalCount
pageInfo { hasNextPage endCursor }
edges { cursor node { id name asset_tag operational } }
}
}
— scores 23:
| Selection | Cost |
|---|---|
totalCount | 1 |
pageInfo { hasNextPage endCursor } | 1 + 2 = 3 |
node { id name asset_tag operational } | 1 + 4 = 5 |
edges { cursor node } | 1 + 1 + 5 = 7 |
equipment connection | 1 + 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.