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": "This action is unauthorized.",
"locations": [{ "line": 2, "column": 3 }],
"path": ["updateContact"],
"extensions": {}
}
]
}
| 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 and can be reworded; 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": "This action is unauthorized.",
"path": ["second"]
}
]
}
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 message is This action is unauthorized. and there are no extensions, so there is nothing to branch on beyond the path telling you which field was refused.
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".
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 rejections carry extensions.category. These are the only ones, and each is raised before or instead of executing your document:
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. |
BILLING_FROZEN | 200 | The account is frozen for billing. Only auth and billing fields still work. |
BILLING_FROZEN is the odd one: it is a rejection, but it arrives 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. | No |
413 | The document or request body is too large. 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, 403 and 503 come from outside GraphQL, so they carry Laravel's plain {"message": "..."} body rather than an errors array.
Telling the two 429s apart
Both rate limits answer with 429, and the body is what distinguishes them:
- No
errorsarray, just{"message": "Too Many Requests."}- 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. 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.