Skip to main content

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": {}
}
]
}
KeyAlways presentWhat it is
messageYesA human-readable description.
locationsNoWhere in your document the problem is.
pathNoThe response path of the field that failed.
extensionsNoMachine-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.

Important!

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"]
}
]
}
caution

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:

categoryHTTPRaised when
TOO_MANY_MUTATIONS429You passed a mutation rate limit.
QUERY_TOO_LARGE413The document text exceeds 8 KB.
OPERATIONS_TOO_LARGE413A multipart upload's operations field exceeds 80 KB.
MAP_TOO_LARGE413A multipart upload's map field exceeds 80 KB.
BODY_TOO_LARGE413The raw request body exceeds 1 MB.
BILLING_FROZEN200The 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

StatusWhat it meansRetry?
400The request never reached GraphQL. See request format below.Not unchanged
403The access token is restricted to certain IP addresses and yours is not among them.No
413The document or request body is too large. Carries the usual errors shape and a category.Not unchanged
429A rate limit. See below.Yes, after Retry-After
503The 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:

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:

  • GET requests are refused. Always POST.
  • A missing Content-Type is refused.
  • A Content-Type of multipart/form-data, application/x-www-form-urlencoded or text/plain is refused unless the request also sends X-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

  1. Check the HTTP status first, then the body. Most failures are 200, but not all of them.
  2. Branch on extensions, not on message. Use validation, guards and category; treat message text as something to show or log.
  3. Check data even when errors is present. Part of the document may have succeeded.
  4. Retry only what is retryable. A 429 with Retry-After and a 503 are worth retrying. A 400, 403 or 413 will fail identically every time.
  5. Log the whole error entry. path and extensions are what make a report actionable later.