Skip to Content
APIPlatformErrors and X-Request-ID

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." } ] } }
FieldTypeAlways presentPurpose
error.codestring, one of 17 valuesYesThe stable, machine-readable classification. Branch on this.
error.messagestringYesA human-readable sentence. Show it; never parse it.
error.fieldsarray of { field, message }NoPresent on input validation failures. Names the exact input to fix.
error.detailsobjectNoStructured 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.

CodeStatusMeaningRetry?
input_validation_error400A field is missing, malformed, or out of range. fields names it.No — fix the request
invalid_json400The body could not be parsed as JSON.No — fix the request
unauthorized401The token is missing, malformed, expired, revoked, or its delegated account session is no longer valid.No — re-authenticate
payment_required402The account cannot fund this work.No — resolve billing
forbidden403Valid token, insufficient scope, or no access to this canvas or asset.No — fix scopes or target
not_found404No such resource, or it is not visible to this account.No
conflict409Revision conflict, or an idempotency key reused for different inputs.Yes — after re-reading, see below
gone410A retired surface. Returned by legacy campaign mutations unless an operator has explicitly enabled legacy compatibility.No — migrate
payload_too_large413The request body exceeds the limit for this operation.No — send less
unsupported_media_type415Wrong Content-Type, most often on a raster upload to POST /assets.No
unprocessable_entity422Syntactically valid but semantically rejected — for example a cross-field rule that no single field violates.No — fix the request
rate_limited429Too many requests reached the service.Yes — back off, honour Retry-After if present
internal_error500An unexpected server-side failure.Yes — with backoff, and only if the request is safe to repeat
upstream_error502A dependency Gavana calls returned an error.Yes — with backoff
service_unavailable503Gavana is temporarily unable to serve the request.Yes — with backoff
upstream_timeout504A dependency Gavana calls did not answer in time.Yes — with backoff
request_failedotherFallback 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-0a1b2c3d4e5f

Capture 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:

  1. 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.
  2. It is the join key for a 5xx, where the response body tells you nothing.
  3. 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.currentRevision carries 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:

  1. 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.
  2. Exponential backoff with jitter, and honour Retry-After when the response provides it.
  3. 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.retryable flag, 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, or 504.
  • Backoff is Retry-After when present (capped at 10 seconds), otherwise 250 ms, then 500 ms.
  • An AbortError or TimeoutError is 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.

Last updated on