<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: RoamProxy</title>
    <description>The latest articles on DEV Community by RoamProxy (@roamproxy).</description>
    <link>https://dev.to/roamproxy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4025962%2F2fda25b7-9438-49c8-8c50-011cf709b9f4.png</url>
      <title>DEV Community: RoamProxy</title>
      <link>https://dev.to/roamproxy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/roamproxy"/>
    <language>en</language>
    <item>
      <title>HTTP 200 Is Not Success: Write a Contract Per Host</title>
      <dc:creator>RoamProxy</dc:creator>
      <pubDate>Sat, 12 Sep 2026 22:39:08 +0000</pubDate>
      <link>https://dev.to/roamproxy/http-200-is-not-success-write-a-contract-per-host-192i</link>
      <guid>https://dev.to/roamproxy/http-200-is-not-success-write-a-contract-per-host-192i</guid>
      <description>&lt;p&gt;The last two posts were about spending an IP well: pace the requests, and hold a sticky session exactly as long as one unit of work. Both assume something I never stated out loud — that you can tell when a request &lt;em&gt;worked&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Most pipelines can't. They check the status code, see &lt;code&gt;200&lt;/code&gt;, and move on. And &lt;code&gt;200&lt;/code&gt; is exactly what a target returns when it has decided to stop giving you real answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode nobody alerts on
&lt;/h2&gt;

&lt;p&gt;Hard failures are easy. A 403, a connection reset, a captcha page that throws on parse — your retry logic catches those, your dashboard turns red, you go look.&lt;/p&gt;

&lt;p&gt;Soft degradation is the expensive one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The page returns &lt;code&gt;200&lt;/code&gt; with a challenge shell and none of the content.&lt;/li&gt;
&lt;li&gt;The search endpoint returns &lt;code&gt;200&lt;/code&gt; with a well-formed, empty result list.&lt;/li&gt;
&lt;li&gt;The API returns &lt;code&gt;200&lt;/code&gt; with a cached response from six hours ago.&lt;/li&gt;
&lt;li&gt;The listing returns &lt;code&gt;200&lt;/code&gt; with 3 rows where it used to return 50.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those sails through a status-code check. Your scraper reports a clean run. Your dataset quietly rots, and you find out days later when someone downstream asks why a whole region went empty.&lt;/p&gt;

&lt;p&gt;Worse, you keep spending the IP. The target has already decided you're not a person; you just haven't been told. Every request after that point is paid for and worthless — and it's building exactly the kind of profile that turns a soft block into a hard one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the contract before you write the parser
&lt;/h2&gt;

&lt;p&gt;For each host you scrape, write down what a &lt;em&gt;good&lt;/em&gt; response looks like. Three assertions is usually enough:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Shape&lt;/strong&gt; — a structural element that only exists on a real page. Not &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, which survives on challenge pages. Pick the container that holds the thing you came for: the results table, the JSON key with the records in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Volume&lt;/strong&gt; — a plausible range, not a minimum of one. If a category page has returned between 20 and 60 rows every day for a month, then 3 rows is a failure even though it parsed fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity&lt;/strong&gt; — proof the response is about what you asked for. The canonical URL, the product ID, the echoed query string. This is what catches stale caches and silent redirects to a generic page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A response that fails any of the three is a failure, no matter what the status line says.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Contract&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="c1"&gt;# the container exists
&lt;/span&gt;    &lt;span class="n"&gt;volume&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;                      &lt;span class="c1"&gt;# plausible record count
&lt;/span&gt;    &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# response matches request
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requested_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shape&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]))&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;volume&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;volume&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requested_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;identity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# passed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing this down takes ten minutes per host and changes what "success rate" means in your metrics. Most people discover their real success rate was never the number on the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The canary tells you &lt;em&gt;whose&lt;/em&gt; fault it is
&lt;/h2&gt;

&lt;p&gt;A contract tells you a response was bad. It doesn't tell you why, and the difference decides what you do next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If &lt;strong&gt;that page&lt;/strong&gt; is broken, skip it and move on. Rotating your IP is a waste.&lt;/li&gt;
&lt;li&gt;If &lt;strong&gt;you&lt;/strong&gt; are blocked, moving on is the worst possible move. Every subsequent request digs the hole deeper.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You separate the two with a canary: one URL per host whose correct answer you already know and that essentially never changes. A stable category page, a documented API example, an "about" endpoint with fixed fields. Run it through the same session, same headers, same exit IP as your real traffic, and check it against the same contract.&lt;/p&gt;

&lt;p&gt;Now the diagnosis is mechanical:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Canary&lt;/th&gt;
&lt;th&gt;Target page&lt;/th&gt;
&lt;th&gt;Diagnosis&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;healthy&lt;/td&gt;
&lt;td&gt;continue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;that page is genuinely bad or changed&lt;/td&gt;
&lt;td&gt;log it, skip, don't rotate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;you're degraded&lt;/td&gt;
&lt;td&gt;stop the unit of work, rotate at the seam&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;flaky canary — fix your canary&lt;/td&gt;
&lt;td&gt;investigate offline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That third row is the one that saves money. A canary failure is the earliest honest signal that your session has stopped being trusted — usually well before anything returns a 403.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the canary like a visitor, not like a monitor
&lt;/h2&gt;

&lt;p&gt;Two rules, both inherited from the earlier posts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't run it per request.&lt;/strong&gt; One canary per unit of work — at the start, so you don't waste a journey, and once more before you commit expensive writes. That's a 1–2% overhead, not 50%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't run it on a timer.&lt;/strong&gt; A request to the same URL every 60.0 seconds, forever, from an IP that otherwise browses like a human is the single most machine-shaped pattern you can emit. The canary exists to detect that you look like a bot; it must not be the reason you do. Same jitter, same pacing, same session as everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys you
&lt;/h2&gt;

&lt;p&gt;The contract turns "did it 200?" into "did I get what I came for?". The canary turns a bad response into an attributable one. Together they change the failure from &lt;em&gt;silent, days-long data rot&lt;/em&gt; into an event you can act on in the same minute it happens.&lt;/p&gt;

&lt;p&gt;And it changes what proxy quality even means to you. Once every response is checked against a contract, you stop arguing about pool sizes and start measuring the thing that matters: how many units of work complete, per IP, before the canary goes quiet. That number is comparable across providers, across weeks, and across your own config changes — which is the whole point.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on &lt;a href="https://roamproxy.com" rel="noopener noreferrer"&gt;Roam&lt;/a&gt;, residential and static residential proxies billed per GB. The measurement habits above are how we evaluate our own pools; they apply no matter whose IPs you're renting.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>proxy</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How Long Should You Hold a Sticky Session?</title>
      <dc:creator>RoamProxy</dc:creator>
      <pubDate>Sat, 05 Sep 2026 17:11:53 +0000</pubDate>
      <link>https://dev.to/roamproxy/how-long-should-you-hold-a-sticky-session-2a2b</link>
      <guid>https://dev.to/roamproxy/how-long-should-you-hold-a-sticky-session-2a2b</guid>
      <description>&lt;p&gt;The last post argued that a residential IP has to &lt;em&gt;behave&lt;/em&gt; like a household — cap the concurrency, pace the rate, jitter the spacing. A natural follow-up question landed in my inbox: "OK, so I'm holding one sticky session and pacing it politely. &lt;strong&gt;How long am I supposed to keep it?&lt;/strong&gt;"&lt;/p&gt;

&lt;p&gt;It's the right question, because both of the obvious answers are wrong.&lt;/p&gt;

&lt;p&gt;Rotate every request, and you throw away everything a session accumulates: cookies issued against one IP, a TLS session the server has seen before, the slow-built trust of a consistent story. You also walk straight back into the geo-mismatch problem — a login minted in one country and replayed from another is exactly the "mixed-currency" signal I keep writing about.&lt;/p&gt;

&lt;p&gt;Hold one session forever, and two clocks are ticking against you. The first is the behavior budget from the last post: every request you send accumulates on that IP, and the longer you hold it, the more implausible its "household" story gets. The second clock isn't yours at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The session can die without you
&lt;/h2&gt;

&lt;p&gt;A residential exit IP is a real device on a real home connection. That device goes to sleep. Its DHCP lease renews. Someone reboots the router. When that happens, your "sticky" session doesn't politely tell you — depending on the provider, the session either dies or is quietly re-pinned to a different device.&lt;/p&gt;

&lt;p&gt;In practice, most residential sticky sessions are only &lt;em&gt;reliably&lt;/em&gt; stable on the order of minutes, not hours. Some survive much longer; you just can't build on it. If your pipeline assumes one IP for a two-hour crawl, you haven't designed a session strategy — you've bought a lottery ticket.&lt;/p&gt;

&lt;p&gt;The corollary: &lt;strong&gt;verify, don't assume.&lt;/strong&gt; A cheap periodic origin check (an httpbin-style echo through the same session) tells you whether the IP under your session silently changed. If it did, everything from the last post applies to a &lt;em&gt;new&lt;/em&gt; IP now — and the cookies you're carrying were minted on the old one. That mismatch is worth knowing about before the target notices it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotate between units of work, not inside them
&lt;/h2&gt;

&lt;p&gt;The useful framing isn't a number of minutes. It's this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One sticky session should live exactly as long as one unit of work — and rotate at the seam.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A unit of work is one coherent journey a single visitor could plausibly have: log in, walk a pagination trail, extract a batch, leave. Everything inside that journey shares state — cookies, referer chain, server-side session — so it must share an IP. Splitting it across exits is self-sabotage.&lt;/p&gt;

&lt;p&gt;Between journeys, there's nothing connecting you. That's the free rotation point. You pay no continuity cost, and you reset the per-IP behavior budget to zero.&lt;/p&gt;

&lt;p&gt;This immediately gives you sizing rules that are about &lt;em&gt;your workload&lt;/em&gt;, not magic numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If your unit of work is 30 seconds of requests, don't hold the session for 10 minutes "to be efficient." You're accumulating budget for nothing.&lt;/li&gt;
&lt;li&gt;If your unit of work takes 40 minutes, that's longer than a residential session reliably lives. Don't reach for a longer TTL — &lt;strong&gt;shrink the unit of work.&lt;/strong&gt; Checkpoint state so a journey can resume, or split the trail into chunks that each fit comfortably inside a session lifetime.&lt;/li&gt;
&lt;li&gt;If your units of work are independent, run them on &lt;em&gt;different&lt;/em&gt; concurrent sessions rather than queuing them through one. Five journeys through five IPs looks like five visitors. Five journeys back-to-back through one IP looks like a shift worker.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When to rotate early
&lt;/h2&gt;

&lt;p&gt;Holding a session to the end of its unit of work is the default, not a vow. Three signals justify cutting it short:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. An IP-scoped block.&lt;/strong&gt; I wrote a whole post on diagnosing block scope; the short version is that rotation only fixes blocks that are actually pinned to the IP. If your probes say the IP itself is burned — fresh session, clean headers, still walled — finish the journey later on a new exit. Retrying through the burned one just documents your persistence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The IP changed under you.&lt;/strong&gt; Your origin check came back different. The sticky abstraction already broke; carrying on pretends it didn't. Restart the unit of work cleanly on the new session rather than dragging old-IP cookies onto a new-IP story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Degradation, not denial.&lt;/strong&gt; Responses through this session are getting slower, challenge pages more frequent, while a control request through a fresh session is fine. You're being softly bucketed. The polite exit is to finish the current page, not the current journey, and re-enter from a new IP with the pacing lessons applied.&lt;/p&gt;

&lt;p&gt;Note what's &lt;em&gt;not&lt;/em&gt; on the list: a timer. "Rotate every N minutes" is the sticky-session equivalent of metronomic request spacing — a rule that ignores what's actually happening. Every rotation decision above is driven by an observable, and all three observables are cheap to collect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the code
&lt;/h2&gt;

&lt;p&gt;None of this needs a framework. It's a small wrapper that owns three things — a session name, a birth-time origin check, and a rotate-at-seam method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;GATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://user-country-us-session-{sid}:pass@gateway:7777&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StickyUnit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;GATE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_origin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_origin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://httpbin.org/ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;origin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;drifted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_origin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_journey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;unit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StickyUnit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drifted&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exit IP changed mid-journey; restart unit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# next journey constructs a fresh unit = fresh seam
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The session ID is random per unit of work, the drift check is periodic and cheap, and rotation isn't an event you schedule — it's just what happens between constructors.&lt;/p&gt;

&lt;p&gt;Full runnable versions of this and the earlier posts' probes live in our examples repo: &lt;a href="https://github.com/roamproxy/proxy-examples" rel="noopener noreferrer"&gt;https://github.com/roamproxy/proxy-examples&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on &lt;a href="https://roamproxy.com?utm_source=devto" rel="noopener noreferrer"&gt;Roam&lt;/a&gt;, a pay-as-you-go proxy network. Sticky sessions there are named exactly like the snippet above — the session suffix pins the exit until you change it, which makes "rotate at the seam" a one-line decision.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>proxy</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your Residential IP Is Clean. Your Request Rate Gives You Away.</title>
      <dc:creator>RoamProxy</dc:creator>
      <pubDate>Tue, 01 Sep 2026 04:49:28 +0000</pubDate>
      <link>https://dev.to/roamproxy/your-residential-ip-is-clean-your-request-rate-gives-you-away-5gcd</link>
      <guid>https://dev.to/roamproxy/your-residential-ip-is-clean-your-request-rate-gives-you-away-5gcd</guid>
      <description>&lt;p&gt;The last post I wrote here was about geo signals — how a German exit IP means nothing if your &lt;code&gt;Accept-Language&lt;/code&gt;, timezone and locale all still say "US developer laptop." A reader pushed back with a fair point: they'd fixed all of that, every signal agreed, and they were &lt;em&gt;still&lt;/em&gt; getting throttled through clean residential IPs. So what was left?&lt;/p&gt;

&lt;p&gt;The exit IP was clean. Every static signal agreed. What gave them away was &lt;strong&gt;how the IP behaved&lt;/strong&gt; — the rate, the concurrency, and the timing of the requests coming out of it. A residential IP doesn't just claim to be a household. It has to &lt;em&gt;act&lt;/em&gt; like one.&lt;/p&gt;

&lt;h2&gt;
  
  
  An IP is a story about a person
&lt;/h2&gt;

&lt;p&gt;When a site sees a residential IP, the implicit claim is "there is a human on a home connection here." Humans, and the households behind them, have a shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They open a few tabs, not forty.&lt;/li&gt;
&lt;li&gt;They read a page for some seconds before clicking the next one.&lt;/li&gt;
&lt;li&gt;They go quiet for hours and then come back.&lt;/li&gt;
&lt;li&gt;Their requests arrive one after another with human gaps, not in perfectly-spaced 200ms intervals and not in a thundering burst.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic bot detection. It's arithmetic the server was already doing for capacity planning. A single residential IP that sustains 40 concurrent connections and 30 requests a second is not a suspicious &lt;em&gt;fingerprint&lt;/em&gt; — it's a physically implausible &lt;em&gt;household&lt;/em&gt;. The IP is clean. The story it's telling is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three numbers that matter per IP
&lt;/h2&gt;

&lt;p&gt;Forget global rate limits for a second. The unit that matters is &lt;strong&gt;per exit IP&lt;/strong&gt;, because that's the unit the target attributes behavior to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Concurrency.&lt;/strong&gt; How many requests are in flight through one IP at once. This is the one people get most wrong, because async frameworks make it invisible. &lt;code&gt;asyncio.gather&lt;/code&gt; over 500 URLs with one sticky session doesn't feel like 500 concurrent requests when you write it — but that's exactly what leaves the IP. Cap it. A real browser rarely has more than ~6 connections open to one host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Rate.&lt;/strong&gt; Requests per second &lt;em&gt;per IP&lt;/em&gt;, sustained. The safe number depends entirely on the target, but the useful mental model is "how fast would one interested person click?" — not "how fast can my machine send." For most sites, sustained single-digit requests per second per IP is already faster than any human.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Spacing.&lt;/strong&gt; The gap &lt;em&gt;between&lt;/em&gt; requests. Two scrapers can have the same average rate and look completely different: one sends a request exactly every 500ms, the other sends them with jittered human-ish gaps. Metronomic timing is itself a signal — real traffic is bursty and irregular. A little randomization in the delay does more than shaving the average rate.&lt;/p&gt;

&lt;p&gt;The trap is optimizing the first two and ignoring the third. A perfectly rate-limited scraper that fires on a clockwork interval still reads as automation, because nothing that has a human in the loop is that regular.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this fails softly (again)
&lt;/h2&gt;

&lt;p&gt;If you've read the earlier posts you know where this goes. Cross the rate a target actually enforces and you get a 429 — loud, obvious, easy to back off from. But most sites don't hard-limit at the first sign; they &lt;em&gt;degrade&lt;/em&gt; you. Slower responses. More challenges. Shorter sticky-session lifetimes. You get quietly moved into a bucket for traffic that's probably automated, and you stay there. Your success rate is 10% lower than it should be and you blame the proxies.&lt;/p&gt;

&lt;p&gt;And rotating the IP doesn't buy back what you spent. If you burn an IP by hammering it, the next IP gets the same treatment the moment it behaves the same way. You're not running out of IPs; you're running the same implausible household story from a new address.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is a budget, not a sleep
&lt;/h2&gt;

&lt;p&gt;The instinct is to sprinkle &lt;code&gt;time.sleep()&lt;/code&gt; around and call it rate limiting. That controls the average and nothing else. What you actually want is a small budget enforced &lt;strong&gt;per IP&lt;/strong&gt;: a cap on in-flight requests, a token-bucket rate with jitter, and a rule that when one IP starts getting slow or challenged, you &lt;em&gt;pace it down&lt;/em&gt; before you rotate it away.&lt;/p&gt;

&lt;p&gt;Here's the shape of it — a per-IP limiter you acquire before every request through that exit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IPBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Per-exit-IP pacing: concurrency cap + jittered token-bucket rate.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_concurrency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jitter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_sem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_concurrency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_min_gap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;rps&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_jitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jitter&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_next_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__aenter__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_sem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_next_at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;gap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_min_gap&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_jitter&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_next_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_next_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__aexit__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_sem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;IPBudget&lt;/code&gt; per exit IP, not one global one. If you hold a sticky residential session, that session gets its own budget for its whole life; when you rotate, the new IP starts fresh. The concurrency cap keeps &lt;code&gt;gather&lt;/code&gt; honest, the token bucket keeps the sustained rate human, and the jitter keeps the spacing from looking like a machine.&lt;/p&gt;

&lt;p&gt;A runnable version — with the per-host bookkeeping and a "slow down before you rotate" hook — is in our examples repo: &lt;a href="https://github.com/roamproxy/proxy-examples" rel="noopener noreferrer"&gt;github.com/roamproxy/proxy-examples&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it with the rest
&lt;/h2&gt;

&lt;p&gt;Across these posts the theme keeps coming back: a clean IP is table stakes, and it's the &lt;em&gt;cheapest&lt;/em&gt; part of not getting blocked. The IP has to look like a household, the client's geo signals have to agree with it, and the behavior coming out of it has to be physically plausible for one person on one connection. Get all three right and the exit IP stops being the thing anyone notices — which is the whole point.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We run &lt;a href="https://roamproxy.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=behavior" rel="noopener noreferrer"&gt;Roam&lt;/a&gt;, a pay-as-you-go residential/datacenter/mobile proxy network — real home-broadband exits, per-GB pricing, balance never expires. The examples above run against any proxy; the repo is provider-agnostic.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your Proxy Is in Germany. The Rest of Your Client Isn't.</title>
      <dc:creator>RoamProxy</dc:creator>
      <pubDate>Tue, 25 Aug 2026 22:18:26 +0000</pubDate>
      <link>https://dev.to/roamproxy/your-proxy-is-in-germany-the-rest-of-your-client-isnt-208b</link>
      <guid>https://dev.to/roamproxy/your-proxy-is-in-germany-the-rest-of-your-client-isnt-208b</guid>
      <description>&lt;p&gt;A reader on an earlier post described a scraper that was pulling Amazon listings through a rotating residential proxy and getting prices back in three different currencies inside one run. The thread treated it as a data-cleaning problem — normalise the currency, move on. I think it was the earliest visible symptom of something that gets scrapers blocked a lot more quietly than any IP list does: &lt;strong&gt;the exit IP said one country, and everything else about the client said another.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Geo-targeting a proxy is one line. &lt;code&gt;country-de&lt;/code&gt; in the username, done. But the target isn't looking at one signal. It's looking at half a dozen, and if the other five still say "US developer laptop," the German IP doesn't make you look German. It makes you look like a US developer laptop &lt;em&gt;using a German proxy&lt;/em&gt;, which is a much more specific — and much more suspicious — thing to look like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signals that have to agree
&lt;/h2&gt;

&lt;p&gt;Here's what a target can read, without any JavaScript trickery, from a single request that arrives via a German residential exit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Accept-Language&lt;/code&gt;.&lt;/strong&gt; Python's &lt;code&gt;requests&lt;/code&gt; sends nothing. &lt;code&gt;httpx&lt;/code&gt; sends nothing. Playwright sends &lt;code&gt;en-US,en;q=0.9&lt;/code&gt; by default. A real German Chrome sends &lt;code&gt;de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7&lt;/code&gt;. This header alone splits "German visitor" from "someone routing through Germany" on the first request, before any fingerprinting happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timezone.&lt;/strong&gt; If there's JS in the picture, &lt;code&gt;Intl.DateTimeFormat().resolvedOptions().timeZone&lt;/code&gt; is one call. A headless browser inherits the host machine's zone. Your CI runner is in &lt;code&gt;UTC&lt;/code&gt;; your laptop is in &lt;code&gt;America/Los_Angeles&lt;/code&gt;; your exit is in Frankfurt. Two of those three will disagree with the IP, and the disagreement is a stronger signal than any single one of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Locale-derived formatting.&lt;/strong&gt; &lt;code&gt;navigator.language&lt;/code&gt;, &lt;code&gt;navigator.languages&lt;/code&gt;, the date and number formats the page's own scripts produce when they run in your context. These all come from the browser's locale, not from the IP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The currency and store you get served.&lt;/strong&gt; This one's the tell that actually shows up in your data. Big e-commerce sites resolve your storefront from a &lt;em&gt;combination&lt;/em&gt; of IP geolocation, &lt;code&gt;Accept-Language&lt;/code&gt;, and any prior locale cookie. When those disagree, different code paths win on different page types — the search page keys on IP, the product page keys on the cookie, the cart keys on the account. That's how one run ends up with EUR, USD and GBP in the same output file. The mixed currencies aren't noise; they're a log of which signal each endpoint trusted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS resolution location.&lt;/strong&gt; Easy to forget. With an HTTP proxy, the proxy resolves the hostname and you're fine. With a SOCKS5 proxy configured as &lt;code&gt;socks5://&lt;/code&gt; rather than &lt;code&gt;socks5h://&lt;/code&gt;, &lt;em&gt;your machine&lt;/em&gt; resolves the hostname, so a CDN sees a DNS query from California and a TCP connection from Frankfurt. That's not a fingerprint most sites act on, but it does mean you get routed to the wrong edge, which is its own source of inconsistency (different A/B buckets, different cache, sometimes different content).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TLS session resumption.&lt;/strong&gt; If you reuse a client across exits, the TLS session ticket from the previous connection can be presented on the next one. A session ticket issued to an IP in Ohio, resumed from Frankfurt, is a small oddity — but it's an oddity the &lt;em&gt;server&lt;/em&gt; sees at the handshake, before your first byte of HTTP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's worse than a bad IP
&lt;/h2&gt;

&lt;p&gt;An IP on a blocklist fails loudly. You get a 403, you notice, you deal with it.&lt;/p&gt;

&lt;p&gt;Inconsistent geo fails &lt;em&gt;softly&lt;/em&gt;. You get served a slightly different page. You land in a "we're not sure where you are" bucket that gets more challenges and fewer cached responses. Sticky sessions expire faster. A/B tests assign you to the control group. Nothing tells you this is happening; your success rate is just 8% lower than it should be, permanently, and the data has small irregularities you'll attribute to the target.&lt;/p&gt;

&lt;p&gt;The other reason it's worse: rotating the IP doesn't fix it. It can't. The IP was the one signal that was &lt;em&gt;right&lt;/em&gt;. If you read the previous post on block scopes, this is the fingerprint-scoped case — it fails identically from every exit because the exit isn't the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the client agree with the exit
&lt;/h2&gt;

&lt;p&gt;The fix is boring: derive every locale-ish setting from the exit's country, in one place, and never let anything else set them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# geo_profile.py — one source of truth per exit country
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;PROFILES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;de&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lang&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Europe/Berlin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locale&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;de-DE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lang&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en-GB,en;q=0.9&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Europe/London&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locale&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en-GB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lang&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en-US,en;q=0.9&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;America/New_York&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locale&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en-US&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lang&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Asia/Tokyo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locale&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ja-JP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;client_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proxy_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PROFILES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;proxy_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept-Language&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lang&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
        &lt;span class="n"&gt;http2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;playwright_context_kwargs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PROFILES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locale&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locale&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timezone_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extra_http_headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept-Language&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lang&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Playwright that's &lt;code&gt;browser.new_context(proxy=..., **playwright_context_kwargs("de"))&lt;/code&gt; — &lt;code&gt;locale&lt;/code&gt; and &lt;code&gt;timezone_id&lt;/code&gt; are first-class context options and they fix &lt;code&gt;navigator.language&lt;/code&gt;, &lt;code&gt;Intl&lt;/code&gt;, and the header in one go. For plain HTTP clients it's just the header, plus using &lt;code&gt;socks5h://&lt;/code&gt; if you're on SOCKS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify it before you trust it
&lt;/h2&gt;

&lt;p&gt;Configuration you haven't checked is a guess. This is the audit I run when a new exit or a new client build goes into rotation — it asks a geo-echo endpoint what the &lt;em&gt;server&lt;/em&gt; sees and compares that to what the client &lt;em&gt;intends&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# geo_audit.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;ECHO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://httpbin.org/anything&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# any endpoint that reflects headers + origin
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expect_country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expect_lang_prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ECHO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;origin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept-Language&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;geo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://ipinfo.io/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;problems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;geo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;expect_country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exit country &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;geo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; != &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;expect_country&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expect_lang_prefix&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept-Language &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt; does not start with &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;expect_lang_prefix&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;geo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;problems&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;geo_profile&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;client_for&lt;/span&gt;
    &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;client_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;problems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en-gb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lang&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;problems&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it once per country you target and once per client build. If &lt;code&gt;ok&lt;/code&gt; is false, the request never reaches the real target — a client that disagrees with its own exit is not allowed into the pool. The audit takes about two seconds and it has caught, in order: a CI image that reset &lt;code&gt;TZ=UTC&lt;/code&gt; after the context was created, a &lt;code&gt;socks5://&lt;/code&gt; that should have been &lt;code&gt;socks5h://&lt;/code&gt;, and a "German" profile that was still sending &lt;code&gt;en-US&lt;/code&gt; because the header was set on the session and then overwritten per-request by a helper someone added later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;An IP is one vote. &lt;code&gt;Accept-Language&lt;/code&gt;, timezone, locale, DNS origin and TLS state are five more, and a target that geolocates at all is counting them. Pick the exit country, derive everything else from it, and assert the agreement before the first real request. Mixed currencies in your output aren't a cleaning job — they're the target telling you which of your signals it didn't believe.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We publish code examples and testing notes for developers who scrape and automate at &lt;a href="https://roamproxy.com" rel="noopener noreferrer"&gt;RoamProxy&lt;/a&gt;. More runnable examples: &lt;a href="https://github.com/roamproxy/proxy-examples" rel="noopener noreferrer"&gt;github.com/roamproxy/proxy-examples&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>playwright</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Before You Rotate the IP, Find Out What Actually Got Blocked</title>
      <dc:creator>RoamProxy</dc:creator>
      <pubDate>Sun, 23 Aug 2026 20:03:27 +0000</pubDate>
      <link>https://dev.to/roamproxy/before-you-rotate-the-ip-find-out-what-actually-got-blocked-3g3p</link>
      <guid>https://dev.to/roamproxy/before-you-rotate-the-ip-find-out-what-actually-got-blocked-3g3p</guid>
      <description>&lt;p&gt;Something in your pipeline just started returning 403s, or a challenge page, or an empty 200 where there used to be JSON. The reflex — in every tutorial, every Stack Overflow answer, every proxy vendor's docs — is to rotate the IP.&lt;/p&gt;

&lt;p&gt;Most of the time that's the wrong first move, and it's expensive in a way that isn't obvious: rotating throws away everything the old identity had accumulated. Session cookies, a solved challenge, whatever internal trust score the target had assigned you after 40 well-behaved requests. If the IP wasn't the thing that got flagged, you paid all of that and fixed nothing — and you now have a second identity showing the same pattern, which is a worse signal than the first one alone.&lt;/p&gt;

&lt;p&gt;The useful question isn't "how do I get unblocked." It's &lt;strong&gt;what scope did the block apply to.&lt;/strong&gt; There are five, they're cheap to tell apart, and the right response is different for each.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five scopes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Request-scoped.&lt;/strong&gt; One specific request was rejected — a missing header, a bad referer, an expired token in a query param, a URL that requires a POST. The next request works fine. This is the most common "block" and it isn't a block at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session-scoped.&lt;/strong&gt; Your cookie jar / TLS session got flagged. A challenge interstitial that keeps re-serving, a &lt;code&gt;cf_clearance&lt;/code&gt; that went stale, a rate limiter keyed on session ID. Everything else about you is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fingerprint-scoped.&lt;/strong&gt; Your client is being rejected for what it &lt;em&gt;is&lt;/em&gt;, not where it's from. Automation-flavoured TLS handshake, a headless-shaped JS environment, a header order no real browser emits. The tell: it fails instantly and identically no matter how long you wait or what IP you use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IP-scoped.&lt;/strong&gt; The address is on a list. Every request from it fails, from every session, with every client, immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Account-scoped.&lt;/strong&gt; You're logged in and the account is limited. Rotating the IP here is actively harmful — a logged-in account that suddenly appears from a new city is a much stronger abuse signal than one that stays put.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four probes that separate them
&lt;/h2&gt;

&lt;p&gt;You don't need to guess. Four requests, under a minute, and the answer falls out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Same IP, fresh session&lt;/strong&gt; (new cookie jar, same client, same exit).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same IP, different client&lt;/strong&gt; (&lt;code&gt;curl&lt;/code&gt; if you were using a browser, or vice versa — this varies the TLS/JA3 and header order).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Different IP, same cookies.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different IP, everything fresh.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cookies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;One probe. Returns (status, len(body), challenged).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cookies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cookies&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
                      &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;http2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;http2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;follow_redirects&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="n"&gt;challenged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt;
                     &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;just a moment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;un momento&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checking your browser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attention required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verify you are human&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;challenged&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;diagnose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old_proxy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_proxy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old_cookies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;browser_headers&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fresh_session_same_ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;old_proxy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;browser_headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;diff_client_same_ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;old_proxy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;old_cookies_new_ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;new_proxy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cookies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;old_cookies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                       &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;browser_headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all_fresh_new_ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;new_proxy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;browser_headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;What to actually change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fresh session on the same IP works&lt;/td&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Drop the cookie jar. Keep the IP.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same IP fails, new IP with old cookies also fails&lt;/td&gt;
&lt;td&gt;Session or account&lt;/td&gt;
&lt;td&gt;Cookies. Not the IP.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everything fails instantly from both IPs, but a plain &lt;code&gt;curl&lt;/code&gt; works&lt;/td&gt;
&lt;td&gt;Fingerprint&lt;/td&gt;
&lt;td&gt;Your client, not your route.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every request from the old IP fails, everything from the new one works&lt;/td&gt;
&lt;td&gt;IP&lt;/td&gt;
&lt;td&gt;Rotate — this is the one case where it's right.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only some URLs fail, others are fine&lt;/td&gt;
&lt;td&gt;Request&lt;/td&gt;
&lt;td&gt;Fix the headers/method/token.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fails only while logged in&lt;/td&gt;
&lt;td&gt;Account&lt;/td&gt;
&lt;td&gt;Slow down. Do &lt;strong&gt;not&lt;/strong&gt; move the account to a new IP.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The row people get wrong is the second one. A stale session on a target that keys its rate limiter to a session ID looks exactly like an IP ban from the inside — every request fails — and rotating the IP "fixes" it, because the new exit also comes with a new cookie jar. So you conclude the IP was burned when the cookie was, and you burn an address per incident forever after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The waiting case
&lt;/h2&gt;

&lt;p&gt;There's a fifth outcome the probes will show you and most people won't believe: &lt;strong&gt;temporary, and the correct action is nothing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I hit this on Medium's Cloudflare edge a couple of weeks ago. Mid-session, after an import that touched several endpoints in quick succession, every page turned into an interstitial. Same tab, same everything, for something like half an hour — reloading did nothing, four attempts did nothing. The exit IP was verifiably unchanged the whole time (I checked, precisely because rotating was the tempting move and that session had a login I didn't want to re-establish). Then it cleared on its own and the next request went through normally.&lt;/p&gt;

&lt;p&gt;If I'd rotated at minute two I would have concluded the IP was blocked, thrown away a good address and a live session, and learned the wrong lesson permanently. The signal that it was temporary was there: the challenge was &lt;em&gt;serving&lt;/em&gt; (200 with a challenge body), not refusing (403 with nothing). &lt;strong&gt;A challenge is an invitation to prove yourself. A 403 with an empty body is a door.&lt;/strong&gt; They deserve opposite responses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change the cheapest thing first
&lt;/h2&gt;

&lt;p&gt;Order your remediations by what they cost you if you're wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Headers / request shape&lt;/strong&gt; — free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie jar&lt;/strong&gt; — cheap, unless you're logged in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait&lt;/strong&gt; — costs only time, and time is what half of these need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client fingerprint&lt;/strong&gt; — moderate; you're rebuilding a client, not an identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit IP&lt;/strong&gt; — expensive. You lose the session, the accumulated trust, and (if you were logged in) you hand the target a "this account moved cities" event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Account&lt;/strong&gt; — most expensive, obviously.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rotation belongs at position five for a reason. It's the loudest change you can make and it's the one people reach for first, because it's the one the tooling makes easiest.&lt;/p&gt;

&lt;p&gt;A practical version of this: keep the diagnostic in your codebase, not in your head. When a run starts failing, &lt;code&gt;diagnose()&lt;/code&gt; runs before any remediation logic does, logs which scope it found, and only the IP-scoped branch is allowed to call &lt;code&gt;rotate()&lt;/code&gt;. It takes an afternoon to write and it stops the reflex from making decisions for you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We publish code examples and testing notes for developers who scrape and automate at &lt;a href="https://roamproxy.com" rel="noopener noreferrer"&gt;RoamProxy&lt;/a&gt;. More runnable examples: &lt;a href="https://github.com/roamproxy/proxy-examples" rel="noopener noreferrer"&gt;github.com/roamproxy/proxy-examples&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>debugging</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your Scraper's Retry Logic Is Probably Making the Block Worse</title>
      <dc:creator>RoamProxy</dc:creator>
      <pubDate>Fri, 21 Aug 2026 04:43:36 +0000</pubDate>
      <link>https://dev.to/roamproxy/your-scrapers-retry-logic-is-probably-making-the-block-worse-1aif</link>
      <guid>https://dev.to/roamproxy/your-scrapers-retry-logic-is-probably-making-the-block-worse-1aif</guid>
      <description>&lt;p&gt;Most scrapers have one retry policy: catch the exception, sleep, try again, give up after N attempts. It's the default in every tutorial, it's what &lt;code&gt;tenacity&lt;/code&gt; gives you in three lines, and on a healthy target it's fine.&lt;/p&gt;

&lt;p&gt;It stops being fine the moment the failures stop being random. A rate limit, a hard block, a TLS reset and a slow origin all arrive as "the request failed," and treating them identically means you spend your entire run budget hammering something that was never going to open — while the one failure that &lt;em&gt;would&lt;/em&gt; have succeeded on retry gets three attempts and then gets dropped.&lt;/p&gt;

&lt;p&gt;Here's the taxonomy I actually use, and the part that took the longest to learn: the counter matters more than the backoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four failures, and what each one means
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;429 with a &lt;code&gt;Retry-After&lt;/code&gt; header.&lt;/strong&gt; The friendliest failure you will ever get. The server is telling you exactly what it wants. Honour it — sleep the stated duration, then continue on the same connection and the same identity. Do not rotate anything. Rotating here is how a temporary throttle turns into a fingerprinted pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;429 with no header.&lt;/strong&gt; The server wants you slower but won't say by how much. Exponential backoff with jitter is genuinely the right tool. Start higher than you think — 5 seconds, not 0.5.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;403 / 401 / a challenge page.&lt;/strong&gt; This is not a retryable condition. Something about the request was rejected, and repeating it byte-for-byte will be rejected the same way. Every retry here is pure waste, and worse, it's the pattern that gets a durable block instead of a temporary one. Short-circuit straight to whatever your fallback is — different identity, different route, or shelve the URL and move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connection reset / timeout / TLS handshake failure.&lt;/strong&gt; This one &lt;em&gt;is&lt;/em&gt; retryable, and it's the one people wrongly lump in with 403. Transport failures are frequently just noise. Retry immediately, twice, then treat it as a hard failure.&lt;/p&gt;

&lt;p&gt;The distinction that matters: &lt;strong&gt;HTTP-level rejections tell you something about your request. Transport-level failures usually tell you nothing at all.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;RETRYABLE_STATUS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;HARD_BLOCK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;407&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;451&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc_or_resp&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc_or_resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transport&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# retry fast, twice
&lt;/span&gt;    &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exc_or_resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;HARD_BLOCK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;            &lt;span class="c1"&gt;# do not retry, change something
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;throttled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;RETRYABLE_STATUS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;server&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fatal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sleep_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;throttled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry-after&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ra&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;server&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transport&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The counter is the part everyone skips
&lt;/h2&gt;

&lt;p&gt;Backoff handles the failure you already got. It does nothing about the one you're about to get, and on a lot of targets the limit isn't time-based at all — it's a volume budget. You get N requests, and the block arrives on N+1 regardless of how politely you spaced them.&lt;/p&gt;

&lt;p&gt;I learned this on a regional grocery chain's stock checker. It would 429 you politely for about an hour, and then flip to a permanent 403 once you crossed some invisible total. Every backoff strategy in the world is useless against that, because the thing being counted isn't your rate — it's your volume.&lt;/p&gt;

&lt;p&gt;The fix is a rolling per-host counter that you enforce on yourself, set well below whatever number killed the last run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deque&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HostBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Rolling request counter per host. Refuses before the server does.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_requests&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;max_requests&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;window_seconds&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deque&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;popleft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
        &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;spent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once that exists, a &lt;code&gt;403&lt;/code&gt; stops being a signal you react to and becomes a signal you &lt;em&gt;record&lt;/em&gt;: whatever the counter said when the block landed is your new ceiling for that host, minus a healthy margin. Write it to disk. The next run starts already knowing.&lt;/p&gt;

&lt;p&gt;The reason the 403 is a bad signal on its own is timing. By the time you see it, you're usually already benched — the block is applied, the cooldown clock started without telling you, and nothing you do in the next hour matters. The counter is the only thing that fires &lt;em&gt;before&lt;/em&gt; the damage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;self-imposed budget hit for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sleep_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# record the ceiling, then hand off — retrying byte-for-byte is waste
&lt;/span&gt;            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;BlockedError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fatal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sleep_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exhausted attempts for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules, in order of how much run time they save:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never retry a hard block unchanged.&lt;/strong&gt; It cannot succeed and it makes the block stickier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count your own requests per host&lt;/strong&gt; and stop before the server stops you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry transport errors fast and cheaply.&lt;/strong&gt; They're usually nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The retry loop is the least glamorous part of a scraper and the one that quietly decides whether a run finishes. Worth twenty minutes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We publish code examples and testing notes for developers who scrape and automate at &lt;a href="https://roamproxy.com?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;RoamProxy&lt;/a&gt;. More runnable examples: &lt;a href="https://github.com/roamproxy/proxy-examples" rel="noopener noreferrer"&gt;github.com/roamproxy/proxy-examples&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Stop Reaching for a Headless Browser to Scrape Documentation Sites</title>
      <dc:creator>RoamProxy</dc:creator>
      <pubDate>Tue, 11 Aug 2026 23:45:28 +0000</pubDate>
      <link>https://dev.to/roamproxy/stop-reaching-for-a-headless-browser-to-scrape-documentation-sites-1l8n</link>
      <guid>https://dev.to/roamproxy/stop-reaching-for-a-headless-browser-to-scrape-documentation-sites-1l8n</guid>
      <description>&lt;p&gt;Every few days someone in a scraping forum asks a version of the same question: &lt;em&gt;"I'm collecting documentation text for an AI tool, but the pages render with JavaScript. What's the lightest way to get the content?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The answers are always the same — open DevTools, check the Network tab, find the XHR. That advice is correct, and for documentation sites specifically it's usually unnecessary work.&lt;/p&gt;

&lt;p&gt;Documentation sites are not arbitrary web apps. They're overwhelmingly built by a handful of static site generators, and those generators leave the content sitting in predictable places. Three routes cover most of what you'll hit, and none of them need a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route 1: the JSON is already in the HTML
&lt;/h2&gt;

&lt;p&gt;Next.js-based docs (which includes a large share of company developer portals) embed the full page payload in a &lt;code&gt;__NEXT_DATA__&lt;/code&gt; script tag. It's in the initial HTML response — no JavaScript execution needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;follow_redirects&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;script id=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__NEXT_DATA__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; type=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;(.*?)&amp;lt;/script&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="c1"&gt;# content location varies by site; dump the tree once and look around
&lt;/span&gt;    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;props&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pageProps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docusaurus (the other big one) doesn't use &lt;code&gt;__NEXT_DATA__&lt;/code&gt;, but it pre-renders the full article text into the static HTML. A plain &lt;code&gt;httpx.get&lt;/code&gt; plus a &lt;code&gt;main&lt;/code&gt; or &lt;code&gt;article&lt;/code&gt; selector gets you everything. The "JavaScript rendering" you see in the browser is hydration for navigation and search — the prose was already there in the initial response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thirty-second check:&lt;/strong&gt; &lt;code&gt;curl -s &amp;lt;url&amp;gt; | grep -c "some sentence you can see on the page"&lt;/code&gt;. If that returns 1 or more, the content is in the HTML and you're done. This one check resolves the majority of "it's JavaScript-rendered" cases, because people judge from DevTools' Elements panel — which shows the &lt;em&gt;hydrated&lt;/em&gt; DOM, not what the server actually sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route 2: the markdown is in a public repo
&lt;/h2&gt;

&lt;p&gt;Most open-source project documentation is markdown in the same repository as the code, usually under &lt;code&gt;docs/&lt;/code&gt;. Scraping the rendered HTML means fetching pages one at a time, parsing them, and stripping navigation chrome you didn't want. Cloning gets you the clean source in one shot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;blob:none &lt;span class="nt"&gt;--sparse&lt;/span&gt; https://github.com/org/project
&lt;span class="nb"&gt;cd &lt;/span&gt;project &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git sparse-checkout &lt;span class="nb"&gt;set &lt;/span&gt;docs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get the original markdown — headings intact, code fences intact, no nav sidebars, no cookie banners, no per-page rate limiting. For a RAG pipeline this is strictly better input than parsed HTML, and it's one request instead of several hundred.&lt;/p&gt;

&lt;p&gt;Worth saying plainly: check the license before you ingest. Documentation is frequently licensed separately from the code, and "the repo is public" is not the same as "you may redistribute this."&lt;/p&gt;

&lt;h2&gt;
  
  
  Route 3: the site publishes a text endpoint
&lt;/h2&gt;

&lt;p&gt;A growing number of documentation hosts expose plain-text views:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/llms.txt&lt;/code&gt;&lt;/strong&gt; — an emerging convention where a site publishes a curated, plain-text map of itself specifically for LLM consumption. Fast-moving developer-tool companies have adopted it quickly. Always worth one request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/sitemap.xml&lt;/code&gt;&lt;/strong&gt; — not text content, but it gives you the complete URL list without crawling, which means you never have to discover pages by following links.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ReadTheDocs&lt;/strong&gt; projects usually offer downloadable HTML and often PDF/ePub builds of the entire docs set from the version menu. One artifact, complete content.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Picking a route in under a minute
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;curl&lt;/code&gt; output contains visible page text&lt;/td&gt;
&lt;td&gt;Parse the static HTML — done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;__NEXT_DATA__&lt;/code&gt; in the HTML&lt;/td&gt;
&lt;td&gt;Extract and walk the JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public repo with a &lt;code&gt;docs/&lt;/code&gt; directory&lt;/td&gt;
&lt;td&gt;Sparse-clone the markdown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/llms.txt&lt;/code&gt; returns 200&lt;/td&gt;
&lt;td&gt;Start there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReadTheDocs / GitBook host&lt;/td&gt;
&lt;td&gt;Look for the download build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;None of the above&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Now&lt;/em&gt; open DevTools&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When you actually do need a browser
&lt;/h2&gt;

&lt;p&gt;Some cases are genuinely dynamic, and it's worth knowing them so you don't over-apply the above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docs behind authentication where the session is established by client-side JavaScript.&lt;/li&gt;
&lt;li&gt;Content assembled from several API calls at runtime with no single payload — more common in interactive API explorers than in prose documentation.&lt;/li&gt;
&lt;li&gt;Sites that gate on a JavaScript challenge before serving anything, where the challenge is the point.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a few hundred pages of prose, though, these are the exception. The default assumption should be that the text is already reachable, and the browser is the fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually costs you
&lt;/h2&gt;

&lt;p&gt;The reason this matters isn't purity — it's that headless browsers change the shape of your project. You go from a script anyone can run to a pipeline with a browser binary, a memory ceiling, per-page startup cost, and a new class of flaky failures that only reproduce sometimes. On a few hundred documentation pages, Route 1 or Route 2 typically finishes before a browser-based run has finished launching.&lt;/p&gt;

&lt;p&gt;Check whether the content is already sitting there in plain text. Most of the time, it is.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We publish code examples and testing notes for developers who scrape and automate at &lt;a href="https://roamproxy.com?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;RoamProxy&lt;/a&gt;. More runnable examples: &lt;a href="https://github.com/roamproxy/proxy-examples" rel="noopener noreferrer"&gt;github.com/roamproxy/proxy-examples&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>We Tried to Beat Cloudflare With Proxies and TLS Tricks. Here's What Actually Failed.</title>
      <dc:creator>RoamProxy</dc:creator>
      <pubDate>Tue, 28 Jul 2026 04:41:54 +0000</pubDate>
      <link>https://dev.to/roamproxy/we-tried-to-beat-cloudflare-with-proxies-and-tls-tricks-heres-what-actually-failed-199c</link>
      <guid>https://dev.to/roamproxy/we-tried-to-beat-cloudflare-with-proxies-and-tls-tricks-heres-what-actually-failed-199c</guid>
      <description>&lt;p&gt;Most advice about scraping Cloudflare-fronted sites is asserted, not measured. "Use residential IPs." "Match Chrome's JA3." People repeat these like folklore. We run a proxy network, so we had the tooling to actually test them — and the results contradicted the two most common pieces of advice.&lt;/p&gt;

&lt;p&gt;Here's what we ran, and what failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;Two variables, four combinations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exit IP:&lt;/strong&gt; a Psychz datacenter address vs a Frontier Communications residential line (both US).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS stack:&lt;/strong&gt; plain Python &lt;code&gt;requests&lt;/code&gt; vs &lt;code&gt;curl_cffi&lt;/code&gt; impersonating Chrome.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Seven Cloudflare-fronted sites, two passes each — 28 requests total, US exits, 27 July 2026. A "pass" required HTTP 200 &lt;em&gt;and&lt;/em&gt; a body without challenge markers. A 200 that returns "Just a moment…" is a block wearing a success code, and it does not count.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Combination&lt;/th&gt;
&lt;th&gt;Passed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Datacenter IP + default Python TLS&lt;/td&gt;
&lt;td&gt;0 / 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Datacenter IP + Chrome-impersonated TLS&lt;/td&gt;
&lt;td&gt;0 / 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Residential IP + default Python TLS&lt;/td&gt;
&lt;td&gt;0 / 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Residential IP + Chrome-impersonated TLS&lt;/td&gt;
&lt;td&gt;0 / 7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every combination failed identically. Two things in that table contradict advice you'll read everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The residential IP bought us nothing
&lt;/h2&gt;

&lt;p&gt;Not a single site treated the Frontier residential exit differently from the Psychz datacenter one. That does &lt;em&gt;not&lt;/em&gt; mean IP reputation is a myth — it means these sites never got as far as judging our IP. A request that fails the bot-management check is refused on other grounds first.&lt;/p&gt;

&lt;p&gt;IP quality decides outcomes in the &lt;em&gt;middle&lt;/em&gt; of the difficulty curve. At the hard end it isn't the binding constraint, and buying better IPs won't move it. If you're stuck at 403 on a genuinely aggressive Cloudflare config, a residential proxy is not the missing piece.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chrome TLS impersonation didn't flip a single site either
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;curl_cffi&lt;/code&gt; with &lt;code&gt;impersonate="chrome"&lt;/code&gt; is great tooling, but on these targets it changed nothing. The sites require &lt;em&gt;executing the JavaScript challenge&lt;/em&gt;, which no HTTP client does, no matter how convincing its TLS handshake. If you're stuck, adding a TLS library isn't the fix — you need something that runs JS.&lt;/p&gt;

&lt;p&gt;For calibration: the same script sailed through Cloudflare-fronted sites that &lt;em&gt;aren't&lt;/em&gt; running aggressive bot mode, on every combination including plain Python from a bare datacenter host. "Protected by Cloudflare" spans an enormous range of strictness. Establish which end your target sits at before spending money on tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: stop chasing a specific JA3 hash
&lt;/h2&gt;

&lt;p&gt;While measuring, we checked what fingerprint our own requests presented. Three consecutive &lt;code&gt;curl_cffi&lt;/code&gt; requests with the same &lt;code&gt;impersonate="chrome"&lt;/code&gt; setting, from the same host, produced &lt;strong&gt;three different JA3 hashes&lt;/strong&gt; (&lt;code&gt;a0052cf3…&lt;/code&gt;, &lt;code&gt;0899dce7…&lt;/code&gt;, &lt;code&gt;d2de58db…&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;That's not a bug. Real Chrome shuffles TLS extensions and injects GREASE values, so its JA3 changes per connection — and a good impersonation library reproduces that. Any guide telling you to match one specific JA3 string is describing a browser that stopped existing years ago.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;did&lt;/em&gt; stay identical across all our requests — direct, via residential proxy, and via datacenter proxy — was the &lt;strong&gt;HTTP/2 fingerprint&lt;/strong&gt; (&lt;code&gt;52d84b11…&lt;/code&gt;), derived from the SETTINGS frame, header table size, window size and pseudo-header order. It's far more stable than JA3, which is exactly why it's worth more to the people fingerprinting you. Check both when you debug a silent block; we used &lt;code&gt;tls.browserleaks.com/json&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what actually works?
&lt;/h2&gt;

&lt;p&gt;Match the tool to where your target sits on the curve:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Soft Cloudflare&lt;/strong&gt; (rate limiting, light bot mode): an HTTP client with a real browser TLS impersonation and a decent IP is plenty. This is most of the web.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard Cloudflare&lt;/strong&gt; (interactive challenge, Turnstile): you need a real browser that executes the JS — Playwright or Puppeteer. The proxy's job there is IP diversity, not passing the check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mistake I see most often is spending money on better proxies to solve a problem that lives in the JavaScript layer. Test the cheap hypothesis first: does plain Python already work? Does a headless browser with no proxy work? Only then start paying for exit quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;The method is boring on purpose: 28 requests, 7 sites, 4 tool combinations, pass = 200 with no challenge markers in the body. If you want to run the same matrix against your own targets, the request-side code (requests, httpx, curl_cffi, Playwright) is in a public examples repo: &lt;a href="https://github.com/roamproxy/proxy-examples" rel="noopener noreferrer"&gt;github.com/roamproxy/proxy-examples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want a residential pool to test the "does a better IP help &lt;em&gt;my&lt;/em&gt; target" hypothesis without a subscription, the network I work on (&lt;a href="https://roamproxy.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=nurture" rel="noopener noreferrer"&gt;Roam&lt;/a&gt;) gives 300MB free on signup — enough to run the matrix above against your actual target before spending anything.&lt;/p&gt;

&lt;p&gt;Got a Cloudflare block that behaves differently from this? I'd genuinely like to hear it — drop the details in the comments.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>api</category>
      <category>testing</category>
    </item>
    <item>
      <title>Your proxy connected. That doesn't mean it hid your IP.</title>
      <dc:creator>RoamProxy</dc:creator>
      <pubDate>Sat, 25 Jul 2026 16:25:52 +0000</pubDate>
      <link>https://dev.to/roamproxy/your-proxy-connected-that-doesnt-mean-it-hid-your-ip-28e5</link>
      <guid>https://dev.to/roamproxy/your-proxy-connected-that-doesnt-mean-it-hid-your-ip-28e5</guid>
      <description>&lt;p&gt;Every proxy checker I've come across answers one question: &lt;em&gt;did the connection open?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's the least interesting thing about a proxy. A proxy can connect perfectly and still hand your real IP address to the destination server in a header. If you're scraping, testing geo-restricted behaviour, or just trying not to be identified, "it connected" tells you almost nothing.&lt;/p&gt;

&lt;p&gt;Here's how to actually check, with code you can run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways a proxy gives you away
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. It forwards your real IP in a header
&lt;/h3&gt;

&lt;p&gt;The most direct leak. The proxy adds your address to the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;GET&lt;/span&gt; &lt;span class="nn"&gt;/whatever&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt;
&lt;span class="na"&gt;X-Forwarded-For&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;203.0.113.44     &amp;lt;- your actual IP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;X-Forwarded-For&lt;/code&gt; is the common one, but &lt;code&gt;X-Real-IP&lt;/code&gt; and the RFC 7239 &lt;code&gt;Forwarded&lt;/code&gt; header do the same job. This is normal, correct behaviour for a reverse proxy sitting in front of &lt;em&gt;your&lt;/em&gt; server — and completely wrong for a forward proxy you're using to not be identified.&lt;/p&gt;

&lt;p&gt;A proxy that does this is called &lt;strong&gt;transparent&lt;/strong&gt;. The destination sees both the proxy's IP and yours.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. It announces that it's a proxy
&lt;/h3&gt;

&lt;p&gt;Sometimes your IP is safely hidden, but the request still carries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Via: 1.1 squid-cache
Proxy-Connection: keep-alive
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your identity is intact, but the server knows the traffic is proxied. Plenty of anti-bot systems treat that alone as reason enough to block, rate-limit, or serve you different content. So it matters even though nothing personal leaked.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The exit is not where it claims to be
&lt;/h3&gt;

&lt;p&gt;A proxy sold as Frankfurt that egresses in Singapore isn't just a labelling problem — it breaks anything geo-dependent, and the latency will be nothing like what you planned for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it properly
&lt;/h2&gt;

&lt;p&gt;The key insight: &lt;strong&gt;you need a baseline.&lt;/strong&gt; Without knowing your own IP first, you can't tell "the proxy leaked my address" apart from "there happens to be an IP in this header."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="c1"&gt;# Baseline: who am I without a proxy?
&lt;/span&gt;&lt;span class="n"&gt;direct_ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.ipify.org&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;PROXY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://user:pass@proxy.example.com:8080&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PROXY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;exit_ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.ipify.org&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;seen_headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://httpbin.org/headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me:   &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;direct_ip&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exit: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exit_ip&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers the server saw: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;seen_headers&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now grade it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PROXY_HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;via&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-forwarded-for&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-real-ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;forwarded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;proxy-connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;grade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;direct_ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;lowered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;

    &lt;span class="c1"&gt;# Our real IP showed up somewhere in what the server received.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;direct_ip&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;direct_ip&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lowered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transparent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# Identity is safe, but the request is visibly proxied.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lowered&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROXY_HEADERS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anonymous&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;elite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three outcomes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Grade&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transparent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your real IP is visible through the proxy. Useless for anonymity.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anonymous&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Real IP hidden, but headers announce a proxy is in use.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;elite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Looks like an ordinary direct request.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The part that's easy to get wrong
&lt;/h3&gt;

&lt;p&gt;Note the &lt;code&gt;direct_ip and ...&lt;/code&gt; guard. If fetching your baseline fails — no network, endpoint down, rate limited — &lt;code&gt;direct_ip&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt;, and a naive implementation that does &lt;code&gt;if direct_ip in value&lt;/code&gt; will either crash or silently match nothing.&lt;/p&gt;

&lt;p&gt;Either way you must &lt;strong&gt;not&lt;/strong&gt; report &lt;code&gt;transparent&lt;/code&gt;. Claiming a proxy is safe when you simply failed to check is the one error mode that actually hurts someone. Degrade to &lt;code&gt;anonymous&lt;/code&gt;, which is what the header evidence alone supports.&lt;/p&gt;

&lt;p&gt;Same reasoning applies to the header check being case-insensitive. Servers and proxies are inconsistent about casing, and &lt;code&gt;"Via"&lt;/code&gt; vs &lt;code&gt;"via"&lt;/code&gt; deciding whether you flag a leak is not a distinction you want in security-adjacent code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing this to a whole list
&lt;/h2&gt;

&lt;p&gt;One proxy at a time is fine for debugging. For a list of a few hundred you want concurrency, timeouts, and output you can pipe somewhere.&lt;/p&gt;

&lt;p&gt;I put the above into a single-file CLI called &lt;a href="https://github.com/roamproxy/proxyprobe" rel="noopener noreferrer"&gt;proxyprobe&lt;/a&gt; — MIT, one dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"httpx[socks]"&lt;/span&gt;
curl &lt;span class="nt"&gt;-O&lt;/span&gt; https://raw.githubusercontent.com/roamproxy/proxyprobe/main/proxyprobe.py
python proxyprobe.py proxies.txt &lt;span class="nt"&gt;--geo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;PROXY                              OK   MS    EXIT IP        ANONYMITY    LOCATION
---------------------------------  ---  ----  -------------  -----------  -----------------
http://user:***@gw.example.com:80  yes  312   203.0.113.44   elite        Tokyo Japan
socks5://198.51.100.7:1080         yes  1180  198.51.100.7   transparent  Frankfurt Germany
http://10.0.0.9:3128               no   -     -              -

2/3 working  |  312ms median  |  1 elite, 1 transparent
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits &lt;code&gt;0&lt;/code&gt; if anything worked and &lt;code&gt;1&lt;/code&gt; if nothing did, so it drops into CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;proxyprobe proxies.txt &lt;span class="nt"&gt;--working-only&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; alive.json &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"all proxies down"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two implementation details worth stealing even if you write your own:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mask passwords greedily.&lt;/strong&gt; Proxy credentials routinely contain &lt;code&gt;@&lt;/code&gt; and &lt;code&gt;:&lt;/code&gt;. A lazy regex like &lt;code&gt;://([^:/@]+):([^@]+)@&lt;/code&gt; stops at the &lt;em&gt;first&lt;/em&gt; &lt;code&gt;@&lt;/code&gt; and leaves the tail of the password in your output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://u:p@ss:word@host:80   -&amp;gt;   http://u:***@ss:word@host:80   # leaked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Match greedily to the last &lt;code&gt;@&lt;/code&gt; instead. A unit test caught this one for me, which is a good argument for unit-testing the boring string function that touches secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the endpoints configurable.&lt;/strong&gt; Anything hardcoded means someone routes their entire proxy list through a third party they never chose. &lt;code&gt;--echo-url&lt;/code&gt; and &lt;code&gt;--headers-url&lt;/code&gt; take about four lines and remove that objection entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't cover
&lt;/h2&gt;

&lt;p&gt;This is an HTTP-layer check. If you're driving a real browser, there's a separate set of leaks that live above it — WebRTC handing out local candidates, DNS resolving outside the tunnel, timezone and locale disagreeing with the exit IP. None of that shows up in request headers, and none of it is detectable with the code above.&lt;/p&gt;

&lt;p&gt;Worth knowing where the boundary is: passing this check means the proxy isn't betraying you at the HTTP layer. It doesn't mean a browser sitting on top of it is quiet.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I work on &lt;a href="https://roamproxy.com" rel="noopener noreferrer"&gt;Roam&lt;/a&gt;, a proxy provider. proxyprobe works with proxies from anyone and has no vendor lock-in — we open-sourced it because we were writing this script over and over internally. If you use it against a competitor's proxies and find a bug, I'd genuinely like the issue.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webscraping</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Playwright + Proxies: Sticky Sessions, Rotation, and the SOCKS5 Trap</title>
      <dc:creator>RoamProxy</dc:creator>
      <pubDate>Sat, 18 Jul 2026 08:51:03 +0000</pubDate>
      <link>https://dev.to/roamproxy/playwright-proxies-sticky-sessions-rotation-and-the-socks5-trap-61d</link>
      <guid>https://dev.to/roamproxy/playwright-proxies-sticky-sessions-rotation-and-the-socks5-trap-61d</guid>
      <description>&lt;p&gt;You wired a proxy into &lt;code&gt;requests&lt;/code&gt; in one line. Then you tried the same thing with Playwright and got a soup of &lt;code&gt;ERR_TUNNEL_CONNECTION_FAILED&lt;/code&gt;, mystery CAPTCHAs, and a bandwidth bill that made no sense. Been there.&lt;/p&gt;

&lt;p&gt;Headless browsers talk to proxies differently than HTTP clients do, and most of the "playwright proxy" snippets floating around skip the three things that actually bite you in production. Here's the setup I wish someone had handed me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The baseline: one browser, one proxy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;playwright.sync_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sync_playwright&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;sync_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;server&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://gw.example.com:41080&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USER-session-alpha&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PASS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://httpbin.org/ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details people miss:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Auth goes in the launch config, not the URL.&lt;/strong&gt; &lt;code&gt;http://user:pass@host:port&lt;/code&gt; as the server string works in some HTTP clients, but Chromium wants credentials separately. If your password has special characters (&lt;code&gt;#&lt;/code&gt;, &lt;code&gt;@&lt;/code&gt;), the URL form will silently mangle them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The proxy applies to &lt;em&gt;everything&lt;/em&gt; the browser loads&lt;/strong&gt; — every image, every font, every tracking pixel. Remember that; it matters in a minute.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Trap #1: SOCKS5 auth doesn't work in Chromium
&lt;/h2&gt;

&lt;p&gt;This one costs people hours. Chromium — and therefore Playwright's default browser — &lt;strong&gt;does not support username/password authentication over SOCKS5&lt;/strong&gt;. Your provider's SOCKS5 endpoint works fine in &lt;code&gt;curl&lt;/code&gt;, so you assume your Playwright config is wrong. It isn't. The browser just never sends the credentials.&lt;/p&gt;

&lt;p&gt;Fixes, in order of sanity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use the &lt;strong&gt;HTTP proxy port with auth&lt;/strong&gt; instead. Any decent provider serves HTTP CONNECT and SOCKS5 (ideally on the same port), and CONNECT tunnels HTTPS just fine. There is no meaningful difference for scraping.&lt;/li&gt;
&lt;li&gt;IP-allowlist auth on the SOCKS5 side, if your provider supports it and your egress IP is stable.&lt;/li&gt;
&lt;li&gt;A local forwarder (e.g. a tiny &lt;code&gt;pproxy&lt;/code&gt; sidecar) that holds the credentials — works, but now you're running infrastructure to avoid a one-line fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trap #2: rotation granularity is per-context, not per-request
&lt;/h2&gt;

&lt;p&gt;With an HTTP client, rotating proxies per request is trivial. A browser is a different animal: a page load is 50–200 requests, and they must all exit from the same IP or you'll trip every anti-bot system on earth (real users don't load HTML from Berlin and CSS from Osaka).&lt;/p&gt;

&lt;p&gt;So the unit of rotation is the &lt;strong&gt;browser context&lt;/strong&gt;, not the request. With a session-based proxy gateway, you pin one session ID per context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;new_identity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;server&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://gw.example.com:41080&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USER-session-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PASS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;

&lt;span class="c1"&gt;# identity per worker: same IP for the whole browsing session
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url_batches&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;new_identity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;worker&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same session ID = same exit IP for the lifetime of the session; new ID = fresh IP. One identity per context, rotate by spawning a new context with a new session ID. That's the whole model. (Playwright also accepts &lt;code&gt;proxy=&lt;/code&gt; on &lt;code&gt;new_context()&lt;/code&gt;, so you can run several identities inside one browser process — cheaper than one process per identity.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap #3: you're paying residential rates for webfonts
&lt;/h2&gt;

&lt;p&gt;Residential bandwidth is billed by the GB. A single product page can pull 3–8 MB, of which the HTML you actually want is maybe 80 KB. Multiply by 100k pages and you've spent real money loading hero images for nobody.&lt;/p&gt;

&lt;p&gt;Route-block everything you don't parse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;BLOCK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;media&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;font&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stylesheet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;register_blocking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;BLOCK&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;continue_&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my runs this cuts bandwidth 60–85% depending on the site, with zero effect on the DOM you scrape. Two caveats: keep stylesheets if you rely on &lt;code&gt;:visible&lt;/code&gt; selectors (layout needs CSS), and don't block XHR/fetch — that's usually where the data lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;The full pattern — sticky identity per context, auth done right, resource blocking on every page — is about 40 lines. I keep a copy-pasteable version (plus requests/httpx/Scrapy equivalents) in a public examples repo: &lt;a href="https://github.com/roamproxy/proxy-examples" rel="noopener noreferrer"&gt;github.com/roamproxy/proxy-examples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want to test against a real residential pool without a subscription, the network I work on (&lt;a href="https://roamproxy.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=nurture" rel="noopener noreferrer"&gt;Roam&lt;/a&gt;) gives 300MB free on signup — enough to verify your Playwright setup against your actual target before spending anything.&lt;/p&gt;

&lt;p&gt;Questions about a setup that's misbehaving? Drop it in the comments — proxy-through-browser bugs are weirdly fun to debug.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>playwright</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>We Sent 10,000 Requests Through a Residential Proxy Network. Here's the Raw Data.</title>
      <dc:creator>RoamProxy</dc:creator>
      <pubDate>Thu, 16 Jul 2026 09:58:07 +0000</pubDate>
      <link>https://dev.to/roamproxy/we-sent-10000-requests-through-a-residential-proxy-network-heres-the-raw-data-mah</link>
      <guid>https://dev.to/roamproxy/we-sent-10000-requests-through-a-residential-proxy-network-heres-the-raw-data-mah</guid>
      <description>&lt;p&gt;Most proxy benchmarks you'll find are vendor marketing: a success rate with no methodology, no failure counts, no raw data. We run a residential proxy network, and we wanted numbers we could actually defend — so we benchmarked ourselves the way we'd want a third party to do it, and published every row.&lt;/p&gt;

&lt;p&gt;Here's what 10,000 real requests look like, and what surprised us.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;10,000 HTTPS requests to &lt;code&gt;https://www.cloudflare.com/cdn-cgi/trace&lt;/code&gt; — small, anycast, and it echoes the exit IP and country back at you.&lt;/li&gt;
&lt;li&gt;5 target countries (US, UK, Germany, Japan, Brazil) × 2,000 requests each.&lt;/li&gt;
&lt;li&gt;Every request forced a &lt;strong&gt;fresh session&lt;/strong&gt; (new exit IP requested from the pool each time).&lt;/li&gt;
&lt;li&gt;Plain &lt;code&gt;curl&lt;/code&gt;, concurrency 20, 30s timeout, &lt;strong&gt;zero retries&lt;/strong&gt;. Every failure stays in the dataset.&lt;/li&gt;
&lt;li&gt;Vantage point: a server in Tokyo (AS3258).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A request only counts as a success if it returned HTTP 200 within the timeout — DNS, proxy CONNECT, TLS handshake and body included.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Success&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;Unique exit IPs (of 2,000)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;US&lt;/td&gt;
&lt;td&gt;99.80%&lt;/td&gt;
&lt;td&gt;2.00s&lt;/td&gt;
&lt;td&gt;7.27s&lt;/td&gt;
&lt;td&gt;1,827&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UK&lt;/td&gt;
&lt;td&gt;99.80%&lt;/td&gt;
&lt;td&gt;2.37s&lt;/td&gt;
&lt;td&gt;8.23s&lt;/td&gt;
&lt;td&gt;1,533&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Germany&lt;/td&gt;
&lt;td&gt;99.75%&lt;/td&gt;
&lt;td&gt;2.39s&lt;/td&gt;
&lt;td&gt;12.22s&lt;/td&gt;
&lt;td&gt;1,651&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Japan&lt;/td&gt;
&lt;td&gt;99.70%&lt;/td&gt;
&lt;td&gt;2.03s&lt;/td&gt;
&lt;td&gt;14.01s&lt;/td&gt;
&lt;td&gt;1,026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brazil&lt;/td&gt;
&lt;td&gt;99.30%&lt;/td&gt;
&lt;td&gt;2.73s&lt;/td&gt;
&lt;td&gt;17.18s&lt;/td&gt;
&lt;td&gt;1,399&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Overall: &lt;strong&gt;99.67% success, 7,436 unique exit IPs across 10,000 requests.&lt;/strong&gt; Geo-targeting accuracy was 98.9–100% per country.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that surprised us
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The tail is 5–6× the median.&lt;/strong&gt; Medians cluster tightly at 2.0–2.7s everywhere, but p95 stretches to 12–17s in Germany, Japan and Brazil. If you set client timeouts based on the median, you'll drop a meaningful slice of otherwise-successful requests. Budget for the tail: 20–30s timeouts for residential traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. 74% of requests got a globally unique IP.&lt;/strong&gt; With a forced new session per request, 7,436 of 10,000 requests exited through an address no other request in the test saw. The US pool was the deepest: 1,827 unique IPs in 2,000 requests (91%).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. IPv6 is a big share of real residential exits.&lt;/strong&gt; Between 42% and 76% of exits per country were IPv6. If your target or your tooling silently breaks on IPv6, that's a chunk of your error budget gone before you've done anything wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it (please do)
&lt;/h2&gt;

&lt;p&gt;The whole thing is one loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 2000&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-m&lt;/span&gt; 30 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code},%{time_total},%{time_starttransfer}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;"http://USER-country-us-session-&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;:PASS@gateway:port"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    https://www.cloudflare.com/cdn-cgi/trace
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point it at any provider, diff the results. Running this against a competitor takes an afternoon, which is exactly why we think publishing raw data should be table stakes in this industry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One vantage point (Tokyo), one small target. A heavy page on a slow origin behaves differently — this measures the network, not your target site.&lt;/li&gt;
&lt;li&gt;Success against Cloudflare's trace endpoint ≠ success against aggressive bot defenses. That depends on your client fingerprint as much as the IP.&lt;/li&gt;
&lt;li&gt;It's our own network, measured by us. That's why the raw CSV (exit IPs anonymized to /24 &amp;amp; /48) and full methodology are public — verify, don't trust.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Full write-up + downloadable CSV (CC BY 4.0): &lt;a href="https://roamproxy.com/residential-proxy-benchmark?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=benchmark-2026-07" rel="noopener noreferrer"&gt;roamproxy.com/residential-proxy-benchmark&lt;/a&gt; · code samples: &lt;a href="https://github.com/roamproxy/proxy-examples" rel="noopener noreferrer"&gt;github.com/roamproxy/proxy-examples&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>testing</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Residential vs. Datacenter Proxies: Which One Do You Actually Need?</title>
      <dc:creator>RoamProxy</dc:creator>
      <pubDate>Tue, 14 Jul 2026 15:44:48 +0000</pubDate>
      <link>https://dev.to/roamproxy/residential-vs-datacenter-proxies-which-one-do-you-actually-need-2dl8</link>
      <guid>https://dev.to/roamproxy/residential-vs-datacenter-proxies-which-one-do-you-actually-need-2dl8</guid>
      <description>&lt;p&gt;If you're scraping the web or managing multiple accounts, the residential-vs-datacenter question decides both your success rate and your bill. Here's the practical breakdown I wish someone had given me earlier — including when the "worse" option is actually the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core difference
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Datacenter proxies&lt;/strong&gt; come from cloud providers and hosting companies. The IP blocks are publicly registered to companies like OVH or Hetzner, so any target site can (and does) look up the ASN and see "this visitor is a server, not a person."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Residential proxies&lt;/strong&gt; are IP addresses assigned by consumer ISPs (Comcast, Vodafone, China Telecom...) to real households. To a target site, a residential IP is indistinguishable from a normal visitor on their couch.&lt;/p&gt;

&lt;p&gt;That single difference drives everything else:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Datacenter&lt;/th&gt;
&lt;th&gt;Residential&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;Fast, stable (~10–50 ms)&lt;/td&gt;
&lt;td&gt;Slower, variable (~100–800 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Cheap (~$0.5–1/GB)&lt;/td&gt;
&lt;td&gt;2–5× more (~$2–8/GB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block resistance&lt;/td&gt;
&lt;td&gt;Weak — ASN is a giveaway&lt;/td&gt;
&lt;td&gt;Strong — looks like real users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IP pool size&lt;/td&gt;
&lt;td&gt;Thousands&lt;/td&gt;
&lt;td&gt;Millions, rotating&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;APIs, unprotected sites, speed-critical jobs&lt;/td&gt;
&lt;td&gt;Protected sites, sneakers, social, ads verification&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A decision rule that actually works
&lt;/h2&gt;

&lt;p&gt;Ask two questions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Does the target actively fight bots?&lt;/strong&gt;&lt;br&gt;
Run a quick probe through a datacenter IP. If you get 200s all day, congratulations — use datacenter and pocket the savings. If you see 403s, CAPTCHAs, or empty shells of pages, the site checks IP reputation and you need residential.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Is per-request latency critical?&lt;/strong&gt;&lt;br&gt;
Price monitoring across 100k SKUs cares about throughput; a checkout bot cares about a single fast round-trip. Datacenter wins raw speed. Residential wins &lt;em&gt;effective&lt;/em&gt; speed once you count retries — a 300 ms residential request beats a 30 ms datacenter request that gets blocked and retried five times.&lt;/p&gt;
&lt;h2&gt;
  
  
  The hybrid pattern most teams end up with
&lt;/h2&gt;

&lt;p&gt;In practice, mature scraping stacks route per-target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;DC&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://user_dc_1:pass@gw.example.com:41080&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# cheap, fast
&lt;/span&gt;&lt;span class="n"&gt;RESI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://user-country-us:pass@gw.example.com:41080&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# stealthy
&lt;/span&gt;
&lt;span class="n"&gt;ROUTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api.competitor.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;DC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# plain JSON API, no protection
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;www.sneaker-shop.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RESI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# aggressive bot detection
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;proxy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ROUTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# default to cheap
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proxies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start every new target on datacenter. Promote it to residential the first time you hit sustained 403s. Your blend usually lands around 70/30 datacenter/residential by request count — but 30/70 by importance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three mistakes to avoid
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Buying residential for everything.&lt;/strong&gt; You'll pay 3× for traffic that a $0.8/GB datacenter pool would have handled fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buying datacenter for protected sites and "solving" blocks with more IPs.&lt;/strong&gt; IP reputation databases classify entire datacenter ranges; rotating within a burned ASN just burns money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring session stickiness.&lt;/strong&gt; For logins and carts you need the &lt;em&gt;same&lt;/em&gt; IP across a flow. Both proxy types support sticky sessions — use them instead of fighting random rotation.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Unprotected targets + speed → &lt;strong&gt;datacenter&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Bot detection, geo-restrictions, accounts → &lt;strong&gt;residential&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Real projects → &lt;strong&gt;both, routed per target&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I work on &lt;a href="https://roamproxy.com?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=resi-vs-dc" rel="noopener noreferrer"&gt;RoamProxy&lt;/a&gt;, a pay-as-you-go proxy service (residential $2/GB, datacenter $0.8/GB, same gateway endpoint for both). Code examples for Python/Node/Go are on our &lt;a href="https://github.com/roamproxy/proxy-examples" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
