<?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>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>
    <item>
      <title>Building a Rotating Proxy Pool in Python That Survives Failures</title>
      <dc:creator>RoamProxy</dc:creator>
      <pubDate>Mon, 13 Jul 2026 10:35:37 +0000</pubDate>
      <link>https://dev.to/roamproxy/building-a-rotating-proxy-pool-in-python-that-survives-failures-ddk</link>
      <guid>https://dev.to/roamproxy/building-a-rotating-proxy-pool-in-python-that-survives-failures-ddk</guid>
      <description>&lt;p&gt;If you scrape the web at any real scale, a single proxy will not cut it. IPs get rate-limited, temporarily banned, or simply time out. The durable fix is a &lt;strong&gt;pool&lt;/strong&gt; of proxies plus logic that rotates through them and retries failed requests on a fresh IP.&lt;/p&gt;

&lt;p&gt;Here is how to build one in Python that does not fall over the first time a proxy misbehaves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one proxy is never enough
&lt;/h2&gt;

&lt;p&gt;A single IP hitting a site hundreds of times per minute looks nothing like a human. Target sites respond with &lt;code&gt;429 Too Many Requests&lt;/code&gt;, throw CAPTCHAs, or quietly start returning empty pages. Even a "good" residential IP has a bad day: the upstream node drops, the TCP connection hangs, DNS flakes.&lt;/p&gt;

&lt;p&gt;So two things are non-negotiable in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rotation&lt;/strong&gt; — spread requests across many IPs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry on a different IP&lt;/strong&gt; — when a request fails, don't just retry the same dead proxy; pick a new one.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The naive version (and why it breaks)
&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;import&lt;/span&gt; &lt;span class="n"&gt;requests&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@gw.roamproxy.com:41080&lt;/span&gt;&lt;span class="sh"&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One bad IP and this raises, with no rotation and no retry. Wrap it in a loop and you're already reinventing a pool — badly. Let's do it properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A resilient pool
&lt;/h2&gt;

&lt;p&gt;The core idea: on every attempt, pick a proxy, and treat both exceptions &lt;strong&gt;and&lt;/strong&gt; retryable status codes (&lt;code&gt;429&lt;/code&gt;, &lt;code&gt;5xx&lt;/code&gt;) as "try again on a fresh IP," with exponential backoff.&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="kn"&gt;import&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;requests&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProxyPool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;RETRYABLE&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="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;proxies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&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;15&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;proxies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proxies&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_retries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;max_retries&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;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_pick&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;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&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;proxies&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;self&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="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;last_exc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_retries&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_pick&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;r&lt;/span&gt; &lt;span class="o"&gt;=&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&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;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="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;RETRYABLE&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;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HTTPError&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;retryable status &lt;/span&gt;&lt;span class="si"&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;status_code&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;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestException&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;last_exc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&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="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="c1"&gt;# 1s, 2s, 4s...
&lt;/span&gt;        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;last_exc&lt;/span&gt;


&lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProxyPool&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://user:pass@gw.roamproxy.com:41080&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# ...add as many gateway endpoints or credentials as you like
&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;pool&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="nf"&gt;print&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;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rotate on every attempt, not just on success.&lt;/strong&gt; The whole point is that attempt 2 uses a different IP than attempt 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exponential backoff&lt;/strong&gt; (&lt;code&gt;2 ** attempt&lt;/code&gt;) keeps you from hammering a struggling target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat &lt;code&gt;429&lt;/code&gt;/&lt;code&gt;5xx&lt;/code&gt; as retryable&lt;/strong&gt;, but let real client errors like &lt;code&gt;404&lt;/code&gt; return normally — retrying them just wastes IPs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With most gateway providers you don't even maintain a list of raw IPs. You point at one endpoint and get a &lt;strong&gt;new exit IP per request&lt;/strong&gt; automatically. Rotation for free:&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;# rotating: a fresh IP on every request
&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-country-us:pass@gw.roamproxy.com:41080&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When you actually want the &lt;em&gt;same&lt;/em&gt; IP
&lt;/h2&gt;

&lt;p&gt;Rotation is the default, but some flows break if the IP changes mid-way: logins, add-to-cart, anything multi-step behind a session cookie. That's a &lt;strong&gt;sticky session&lt;/strong&gt; — pin a session id in the username so the gateway keeps handing you the same exit IP until you rotate 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="c1"&gt;# sticky: same IP while the session id stays constant
&lt;/span&gt;&lt;span class="n"&gt;sticky&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-abc123:pass@gw.roamproxy.com:41080&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Session&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="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;sticky&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;sticky&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="nf"&gt;post&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://example.com/login&lt;/span&gt;&lt;span class="sh"&gt;"&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;creds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# same IP...
&lt;/span&gt;&lt;span class="n"&gt;s&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://example.com/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# ...as this one
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the session id and you get a new IP. That single lever — rotate vs. pin — covers the vast majority of scraping needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't reinvent the whole thing
&lt;/h2&gt;

&lt;p&gt;The pattern above (rotate, retry-on-failure with backoff, plus sticky sessions and a pool that recycles slots) is common enough that we packaged it into a tiny zero-dependency SDK: &lt;a href="https://github.com/roamproxy/roamproxy-python" rel="noopener noreferrer"&gt;&lt;strong&gt;roamproxy-python&lt;/strong&gt;&lt;/a&gt;. It gives you a &lt;code&gt;Client&lt;/code&gt;, a &lt;code&gt;StickySession&lt;/code&gt; context manager, and a &lt;code&gt;SessionPool&lt;/code&gt; that retries on &lt;code&gt;5xx&lt;/code&gt;/&lt;code&gt;429&lt;/code&gt;/connection errors out of the box:&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;roamproxy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SessionPool&lt;/span&gt;

&lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SessionPool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_on_failure&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;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's MIT-licensed — copy the ideas, or &lt;code&gt;pip install&lt;/code&gt; it, whatever's faster for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;A production scraper isn't "requests + a proxy." It's &lt;strong&gt;a pool that rotates, retries on a fresh IP, backs off, and can pin a sticky session when a flow needs one.&lt;/strong&gt; Build those four behaviors in and most "the site keeps blocking me" problems quietly disappear.&lt;/p&gt;

&lt;p&gt;If you'd rather not run your own IP infrastructure, that's the problem &lt;a href="https://roamproxy.com" rel="noopener noreferrer"&gt;RoamProxy&lt;/a&gt; solves — pay-as-you-go residential and datacenter proxies from $2/GB, with the rotating/sticky gateway shown above. Either way, the engineering above is the part that matters.&lt;/p&gt;

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