Errors & Rate Limits
Error format
Every failure the API produces returns the same envelope, so clients can branch on a stable machine readable code rather than parsing prose.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "title must be 1-500 characters"
}
}Internal faults carry a short context in message, such as "database error" or "key vault", and never the underlying detail. The detail is written to the server log under the request id. Every response carries an x-request-id header, and that is the value to quote when reporting a problem.
Status codes
There are ten codes, one per status.
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Malformed input, such as a bad field, an invalid JSON body, a malformed token or invocation, or a missing x-dataverse-token. Do not retry unchanged. |
| 401 | AUTH_ERROR | The session token is missing, malformed, expired or unknown, or a wallet challenge did not verify. Sign in again. |
| 403 | FORBIDDEN | Authenticated but not permitted. The file is not yours, the pass fails validation, or the invocation is for another file or action. |
| 404 | NOT_FOUND | No such CID, DID, paper, task, token or IPNS record. |
| 409 | CONFLICT | The action needs a state you do not have, such as a linked wallet or a registered browser key, or it conflicts with one you do, such as an anchored DID retired through the wrong route or an ingestion already running. |
| 413 | PAYLOAD_TOO_LARGE | The request body exceeds MAX_UPLOAD_BYTES. |
| 429 | RATE_LIMIT_EXCEEDED | Throttled. A Retry-After: 60 header is set. |
| 500 | INTERNAL_ERROR | A server side fault in the database, the key vault or a background task. |
| 502 | EXTERNAL_SERVICE_ERROR | An upstream service failed or timed out. That is the IPFS cluster, BioAgents, Dataverse or the AI model. |
| 503 | UNAVAILABLE | A feature that is not configured, such as /api/ai/* without an API key. |
Two responses come from the middleware rather than a handler and carry no JSON body. A request that runs past REQUEST_TIMEOUT_SECS answers 504. A body that hits the limit before the handler reads it answers 413.
401 and 403
These carry different meanings and deserve different handling.
- A 401 means the session credential itself is the problem. Sign in again.
- A 403 means the session is valid but does not authorise this action. Retrying does not help. The caller needs a capability they do not have.
A rejected UCAN pass reports 403, with the validator's reason in the message, for example "UCAN rejected: expired" or "UCAN was issued to another principal". A pass that cannot be decoded at all is 400. Neither touches the session. Your token is still good, and the file is still not yours.
Rate limits
Requests are budgeted at 120 per minute with a burst of 20, set by RATE_LIMIT_RPM and RATE_LIMIT_BURST. Exceeding the budget returns 429 with Retry-After: 60.
The budget is keyed by session when the request carries a bearer token, using a hash of the token so the key never stores it, and by client address otherwise. Two consequences follow.
- Users behind one NAT do not consume each other's budget once signed in.
- Unauthenticated traffic from a shared address shares a bucket, so sign in before doing bulk work.
Behind a reverse proxy the address is the socket peer unless TRUST_PROXY_HEADERS is on. With it on, the first hop of x-forwarded-for, or x-real-ip, is used. GET /api/health and GET /api/ready sit outside the limiter.
Backing off
Treat 429 as a signal to slow down rather than to fail.
async function withRetry(fn, attempts = 5) {
for (let i = 0; i < attempts; i++) {
const res = await fn();
if (res.status !== 429) return res;
// Exponential backoff with jitter.
const wait = Math.min(2 ** i * 250, 8000) * (0.5 + Math.random());
await new Promise((r) => setTimeout(r, wait));
}
throw new Error('rate limited');
}Jitter matters. Without it, every client throttled in the same window retries in the same instant and the stampede repeats.
Operational limits
| Limit | Default | Environment variable |
|---|---|---|
| Requests per minute | 120 | RATE_LIMIT_RPM |
| Burst | 20 | RATE_LIMIT_BURST |
| Request body | 100 MiB | MAX_UPLOAD_BYTES |
| Request timeout | 120 s | REQUEST_TIMEOUT_SECS |
| Session lifetime | 24 h | SESSION_TTL_SECS |
There is no cap on concurrent uploads. A large upload holds its request open until the cluster has the bytes. With ?async=true it returns a task handle at once and finishes in the background. For anything that takes more than a few seconds, prefer the asynchronous path described in File Storage, so the request timeout is never in play.