Errors and X-Request-ID
Every failing Canvas API call returns the same JSON envelope, whatever the status code and whichever operation produced it. There is one shape to handle, one field to branch on, and one id to quote when you need help.
{
"error": {
"code": "input_validation_error",
"message": "baseRevision is required. Read the canvas before mutating it.",
"fields": [
{
"field": "baseRevision",
"message": "Read the canvas and pass its current revision."
}
]
}
}| Field | Type | Always present | Purpose |
|---|---|---|---|
error.code | string, one of 17 values | Yes | The stable, machine-readable classification. Branch on this. |
error.message | string | Yes | A human-readable sentence. Show it; never parse it. |
error.fields | array of { field, message } | No | Present on input validation failures. Names the exact input to fix. |
error.details | object | No | Structured context for a specific code — most importantly currentRevision on a revision conflict. |
Error responses carry Cache-Control: no-store and, like every response,
X-Request-ID.
Never branch on error.message. Message wording is not part of the contract and
changes without notice; error.code is. If a message is the only thing that
distinguishes two situations you need to handle differently, treat that as a gap
and say so rather than shipping a substring match.
The seventeen codes
The code is derived from the HTTP status, so the mapping is one-to-one and
predictable. request_failed is the fallback for any status not listed.
| Code | Status | Meaning | Retry? |
|---|---|---|---|
input_validation_error | 400 | A field is missing, malformed, or out of range. fields names it. | No — fix the request |
invalid_json | 400 | The body could not be parsed as JSON. | No — fix the request |
unauthorized | 401 | The token is missing, malformed, expired, revoked, or its delegated account session is no longer valid. | No — re-authenticate |
payment_required | 402 | The account cannot fund this work. | No — resolve billing |
forbidden | 403 | Valid token, insufficient scope, or no access to this canvas or asset. | No — fix scopes or target |
not_found | 404 | No such resource, or it is not visible to this account. | No |
conflict | 409 | Revision conflict, or an idempotency key reused for different inputs. | Yes — after re-reading, see below |
gone | 410 | A retired surface. Returned by legacy campaign mutations unless an operator has explicitly enabled legacy compatibility. | No — migrate |
payload_too_large | 413 | The request body exceeds the limit for this operation. | No — send less |
unsupported_media_type | 415 | Wrong Content-Type, most often on a raster upload to POST /assets. | No |
unprocessable_entity | 422 | Syntactically valid but semantically rejected — for example a cross-field rule that no single field violates. | No — fix the request |
rate_limited | 429 | Too many requests reached the service. | Yes — back off, honour Retry-After if present |
internal_error | 500 | An unexpected server-side failure. | Yes — with backoff, and only if the request is safe to repeat |
upstream_error | 502 | A dependency Gavana calls returned an error. | Yes — with backoff |
service_unavailable | 503 | Gavana is temporarily unable to serve the request. | Yes — with backoff |
upstream_timeout | 504 | A dependency Gavana calls did not answer in time. | Yes — with backoff |
request_failed | other | Fallback classification for any status not in this table. | Depends — inspect the status |
5xx responses are deliberately thin
For any status of 500 or above, Gavana replaces the internal message with a
generic one and omits fields and details entirely. You will see
"An upstream service could not complete the request." rather than the
underlying cause. This is not a bug and not something you can turn on — the
detail exists in the server logs, keyed by the request id.
That is the moment X-Request-ID earns its keep: it is the only handle that
connects the response you got to the diagnosis someone else can perform.
X-Request-ID
Every response — successful or not — carries an X-Request-ID header containing
a UUID generated for that request.
HTTP/1.1 409 Conflict
Content-Type: application/json
Cache-Control: no-store
X-Request-ID: 8c1f2d34-5a6b-4c7d-8e9f-0a1b2c3d4e5fCapture it on every response, not only failures, and log it alongside your own correlation id. Three things make it worth the two lines of code:
- It is safe to share. It contains no account data and no credential. It is the right thing — and the only thing — to paste into a support request.
- It is the join key for a 5xx, where the response body tells you nothing.
- It disambiguates retries. When you resend an idempotent request, the replayed response has a different request id from the original, which is how you reconstruct what actually happened during an incident.
const response = await fetch(url, init)
const requestId = response.headers.get('x-request-id')
// Log requestId with your own trace id, whether or not the call succeeded.Header names are case-insensitive in HTTP; fetch and most clients normalise
them, so read x-request-id in lowercase if your client exposes raw keys.
What to retry, and how
Split failures into three buckets and treat them differently.
Never retry
input_validation_error, invalid_json, unauthorized, payment_required,
forbidden, not_found, gone, payload_too_large,
unsupported_media_type, unprocessable_entity.
These are deterministic. The identical request will fail identically. Repeating it wastes quota and buries the real signal. Fix the request, the token, or the target.
Retry after re-reading
conflict (409) is the interesting one, and it means two different things
depending on which operation returned it:
- A revision conflict. Someone changed the canvas after you read it.
error.details.currentRevisioncarries the revision you now need. Re-read the canvas, rebuild your batch against the new state, and retry. Do not blindly resend with the new revision — your operations may no longer make sense against a graph that changed. See Revisions and baseRevision. - An idempotency-key collision. You reused a key that was already used for a different request body. Retrying is guaranteed to fail again. Either resend the original body, or use a new key for the new intent. See Idempotency.
Retry with backoff
rate_limited, internal_error, upstream_error, service_unavailable,
upstream_timeout, and transport-level failures such as a socket error or a
client-side timeout.
Three rules keep this safe:
- Only retry a mutation that carries an idempotency key, and resend the identical key and body. Without a stable key, a retry after a timeout can duplicate the work — the first request may have succeeded and only the response was lost.
- Exponential backoff with jitter, and honour
Retry-Afterwhen the response provides it. - Never auto-retry a paid request. Image generation, image edits, image
variations, video generation, and Recipe Runs can spend provider credit. A
failed paid call gets surfaced to a human with its
failure.retryableflag, not silently repeated. This is a product rule, not a technical one, and it holds even when the error code is in the retryable column.
What the first-party client does
The Gavana CLI’s HTTP client is a useful reference point because it is deliberately conservative:
- It retries GET requests only — never POST, PUT, PATCH, or DELETE.
- Up to three total attempts.
- It retries on a transport error, or on status
429,502,503, or504. - Backoff is
Retry-Afterwhen present (capped at 10 seconds), otherwise 250 ms, then 500 ms. - An
AbortErrororTimeoutErroris surfaced immediately as a timeout, not retried.
Mutations are left to the caller precisely because the caller is the only party that knows whether repeating the intent is acceptable.
Transport failures are not API errors
A DNS failure, a connection reset, or a client-side timeout produces no envelope and no request id — there is no response to read one from. Do not synthesise an error code for these; classify them separately in your own client, and remember that a timeout on a mutation is the ambiguous case idempotency keys exist to resolve. When in doubt after a timeout on a mutation, re-read the resource to find out whether the write landed rather than assuming either way.
A validation failure worth reading closely
fields is the most actionable part of the envelope, and it exists on the codes
you will hit most often while integrating:
{
"error": {
"code": "input_validation_error",
"message": "limit must be an integer between 1 and 25.",
"fields": [
{ "field": "limit", "message": "Choose a whole number from 1 through 25." }
]
}
}For an agent, this is the difference between a retry loop and a fix: the field
value tells you which input to change, and the per-field message usually tells
you what a valid value looks like. Surface both to the user or to your own
repair logic before considering any other recovery.
Related
- Idempotency — the mechanism that makes a retry safe
- Revisions and baseRevision — the other meaning of
409 - Authentication — reading
401versus403 - Jobs and Runs — terminal
failureobjects are a different thing from an HTTP error