import type { Metadata } from "next"; import { PLAN_LIMITS } 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 { CodeTabs } from "@/components/docs/code-tabs"; import { ResponseExample } from "@/components/docs/response-example"; import { fetchTabs } from "@/components/docs/snippets"; export const metadata: Metadata = { title: "Retries", description: "How Fetcha retries and escalates: what triggers a retry, the retries option and its maximum, the shared timeout, attempt metadata, bandwidth implications and idempotency.", }; const L = PLAN_LIMITS.unlimited; export default function RetriesPage() { return (

How a retry works

Before executing, the routing engine produces an ordered list of candidate routes for the request (see Network selection). Fetcha walks that list:

What triggers a retry

Outcome of an attempt Retried? If all attempts fail
Block page: HTTP 403, 407, 429 or 999; 503 challenge; captcha, anti-bot or WAF signatures Yes 200 with success: false and the last blocked page
Timeout waiting for the target Yes, while budget remains 504 TARGET_TIMEOUT
Connection failure to the target Yes 502 TARGET_UNAVAILABLE
Network-side failure (gateway error, upstream auth, 5xx from the route) Yes 503 PROVIDER_UNAVAILABLE
2xx/3xx response that is not a block No, returned immediately —
4xx/5xx response that is not a block (plain 404, 500, …) No, returned immediately with success: false —
Response larger than the size cap No 502 RESPONSE_TOO_LARGE
More than max_redirects hops No 502 TOO_MANY_REDIRECTS
Redirect to a private or internal host No 400 URL_NOT_ALLOWED

The retries option

retries is the number of additional attempts after the first; total attempts are retries + 1. Omit it to use the maximum. The schema accepts 0 to{" "} {L.max_retries}; the same maximum applies to every organization (single plan).

Limit Value
Max retries {L.max_retries}
Max attempts {L.max_retries + 1}
Max timeout {L.max_timeout_ms / 1000} s
Browser fallback On by default (browser_fallback: true): a JavaScript challenge on an HTTP attempt escalates to the managed browser within the same budget.

The candidate list is also bounded by how many distinct routes exist for the request. When there are fewer routes than attempts, the engine repeats the best route (with a new IP each time), so metadata.attempts may come out lower than retries + 1 even on a fully blocked target.

One timeout for everything

timeout is the budget for the whole request, not per attempt. Each attempt receives what is left; when fewer than 500 ms remain, Fetcha stops and returns{" "} TARGET_TIMEOUT. If you rely on several attempts against slow targets, size timeout accordingly (up to {L.max_timeout_ms / 1000} s) rather than raising retries.

Attempts in metadata

metadata.attempts counts every attempt including the final one. metadata.duration_ms and metadata.bytes cover all of them. With debug: true,{" "} metadata.debug.attempts lists each attempt with the route alias, network, country, outcome, target status and duration.

Usage implications

Fetcha has no billing, but retries still show up in your usage figures and in the internal cost estimate behind optional spending limits:

  • Bandwidth is counted for every attempt. metadata.bytes sums request and response bytes across attempts. A block page is usually small, but a large page that gets blocked after being fully downloaded counts its full size.
  • Every request counts in your usage, including failed and blocked ones, but there is no monthly quota; see Rate limits.
  • Requests refused before any attempt (validation, limits, URL_NOT_ALLOWED, session errors) transfer no bytes and are not counted.

Idempotency for POST and other writes

A retry sends the same method and body again. If your POST creates an order, sends a message or otherwise has side effects on the target, an attempt that was processed by the origin but answered with something Fetcha classifies as a block (a 429 for instance) would be replayed.
  • Set {`"retries": 0`} for non-idempotent writes and implement your own retry decision based on status.
  • Where the target supports it, include an idempotency token in your body or headers so replays are harmless.
  • Remember that a 301/302 after a POST, and any 303, is followed as a GET without the body (standard browser behaviour).
  • For POST /v1/sessions, use the Idempotency-Key header to make session creation safe to retry on your side.

Retrying on your side

Fetcha already retries target-side problems. Client-side retries are still appropriate for RATE_LIMITED (honour Retry-After), CONCURRENCY_LIMIT (when a slot frees up), PROVIDER_UNAVAILABLE and INTERNAL_ERROR (exponential backoff), and for a success: false block you may want to retry later with a different country or a fresh session.

= maxTries) throw e; await new Promise((r) => setTimeout(r, Math.min(8000, 500 * 2 ** i) + Math.random() * 250)); } } }`} />
); }