SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
29.7 KB · 407 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import Link from "next/link";3import { Download } from "lucide-react";4import { Button } from "@/components/ui/button";5import { CodeBlock } from "@/components/ui/code-block";6import { DocPage } from "@/components/docs/doc-page";7import { A, Code, H2, H3, Li, P, Strong, Table, TBody, Td, Th, THead, Tr, Ul } from "@/components/docs/prose";8import { Callout } from "@/components/docs/callout";9import { Endpoint } from "@/components/docs/endpoint";10import { ParamTable } from "@/components/docs/param-table";11import { CodeTabs } from "@/components/docs/code-tabs";12import { ResponseExample } from "@/components/docs/response-example";13import { fetchTabs } from "@/components/docs/snippets";1415export const metadata: Metadata = {16  title: "Fetch API",17  description: "Complete reference for POST /v1/fetch: every request field including browser rendering, the response shape, formats (html, text, markdown, json, raw), page metadata, links, redirects, size limits and error behaviour.",18};1920const EXAMPLE_BODY = {21  url: "https://example.com/products/42",22  country: "CA",23  region: "QC",24  format: "text",25  timeout: 20000,26  headers: { Accept: "text/html" },27  retries: 2,28  debug: true,29};3031const SUCCESS = {32  request_id: "req_k3j9d0f2a8b1c7e4",33  success: true,34  status: 200,35  url: "https://example.com/products/42",36  final_url: "https://example.com/products/42",37  content: null,38  content_type: "text/html; charset=utf-8",39  headers: {40    "content-type": "text/html; charset=utf-8",41    "cache-control": "private, max-age=0",42    "set-cookie": "sid=3f9a…; Path=/; Domain=example.com; HttpOnly",43  },44  cookies: [{ name: "sid", value: "3f9a…", domain: "example.com", path: "/" }],45  text: "Example Product 42\nCA$ 129.00\nIn stock — ships from Montréal…",46  page: { title: "Example Product 42 — Example Shop", description: "Buy Example Product 42 for CA$ 129.00.", canonical: "https://example.com/products/42", lang: "en", og: { "og:title": "Example Product 42", "og:type": "product" }, links_count: 38 },47  metadata: {48    network: "residential",49    country: "CA",50    mode: "http",51    attempts: 1,52    duration_ms: 1184,53    bytes: 48211,54    session: null,55    cached: false,56    timing: { dns_ms: 21, proxy_connect_ms: 0, tls_ms: 0, origin_ms: 934, processing_ms: 12, total_ms: 1184 },57    debug: { attempts: [{ provider: "network-a", network: "residential", mode: "http", country: "CA", outcome: "success", block_reason: null, status: 200, duration_ms: 1102 }] },58  },59};6061const ESCALATED = {62  request_id: "req_7h2k9m4p1q8r5s3t",63  success: true,64  status: 200,65  url: "https://app.example.io/listings",66  final_url: "https://app.example.io/listings",67  content: null,68  content_type: "text/html; charset=utf-8",69  headers: { "content-type": "text/html; charset=utf-8" },70  cookies: [{ name: "cf_clearance", value: "…", domain: ".example.io", path: "/" }],71  markdown: "# Listings\n\n| Ref | Price |\n| --- | --- |\n| A-1041 | CA$ 1,250 |\n…",72  page: { title: "Listings", description: null, canonical: null, lang: "en", og: {}, links_count: 112 },73  metadata: {74    network: "residential",75    country: "CA",76    mode: "browser",77    attempts: 2,78    duration_ms: 6820,79    bytes: 391204,80    session: null,81    cached: false,82    timing: { dns_ms: 19, proxy_connect_ms: 0, tls_ms: 0, origin_ms: 5210, processing_ms: 88, total_ms: 6820 },83    debug: {84      attempts: [85        { provider: "network-a", network: "residential", mode: "http", country: "CA", outcome: "blocked", block_reason: "cloudflare_challenge", status: 403, duration_ms: 1204 },86        { provider: "network-a", network: "residential", mode: "browser", country: "CA", outcome: "success", block_reason: null, status: 200, duration_ms: 5480 },87      ],88    },89  },90};9192const BLOCKED = {93  request_id: "req_2b7e4c9a1d0f8g6h",94  success: false,95  status: 403,96  url: "https://shop.example.net/",97  final_url: "https://shop.example.net/",98  content: "<html><head><title>Access Denied</title></head><body>…</body></html>",99  content_type: "text/html",100  headers: { "content-type": "text/html", server: "AkamaiGHost" },101  cookies: [],102  metadata: {103    network: "residential",104    country: "US",105    mode: "browser",106    attempts: 4,107    duration_ms: 11410,108    bytes: 9120,109    session: null,110    cached: false,111    timing: { dns_ms: 18, proxy_connect_ms: 0, tls_ms: 0, origin_ms: 1710, processing_ms: 4, total_ms: 11410 },112  },113};114115const INVALID = {116  error: {117    code: "INVALID_REQUEST",118    message: "The request body is invalid.",119    request_id: "req_9c1d2e3f4a5b6c7d",120    details: {121      issues: [122        { path: "timeout", message: "Number must be greater than or equal to 1000" },123        { path: "format", message: "Invalid enum value. Expected 'html' | 'text' | 'markdown' | 'json' | 'raw', received 'pdf'" },124      ],125    },126  },127};128129export default function FetchApiPage() {130  return (131    <DocPage path="/docs/fetch" eyebrow="Core API" title="Fetch API" description="POST /v1/fetch retrieves a URL through the routing engine and returns the body, headers, cookies and routing metadata in one JSON document." status="Stable">132      <Endpoint method="POST" path="/v1/fetch" scope="fetch:execute" status="Live" />133      <div className="my-5 flex flex-wrap items-center gap-3 text-[13px] text-fg-muted">134        <Button asChild variant="outline" size="sm">135          <Link href="/docs/openapi.json" prefetch={false}>136            <Download className="size-3.5" aria-hidden />137            Download OpenAPI 3.1138          </Link>139        </Button>140        <span>Machine-readable description of the public endpoints (fetch, crawl, map, sessions, me, usage).</span>141      </div>142143      <H2>Request</H2>144      <P>145        Send a JSON object with <Code>Content-Type: application/json</Code>. The schema is <Strong>strict</Strong>: unknown fields cause <Code>400 INVALID_REQUEST</Code> rather than being ignored. The146        request body itself is limited to 4 MB.147      </P>148      <ParamTable149        rows={[150          { name: "url", type: "string", required: true, constraints: "1–8,192 chars, http or https", description: <>Absolute URL to fetch. Credentials in the URL, private/internal hosts and non-http schemes are refused with <code>URL_NOT_ALLOWED</code>.</> },151          { name: "method", type: '"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"', default: '"GET"', description: <>HTTP method sent to the target.</> },152          { name: "headers", type: "object<string, string>", constraints: "≤ 64 entries; name ≤ 256, value ≤ 8,192 chars", description: <>Headers forwarded to the target. They override Fetcha&apos;s defaults: a complete, ordered browser header profile (Chrome, Firefox or Safari, rotated per attempt) with matching <code>User-Agent</code>, <code>Accept</code>, <code>Accept-Language</code>, <code>Accept-Encoding</code> and client hints. Hop-by-hop headers and <code>Host</code> are dropped.</> },153          { name: "cookies", type: "object<string, string>", constraints: "name ≤ 256, value ≤ 4,096 chars", description: <>Cookies sent with the request. They are serialised into a <code>Cookie</code> header and appended to any <code>Cookie</code> header you also set.</> },154          { name: "body", type: "string | object", constraints: "string ≤ 2,000,000 chars", description: <>Request body. Strings are sent verbatim; objects are JSON-serialised. If you do not set <code>Content-Type</code>, it defaults to <code>application/json</code>. Ignored for <code>GET</code> and <code>HEAD</code>.</> },155          { name: "timeout", type: "integer (ms)", default: "30000", constraints: "1,000–120,000", description: <>Overall deadline for the whole request, including every retry and any browser render (maximum 120 s). When fewer than 500 ms remain, Fetcha stops retrying and returns <code>TARGET_TIMEOUT</code>.</> },156          { name: "country", type: "string", constraints: "exactly 2 chars, upper-cased", description: <>ISO 3166-1 alpha-2 country of the exit IP, e.g. <code>{`"CA"`}</code>. See <Link href="/docs/geolocation" className="text-accent underline underline-offset-2">Geolocation</Link>.</> },157          { name: "region", type: "string", constraints: "≤ 64 chars", description: <>State or province. US states and Canadian provinces accept two-letter codes (<code>{`"QC"`}</code>, <code>{`"NY"`}</code>) or full names; other values are slugified.</> },158          { name: "city", type: "string", constraints: "≤ 128 chars", description: <>City name, slugified (<code>{`"Quebec"`}</code> → <code>quebec</code>, <code>{`"New York"`}</code> → <code>new_york</code>).</> },159          { name: "network", type: '"auto" | "datacenter" | "residential" | "isp" | "mobile"', default: '"auto"', description: <>Network class. <code>auto</code> lets the engine choose and escalate. Only <code>residential</code> is live today; explicitly requesting another class returns <code>NETWORK_UNAVAILABLE</code>. See <Link href="/docs/networks" className="text-accent underline underline-offset-2">Network selection</Link>.</> },160          { name: "session", type: "string", constraints: "≤ 64 chars", description: <>Id of a session created with <code>POST /v1/sessions</code> (<code>sess_…</code>). Pins the exit identity, network and default country for this request. Unknown ids return <code>SESSION_NOT_FOUND</code>; expired or closed ones return <code>SESSION_EXPIRED</code>.</> },161          { name: "format", type: '"html" | "text" | "markdown" | "json" | "raw"', default: '"html"', description: <>Controls the response. <code>html</code> and <code>raw</code> return the body in <code>content</code>. <code>text</code> returns readable text in <code>text</code> (scripts, styles and tags removed) and sets <code>content</code> to <code>null</code>. <code>markdown</code> converts the page to Markdown in <code>markdown</code> (main content first, navigation and boilerplate removed; headings, lists, links, tables and code preserved) and sets <code>content</code> to <code>null</code>. <code>json</code> returns the body in <code>content</code> and, when it parses, the parsed value in <code>json</code>.</> },162          { name: "follow_redirects", type: "boolean", default: "true", description: <>Follow 301/302/303/307/308 responses. When <code>false</code>, the redirect response is returned as-is (<code>success</code> is <code>true</code> for 3xx).</> },163          { name: "max_redirects", type: "integer", default: "10", constraints: "0–20", description: <>Maximum hops before <code>TOO_MANY_REDIRECTS</code>.</> },164          { name: "max_response_bytes", type: "integer", constraints: "1,024–50,000,000", description: <>Lower the response size cap for this request. The platform limit is 20 MB; values above it are reduced to 20 MB. Exceeding the cap returns <code>RESPONSE_TOO_LARGE</code>.</> },165          { name: "retries", type: "integer", default: "5", constraints: "0–5", description: <>Number of additional attempts after the first. Retries use a fresh exit IP and a different header profile, with jittered backoff; a <code>Retry-After</code> header from the target is honoured within the timeout. See <Link href="/docs/retries" className="text-accent underline underline-offset-2">Retries</Link>.</> },166          { name: "device", type: '"desktop" | "mobile" | "tablet"', description: <><code>mobile</code> sets an iPhone Safari <code>User-Agent</code> unless you provide your own. <code>desktop</code> is the default behaviour. <code>tablet</code> is accepted but currently changes nothing.</> },167          { name: "locale", type: "string", constraints: "≤ 16 chars", description: <>Sets the <code>Accept-Language</code> header, e.g. <code>{`"fr-CA"`}</code>.</> },168          { name: "debug", type: "boolean", default: "false", description: <>Adds <code>metadata.debug.attempts</code> with one entry per attempt (route alias, network, mode, country, outcome, block reason, status, duration).</> },169          { name: "links", type: "boolean", default: "false", description: <>Return every hyperlink of the page in <code>links[]</code> as absolute URLs with anchor text, <code>internal</code> (same registrable domain) and <code>nofollow</code> flags. HTML responses only. <code>page.links_count</code> is always present.</> },170          { name: "referer", type: '"auto" | "none" | url', default: '"auto"', constraints: "url ≤ 2,048 chars", description: <><code>Referer</code> strategy. <code>auto</code> sends none on the first attempt and a search-engine referer on retries; <code>none</code> never sends one; a literal URL is sent as-is.</> },171          { name: "browser", type: "boolean", default: "false", description: <>Render the page in the managed headless Chromium, routed through the same network class, geography and session as a plain fetch. Returns the DOM after the page settles. See <Link href="/docs/browser" className="text-accent underline underline-offset-2">Browser</Link>.</> },172          { name: "browser_fallback", type: "boolean", default: "true", description: <>When an HTTP attempt is blocked by a JavaScript challenge or anti-bot page, automatically retry in the browser. Set to <code>false</code> to stay on plain HTTP.</> },173          { name: "wait_for", type: "string (CSS selector)", constraints: "≤ 512 chars", description: <>Browser: selector that must be present before the DOM is captured.</> },174          { name: "wait_ms", type: "integer (ms)", constraints: "0–30,000", description: <>Browser: extra settle time after the wait condition (and after <code>wait_for</code> when set).</> },175          { name: "wait_until", type: '"load" | "domcontentloaded" | "networkidle"', default: '"domcontentloaded"', description: <>Browser: navigation event to wait for before applying <code>wait_for</code> / <code>wait_ms</code>.</> },176          { name: "javascript", type: "boolean", default: "true", description: <>Browser: set to <code>false</code> to render with scripting disabled.</> },177          { name: "block_resources", type: "boolean", default: "true", description: <>Browser: skip images, fonts and media to save bandwidth and time. Page scripts and XHR still run.</> },178          { name: "screenshot", type: "boolean", default: "false", description: <>Browser: return a PNG of the viewport, base64-encoded, in <code>screenshot</code>.</> },179          { name: "solve_captcha", type: "boolean", default: "true", description: <>Browser: when a Cloudflare Turnstile challenge blocks the render, obtain a token from the managed captcha solver and pass it to the page. Set <code>false</code> to never spend solver credits on a request. Attempts that needed a token report <code>captcha_solved: true</code> in <code>metadata.debug</code>.</> },180          { name: "cache", type: "{ enabled?: boolean, ttl?: integer }", reserved: true, constraints: "ttl 1–86,400 s", description: <>Reserved for response caching. Accepted, ignored; <code>metadata.cached</code> is always <code>false</code> today.</> },181        ]}182      />183      <Callout variant="info" title="Browser fields outside browser mode">184        <Code>wait_for</Code>, <Code>wait_ms</Code>, <Code>wait_until</Code>, <Code>javascript</Code>, <Code>block_resources</Code> and <Code>screenshot</Code> only take effect when a render happens: either because <Code>browser: true</Code>, or because an HTTP attempt was blocked and <Code>browser_fallback</Code> escalated to the browser. On a plain HTTP response they are accepted and have no effect.185      </Callout>186187      <H3>Example</H3>188      <P>Fetch a product page as readable text through a Canadian residential exit in Québec, with a 20-second budget and debug metadata.</P>189      <CodeTabs190        tabs={fetchTabs(EXAMPLE_BODY, {191          javascript: `console.log(data.success, data.status, data.metadata.attempts);\nconsole.log(data.text);`,192          python: `print(data["success"], data["status"], data["metadata"]["attempts"])\nprint(data["text"])`,193          go: `\tfmt.Println(data["success"], data["status"])\n\tfmt.Println(data["text"])`,194          php: `echo $data['text'];`,195          ruby: `puts data["text"]`,196          csharp: `Console.WriteLine(doc.RootElement.GetProperty("text").GetString());`,197        })}198      />199200      <H2>Response</H2>201      <P>202        Whenever Fetcha obtains a response from the target, the API answers <Code>200 OK</Code> with the document below, even if the target replied with an error status. The HTTP status of the203        API call therefore means &ldquo;Fetcha did its job&rdquo;; the <Code>success</Code> and <Code>status</Code> fields tell you what the target did.204      </P>205      <ResponseExample status={200} body={SUCCESS} />206      <ParamTable207        showDefault={false}208        rows={[209          { name: "request_id", type: "string", description: <>Identifier of this request (<code>req_…</code>). Also sent as the <code>X-Fetcha-Request-ID</code> header.</> },210          { name: "success", type: "boolean", description: <><code>true</code> when the final response has a 2xx or 3xx status and was not classified as a block page. <code>false</code> for 4xx/5xx and for targets that blocked every attempt.</> },211          { name: "status", type: "integer", description: <>HTTP status returned by the target on the final attempt.</> },212          { name: "url", type: "string", description: <>The URL you requested.</> },213          { name: "final_url", type: "string", description: <>URL after redirects.</> },214          { name: "content", type: "string | null", description: <>Response body for <code>html</code>, <code>raw</code> and <code>json</code> formats; <code>null</code> for <code>text</code>. Text-like content types (<code>text/*</code>, JSON, XML, JavaScript, form-encoded) are returned as UTF-8 text; everything else (images, PDFs, archives) is returned <strong>base64-encoded</strong>.</> },215          { name: "content_type", type: "string | null", description: <>The target&apos;s <code>Content-Type</code> header.</> },216          { name: "headers", type: "object<string, string>", description: <>Response headers from the target with lower-cased names. Multiple values are joined with <code>{`", "`}</code>. <code>content-encoding</code> is removed because Fetcha decompresses gzip, deflate, brotli and zstd bodies for you.</> },217          { name: "cookies", type: "array", description: <>Cookies parsed from <code>Set-Cookie</code>: <code>{`{ name, value, domain?, path? }`}</code>. Replay them via the <code>cookies</code> request field.</> },218          { name: "text", type: "string | null", description: <>Present only for <code>{`format: "text"`}</code>. Readable text extracted from HTML; <code>null</code> when the body was binary.</> },219          { name: "markdown", type: "string | null", description: <>Present only for <code>{`format: "markdown"`}</code>. The page converted to Markdown, main content first with navigation, footers, cookie banners and scripts removed; <code>null</code> when the body was not HTML.</> },220          { name: "json", type: "any", description: <>Present only for <code>{`format: "json"`}</code> and only when the body parsed as JSON. Absent otherwise; check <code>content</code> in that case.</> },221          { name: "page", type: "object | null", description: <>Parsed page metadata for HTML responses: <code>{`{ title, description, canonical, lang, og, links_count }`}</code>. <code>og</code> maps Open Graph property names to values. <code>null</code> for non-HTML bodies.</> },222          { name: "links", type: "array", description: <>Present only with <code>links: true</code> on an HTML response. Items are <code>{`{ url, text, internal, nofollow }`}</code> with absolute URLs, de-duplicated, in document order.</> },223          { name: "screenshot", type: "string", description: <>Present only when the page was rendered in the browser with <code>screenshot: true</code>. PNG, base64-encoded.</> },224          { name: "metadata", type: "object", description: <>Routing information, see below.</> },225        ]}226      />227      <H3>Escalated to the browser</H3>228      <P>229        The same document is returned when Fetcha had to render the page. <Code>metadata.mode</Code> tells you how the final attempt was made, and with <Code>debug: true</Code> each attempt shows230        its <Code>mode</Code> and, when blocked, the <Code>block_reason</Code> that triggered the escalation.231      </P>232      <ResponseExample status={200} title="200 OK · format: markdown · escalated to the browser" body={ESCALATED} />233234      <H3>metadata</H3>235      <ParamTable236        showDefault={false}237        rows={[238          { name: "network", type: '"datacenter" | "residential" | "isp" | "mobile"', description: <>Concrete network class that served the final attempt. Never <code>auto</code>.</> },239          { name: "country", type: "string | null", description: <>Country targeted by the final attempt (the request&apos;s <code>country</code>, or the session&apos;s). <code>null</code> when no geography was requested.</> },240          { name: "mode", type: '"http" | "browser"', description: <><code>http</code> for a plain fetch, <code>browser</code> when the final attempt was rendered in the managed browser (requested or escalated).</> },241          { name: "attempts", type: "integer", description: <>Number of attempts made, including the successful one. Greater than 1 means Fetcha retried or escalated.</> },242          { name: "duration_ms", type: "integer", description: <>Wall-clock time of the whole request inside Fetcha, all attempts included.</> },243          { name: "bytes", type: "integer", description: <>Bytes transferred across <em>all</em> attempts (request and response). This is the quantity used for bandwidth pricing on premium networks.</> },244          { name: "session", type: "string | null", description: <>Session id used, if any.</> },245          { name: "cached", type: "boolean", description: <>Always <code>false</code> today (caching is reserved).</> },246          { name: "timing", type: "object", description: <>Breakdown of the final attempt, see below.</> },247          { name: "debug", type: "object", description: <>Only with <code>debug: true</code>. <code>attempts[]</code> of <code>{`{ provider, network, mode, country, outcome, block_reason, status, duration_ms }`}</code>. <code>provider</code> is a neutral route alias (<code>network-a</code>, <code>network-b</code>, …); <code>mode</code> is <code>http</code> or <code>browser</code>; <code>outcome</code> is one of <code>success</code>, <code>blocked</code>, <code>timeout</code>, <code>error</code>, <code>provider_error</code>, <code>too_large</code>; <code>block_reason</code> names the detector that classified a blocked attempt (for example <code>cloudflare_challenge</code>, <code>datadome</code>, <code>captcha</code>, <code>soft_block</code>) and is <code>null</code> otherwise.</> },248        ]}249      />250251      <H3>metadata.timing</H3>252      <P>All values are integers in milliseconds and describe the final attempt (except <Code>total_ms</Code>).</P>253      <Table>254        <THead>255          <Tr>256            <Th>Field</Th>257            <Th>Meaning</Th>258          </Tr>259        </THead>260        <TBody>261          <Tr>262            <Td mono>dns_ms</Td>263            <Td>Time Fetcha spent resolving the target hostname to validate it against the URL policy (SSRF protection). This happens once per request, before routing.</Td>264          </Tr>265          <Tr>266            <Td mono>proxy_connect_ms</Td>267            <Td>268              Time to establish the connection to the network. <Strong>Reported as 0 in the current build</Strong>; connection time is included in <Code>origin_ms</Code>.269            </Td>270          </Tr>271          <Tr>272            <Td mono>tls_ms</Td>273            <Td>274              TLS handshake time. <Strong>Reported as 0 in the current build</Strong>; included in <Code>origin_ms</Code>.275            </Td>276          </Tr>277          <Tr>278            <Td mono>origin_ms</Td>279            <Td>Time from sending the request until the first byte of the final response (connection, handshake, upstream routing and origin processing), summed across redirect hops of that attempt.</Td>280          </Tr>281          <Tr>282            <Td mono>processing_ms</Td>283            <Td>Time Fetcha spent decompressing the body and converting it to the requested format.</Td>284          </Tr>285          <Tr>286            <Td mono>total_ms</Td>287            <Td>288              End-to-end duration inside Fetcha for the whole request, identical to <Code>metadata.duration_ms</Code>.289            </Td>290          </Tr>291        </TBody>292      </Table>293294      <H2>Blocked targets</H2>295      <P>296        A block is any response Fetcha classifies as anti-bot interference. Detection covers HTTP 403, 407, 429 and 999, 503 challenge pages, captcha markup and WAF signatures on 4xx/5xx, plus297        vendor-specific fingerprints in headers, cookies and the first part of the body: Cloudflare challenges and Turnstile, DataDome, PerimeterX / HUMAN, Akamai Bot Manager, Kasada, Imperva /298        Incapsula, AWS WAF challenges and Vercel attack-mode pages. <Strong>Soft blocks</Strong> are detected too: a 200 whose body is a challenge, an interstitial, an empty shell or a299        &ldquo;verify you are human&rdquo; page rather than the real content.300      </P>301      <P>302        Blocks trigger escalation: a new exit IP and header profile, then a premium route, and, when the block is a JavaScript challenge and <Code>browser_fallback</Code> is on (the default), a303        render in the managed browser, which solves most challenge pages. If <Strong>every</Strong> attempt is blocked, Fetcha still returns <Code>200</Code> with the last blocked page so you can304        inspect it, but with <Code>success: false</Code>. The request log records it as <Code>TARGET_BLOCKED</Code>, and <Code>debug.attempts[].block_reason</Code> tells you what was detected.305      </P>306      <ResponseExample status={200} title="200 OK · success: false" body={BLOCKED} />307      <Callout variant="info" title="Non-blocked errors are returned immediately">308        A plain 404 or 500 from the origin is not a block. It is returned after a single attempt with <Code>success: false</Code> and the origin status, without retrying.309      </Callout>310311      <H2>Redirects</H2>312      <Ul>313        <Li>314          301, 302, 303, 307 and 308 with a <Code>Location</Code> header are followed while <Code>follow_redirects</Code> is <Code>true</Code>, up to <Code>max_redirects</Code> hops.315        </Li>316        <Li>317          <Strong>Every hop is validated</Strong> against the same URL policy as the original URL. A redirect to a private or internal address fails the request with <Code>URL_NOT_ALLOWED</Code>{" "}318          and is not retried.319        </Li>320        <Li>321          A 303, or a 301/302 following a <Code>POST</Code>, switches the next hop to <Code>GET</Code> and drops the body, matching browser behaviour.322        </Li>323        <Li>324          Exceeding <Code>max_redirects</Code> fails with <Code>502 TOO_MANY_REDIRECTS</Code>, without retrying.325        </Li>326      </Ul>327328      <H2>Size limits</H2>329      <Ul>330        <Li>331          Response bodies are capped at <Strong>20 MB</Strong>. You can lower the cap per request with <Code>max_response_bytes</Code>; you cannot raise it above 20 MB. Larger bodies fail with{" "}332          <Code>502 RESPONSE_TOO_LARGE</Code>.333        </Li>334        <Li>Request bodies to the API are capped at 4 MB (413, reported as <Code>INVALID_REQUEST</Code>).</Li>335        <Li>336          <Code>body</Code> strings are limited to 2,000,000 characters by the schema.337        </Li>338      </Ul>339340      <H2>Content types</H2>341      <P>342        Fetcha does not transform binary content. When the target&apos;s <Code>Content-Type</Code> is not text-like (<Code>text/*</Code>, JSON, XML, JavaScript or form-encoded), <Code>content</Code>{" "}343        contains the body encoded as base64 and <Code>text</Code> is <Code>null</Code>. Decode it with your language&apos;s base64 helper; <Code>content_type</Code> tells you what it is.344      </P>345      <CodeBlock346        lang="javascript"347        code={`const r = await fetcha.fetch({ url: "https://example.com/report.pdf", format: "raw" });348if (r.content_type?.startsWith("application/pdf")) {349  const pdf = Buffer.from(r.content!, "base64");350  await fs.promises.writeFile("report.pdf", pdf);351}`}352      />353354      <H2>Errors</H2>355      <P>356        When Fetcha cannot produce a response document, the API returns a non-200 status with an error envelope. Validation failures list every offending field in <Code>details.issues</Code>.357        The full catalogue is on the <A href="/docs/errors">Errors</A> page.358      </P>359      <ResponseExample status={400} statusText="Bad Request" body={INVALID} />360      <Table dense>361        <THead>362          <Tr>363            <Th>Situation</Th>364            <Th>Result</Th>365          </Tr>366        </THead>367        <TBody>368          <Tr>369            <Td>Invalid or unknown field, malformed JSON, wrong content type, body over 4 MB</Td>370            <Td mono>400 INVALID_REQUEST</Td>371          </Tr>372          <Tr>373            <Td>Private, internal or non-http URL (initial or via redirect)</Td>374            <Td mono>400 URL_NOT_ALLOWED</Td>375          </Tr>376          <Tr>377            <Td>Network class not in your plan or not live</Td>378            <Td mono>400 NETWORK_UNAVAILABLE</Td>379          </Tr>380          <Tr>381            <Td>382              <Code>browser: true</Code> while the browser pool is disabled or down383            </Td>384            <Td mono>400 BROWSER_UNAVAILABLE</Td>385          </Tr>386          <Tr>387            <Td>Page did not settle in the browser before the timeout</Td>388            <Td mono>504 BROWSER_TIMEOUT</Td>389          </Tr>390          <Tr>391            <Td>Timeout exhausted across all attempts</Td>392            <Td mono>504 TARGET_TIMEOUT</Td>393          </Tr>394          <Tr>395            <Td>DNS failure or connection refused on every attempt</Td>396            <Td mono>502 TARGET_UNAVAILABLE</Td>397          </Tr>398          <Tr>399            <Td>No route available at all</Td>400            <Td mono>503 PROVIDER_UNAVAILABLE</Td>401          </Tr>402        </TBody>403      </Table>404    </DocPage>405  );406}407