import type { Metadata } from "next"; import { ERROR_CODES, ERROR_HTTP_STATUS, ERROR_MESSAGES, type ErrorCode } from "@fetcha/core"; import { CodeBlock } from "@/components/ui/code-block"; import { DocPage } from "@/components/docs/doc-page"; import { A, Code, H2, H3, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose"; import { Callout } from "@/components/docs/callout"; import { ParamTable } from "@/components/docs/param-table"; import { ResponseExample } from "@/components/docs/response-example"; import { CodeTabs } from "@/components/docs/code-tabs"; export const metadata: Metadata = { title: "Errors", description: "Error envelope, every error code with its HTTP status and meaning, validation details, Retry-After, request IDs and error handling in the SDKs.", }; /** Extra guidance per code, beyond the canonical message. */ const NOTES: Record = { INVALID_API_KEY: { retry: "after-fix", note: "Missing header, malformed token, wrong prefix, revoked or expired key. Check the message for the exact reason." }, EMAIL_NOT_VERIFIED: { retry: "after-fix", note: "Verify the organization owner's email address in the dashboard, then retry." }, RATE_LIMITED: { retry: "backoff", note: "A sliding-window limit was hit. Honour the Retry-After header (seconds); details.retry_after_ms has the precise value." }, CONCURRENCY_LIMIT: { retry: "backoff", note: "Too many in-flight requests for the organization. details.limit is your plan's concurrency. Queue and retry when a request finishes." }, INVALID_REQUEST: { retry: "after-fix", note: "Schema violation, unknown field, malformed JSON, wrong Content-Type or body over 4 MB. details.issues lists each problem." }, URL_NOT_ALLOWED: { retry: "no", note: "Non-http scheme, credentials in URL, or a host that is local, private, link-local, metadata or internal — on the initial URL or a redirect hop." }, TARGET_TIMEOUT: { retry: "later", note: "The request budget (timeout) was exhausted across all attempts. Raise timeout within your plan cap, or retry later." }, TARGET_BLOCKED: { retry: "later", note: "Recorded in the request log when every attempt was blocked. On the API this surfaces as a 200 with success:false and the last blocked page." }, TARGET_UNAVAILABLE: { retry: "later", note: "DNS resolution failed, no address records, or the connection was refused on every attempt." }, PROVIDER_UNAVAILABLE: { retry: "backoff", note: "No route is available for an auto request (all circuits open or nothing configured for the geography). Transient; retry with backoff." }, NETWORK_UNAVAILABLE: { retry: "after-fix", note: "Explicit class not in your plan, not live yet, or without a sticky-capable route (sessions). Use auto or residential." }, BROWSER_UNAVAILABLE: { retry: "later", note: "Browser pool disabled or unavailable. Retry later, or send the request without browser: true." }, BROWSER_TIMEOUT: { retry: "later", note: "The page did not settle in the managed browser within the request timeout. Raise timeout, relax wait_for / wait_until, or retry." }, RESPONSE_TOO_LARGE: { retry: "after-fix", note: "Body exceeded max_response_bytes or the 20 MB platform cap. Not retried across routes." }, TOO_MANY_REDIRECTS: { retry: "after-fix", note: "More than max_redirects hops. Not retried across routes." }, INSUFFICIENT_CREDITS: { retry: "after-fix", note: "Reserved for prepaid balances. Not emitted today." }, USAGE_LIMIT_REACHED: { retry: "after-fix", note: "Monthly plan quota, project request limit, or a hard spending limit (organization or project). details tells which; see Rate limits." }, SESSION_NOT_FOUND: { retry: "after-fix", note: "Unknown session id, or a session that belongs to another project." }, SESSION_EXPIRED: { retry: "after-fix", note: "Session past expires_at or closed. Create a new one." }, NOT_FOUND: { retry: "no", note: "Unknown route or resource. The message names the route." }, FORBIDDEN: { retry: "after-fix", note: "Key lacks the required scope, project archived, or organization/account suspended." }, INTERNAL_ERROR: { retry: "backoff", note: "Unexpected failure inside Fetcha. Retry with backoff; if it persists, send us the request_id." }, CRAWL_NOT_FOUND: { retry: "after-fix", note: "GET/DELETE /v1/crawl/:id with an id that does not exist or belongs to another project." }, CRAWL_LIMIT_REACHED: { retry: "later", note: "Too many crawl jobs running for the organization (5 concurrent). Wait for one to finish or cancel it." }, }; const RETRY_LABEL: Record<(typeof NOTES)[ErrorCode]["retry"], string> = { no: "Do not retry", "after-fix": "Fix, then retry", backoff: "Retry with backoff", later: "Retry later", }; const GROUPS: Array<{ title: string; codes: ErrorCode[] }> = [ { title: "Authentication and authorization", codes: ["INVALID_API_KEY", "EMAIL_NOT_VERIFIED", "FORBIDDEN"] }, { title: "Request validation", codes: ["INVALID_REQUEST", "URL_NOT_ALLOWED", "NETWORK_UNAVAILABLE", "BROWSER_UNAVAILABLE", "NOT_FOUND"] }, { title: "Limits and quotas", codes: ["RATE_LIMITED", "CONCURRENCY_LIMIT", "USAGE_LIMIT_REACHED", "INSUFFICIENT_CREDITS", "CRAWL_LIMIT_REACHED"] }, { title: "Target and network", codes: ["TARGET_TIMEOUT", "TARGET_BLOCKED", "TARGET_UNAVAILABLE", "PROVIDER_UNAVAILABLE", "RESPONSE_TOO_LARGE", "TOO_MANY_REDIRECTS", "BROWSER_TIMEOUT"] }, { title: "Sessions and crawls", codes: ["SESSION_NOT_FOUND", "SESSION_EXPIRED", "CRAWL_NOT_FOUND"] }, { title: "Server", codes: ["INTERNAL_ERROR"] }, ]; const JS_HANDLING = `import { Fetcha, FetchaError } from "@fetcha/sdk"; const fetcha = new Fetcha({ apiKey: process.env.FETCHA_API_KEY! }); try { const r = await fetcha.fetch({ url: "https://example.com", country: "CA" }); if (!r.success) { // Target answered but with an error or a block page: r.status, r.metadata.attempts console.warn("target status", r.status, "after", r.metadata.attempts, "attempts"); } } catch (e) { if (e instanceof FetchaError) { // e.code, e.status (HTTP), e.message, e.requestId switch (e.code) { case "RATE_LIMITED": case "CONCURRENCY_LIMIT": case "PROVIDER_UNAVAILABLE": // back off and retry break; case "INVALID_REQUEST": // fix the request; details are in e.message break; default: console.error(\`\${e.code} (\${e.status}) \${e.message} — request \${e.requestId}\`); } } else { throw e; // network failure reaching Fetcha, JSON parse error, abort } }`; const PY_HANDLING = `from fetcha import Fetcha, FetchaError client = Fetcha(api_key=os.environ["FETCHA_API_KEY"]) try: r = client.fetch("https://example.com", country="CA") if not r.success: # Target answered but with an error or a block page print("target status", r.status, "after", r.metadata["attempts"], "attempts") except FetchaError as e: # e.code, e.status (HTTP), e.message, e.request_id if e.code in ("RATE_LIMITED", "CONCURRENCY_LIMIT", "PROVIDER_UNAVAILABLE"): ... # back off and retry elif e.code == "INVALID_REQUEST": ... # fix the request else: print(f"{e.code} ({e.status}) {e.message} — request {e.request_id}")`; export default function ErrorsPage() { const all: ErrorCode[] = [...ERROR_CODES]; return (

Error envelope

Every non-2xx response from the API has this shape. code is stable and meant for programmatic handling; message is human-readable and may change;{" "} details is present only for some codes.

One of the {all.length} codes below. }, { name: "error.message", type: "string", description: <>Explanation for humans. Often more specific than the default message (for example which scope is missing or which plan lacks a network). }, { name: "error.request_id", type: "string | null", description: <>The request identifier, also in the X-Fetcha-Request-ID header. }, { name: "error.details", type: "object", description: <>Optional structured context: issues for validation, retry_after_ms for rate limits, limit for concurrency, limit/used or limit_usd/spent_usd for quotas. }, ]} /> /v1/fetch returns 200 whenever Fetcha obtained a response from the target, including a 404, a 500 or a block page. Check success and status{" "} in the body. Error envelopes are used only when Fetcha itself could not complete the request. See Blocked targets.

Error codes

The status, code and default message come straight from the API's error catalogue. The last two columns are guidance.

{GROUPS.map((g) => (

{g.title}

{g.codes.map((code) => ( ))}
Status Code Default message and notes Retry
{ERROR_HTTP_STATUS[code]} {code}
{ERROR_MESSAGES[code]}
{NOTES[code].note}
{RETRY_LABEL[NOTES[code].retry]}
))}

Validation details

INVALID_REQUEST raised by schema validation includes details.issues, an array with one entry per problem. path is the dotted path of the offending field (empty for unknown top-level keys), message explains the constraint.

Other request-level failures reuse the same code with a specific message and no issues:

  • Malformed JSON body.
  • Send a JSON body with Content-Type: application/json.
  • Request body too large. (HTTP 413)

Retry-After

RATE_LIMITED responses include a Retry-After header in whole seconds (rounded up from details.retry_after_ms). Honour it. CONCURRENCY_LIMIT{" "} does not carry the header because the right moment to retry is when one of your own in-flight requests completes.

Request IDs and support

Every response has an X-Fetcha-Request-ID; error envelopes repeat it in error.request_id. Log it alongside your own correlation id. When you contact{" "} support@fetcha.co, include the request id, the approximate time (UTC) and the target domain: with the id we can see every attempt, the route used, the outcome and the timing, without you sharing any content.

Retention: request metadata is kept for the retention period of your plan (3 to 365 days). Response bodies are not stored, and sensitive request headers are redacted before logging.

Handling errors in the SDKs

Both SDKs raise a FetchaError for any non-2xx response, exposing code, status, message and the request id. A fetch that reached the target never throws: inspect success instead. See SDKs.

); }