CormorantDocs
Menu

Errors & retries

Cormorant reports problems on two levels:

  1. HTTP status codes — something is wrong with the request: the key, a limit, validation, or a wait that ran out.
  2. The error field in search and fetch results — the request was fine, but the web said no: a 404, a robots.txt rule, a site that blocks every tier. These come with HTTP 200 (or as a batch item's error).

HTTP status codes#

Status Body Meaning Retry?
422 {"detail": [{"loc": […], "msg": "…", "type": "…"}]} or {"detail": "…"} Invalid request (a field out of range, a bad date, a non-http callback_url). No — fix the request.
401 {"detail": "missing bearer token"} / {"detail": "invalid API key"} No key, or an unknown/revoked key. No.
403 {"detail": {"error": "customer_suspended", "message": "…"}} The account that owns the key's project is suspended (any /v1 endpoint). No — contact the operator.
403 {"detail": {"error": "user_agent_not_allowed", "hint": "…"}} A user_agent that is not on the project allowlist. No — ask the operator or drop the field.
404 {"detail": "job not found"} / {"detail": "body not found"} Unknown job or body, or one that belongs to another project (or has expired). No.
409 {"detail": "idempotency_key already used for a different job"} Same idempotency_key with a different kind or item count. No — use a new key.
413 {"detail": "at most 50000 items per job"} Too many items (or a request body over 64 MB). Split the job.
403 {"detail": {"error": "key_category_not_allowed", "message": "…"}} The operator limited this key to other categories (e.g. search only). No — use another key.
429 {"detail": "rate limit exceeded"} + Retry-After: 1 Project rate limit. Yes, after Retry-After.
429 {"detail": {"error": "key_rate_limited", …}} + Retry-After: 1 The key's own requests-per-second limit. Yes, after Retry-After.
429 {"detail": {"error": "key_quota_exhausted", "category": "fetch", "limit": 100000, …}} The key used its monthly request quota for that category (search, fetch). Retry-After = seconds until the month resets. Not before the reset — see GET /v1/quota.
429 {"detail": {"error": "key_budget_exhausted", "limit": "50.00", "currency": "USD", …}} The key reached its monthly spend cap. Retry-After = seconds until the month resets. Not before the reset.
503 {"detail": "…"} A dependency is briefly unavailable (e.g. object storage while downloading a body). Yes, with backoff.
504 {"detail": {"error": "timeout", "request_id": "req_…"}} No result within the sync wait (150 s, or timeout_s + 15 s). Yes, later — the work often completes in the background and the retry is a cache hit. Better: use a batch.

Fetch errors (error in the result)#

error Meaning
invalid_url not an http(s) URL
blocked_destination private, loopback, link-local or metadata address (also after redirects)
robots_disallowed robots.txt disallows the path for CormorantBot/*
too_large body exceeds max_bytes (batch PDFs with max_bytes unset: 200 MB)
pdf_not_accepted PDF with accept_pdf=false
http_<status> definitive upstream status (e.g. http_404)
all_tiers_failed every allowed tier failed or was blocked (status = last HTTP status)
domain_unavailable every tier failed for this domain in 3 consecutive fetches: it is skipped for a while (10 min, then 30 min, 2 h, 6 h); retry_after_s says when to try again. Any success ends the skip
domain_dead the host name does not exist (NXDOMAIN twice within 6 h); answered at once for 6 h, without trying. With allow_archive, a snapshot is returned instead when one exists (served_from_archive_because: "domain_dead")
timeout timeout_s ran out (see per-request controls), or a large PDF took longer than 5 minutes to download (attempt reason pdf_download_timeout)
no_tier_allowed tiers/allow_paid left no tier to try
politeness_timeout could not get a per-domain slot in time (transient)
sec_not_configured SEC EDGAR URL, but the operator has not configured Cormorant's SEC identity: SEC is disabled
rate_limited SEC EDGAR URL: the cluster-wide SEC rate limit (at most 10 requests/s for all clients) had no slot within 10 s; retry after retry_after_s (transient)
sec_unavailable SEC EDGAR throttled (403/429/503: all SEC requests back off), failed (5xx) or could not be reached; retry after retry_after_s (transient)
redirect_not_allowed SEC EDGAR URL that redirected to a host outside SEC (not followed)

When no tier reaches min_text_chars, the attempt with the most text is returned (no error).

error Retry?
invalid_url, blocked_destination No.
robots_disallowed No — the site asked crawlers not to fetch this path.
too_large, pdf_not_accepted, no_tier_allowed Not as is — change max_bytes, accept_pdf, tiers or allow_paid.
http_404, http_410 and other definitive http_<status> No (perhaps much later, if the page may reappear).
all_tiers_failed Later (hours), or with paid tiers allowed if you had allow_paid: false. Look at attempts to see what blocked.
timeout Yes, with a larger timeout_s — or in a batch.
domain_unavailable Not before retry_after_s seconds — earlier requests get the same answer at once, without trying. Try another source meanwhile.
domain_dead No (hours at the earliest) — the host name does not exist. Look for the page elsewhere; with allow_archive (the default) you already got the archived copy when there is one.
politeness_timeout Yes, after a short pause: the domain was busy. Batches handle this for you.
rate_limited, sec_unavailable Yes, after retry_after_s (SEC EDGAR's shared rate limit or backoff). Batches handle this for you.
sec_not_configured No — SEC EDGAR is not enabled on this deployment; ask the operator.
redirect_not_allowed No — an SEC EDGAR URL redirected outside SEC.

Batch items are retried automatically for transient problems (connection errors, politeness timeouts, provider rate limits); what ends up in an item's error is final for that job.

Search errors#

A search whose providers could not answer returns results: [] and error — for example budget_exhausted (no budget left for the paid fallback), brave_http_429 (the fallback was rate-limited) or a SearXNG failure such as searxng_timeout. These are transient: retry later with backoff. A search that the engines answered but that found nothing is not an error: results: [] with error: null — rephrase the query instead of retrying.

Backoff#

Retry only what is retryable, cap the number of attempts, and back off exponentially with jitter. Respect Retry-After when present.

Python
import random
import time

import httpx

RETRY_STATUS = {429, 502, 503, 504}  # but not key_quota/budget_exhausted: see below


def post_with_retries(client: httpx.Client, path: str, body: dict, attempts: int = 5) -> dict:
    for attempt in range(attempts):
        try:
            response = client.post(path, json=body)
        except httpx.TransportError:
            response = None
        if response is not None and response.status_code not in RETRY_STATUS:
            response.raise_for_status()  # 4xx other than 429: do not retry
            return response.json()
        if attempt == attempts - 1:
            break
        retry_after = response.headers.get("retry-after") if response is not None else None
        delay = (
            float(retry_after) if retry_after else min(60, 2**attempt) * random.uniform(0.5, 1.5)
        )
        time.sleep(delay)
    raise RuntimeError(f"{path} failed after {attempts} attempts")
TypeScript
const RETRY_STATUS = new Set([429, 502, 503, 504]);

async function postWithRetries(path: string, body: unknown, attempts = 5): Promise<any> {
  for (let attempt = 0; attempt < attempts; attempt++) {
    let res: Response | undefined;
    try {
      res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
    } catch {
      res = undefined; // network error: retry
    }
    if (res && !RETRY_STATUS.has(res.status)) {
      if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
      return res.json();
    }
    if (attempt === attempts - 1) break;
    const retryAfter = Number(res?.headers.get("retry-after"));
    const delay = retryAfter > 0 ? retryAfter * 1000 : Math.min(60_000, 2 ** attempt * 1000) * (0.5 + Math.random());
    await new Promise((r) => setTimeout(r, delay));
  }
  throw new Error(`${path} failed after ${attempts} attempts`);
}

A fetch that returns 200 with an error is a result. Decide per code with the table above whether to retry it later; do not loop on it immediately.

Debugging#

Every response and every batch item has a request_id. attempts shows each tier with its HTTP status, reason (challenge_cloudflare, http_403, thin_content, timeout, …) and duration. When you ask the operator about a request, send the request_id.

View as Markdown