Why is my agent getting a 403, and what's the legitimate fix?
A 403 means the site understood the request and refused it, and the reason decides the fix. If the page needs a login or a subscription, or robots.txt disallows it, stop. If you were sending too fast, slow down and honour Retry-After. If the site refuses your network or your location, one retry from a real device in the country the page is meant for is reasonable, and if that device is refused too, the refusal is meant for everyone.
Updated · Read as markdown
Read the refusal before you retry
A status code is the site telling you something. An agent that answers every failure the same way, by retrying faster from somewhere else, is the kind that gets blocked for good. Read what came back first:
| Status or symptom | Likely cause | The legitimate fix | Another network? |
|---|---|---|---|
| robots.txt disallows the URL | The site asked crawlers to stay out of that path | Stop. Check robots.txt before the first request to a site | No |
| 401, or a redirect to a login page | The page needs an account | Stop. If the site offers an API with keys, use that within its terms | No |
| 402, or a "subscribe to continue" page | A paywall | Stop, or license the content | No |
| 403 with a page that asks you to sign in, subscribe or accept terms | An account wall, sent as a 403 | Stop | No |
| 403 or a block page from the site's CDN, while the same URL opens in an ordinary browser | The site distrusts your network (often cloud and data-center addresses) or requests that don't say who they are | Send a User-Agent that names your agent with a contact URL, use the site's API or feed if it has one, then retry once from a real device | Yes, once |
| 403, or a "not available in your country" page | The site serves a different region from the one your agent runs in | Fetch from the country the page is meant for | Yes, from that country |
| 451 | A legal demand blocks the page (RFC 7725) | Stop | No |
| 429 | You sent too many requests too fast | Wait as long as Retry-After says, back off with jitter, send fewer requests at once, cache | No |
| 503, often with Retry-After | The server is overloaded or down for maintenance | The same as a 429 | No |
| 500, 502, 504 | The server, or a proxy in front of it, failed | Retry a few times with backoff, then come back later | Rarely |
| 404 or 410 | The page is gone (RFC 9110 also lets a server answer 404 to hide a page it won't show) | Stop. Look for the new URL in the sitemap | No |
| 200, but an almost empty page or "enable JavaScript" | The content is built in the browser | Render JavaScript, or use the public JSON API the page itself calls | Yes, one that renders |
| A CAPTCHA that also appears on a real device | The site wants a person | Stop. Don't automate solving it | No |
| Timeouts or connection resets | Network trouble, or a site that drops connections from some networks | One retry with backoff, then another network | Yes, after one retry |
| The site's terms forbid automated access | The owner said no | Stop, or ask for permission or an API | No |
"Another network" means sending the request from somewhere else, such as a real device in another place. It's never the answer to "too fast", "not allowed" or "gone".
When to stop
robots.txt. RFC 9309 (2022) is the standard. Find the group for your agent's name, or the * group if there isn't one; the longest matching rule wins, and Allow wins a tie. If robots.txt answers with a 4xx, there are no rules. If it answers with a 5xx or not at all, assume everything is disallowed. Don't use a copy older than 24 hours. The RFC says these rules "are not a form of access authorization": they're a request, which is exactly why a well-behaved agent honours them. Wikipedia's robots.txt, for example, disallows /wiki/Special: for every crawler, so Special:Random is off limits while the articles are fine. Crawl-delay isn't part of RFC 9309, but some sites set it (Hacker News asks for 30 seconds), and it costs nothing to honour.
Logins and paywalls. A page behind an account or a subscription isn't a public page. Stop, or use the access the site sells or grants. Don't reuse someone else's session to get round it. UpRock fetches public pages only and never logs in.
Terms and legal blocks. Some sites forbid automated access in their terms, and no status code tells you that, so read the terms of the sites your agent depends on. A 451 means a legal demand blocks the page; respect it.
A refusal that follows you. If a real device in the right country gets the same refusal as your server, the site means it for everyone.
Slow down: 429, Retry-After and caching
A 429 means you sent too many requests in a given time, and the server may add a Retry-After header saying how long to wait (RFC 6585). Retry-After holds either a number of seconds or an HTTP date (RFC 9110); a 503 can carry it too. Wait that long. Without it, back off exponentially (about 1, 2, 4 and 8 seconds) with a random extra fraction, so that many workers don't retry in lockstep, and give up for a while after a few tries.
Then stop causing the 429:
- Send fewer requests at once. One or two at a time to a single site is plenty for most agents.
- Cache. Don't fetch the same page twice in a run. Send
If-None-MatchorIf-Modified-Sincewith what you stored, and an unchanged page comes back as a 304 with no body. - Say who you are. Wikimedia's User-Agent policy says scripts should use an informative User-Agent with contact information "or they may be blocked without notice", and that generic ones such as
python-requests/xmay be blocked. - Use the API or the feed. Many sites publish one, and it's faster and cheaper for both sides. Hacker News, for instance, has an official API whose docs say there's currently no rate limit, while its robots.txt asks crawlers of the website to wait 30 seconds between requests. RSS and Atom feeds and sitemaps help too.
Sending the same load from a different network isn't a fix for a 429. It's the same load.
When a second network is the right fix
Two kinds of refusal are about where a request comes from, not what it asks for:
- The network. Many sites treat cloud and data-center addresses with suspicion. An agent running in a cloud function can get a 403 for a public page that opens fine on a laptop.
- The location. Some pages are only served in some countries, or change by country, and your agent runs in one place.
Pages built with JavaScript belong here too: they need a real browser, not a plain HTTP request.
For these cases, UpRock's crawl_fetch loads the page in a real browser on a desktop or mobile device whose owner opted in, in the country you choose (63 countries, or a region such as EU), and returns markdown. It fetches public pages only and never logs in, at $0.50 per 1,000 pages. Treat it as one more try, not a loop. On 25 Oct 2026 we sent a test URL that answers 403 to every request (postman-echo.com/status/403) to a device in Germany, and it got back the same {"status":403}. That's the site's answer, and the code below stops there. One caveat: crawl_fetch's meta.status_code isn't reliable yet (it said 200 for that page), so the code reads the returned text instead.
A polite fetch, in TypeScript and Python
Every version does the same things in the same order: check robots.txt (with the RFC 9309 rules above, cached for up to 24 hours), fetch the page directly with an honest User-Agent, back off on a 429 or a 5xx using Retry-After, stop on the refusals that mean no, and send only the network, location and JavaScript cases to UpRock. robots.txt is parsed with robots-parser in TypeScript and Protego, Scrapy's parser, in Python; both apply the longest-match rule. The login-wall check is a simple text match that leans toward stopping, and the JavaScript check only fires on near-empty pages, so adapt both to the sites you use. UpRock gets one POST /v1/crawl request with your key in an Authorization: Bearer header; the MCP and CLI tabs do the same through UpRock's MCP server (TypeScript) or CLI (Python).
// npm i robots-parser (Node 18+) env: UPROCK_API_KEY// node fetch.ts <url> <country> country: where the page's readers are, e.g. DEimport { createRequire } from "node:module";// robots-parser is CommonJS, and its types don't resolve under NodeNext; require() it.const robotsParser: typeof import("robots-parser").default = createRequire( import.meta.url,)("robots-parser");// Say who you are and how to reach you, so a site can contact you, not block you.const AGENT = "ExampleAgent/1.0 (+https://example.com/agent)";const TOKEN = "ExampleAgent"; // the name robots.txt groups are matched againsttype Result = | { outcome: "page"; via: "direct" | "uprock"; text: string } | { outcome: "stop" | "later"; reason: string }; // stop: respect it. later: wait.const LOGIN_OR_PAYWALL = /\b(sign in|log in|login|subscribe|subscription|paywall)\b/i;const NEEDS_JS = /enable javascript|requires javascript|javascript is (disabled|required)/i;const REFUSAL = /\b(403|forbidden|access denied)\b/i;const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));const backoff = (attempt: number) => 2 ** attempt * 1000 + Math.random() * 1000;const visibleText = (html: string) => html .replace(/<(script|style)[\s\S]*?<\/\1>/gi, "") .replace(/<[^>]+>/g, "") .trim();// Retry-After holds either seconds or an HTTP date (RFC 9110, section 10.2.3).function retryAfter(value: string | null): number | undefined { if (!value) return undefined; if (/^\d+$/.test(value.trim())) return Number(value) * 1000; const date = Date.parse(value); return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());}// RFC 9309: follow the rules; a 4xx means there are none; a 5xx or no answer means// assume everything is disallowed. Don't reuse a copy older than 24 hours.const robotsCache = new Map< string, { at: number; robots?: ReturnType<typeof robotsParser> }>();async function robotsCheck(url: string): Promise<"allow" | "disallow" | "unreachable"> { const robotsUrl = new URL("/robots.txt", url).href; let entry = robotsCache.get(robotsUrl); if (!entry || Date.now() - entry.at > 86_400_000) { try { const res = await fetch(robotsUrl, { headers: { "User-Agent": AGENT }, signal: AbortSignal.timeout(15_000), }); if (res.status >= 500 || res.status === 429) return "unreachable"; const robots = res.ok ? robotsParser(robotsUrl, await res.text()) : undefined; entry = { at: Date.now(), robots }; robotsCache.set(robotsUrl, entry); } catch { return "unreachable"; } } return entry.robots?.isAllowed(url, TOKEN) === false ? "disallow" : "allow";}// A real device in the country the page is meant for. Only for refusals about the// network or the location, and pages that need a browser.async function viaUpRock(url: string, country: string, why: string): Promise<Result> {console.warn(`${why}: trying a real device in ${country} through UpRock`);// One call: UpRock loads the page on a real device in that country, tries a fresh device// every 7 s (up to 3 tries) and returns markdown. Failed calls aren't billed.const res = await fetch("https://edge.uprock.com/v1/crawl", { method: "POST", headers: { Authorization: `Bearer ${process.env.UPROCK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url, country }), signal: AbortSignal.timeout(90_000),});const page = await res.json();if (!res.ok) return { outcome: "later", reason: `UpRock ${res.status} ${page.code}: ${page.error}` };const text: string = page.markdown ?? "";// The crawl's status_code isn't reliable yet (it can read 0 for a page that loaded), so// read the page: a short page that still says 403 or "forbidden" means the site refuses// everyone. That's its answer.if (text.length < 2_000 && REFUSAL.test(text)) return { outcome: "stop", reason: "a real device got the same refusal" };return { outcome: "page", via: "uprock", text };}export async function politeFetch( url: string, country: string, tries = 4,): Promise<Result> { const robots = await robotsCheck(url); if (robots === "disallow") return { outcome: "stop", reason: "robots.txt disallows this URL for our agent" }; if (robots === "unreachable") return { outcome: "later", reason: "robots.txt didn't answer, so assume disallow" }; for (let attempt = 0; attempt < tries; attempt++) { let res: Response; try { res = await fetch(url, { headers: { "User-Agent": AGENT }, signal: AbortSignal.timeout(30_000), }); } catch (err) { if (attempt === 0) { await sleep(backoff(0)); // one retry from here first continue; } return viaUpRock(url, country, `no answer from this network (${err})`); } const status = res.status; const body = await res.text(); if (res.ok) { if (NEEDS_JS.test(body) && visibleText(body).length < 500) return viaUpRock(url, country, "the page is built with JavaScript"); return { outcome: "page", via: "direct", text: body }; } if (status === 429 || status >= 500) { // Too fast or overloaded: wait as asked. Another network won't fix it. const wait = retryAfter(res.headers.get("retry-after")) ?? backoff(attempt); if (attempt === tries - 1 || wait > 120_000) return { outcome: "later", reason: `${status}, wait ${Math.ceil(wait / 1000)} s`, }; console.warn(`${status}: waiting ${Math.ceil(wait / 1000)} s`); await sleep(wait); continue; } if (status === 401 || status === 402) return { outcome: "stop", reason: `${status}: needs an account or payment` }; if (status === 404 || status === 410) return { outcome: "stop", reason: `${status}: the page doesn't exist` }; if (status === 451) return { outcome: "stop", reason: "451: unavailable for legal reasons" }; if (status === 403) { if (LOGIN_OR_PAYWALL.test(visibleText(body))) return { outcome: "stop", reason: "403: needs an account or a subscription" }; return viaUpRock(url, country, "403 from this network"); } return { outcome: "stop", reason: `HTTP ${status}` }; } return { outcome: "later", reason: "no answer after retries" };}// node fetch.ts https://developer.mozilla.org/en-US/docs/Web/HTTP DEconst [url = "https://developer.mozilla.org/en-US/docs/Web/HTTP", country = "US"] = process.argv.slice(2);const result = await politeFetch(url, country);const preview = (text: string) => text.slice(0, 80).replace(/\s+/g, " ").trim();console.log( result.outcome === "page" ? `page via ${result.via}: ${preview(result.text)}` : `${result.outcome}: ${result.reason}`,);# pip install requests protego env: UPROCK_API_KEY# python fetch.py <url> <country> country: where the page's readers are, e.g. DEimport osimport randomimport reimport sysimport timefrom email.utils import parsedate_to_datetimefrom urllib.parse import urljoinimport requestsfrom protego import Protego# Say who you are and how to reach you, so a site can contact you, not block you.AGENT = "ExampleAgent/1.0 (+https://example.com/agent)"TOKEN = "ExampleAgent" # the name robots.txt groups are matched againstLOGIN_OR_PAYWALL = re.compile( r"\b(sign in|log in|login|subscribe|subscription|paywall)\b", re.IGNORECASE)NEEDS_JS = re.compile( r"enable javascript|requires javascript|javascript is (disabled|required)", re.IGNORECASE,)REFUSAL = re.compile(r"\b(403|forbidden|access denied)\b", re.IGNORECASE)def backoff(attempt: int) -> float: return 2**attempt + random.random()def visible_text(html: str) -> str: html = re.sub(r"<(script|style)[\s\S]*?</\1>", "", html, flags=re.IGNORECASE) return re.sub(r"<[^>]+>", "", html).strip()def retry_after(value: str | None) -> float | None: """Retry-After holds either seconds or an HTTP date (RFC 9110, section 10.2.3).""" if not value: return None if value.strip().isdigit(): return float(value) try: return max(0.0, parsedate_to_datetime(value).timestamp() - time.time()) except (TypeError, ValueError): return None# RFC 9309: follow the rules; a 4xx means there are none; a 5xx or no answer means# assume everything is disallowed. Don't reuse a copy older than 24 hours._robots: dict[str, tuple[float, Protego | None]] = {}def robots_check(url: str) -> str: robots_url = urljoin(url, "/robots.txt") entry = _robots.get(robots_url) if entry is None or time.time() - entry[0] > 86_400: try: res = requests.get(robots_url, headers={"User-Agent": AGENT}, timeout=15) except requests.RequestException: return "unreachable" if res.status_code >= 500 or res.status_code == 429: return "unreachable" entry = (time.time(), Protego.parse(res.text) if res.ok else None) _robots[robots_url] = entry robots = entry[1] return "allow" if robots is None or robots.can_fetch(url, TOKEN) else "disallow"def via_uprock(url: str, country: str, why: str) -> dict: """A real device in the country the page is meant for. Only for refusals about the network or the location, and pages that need a browser.""" print(f"{why}: trying a real device in {country} through UpRock", file=sys.stderr) # One call: UpRock loads the page on a real device in that country, tries a fresh device # every 7 s (up to 3 tries) and returns markdown. Failed calls aren't billed. res = requests.post( "https://edge.uprock.com/v1/crawl", headers={"Authorization": f"Bearer {os.environ['UPROCK_API_KEY']}"}, json={"url": url, "country": country}, timeout=90, ) page = res.json() if not res.ok: return {"outcome": "later", "reason": f"UpRock {res.status_code} {page.get('code')}: {page.get('error')}"} text = page.get("markdown", "") # The crawl's status_code isn't reliable yet (it can read 0 for a page that loaded), so # read the page: a short page that still says 403 or "forbidden" means the site refuses # everyone. That's its answer. if len(text) < 2_000 and REFUSAL.search(text): return {"outcome": "stop", "reason": "a real device got the same refusal"} return {"outcome": "page", "via": "uprock", "text": text}def polite_fetch(url: str, country: str, tries: int = 4) -> dict: robots = robots_check(url) if robots == "disallow": return { "outcome": "stop", "reason": "robots.txt disallows this URL for our agent", } if robots == "unreachable": return { "outcome": "later", "reason": "robots.txt didn't answer, so assume disallow", } for attempt in range(tries): try: res = requests.get(url, headers={"User-Agent": AGENT}, timeout=30) except requests.RequestException as err: if attempt == 0: time.sleep(backoff(0)) # one retry from here first continue return via_uprock(url, country, f"no answer from this network ({err})") status = res.status_code if res.ok: if NEEDS_JS.search(res.text) and len(visible_text(res.text)) < 500: return via_uprock(url, country, "the page is built with JavaScript") return {"outcome": "page", "via": "direct", "text": res.text} if status == 429 or status >= 500: # Too fast or overloaded: wait as asked. Another network won't fix it. wait = retry_after(res.headers.get("Retry-After")) or backoff(attempt) if attempt == tries - 1 or wait > 120: return {"outcome": "later", "reason": f"{status}, wait {wait:.0f} s"} print(f"{status}: waiting {wait:.0f} s", file=sys.stderr) time.sleep(wait) continue if status in (401, 402): return { "outcome": "stop", "reason": f"{status}: needs an account or payment", } if status in (404, 410): return {"outcome": "stop", "reason": f"{status}: the page doesn't exist"} if status == 451: return {"outcome": "stop", "reason": "451: unavailable for legal reasons"} if status == 403: if LOGIN_OR_PAYWALL.search(visible_text(res.text)): return { "outcome": "stop", "reason": "403: needs an account or a subscription", } return via_uprock(url, country, "403 from this network") return {"outcome": "stop", "reason": f"HTTP {status}"} return {"outcome": "later", "reason": "no answer after retries"}if __name__ == "__main__": # python fetch.py https://developer.mozilla.org/en-US/docs/Web/HTTP DE url = sys.argv[1] if len(sys.argv) > 1 else "https://developer.mozilla.org/en-US/docs/Web/HTTP" country = sys.argv[2] if len(sys.argv) > 2 else "US" result = polite_fetch(url, country) if result["outcome"] == "page": print(f"page via {result['via']}: {' '.join(result['text'][:80].split())}") else: print(f"{result['outcome']}: {result['reason']}")// npm i robots-parser @modelcontextprotocol/sdk (Node 18+) env: UPROCK_API_KEY// node fetch.ts <url> <country> country: where the page's readers are, e.g. DEimport { createRequire } from "node:module";import { Client } from "@modelcontextprotocol/sdk/client/index.js";import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";// robots-parser is CommonJS, and its types don't resolve under NodeNext; require() it.const robotsParser: typeof import("robots-parser").default = createRequire( import.meta.url,)("robots-parser");// Say who you are and how to reach you, so a site can contact you, not block you.const AGENT = "ExampleAgent/1.0 (+https://example.com/agent)";const TOKEN = "ExampleAgent"; // the name robots.txt groups are matched againsttype Result = | { outcome: "page"; via: "direct" | "uprock"; text: string } | { outcome: "stop" | "later"; reason: string }; // stop: respect it. later: wait.const LOGIN_OR_PAYWALL = /\b(sign in|log in|login|subscribe|subscription|paywall)\b/i;const NEEDS_JS = /enable javascript|requires javascript|javascript is (disabled|required)/i;const REFUSAL = /\b(403|forbidden|access denied)\b/i;const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));const backoff = (attempt: number) => 2 ** attempt * 1000 + Math.random() * 1000;const visibleText = (html: string) => html .replace(/<(script|style)[\s\S]*?<\/\1>/gi, "") .replace(/<[^>]+>/g, "") .trim();// Retry-After holds either seconds or an HTTP date (RFC 9110, section 10.2.3).function retryAfter(value: string | null): number | undefined { if (!value) return undefined; if (/^\d+$/.test(value.trim())) return Number(value) * 1000; const date = Date.parse(value); return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());}// RFC 9309: follow the rules; a 4xx means there are none; a 5xx or no answer means// assume everything is disallowed. Don't reuse a copy older than 24 hours.const robotsCache = new Map< string, { at: number; robots?: ReturnType<typeof robotsParser> }>();async function robotsCheck(url: string): Promise<"allow" | "disallow" | "unreachable"> { const robotsUrl = new URL("/robots.txt", url).href; let entry = robotsCache.get(robotsUrl); if (!entry || Date.now() - entry.at > 86_400_000) { try { const res = await fetch(robotsUrl, { headers: { "User-Agent": AGENT }, signal: AbortSignal.timeout(15_000), }); if (res.status >= 500 || res.status === 429) return "unreachable"; const robots = res.ok ? robotsParser(robotsUrl, await res.text()) : undefined; entry = { at: Date.now(), robots }; robotsCache.set(robotsUrl, entry); } catch { return "unreachable"; } } return entry.robots?.isAllowed(url, TOKEN) === false ? "disallow" : "allow";}let uprock: Client | undefined;const textOf = (content: unknown) => (content as { type: string; text?: string }[]).find((c) => c.type === "text")?.text ?? "";// A real device in the country the page is meant for. Only for refusals about the// network or the location, and pages that need a browser.async function viaUpRock(url: string, country: string, why: string): Promise<Result> { console.warn(`${why}: trying a real device in ${country} through UpRock`); if (!uprock) { uprock = new Client({ name: "polite-fetch", version: "1.0.0" }); await uprock.connect( new StreamableHTTPClientTransport( new URL("https://mcp.uprock.ai/mcp"), { requestInit: { headers: { Authorization: `Bearer ${process.env.UPROCK_API_KEY}` } } }, ), ); } const res = await uprock.callTool( { name: "crawl_fetch", arguments: { url, country } }, undefined, { timeout: 300_000 }, ); if (res.isError) return { outcome: "later", reason: `UpRock: ${textOf(res.content)}` }; const uri = JSON.parse(textOf(res.content)).content?.markdown?.resource; if (!uri) return { outcome: "later", reason: "UpRock returned no markdown" }; const text = textOf( (await uprock.callTool({ name: "resource_fetch", arguments: { uri } })).content, ); // crawl_fetch's status code isn't reliable yet, so read the page: a short page that // still says 403 or "forbidden" means the site refuses everyone. That's its answer. if (text.length < 2_000 && REFUSAL.test(text)) return { outcome: "stop", reason: "a real device got the same refusal" }; return { outcome: "page", via: "uprock", text };}export async function politeFetch( url: string, country: string, tries = 4,): Promise<Result> { const robots = await robotsCheck(url); if (robots === "disallow") return { outcome: "stop", reason: "robots.txt disallows this URL for our agent" }; if (robots === "unreachable") return { outcome: "later", reason: "robots.txt didn't answer, so assume disallow" }; for (let attempt = 0; attempt < tries; attempt++) { let res: Response; try { res = await fetch(url, { headers: { "User-Agent": AGENT }, signal: AbortSignal.timeout(30_000), }); } catch (err) { if (attempt === 0) { await sleep(backoff(0)); // one retry from here first continue; } return viaUpRock(url, country, `no answer from this network (${err})`); } const status = res.status; const body = await res.text(); if (res.ok) { if (NEEDS_JS.test(body) && visibleText(body).length < 500) return viaUpRock(url, country, "the page is built with JavaScript"); return { outcome: "page", via: "direct", text: body }; } if (status === 429 || status >= 500) { // Too fast or overloaded: wait as asked. Another network won't fix it. const wait = retryAfter(res.headers.get("retry-after")) ?? backoff(attempt); if (attempt === tries - 1 || wait > 120_000) return { outcome: "later", reason: `${status}, wait ${Math.ceil(wait / 1000)} s`, }; console.warn(`${status}: waiting ${Math.ceil(wait / 1000)} s`); await sleep(wait); continue; } if (status === 401 || status === 402) return { outcome: "stop", reason: `${status}: needs an account or payment` }; if (status === 404 || status === 410) return { outcome: "stop", reason: `${status}: the page doesn't exist` }; if (status === 451) return { outcome: "stop", reason: "451: unavailable for legal reasons" }; if (status === 403) { if (LOGIN_OR_PAYWALL.test(visibleText(body))) return { outcome: "stop", reason: "403: needs an account or a subscription" }; return viaUpRock(url, country, "403 from this network"); } return { outcome: "stop", reason: `HTTP ${status}` }; } return { outcome: "later", reason: "no answer after retries" };}// node fetch.ts https://developer.mozilla.org/en-US/docs/Web/HTTP DEconst [url = "https://developer.mozilla.org/en-US/docs/Web/HTTP", country = "US"] = process.argv.slice(2);try { const result = await politeFetch(url, country); const preview = (text: string) => text.slice(0, 80).replace(/\s+/g, " ").trim(); console.log( result.outcome === "page" ? `page via ${result.via}: ${preview(result.text)}` : `${result.outcome}: ${result.reason}`, );} finally { await uprock?.close();}# pip install requests protego; npm i -g uprock env: UPROCK_API_KEY# python fetch.py <url> <country> country: where the page's readers are, e.g. DEimport jsonimport randomimport reimport subprocessimport sysimport timefrom email.utils import parsedate_to_datetimefrom urllib.parse import urljoinimport requestsfrom protego import Protego# Say who you are and how to reach you, so a site can contact you, not block you.AGENT = "ExampleAgent/1.0 (+https://example.com/agent)"TOKEN = "ExampleAgent" # the name robots.txt groups are matched againstLOGIN_OR_PAYWALL = re.compile( r"\b(sign in|log in|login|subscribe|subscription|paywall)\b", re.IGNORECASE)NEEDS_JS = re.compile( r"enable javascript|requires javascript|javascript is (disabled|required)", re.IGNORECASE,)REFUSAL = re.compile(r"\b(403|forbidden|access denied)\b", re.IGNORECASE)def backoff(attempt: int) -> float: return 2**attempt + random.random()def visible_text(html: str) -> str: html = re.sub(r"<(script|style)[\s\S]*?</\1>", "", html, flags=re.IGNORECASE) return re.sub(r"<[^>]+>", "", html).strip()def retry_after(value: str | None) -> float | None: """Retry-After holds either seconds or an HTTP date (RFC 9110, section 10.2.3).""" if not value: return None if value.strip().isdigit(): return float(value) try: return max(0.0, parsedate_to_datetime(value).timestamp() - time.time()) except (TypeError, ValueError): return None# RFC 9309: follow the rules; a 4xx means there are none; a 5xx or no answer means# assume everything is disallowed. Don't reuse a copy older than 24 hours._robots: dict[str, tuple[float, Protego | None]] = {}def robots_check(url: str) -> str: robots_url = urljoin(url, "/robots.txt") entry = _robots.get(robots_url) if entry is None or time.time() - entry[0] > 86_400: try: res = requests.get(robots_url, headers={"User-Agent": AGENT}, timeout=15) except requests.RequestException: return "unreachable" if res.status_code >= 500 or res.status_code == 429: return "unreachable" entry = (time.time(), Protego.parse(res.text) if res.ok else None) _robots[robots_url] = entry robots = entry[1] return "allow" if robots is None or robots.can_fetch(url, TOKEN) else "disallow"def via_uprock(url: str, country: str, why: str) -> dict: """A real device in the country the page is meant for. Only for refusals about the network or the location, and pages that need a browser.""" print(f"{why}: trying a real device in {country} through UpRock", file=sys.stderr) run = subprocess.run( ["uprock", "ai", "crawl", url, "--country", country, "--content"], capture_output=True, text=True, timeout=330, check=False, ) if run.returncode != 0: return {"outcome": "later", "reason": f"UpRock: {run.stderr.strip()}"} text = json.loads(run.stdout).get("inlined_markdown", "") # The crawl's status code isn't reliable yet, so read the page: a short page that # still says 403 or "forbidden" means the site refuses everyone. That's its answer. if len(text) < 2_000 and REFUSAL.search(text): return {"outcome": "stop", "reason": "a real device got the same refusal"} return {"outcome": "page", "via": "uprock", "text": text}def polite_fetch(url: str, country: str, tries: int = 4) -> dict: robots = robots_check(url) if robots == "disallow": return { "outcome": "stop", "reason": "robots.txt disallows this URL for our agent", } if robots == "unreachable": return { "outcome": "later", "reason": "robots.txt didn't answer, so assume disallow", } for attempt in range(tries): try: res = requests.get(url, headers={"User-Agent": AGENT}, timeout=30) except requests.RequestException as err: if attempt == 0: time.sleep(backoff(0)) # one retry from here first continue return via_uprock(url, country, f"no answer from this network ({err})") status = res.status_code if res.ok: if NEEDS_JS.search(res.text) and len(visible_text(res.text)) < 500: return via_uprock(url, country, "the page is built with JavaScript") return {"outcome": "page", "via": "direct", "text": res.text} if status == 429 or status >= 500: # Too fast or overloaded: wait as asked. Another network won't fix it. wait = retry_after(res.headers.get("Retry-After")) or backoff(attempt) if attempt == tries - 1 or wait > 120: return {"outcome": "later", "reason": f"{status}, wait {wait:.0f} s"} print(f"{status}: waiting {wait:.0f} s", file=sys.stderr) time.sleep(wait) continue if status in (401, 402): return { "outcome": "stop", "reason": f"{status}: needs an account or payment", } if status in (404, 410): return {"outcome": "stop", "reason": f"{status}: the page doesn't exist"} if status == 451: return {"outcome": "stop", "reason": "451: unavailable for legal reasons"} if status == 403: if LOGIN_OR_PAYWALL.search(visible_text(res.text)): return { "outcome": "stop", "reason": "403: needs an account or a subscription", } return via_uprock(url, country, "403 from this network") return {"outcome": "stop", "reason": f"HTTP {status}"} return {"outcome": "later", "reason": "no answer after retries"}if __name__ == "__main__": # python fetch.py https://developer.mozilla.org/en-US/docs/Web/HTTP DE url = sys.argv[1] if len(sys.argv) > 1 else "https://developer.mozilla.org/en-US/docs/Web/HTTP" country = sys.argv[2] if len(sys.argv) > 2 else "US" result = polite_fetch(url, country) if result["outcome"] == "page": print(f"page via {result['via']}: {' '.join(result['text'][:80].split())}") else: print(f"{result['outcome']}: {result['reason']}")What each case returned on 25 Oct 2026, with the country set to DE. Both versions gave the same answers:
| URL | What happened | Result |
|---|---|---|
en.wikipedia.org/wiki/Special:Random | robots.txt disallows /wiki/Special: for every crawler | stop, without requesting the page |
www.iana.org/help/example-domains | 200 | page via direct |
postman-echo.com/status/404 | 404 | stop |
postman-echo.com/status/401 | 401 | stop |
postman-echo.com/status/451 | 451 | stop |
postman-echo.com/status/429 | 429 with no Retry-After, so it waited about 2, 3 and 5 seconds | later, after four tries and about 9 seconds |
postman-echo.com/status/403 | 403 with no login wording, so it went to UpRock, and the device in Germany got {"status":403} too | stop: a real device got the same refusal |
Tested 25 Oct 2026 against the live UpRock API, running the code blocks on this page (the TypeScript type-checked with tsc --strict). In the 403 case, UpRock answered in under a second with a copy of that page it had stored from our first fetch from Germany earlier that day, which took 8 seconds. We had no neutral test page for the JavaScript branch or the network-error branch, so those two paths are untested. On 6 Nov 2026 we ran the HTTP and MCP versions again with an UpRock API key in an Authorization header: the 403 test URL went to a device in Germany, got the same refusal, and the code stopped.
FAQ
Is it OK to retry a 403 from another network?
Only when the refusal is about the network or the location, and only once. If the page needs a login or a subscription, robots.txt disallows it, or the site's terms forbid automated access, a different network doesn't change the answer. And if a real device in the right country is refused too, stop.
What's the difference between a 403 and a 429?
A 403 means the server refuses this request (RFC 9110). A 429 means you sent too many requests in a given time (RFC 6585), so wait and try again, for as long as Retry-After says if it's there. Some sites answer 403 when you're too fast as well, so if 403s start after a burst of requests, slow down first.
Does robots.txt apply to AI agents?
RFC 9309 is written for crawlers. Some operators treat a fetch a person asked for differently from a crawl, but for an agent that fetches pages on its own the safe default is to follow robots.txt, which is what the code on this page does.
Will a browser User-Agent string fix a 403?
Pretending to be a browser isn't a fix. Send an honest User-Agent that names your agent and gives a contact URL. Wikimedia's policy asks for exactly that and warns that scripts without one may be blocked without notice.
Does UpRock solve CAPTCHAs or log in?
No. UpRock loads public pages on real devices in the country you choose and never logs in. If a page shows a CAPTCHA to a real device as well, treat it as a stop.
Other use cases to explore
- See a page as people in another country see it: one store page from five countries, with the price, language and cookie banner each one got.
- Read a scraping API's failure codes: which errors clear on a retry, which to send elsewhere, and which to respect.
- Fetch from countries your crawler doesn't cover: such as South Korea and Indonesia, sent straight to a device in that country.
- Turn the pages you get into markdown: token counts for six pages as HTML and as markdown, and what the conversion drops.
References
- RFC 9309, Robots Exclusion Protocol, checked 25 Oct 2026
- RFC 9110, HTTP Semantics (403 Forbidden, Retry-After), checked 25 Oct 2026
- RFC 6585, section 4 (429 Too Many Requests), checked 25 Oct 2026
- RFC 7725 (451 Unavailable For Legal Reasons), checked 25 Oct 2026
- Wikimedia Foundation, User-Agent policy, checked 25 Oct 2026
- Wikipedia, robots.txt, checked 25 Oct 2026
- Hacker News, robots.txt (Crawl-delay), checked 25 Oct 2026
- Hacker News API on GitHub, checked 25 Oct 2026
- robots-parser on GitHub (TypeScript example), checked 25 Oct 2026
- Protego on GitHub (Python example), checked 25 Oct 2026
- UpRock, everything in one file (API, CLI, MCP, pricing), checked 25 Oct 2026
Get started
Add UpRock in one paste.
Paste this into Claude Code, Codex, Cursor or any agent with a terminal. It reads our docs, sets up the MCP server or CLI, and tells you the cost before it spends anything.
Set up UpRock for me: read https://uprock.ai/agents.md, connect the UpRock MCP server, and run one test crawl. Tell me what it costs before you spend anything.