Skip to content
Command Palette
Search for a command to run...
QuizBase · Docs

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 per type)
  • status — HTTP status, same as response code
  • detail — context-specific message with values (rate, limit, retry-after)
  • instance — the URL you called (with query string)
  • code — kebab/snake version of type slug, for switch statements
  • Optional per-error fields: retryAfter (seconds), upgradeUrl, errors[] for validation

Status codes you’ll see#

StatusWhenRetry?
400Invalid parameter (lang=xyz, amount=999, malformed cursor)No — fix the request
401Missing or invalid X-API-KeyNo — fix the key
403Key valid but lacks permission (admin-only endpoint)No
404Question deleted, soft-hidden by moderation, or id unknownNo
429Rate limit exceededYes — wait Retry-After seconds
500Our bugYes — with backoff
503Brief 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 window
  • RateLimit-Remaining — requests left in this window
  • RateLimit-Reset — seconds until the window resets
  • RateLimit-Policylimit;w=<seconds> — for clients that want the window explicitly

On 429 we also set Retry-After: <seconds>.

429 body#

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 &lt; 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-After on 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-After with 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