TypeScript 97.5%
SQL 1.4%
Python 0.8%
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'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 “Fetcha did its job”; 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'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's <code>country</code>, or the session'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 “verify you are human” 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'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'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