Errors & Rate Limits
Error format
Every failure returns the same envelope, so clients can branch on a stable machine-readable code rather than parsing prose:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "CID cannot be empty"
}
}Status codes
| Status | Codes | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR, FILE_ERROR, REQUEST_ERROR, DESERIALIZATION_ERROR, DID_ERROR | Malformed input - do not retry unchanged |
| 401 | AUTH_ERROR, UCAN_ERROR | Missing, expired, or unverifiable credential |
| 403 | FORBIDDEN | Authenticated, but not permitted on this resource |
| 404 | NOT_FOUND | No such CID, DID, token, or record |
| 429 | RATE_LIMIT_EXCEEDED | Throttled - back off and retry |
| 500 | INTERNAL_ERROR, DATABASE_ERROR, IPFS_ERROR, SERIALIZATION_ERROR | Server-side fault |
| 502 | EXTERNAL_SERVICE_ERROR, DATAVERSE_ERROR | An upstream (IPFS node, Dataverse) failed |
401 vs 403
These carry different meanings and deserve different handling:
- 401 - the credential itself is the problem. Re-authenticate.
- 403 - the credential is valid but does not authorise this action. Retrying is pointless; the caller needs a capability they do not have.
A UCAN failure reports 401 (UCAN_ERROR): a token whose signature does not verify, that has expired, or that has been revoked is treated as no credential, not as insufficient permission.
Rate limits
Requests are throttled at 100 requests/minute with a burst of 10 (configurable via RATE_LIMIT_RPM). Exceeding the limit returns 429.
The limit is keyed by authenticated user, falling back to client IP for unauthenticated calls. Two consequences:
- Users behind one NAT do not consume each other's budget once signed in.
- Unauthenticated traffic from a shared address does share a bucket, so authenticate before doing bulk work.
Backing off
Treat 429 as a signal to slow down, not 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 | 100 | RATE_LIMIT_RPM |
| Concurrent uploads | 50 | MAX_CONCURRENT_UPLOADS |
| Session token lifetime | 24h | JWT_EXPIRATION_SECS |
Uploads beyond the concurrency cap queue rather than fail - a burst of large uploads is slow, not rejected. For those, prefer the asynchronous upload path in File Storage so the connection is not held open.