Errors
- Updated
- Reading time
- 4 min
- Level
- beginner
Every error on every endpoint has the same shape, and code is the contract.
{
"error": {
"code": "NO_AGENT_AVAILABLE",
"message": "No idle agent is available for provider 'chatgpt'.",
"request_id": "req_01JAY7Q2K8",
"details": { "provider_kind": "chatgpt" }
}
}| Field | |
|---|---|
code | The contract. Branch on this |
message | Written for humans and may change in any release. Never parse it |
request_id | Quote it in a support request and the whole trace is recoverable |
details | Structured context. details.field on validation errors, details.attempts on JOB_FAILED |
Every response also carries X-Request-Id. Log it on failures — it is the difference between a
support conversation and a guess.
Every code
| Code | HTTP | Retry? | What it means for you |
|---|---|---|---|
INVALID_REQUEST | 400 | No | Failed validation. details.field names the offender |
UNAUTHORIZED | 401 | No | Missing, malformed, expired or revoked key |
FORBIDDEN | 403 | No | Valid key, wrong scope |
NOT_FOUND | 404 | No | No such job — or not yours |
CONFLICT | 409 | No | Idempotency key reused with a different body |
PAYLOAD_TOO_LARGE | 413 | No | Prompt over 100 000 characters, or body over 1 MiB |
RATE_LIMITED | 429 | After Retry-After | Revoye's ingress limit |
JOB_CANCELLED | 499 | No | Cancelled by you or from the dashboard |
INTERNAL_ERROR | 500 | Yes | Ours, and it never carries internal detail |
JOB_FAILED | 502 | Maybe | Attempts exhausted with agent-reported errors. Read details.attempts |
NO_DEVICE_ONLINE | 503 | Yes | No paired device is connected |
NO_AGENT_AVAILABLE | 503 | Yes | A device is connected, but no agent is eligible right now |
PROVIDER_RATE_LIMITED | 503 | Yes | Your own hourly cap, set in your dashboard |
JOB_TIMEOUT | 504 | Yes | Held as long as you asked; the job continues |
The three that are not failures
These describe the state of your fleet. Treating them as outages will make you retry aggressively against a system that is behaving exactly as designed.
NO_DEVICE_ONLINE — no paired machine is connected. You only ever see this when you asked not to
wait and gave no callback_url; with wait: true, or with a webhook, the job queues instead. The
fix is to turn a machine on, or to stop asking for an immediate answer from a fleet that is asleep.
NO_AGENT_AVAILABLE — a device is connected, but nothing is eligible: every agent is busy,
disabled, rate-limited, or has already failed this job. Back off and retry. If it persists, you need
more agents, not more retries. GET /v1/status will tell you which.
PROVIDER_RATE_LIMITED — you hit a cap you configured. This is your own throttle protecting
your own provider account. Honour it; do not retry immediately, and do not raise the cap without
thinking about why you set it.
RATE_LIMITED versus PROVIDER_RATE_LIMITED
The distinction that matters most, and the one most often confused:
| Whose limit | What to do | |
|---|---|---|
RATE_LIMITED (429) | Revoye's ingress limit on requests per minute | Respect Retry-After. You are sending requests too fast |
PROVIDER_RATE_LIMITED (503) | Yours, set in your dashboard | Slow down, or route to another provider. The job may simply wait |
JOB_FAILED and details.attempts
JOB_FAILED means every attempt was used and none produced an answer. details.attempts lists what
happened on each one — which agent, which provider, and why it ended. That is where you find out
whether your prompt is triggering a provider refusal, whether one agent is broken, or whether a
provider's interface has changed.
Retry only after reading it. A prompt that fails on every agent will fail again.
A retry policy that works
RETRYABLE = {
"NO_DEVICE_ONLINE", "NO_AGENT_AVAILABLE", "PROVIDER_RATE_LIMITED",
"JOB_TIMEOUT", "INTERNAL_ERROR",
}
def submit(body, key):
delay = 2
for attempt in range(6):
r = post("/v1/completions", headers={"Idempotency-Key": key}, json=body)
if r.status_code < 400:
return r.json()
code = r.json()["error"]["code"]
if code not in RETRYABLE:
raise RevoyeError(r.json()["error"]) # 4xx: fix the request, do not retry
if code == "RATE_LIMITED":
delay = int(r.headers.get("Retry-After", delay))
sleep(delay + random.uniform(0, 1)) # jitter, so a fleet does not synchronise
delay = min(delay * 2, 60)
raise RevoyeError("retries exhausted")Note that the same idempotency key is used throughout. That is what makes the loop safe: if an earlier attempt actually succeeded and you never saw the response, the retry returns that job rather than running a second one.
Internal errors
INTERNAL_ERROR is logged our side with a stack trace and returned with none. Nothing about a file
path, a query or a hostname reaches a caller. Quote the request_id and the trace is recoverable.