Command Palette
Search for a command to run...
Errors and retries#
QuizBase returns errors in RFC 9457 Problem Details format — one shape for every non-2xx response. Rate limits follow the IETF RateLimit draft.
Error body — always the same shape#
{
"type": "https://quizbase.runriva.com/errors/rate-limit-exceeded",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded on requests per day. Limit: 100/day on Free tier. Current: 101/day. Retry after: 3600s.",
"instance": "/api/v1/questions?amount=10&lang=pl",
"code": "rate_limit_exceeded"
} type— absolute URL documenting this error class (stable, machine-readable)title— short human summary (stable pertype)status— HTTP status, same as response codedetail— context-specific message with values (rate, limit, retry-after)instance— the URL you called (with query string)code— kebab/snake version oftypeslug, for switch statements- Optional per-error fields:
retryAfter(seconds),upgradeUrl,errors[]for validation
Status codes you’ll see#
| Status | When | Retry? |
|---|---|---|
400 | Invalid parameter (lang=xyz, amount=999, malformed cursor) | No — fix the request |
401 | Missing or invalid X-API-Key | No — fix the key |
403 | Key valid but lacks permission (admin-only endpoint) | No |
404 | Question deleted, soft-hidden by moderation, or id unknown | No |
429 | Rate limit exceeded | Yes — wait Retry-After seconds |
500 | Our bug | Yes — with backoff |
503 | Brief outage (deploy, database failover) | Yes — with backoff |
Rate-limit headers (IETF)#
Every response — not just 429 — includes:
RateLimit-Limit: 100
RateLimit-Remaining: 87
RateLimit-Reset: 3487
RateLimit-Policy: 100;w=86400 RateLimit-Limit— your quota for the current windowRateLimit-Remaining— requests left in this windowRateLimit-Reset— seconds until the window resetsRateLimit-Policy—limit;w=<seconds>— for clients that want the window explicitly
On 429 we also set Retry-After: <seconds>.
429 body#
{
"type": "https://quizbase.runriva.com/errors/rate-limit-exceeded",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded on requests per day. Limit: 100/day on Free tier. Current: 101/day. Retry after: 3600s.",
"instance": "/api/v1/questions?amount=10&lang=pl",
"code": "rate_limit_exceeded",
"retryAfter": 3600,
"upgradeUrl": "https://quizbase.runriva.com/pricing"
}upgradeUrl is a hint — you can route users to the pricing page when they hit the free-tier ceiling.
Backoff strategy#
Retry 429 and 5xx. Never retry 4xx (except 429 — which is technically 4xx but rate-limit not permanent).
The rule of thumb#
async function fetchWithRetry(url: string, key: string, maxAttempts = 5) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await fetch(url, { headers: { 'X-API-Key': key } });
if (res.ok) return res.json();
// 4xx (except 429) — fix the request, don't retry
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
throw new Error(`Client error ${res.status}: ${await res.text()}`);
}
// 429: respect Retry-After; 5xx: exponential backoff with jitter
const retryAfter = res.status === 429
? parseInt(res.headers.get('Retry-After') ?? '60', 10) * 1000
: Math.min(60_000, 1000 * 2 ** attempt) + Math.random() * 1000;
await new Promise((r) => setTimeout(r, retryAfter));
}
throw new Error(`Exhausted ${maxAttempts} retries`);
} Checklist#
- ✅ Respect
Retry-Afteron 429 to the second — we mean it - ✅ Add jitter (
+ Math.random() * 1000) on 5xx backoff to avoid thundering herd - ✅ Cap total retries (3-5) and total wall-clock time
- ❌ Don’t retry 400/401/403/404
- ❌ Don’t ignore
Retry-Afterwith your own shorter delay
Request ID#
Every response has X-Request-Id. Include it when you contact support — it lets us find your request in logs.
X-Request-Id: req_01HZABC123DEF Next#
- Rate limits in practice — full worked examples with idempotency and queue patterns
- Performance — SLOs and p95 numbers so you know what timeouts to set
- Support — what to send us when an error needs investigation