Error handling
Kavitro follows the usual GraphQL convention: almost everything, including most failures, comes back as HTTP 200 with an errors array in the body. Checking the status code is not enough, and a handful of rejections do use a non-200 status, so a robust client checks both.
The shape of an error
Each entry in errors carries up to four keys:
{
"errors": [
{
"message": "You do not have permission to do this.",
"locations": [{ "line": 2, "column": 3 }],
"path": ["updateContact"],
"extensions": { "category": "FORBIDDEN" }
}
]
}
| Key | Always present | What it is |
|---|---|---|
message | Yes | A human-readable description. |
locations | No | Where in your document the problem is. |
path | No | The response path of the field that failed. |
extensions | No | Machine-readable detail. Omitted entirely when there is none. |
extensions is the part worth branching on. message is written for humans: it can be reworded, and many messages are in the language set for the user you authenticate as, so the same error can read differently from one user to the next. extensions is stable.
In production, errors never include trace, debugMessage, file or line. Those appear only when the server runs in debug mode, which it does not do in production, so do not build handling around them.
data and errors can both be present
An errors array does not mean nothing happened. GraphQL resolves each field independently, so a document can partly succeed:
{
"data": {
"first": { "id": "1" },
"second": null
},
"errors": [
{
"message": "You do not have permission to do this.",
"path": ["second"],
"extensions": { "category": "FORBIDDEN" }
}
]
}
first was created; second was not. Use path to work out which part failed, and never treat the presence of errors as "the whole request was rejected". This matters most for documents carrying several mutations.
Errors you will actually meet
Validation
Raised when input fails a rule. The offending fields are listed under extensions.validation, keyed by their path in the input.
{
"errors": [
{
"message": "Validation failed for the field [createContact].",
"extensions": {
"validation": {
"input.name": ["The input.name field is required."]
}
}
}
]
}
Read extensions.validation rather than parsing the message.
Authentication
Your token is missing, expired or invalid. The message is always Unauthenticated., and extensions.guards names the guard that rejected you.
{
"errors": [
{
"message": "Unauthenticated.",
"extensions": { "guards": ["api"] }
}
]
}
This is the error to watch for when refreshing a token. There is no HTTP 401.
Authorization
You are authenticated, but not allowed to do this. The error carries extensions.category FORBIDDEN, and path tells you which field was refused.
{
"errors": [
{
"message": "You do not have permission to do this.",
"path": ["updateContact"],
"extensions": { "category": "FORBIDDEN" }
}
]
}
Branch on the category, not the message. The message is in the user's language, and a refusal can give a more specific reason than the generic one shown here.
Both a genuine permission gap and a record you cannot see can produce a refusal, so treat it as "not permitted" rather than "does not exist".
Refusals used to say This action is unauthorized. in every language and carried no extensions. If your client matched that text, switch to extensions.category.
Business rules
Most of what a mutation can refuse is a rule about the data: an invoice that is not a draft, an entity still referenced elsewhere, a record locked by its status. These arrive as plain messages with no extensions:
{
"errors": [
{
"message": "Only draft invoices can be finalized",
"path": ["finalizeInvoice"]
}
]
}
Business-rule errors have no machine-readable code. The message text is the only thing distinguishing one from another, and it is not a stable API. Surface it to the user, log it, and branch on the operation you attempted rather than on the wording.
Internal errors
Anything unexpected is masked to Internal server error with no detail. That is deliberate: the specifics are recorded server-side. Retrying can be reasonable, but the request as sent is not necessarily at fault, so do not retry in a tight loop.
Machine-readable categories
A few errors carry extensions.category. These are the only ones. All but FORBIDDEN are raised before or instead of executing your document; FORBIDDEN is raised per field while it runs, so it can arrive alongside data from the fields that succeeded:
category | HTTP | Raised when |
|---|---|---|
TOO_MANY_MUTATIONS | 429 | You passed a mutation rate limit. |
QUERY_TOO_LARGE | 413 | The document text exceeds 8 KB. |
OPERATIONS_TOO_LARGE | 413 | A multipart upload's operations field exceeds 80 KB. |
MAP_TOO_LARGE | 413 | A multipart upload's map field exceeds 80 KB. |
BODY_TOO_LARGE | 413 | The raw request body exceeds 1 MB. |
IP_NOT_ALLOWED | 403 | The access token is restricted to certain IP addresses and yours is not among them. |
UPLOAD_TICKET_INVALID | 403 | A file upload carried no valid, unexpired upload ticket. |
UPLOAD_ROUTE_NOT_MULTIPART | 415 | A request to the upload endpoint was not multipart/form-data. |
UPLOAD_ROUTE_REJECTED | 400 | A request to the upload endpoint was not a single upload mutation. The message names the rule it broke. |
BILLING_FROZEN | 200 | The account is frozen for billing. Only auth and billing fields still work. |
FORBIDDEN | 200 | You are not allowed to read or change this field or record. See Authorization. |
BILLING_FROZEN and FORBIDDEN are the odd ones: they are rejections, but they arrive with a normal 200. Another reason not to lean on the status code alone.
When the status is not 200
| Status | What it means | Retry? |
|---|---|---|
400 | The request never reached GraphQL. See request format below. | Not unchanged |
403 | The access token is restricted to certain IP addresses and yours is not among them, or a file upload carried no valid upload ticket. Carries the usual errors shape and a category. | Only for an upload ticket, with a new ticket |
413 | The document or request body is too large. Carries the usual errors shape and a category. | Not unchanged |
415 | A request to the upload endpoint was not a file upload. Carries the usual errors shape and a category. | Not unchanged |
429 | A rate limit. See below. | Yes, after Retry-After |
503 | The API is temporarily down for maintenance. | Yes, later |
A 400 and 503 come from outside GraphQL, so they carry Laravel's plain {"message": "..."} body rather than an errors array. So does the request rate limit's 429, described next.
Telling the two 429s apart
Both rate limits answer with 429, and the body is what distinguishes them:
- No
errorsarray, just{"message": "Too Many Attempts."}- you passed the request rate limit. - With an
errorsarray carryingTOO_MANY_MUTATIONS- you passed a mutation rate limit.
A Retry-After header means waiting will help. Its absence on a mutation-limit error means the single request asked for more than a whole minute allows, so waiting never will; change the request instead.
Request format (400)
The endpoint rejects some requests before parsing anything:
GETrequests are refused. AlwaysPOST.- A missing
Content-Typeis refused. - A
Content-Typeofmultipart/form-data,application/x-www-form-urlencodedortext/plainis refused unless the request also sendsX-Requested-With: XMLHttpRequest.
That last rule is the one that catches people out, because it applies to file uploads, which are necessarily multipart and go to the upload endpoint. If uploads return a 400 while ordinary queries work, the missing X-Requested-With header is almost certainly why.
Plain JSON requests (Content-Type: application/json) are unaffected and need no extra header.
Handling errors well
- Check the HTTP status first, then the body. Most failures are
200, but not all of them. - Branch on
extensions, not onmessage. Usevalidation,guardsandcategory; treat message text as something to show or log. - Check
dataeven whenerrorsis present. Part of the document may have succeeded. - Retry only what is retryable. A
429withRetry-Afterand a503are worth retrying. A400,403or413will fail identically every time. - Log the whole error entry.
pathandextensionsare what make a report actionable later.