Use cases

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 symptomLikely causeThe legitimate fixAnother network?
robots.txt disallows the URLThe site asked crawlers to stay out of that pathStop. Check robots.txt before the first request to a siteNo
401, or a redirect to a login pageThe page needs an accountStop. If the site offers an API with keys, use that within its termsNo
402, or a "subscribe to continue" pageA paywallStop, or license the contentNo
403 with a page that asks you to sign in, subscribe or accept termsAn account wall, sent as a 403StopNo
403 or a block page from the site's CDN, while the same URL opens in an ordinary browserThe site distrusts your network (often cloud and data-center addresses) or requests that don't say who they areSend 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 deviceYes, once
403, or a "not available in your country" pageThe site serves a different region from the one your agent runs inFetch from the country the page is meant forYes, from that country
451A legal demand blocks the page (RFC 7725)StopNo
429You sent too many requests too fastWait as long as Retry-After says, back off with jitter, send fewer requests at once, cacheNo
503, often with Retry-AfterThe server is overloaded or down for maintenanceThe same as a 429No
500, 502, 504The server, or a proxy in front of it, failedRetry a few times with backoff, then come back laterRarely
404 or 410The 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 sitemapNo
200, but an almost empty page or "enable JavaScript"The content is built in the browserRender JavaScript, or use the public JSON API the page itself callsYes, one that renders
A CAPTCHA that also appears on a real deviceThe site wants a personStop. Don't automate solving itNo
Timeouts or connection resetsNetwork trouble, or a site that drops connections from some networksOne retry with backoff, then another networkYes, after one retry
The site's terms forbid automated accessThe owner said noStop, or ask for permission or an APINo

"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-Match or If-Modified-Since with 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/x may 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 against​type 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}`,);

What each case returned on 25 Oct 2026, with the country set to DE. Both versions gave the same answers:

URLWhat happenedResult
en.wikipedia.org/wiki/Special:Randomrobots.txt disallows /wiki/Special: for every crawlerstop, without requesting the page
www.iana.org/help/example-domains200page via direct
postman-echo.com/status/404404stop
postman-echo.com/status/401401stop
postman-echo.com/status/451451stop
postman-echo.com/status/429429 with no Retry-After, so it waited about 2, 3 and 5 secondslater, after four tries and about 9 seconds
postman-echo.com/status/403403 with no login wording, so it went to UpRock, and the device in Germany got {"status":403} toostop: 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

References

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.

Why is my agent getting a 403, and what's the legitimate fix? | UpRock AI