Error Handling & Rate Limiting
The error format, the status codes and error codes the API returns, rate limits, and how to retry an action invoke without running it twice.
This guide covers the errors the API returns, rate limits, and how to retry safely.
Error response format
Errors share one format:
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Human-readable error message"
},
"requestId": "..."
}
Match on error.code. The hosted API leaves error.details out, so read the code and the message. Quote requestId when you contact support.
A refused action is not an HTTP error
An action invoke that Anlyon refuses at dispatch, or whose outcome is unknown, answers HTTP 200. The refusal is in the body. Read status and grade, not the status code alone.
{
"status": "failed",
"grade": "refused",
"error": "Dispatch refused (impact_limit_exceeded): Not enough allowance: daily emails needs 3, has 2."
}
See When the limit is hit and When the outcome is unknown.
HTTP status codes
| Status code | Meaning |
|---|---|
200 |
Success. For an action invoke, read status and grade in the body |
202 |
An action invoke is parked for approval |
400 |
Invalid input |
401 |
Authentication failed |
403 |
A missing scope or role, or a capability the plan does not include |
404 |
The resource does not exist in this workspace and environment |
409 |
A conflict, such as a reused idempotency key or a preview that no longer matches |
429 |
A rate limit, a quota or a per-key budget was reached |
500 |
Internal server error |
503 |
Service unavailable, or new expensive work is paused |
Common error codes
AUTHENTICATION_ERROR (401)
The key did not authenticate. An invalid, expired or revoked key gets the same answer.
Solutions:
- Verify the API key is correct
- Check the
Authorizationheader format:Bearer anlyon_live_... - Check in the dashboard that the key has not been revoked or expired
AUTHORIZATION_ERROR (403)
The credential authenticated and lacks a scope or a role. The message names the missing scope, for example API key is missing required scope: actions:invoke.
Solutions:
- Create a key with that scope. Scopes are fixed when a key is created
- See Scopes
RATE_LIMIT_EXCEEDED (429)
Too many requests in one minute.
A 429 response carries no rate limit headers. Read X-RateLimit-Remaining on the responses before it. The window is one minute.
Solutions:
- Wait for the window to reset
- Retry with exponential backoff
- Reduce request frequency
- Rate limits are fixed during Early Beta Access. Paid plans with higher limits arrive after the beta
QUOTA_EXCEEDED (429 or 402)
A monthly allowance, storage limit, or resource cap was reached. Nothing is
charged and nothing is upgraded automatically. The message says which limit
and what to do about it. Most paths answer 429 and a few answer 402, so
match on the code.
Solutions:
- Monthly allowance (executions, messages, egress, memory processing): wait for
the reset at 00:00 UTC on the first of the month, shown in
Settings → Plan & Billing and in
GET /api/v2/billing/planasaccess.usageResetsAt, or contact support. There is no guaranteed increase. - Storage or a resource cap (data, seats, actions, memories): remove unused items to free room. These limits do not reset.
- Memory processing: existing data is kept, and processing resumes at the reset.
- Monitor usage with
GET /api/v2/billing/quotaand set up usage alerts - Do not call the upgrade endpoint in response: until paid plans open
it returns
PAID_PLANS_UNAVAILABLE
BUDGET_EXCEEDED (429)
The calling key reached its monthly cap on an Anlyon operation. A budget caps Anlyon operations. It does not cap model tokens or the money an action moves.
Solutions:
- Wait for the next UTC month, or have an operator raise the cap with
budgets:write
VALIDATION_ERROR (400)
Invalid request data.
Solutions:
- Read the message for the field that failed
- Check the API reference for required fields
FEATURE_NOT_AVAILABLE (403)
The workspace’s plan does not include the capability.
Solutions:
- Early Beta Access includes environment protection, controlled promotion and the email notification channel
- Paid plans arrive after the beta. Review Billing & Pricing for what each plan includes
EXPENSIVE_WORK_PAUSED (503)
New expensive work (memory processing, hosted action execution, file and cache writes) is temporarily paused for every workspace to protect the service. Signing in, viewing, and deleting your data still work.
Solutions:
- Retry later with backoff
- Check the status page
NOT_FOUND (404)
The resource does not exist where the credential can see it.
Solutions:
- Verify the resource ID is correct
- Check that the key belongs to the environment the resource is in
- Check that the resource has not been deleted
Action invokes have their own 409 codes, such as PREVIEW_MISMATCH, PREVIEW_EXPIRED, PREVIEW_ALREADY_USED, OPERATION_KEY_REUSE and idempotency_key_reuse. See When approval expires or changes and Idempotency and retries.
Rate limiting
Rate limits by plan
| Plan | Requests per minute |
|---|---|
| Free | 300 |
| Production / Growth (planned) | 1,000 |
| Enterprise | No fixed limit |
Rate limits are applied per workspace, in one-minute windows.
Rate limit headers
Responses that pass the limit carry rate limit headers. A 429 does not. X-RateLimit-Reset is a Unix time in milliseconds.
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 299
X-RateLimit-Reset: 1737381660000
Error handling best practices
Check the response status
const response = await fetch(url, options);
const body = await response.json();
if (!response.ok) {
throw Object.assign(new Error(body.error.message), {
code: body.error.code,
status: response.status,
});
}
return body.data;Handle specific error codes
try {
await invokeAction('update-status-file', input, idempotencyKey);
} catch (error) {
if (error.code === 'QUOTA_EXCEEDED') {
console.warn('Quota exceeded. Wait for the monthly reset or free up room.');
} else if (error.code === 'RATE_LIMIT_EXCEEDED') {
// Wait, then send the same request with the same Idempotency-Key.
} else {
console.error('Unexpected error:', error);
}
}Retry an invoke with the same Idempotency-Key
Anlyon does not retry an action invocation. If your code retries an invoke,
send the same Idempotency-Key with the same request. The retry is answered
from the recorded invocation, and the action does not run again.
async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries - 1) throw error;
// Do not retry client errors, except a rate limit.
if (error.status >= 400 && error.status < 500 && error.status !== 429) {
throw error;
}
const delay = baseDelay * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Max retries exceeded');
}Do not retry an invocation whose status is unknown under a new key. Reconcile it first.
Code examples
The SDKs do this for you. These clients show the same handling over plain HTTP.
class AnlyonClient {
private apiKey: string;
private baseUrl: string;
constructor(apiKey: string, baseUrl = 'https://api.anlyon.com') {
this.apiKey = apiKey;
this.baseUrl = baseUrl;
}
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
...options.headers,
},
});
const data = await response.json();
if (!response.ok) {
const error = new Error(data.error?.message || 'Request failed');
(error as any).code = data.error?.code;
(error as any).status = response.status;
(error as any).requestId = data.requestId;
throw error;
}
return data.data;
}
async invokeAction(ref: string, input: Record<string, unknown>, idempotencyKey: string) {
return this.request(`/api/v2/actions/${ref}/invoke`, {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body: JSON.stringify({ input }),
});
}
}import requests
from typing import Any, Dict
class AnlyonClient:
def __init__(self, api_key: str, base_url: str = "https://api.anlyon.com"):
self.api_key = api_key
self.base_url = base_url
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
})
def request(self, endpoint: str, method: str = "GET", **kwargs) -> Dict[str, Any]:
url = f"{self.base_url}{endpoint}"
response = self.session.request(method, url, **kwargs)
try:
data = response.json()
except ValueError:
data = {}
if not response.ok:
error = data.get("error", {})
raise Exception(
f"API Error: {error.get('message', 'Request failed')} "
f"(Code: {error.get('code')}, Status: {response.status_code})"
)
return data.get("data", data)
def invoke_action(self, ref: str, input: Dict[str, Any], idempotency_key: str) -> Dict[str, Any]:
return self.request(
f"/api/v2/actions/{ref}/invoke",
method="POST",
json={"input": input},
headers={"Idempotency-Key": idempotency_key},
)
