DEV Community

Cover image for Debugging 403, 429 and CAPTCHA Errors with Proxies
Dev Stack Hub
Dev Stack Hub

Posted on

Debugging 403, 429 and CAPTCHA Errors with Proxies

Your scraper worked fine on 50 requests. At 500, it started returning 403 Forbidden. At 5,000, you're staring at 429 Too Many Requests and CAPTCHA pages with a 200 status code. Adding more proxies didn't help, or helped for ten minutes.

The mistake most developers make here is changing several things at once: new proxies, new headers, more retries. This guide gives you a repeatable way to find out why you're being blocked so that you can fix the right thing.

What we'll cover

  • What 403, 429, and CAPTCHA responses actually mean
  • A step-by-step isolation process (and a small Python probe script)
  • Detecting "soft blocks" that return HTTP 200
  • Backoff, Retry-After, and pacing done properly
  • Tracking proxy health so bad IPs get benched automatically
  • When the problem isn't the IP at all

Examples use Python and requests, with placeholder proxies from the documentation range (203.0.113.x).

What each signal usually means

Signal What the server is telling you Typical root cause
429 Too Many Requests You exceeded a rate limit Too many requests per IP, per session or per account in a time window
403 Forbidden The server understood you and refuses IP reputation, bot detection, missing or inconsistent headers, geo restriction
200 with a CAPTCHA page A challenge is served instead of content Risk score too high: IP, behavior or fingerprint looks automated
407 Proxy Authentication Required The proxy rejected you, not the target Wrong credentials or non-whitelisted IP

Note that 407 is not a block at all. It's your proxy configuration, and it's the first thing to rule out.

Step 1: Isolate where the block happens

Before touching your scraper, test the target with the simplest possible client. If curl through the proxy works but your script fails, the problem is in your code. If both fail, it's the IP or the target.

# Direct (no proxy)
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/products

# Through the proxy
curl -s -o /dev/null -w "%{http_code}\n" -x http://user:pass@203.0.113.10:8080 https://example.com/products
🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025 🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025

Then reproduce the comparison in Python with a small probe that varies one thing at a time:

import requests

TARGET = 'https://example.com/products'
PROXY = 'http://user:pass@203.0.113.10:8080'
PROXIES = {'http': PROXY, 'https': PROXY}

BROWSER_HEADERS = {
   'User-Agent': (
       'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
       '(KHTML, like Gecko) Chrome/124.0 Safari/537.36'
   ),
   'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
   'Accept-Language': 'en-US,en;q=0.9',
}

def probe(label, **kwargs):
   try:
       r = requests.get(TARGET, timeout=(5, 15), **kwargs)
       print(f'{label:<28} {r.status_code}  {len(r.content):>8} bytes')
   except requests.RequestException as exc:
       print(f'{label:<28} ERROR {type(exc).__name__}')

probe('direct, default headers')
probe('direct, browser headers', headers=BROWSER_HEADERS)
probe('proxy, default headers', proxies=PROXIES)
probe('proxy, browser headers', proxies=PROXIES, headers=BROWSER_HEADERS)
🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025 🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025

Read the results like a truth table:

  • Direct works, proxy fails: the proxy IP is the problem (reputation, type, or geo).
  • Default headers fail, browser headers pass: the site checks the User-Agent and friends. The default python-requests/x.y agent is an easy signal to filter.
  • Everything fails: the page may need JavaScript, a cookie, or a login. The proxy is not the issue.
  • Everything passes: your problem is volume or pacing, so move on to Step 3.

Also confirm the proxy really is being used by calling an IP-echo endpoint such as https://api.ipify.org?format=json through it. A surprising number of "proxy problems" are requests silently going out directly.

Step 2: Detect soft blocks

A response code of 200 doesn't mean you got the page. Many sites serve a CAPTCHA or an "access denied" page with a success status, and your parser then silently stores garbage. Classify every response before you trust it:

BLOCK_MARKERS = (
   'captcha',
   'unusual traffic',
   'access denied',
   'verify you are human',
   'are you a robot',
)

def classify(response):
   if response.status_code == 407:
       return 'proxy_auth'
   if response.status_code == 429:
       return 'rate_limited'
   if response.status_code in (401, 403):
       return 'blocked'

   snippet = response.text[:5000].lower()
   if any(marker in snippet for marker in BLOCK_MARKERS):
       return 'challenge'
   return 'ok'
🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025 🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025

The marker list is site-specific, so tune it to the challenge pages you actually see. A cheaper complementary check is to assert that the content you expect (a product title, a JSON key) is present, and treat its absence as a failure.

Step 3: Fix 429s with backoff and pacing

A 429 is the friendliest error you'll get, because the server is telling you exactly what to do: slow down. Many servers include a Retry-After header, either as several seconds or as an HTTP date. Honor it:

import random
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime

def retry_after_seconds(response, default=30):
   value = response.headers.get('Retry-After')
   if not value:
       return default
   if value.isdigit():
       return int(value)
   try:
       when = parsedate_to_datetime(value)
       return max(0, (when - datetime.now(timezone.utc)).total_seconds())
   except (TypeError, ValueError):
       return default

def polite_sleep(base=1.0, jitter=1.5):
   # randomized delay between requests, so traffic isn't perfectly periodic
   time.sleep(base + random.uniform(0, jitter))
🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025 🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025

Three principles prevent most 429s:

  1. Limit concurrency per IP, not just overall. Ten threads on one IP is a burst. Ten threads across ten IPs is not.
  2. Add jitter. A request every exactly 1.000 seconds looks robotic.
  3. Use exponential backoff on repeated failures. If a 429 comes back after waiting, wait longer next time instead of retrying at the same speed.

If you're still rate-limited at a sensible pace, the limit probably applies per IP, and the fix is a larger pool rather than a faster loop.

Step 4: Track proxy health and bench bad IPs

Random rotation keeps sending traffic to IPs that are already burned. Instead, record outcomes and give failing proxies a cooldown:

import random
import time
from collections import defaultdict

class ProxyPool:
   def __init__(self, proxies, cooldown=300, max_failures=3):
       self.proxies = list(proxies)
       self.cooldown = cooldown
       self.max_failures = max_failures
       self.failures = defaultdict(int)
       self.benched_until = {}

   def get(self):
       now = time.time()
       available = [p for p in self.proxies if self.benched_until.get(p, 0) <= now]
       if not available:
           raise RuntimeError('No healthy proxies: slow down or add more IPs')
       return random.choice(available)

   def report(self, proxy, outcome):
       if outcome == 'ok':
           self.failures[proxy] = 0
           return
       self.failures[proxy] += 1
       # a 429 benches the IP immediately; other failures need a streak
       if outcome == 'rate_limited' or self.failures[proxy] >= self.max_failures:
           self.benched_until[proxy] = time.time() + self.cooldown
           self.failures[proxy] = 0
🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025 🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025

And a fetch function that ties the pieces together:

import requests

def fetch(url, pool, attempts=4):
   for _ in range(attempts):
       proxy = pool.get()
       try:
           r = requests.get(
               url,
               proxies={'http': proxy, 'https': proxy},
               headers=BROWSER_HEADERS,
               timeout=(5, 15),
           )
       except requests.RequestException:
           pool.report(proxy, 'error')
           continue

       outcome = classify(r)
       pool.report(proxy, outcome)

       if outcome == 'ok':
           return r
       if outcome == 'rate_limited':
           time.sleep(retry_after_seconds(r))
       elif outcome == 'proxy_auth':
           raise RuntimeError('Proxy credentials rejected (407). Check auth or IP whitelist.')

       polite_sleep()

   raise RuntimeError(f'Failed to fetch {url} after {attempts} attempts')
🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025 🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025

Log the outcome, proxy, and URL for every request. After a few hundred requests, you'll be able to see whether failures cluster on specific IPs (bad IPs), specific URLs (protected pages), or specific times (rate-limit windows). That pattern tells you which fix to apply.

Step 5: When it's the IP type

If your probe showed that browser-like headers pass directly but fail through the proxy, you're dealing with IP reputation. Different proxy types carry different levels of trust:

Proxy type Trust with strict sites Strengths Watch out for
Datacenter / IPv6 Lower Fast, cheap, easy to scale Whole ranges are often flagged; shared IPs inherit other users' behavior
ISP (static residential) Higher Looks like a home user, stable for long sessions Priced per IP, so pool size is limited by budget
Residential (rotating) High Huge pools, precise geo-targeting Usually billed per GB; can be slower
Mobile (4G/5G) Highest Carrier IPs are shared by many real users, so sites are cautious about blocking them Most expensive; speed varies

A few related checks:

  • Dedicated vs shared: a shared IP can be burned by someone else before you send a single request.
  • Geo mismatch: if your Accept-Language says en-US but your exit IP is in another country, or your IP is in Germany while you request a US-only price page, you will get blocked or served different content.
  • Burned static IPs: a static IP that has been hammered for days may need a rest or a replacement. Rotation isn't just for volume; it's hygiene.

Step 6: When it isn't the IP at all

If you've tried clean IPs and sensible pacing and still get 403s, look beyond the proxy:

  • Session and cookies: Many sites set a cookie on the first page and expect it on the next. Use a requests.Session and keep the same IP for the whole flow (a sticky session if your provider supports one).
  • Inconsistent headers: A Chrome User-Agent with a missing Accept-Language, or headers in an unusual order, is a signal. Keep the set realistic and consistent per session.
  • TLS fingerprinting: Some bot-protection systems inspect the TLS handshake itself, and Python's default stack looks different from a real browser's. If headers and IPs are fine but a site still refuses you, a real browser (Playwright or Selenium) or an HTTP client that mimics browser TLS such as curl_cffi may be the next thing to test.
  • JavaScript-rendered content: If the data is loaded by JavaScript, plain requests get an empty shell. Look for the JSON API call the page makes in your browser's network tab, or use a headless browser.
  • Behavioral signals: Hitting deep URLs with no prior navigation, or requesting pages at inhuman speed, can raise a risk score even from a perfect IP.

A word on CAPTCHAs

A CAPTCHA is not an error to defeat. It's a signal that your traffic looks risky. The most reliable strategy is to trigger fewer of them: better IP quality, lower rate, consistent sessions, realistic headers. If you're seeing them constantly, treat that as a symptom and work back through the steps above.

It's also worth asking whether you should be scraping that page at all. Check for an official API or data export first, read the site's terms of service and robots.txt, and only collect data you're permitted to use. Circumventing access controls on sites that forbid automated access can create legal problems that no proxy will solve.

Choosing proxies for testing

To follow along, you need proxies that let you compare types on the same target. I used a provider that offers datacenter, ISP, residential, and mobile IPs with both HTTP/HTTPS and SOCKS5 so that I could swap types without changing code. Disclosure: [add your affiliate or sponsorship note here if applicable]. Whichever service you use, buy a small amount of each type you're considering, run the probe script from Step 1, and compare success rate and latency on your target before committing to a larger plan.

Quick reference

  • 407? Fix proxy auth first. It isn't a block.
  • Isolate: compare direct vs proxy, default vs browser headers.
  • Classify every response, including 200 pages that are really CAPTCHAs.
  • 429: honor Retry-After, add jitter, limit per-IP concurrency, then grow the pool.
  • Bench bad IPs with a cooldown instead of rotating unquestioningly.
  • 403 on clean IPs: check sessions, headers, TLS fingerprint, and JavaScript rendering.
  • Constant CAPTCHAs: reduce the triggers, and check whether an official API exists.

Got a blocking pattern that isn't covered here? Share it in the comments, and I'll add it to the guide.

Top comments (6)

🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025
 
andersonkevin profile image
Kevin Anderson •

The “change one variable at a time” framing is what most scraping tutorials skip, and the direct vs. proxy, default vs. browser headers truth table is a quick way to see where the block actually is.

Two things I especially liked:

Soft-block detection. A 200 response that is really a CAPTCHA page quietly corrupts your data, and classify() catches that before it reaches the parser. Pairing it with a check that the expected content is present would make it sturdier still.
Benching proxies instead of rotating blindly. Random rotation keeps feeding traffic to burned IPs, and the cooldown approach fixes that cleanly.

One thing I’d add: confirm the proxy is really being used (the ipify check) before blaming it. I’ve seen requests silently go out direct because of a misconfigured proxies dict.

Curious how you’d handle sticky sessions with rotating residential proxies when a flow spans several pages. Do you pin one IP per session, or re-authenticate when it changes?

🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025
 
devstackhub profile image
Dev Stack Hub • • Edited

Thanks for the thoughtful read, and good call on the ipify check. A silently misconfigured proxies dict is one of the most common reasons people end up blaming the proxy for something that never went through it. The usual culprit is a dict keyed only on "http" while the target is HTTPS` so requests quietly go out direct. I should have made that check step zero in the post.

Agreed on pairing classify() with an expected-content check too. Status code and block-page markers catch the obvious cases, but asserting that a known selector or field is present catches the subtle ones, like a trimmed page or a "lite" response served to suspected bots.

On sticky sessions: I pin one IP per flow. For anything spanning multiple pages (login, search, checkout, pagination with session state), the site ties cookies, CSRF tokens, and often fingerprint signals to the IP, so a mid-flow change usually invalidates the session or trips risk scoring. Most residential providers support this by embedding a session ID in the proxy username, which holds the same exit IP for a set window (typically 1 to 30 minutes, depending on the provider).

My rules of thumb:

  • One session ID per logical flow, with a fresh one for each new flow.
  • Keep the cookie jar and headers tied to that session, so IP, cookies, and fingerprint all rotate together, never separately.
  • Set the sticky window longer than your slowest expected flow, with some margin.
  • If the IP drops mid-flow anyway, don't try to re-authenticate and continue. Treat the flow as failed, discard the session state, and restart it cleanly on a new IP. Resuming on a new IP with old cookies is a classic way to get flagged.
  • Bench the session, not just the IP. If a session ID starts returning soft blocks, retire it using the same cooldown logic.

Re-authenticating is a fallback for flows that are expensive to restart, but I'd treat it as the exception.

How are you handling it on your end? Are your flows short enough that sticky windows aren't an issue?

🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025
 
sidra-jefferi profile image
Sidra Jefferi •

One gap I noticed: Step 3 recommends exponential backoff and per-IP concurrency limits, but the code in fetch() doesn’t implement either. Every retry uses the same polite_sleep() delay, so a persistently throttled target is retried at a constant pace. Something like delay = min(cap, base * 2 ** attempt) + jitter would make the code match the advice, and a per-proxy semaphore (or a simple “last request time per IP” map) would enforce the concurrency rule.

A related detail for the SOCKS5 mention: with requests, socks5:// resolves DNS on your machine, while socks5h:// resolves it through the proxy. If a site serves geo-specific content, local DNS resolution can leak your real location even though the exit IP is correct. The IP-echo check from Step 1 wouldn’t catch that.

Have you compared success rates for the same target with requests versus curl_cffi? I’d be curious how much of the 403 gap on clean IPs is TLS fingerprinting.

🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025
 
debugtodeploy profile image
Vinay Shah •

The “change one variable at a time” framing in Step 1 is what most scraping tutorials skip, and the direct-vs-proxy, default-vs-browser headers truth table is a quick way to stop guessing.

A few things that stood out:

  • Soft blocks are underrated. Treating a 200 response as a failure when it’s actually a CAPTCHA page saves a lot of silent bad data. Asserting that the expected content is present (a product title or JSON key) works well alongside the marker list, since marker strings can drift when a site changes its challenge page.
  • Benching 429s immediately is a smart default. It stops one burned IP from dragging down the whole pool.
  • Small suggestions for the fetch loop: you mention exponential backoff in Step 3, but the loop uses a fixed polite_sleep() after failures, so it might be worth wiring the backoff in. If people run this with threads, ProxyPool will also need a lock around failures and benched_until.
  • Sticky sessions. Step 6 could stress that a rotating proxy mid-session often causes 403s by itself. Cookies set on one IP and replayed from another are a classic trigger.

Also glad you ended with the “check for an official API and read the ToS first” reminder. It gets skipped far too often.

Curious whether you’ve seen curl_cffi fully resolve TLS-fingerprint blocks in practice, or whether you still end up needing Playwright for the stricter sites.

🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025
 
mayur-upadhyay profile image
Mayur Upadhyay •

The truth-table approach in Step 1 is a clean way to separate proxy issues from code issues.

A few technical details that have bitten me in similar setups:

  1. Exponential backoff isn’t in the code yet. The Step 3 text recommends it, but fetch() only sleeps for Retry-After (or the 30s default) and then polite_sleep(). If a server keeps returning 429 without a Retry-After header, every retry waits the same amount. Something like delay = min(60, 2 ** attempt) + random.uniform(0, 1) inside the loop would close that gap.

  2. Header order matters, and requests doesn’t preserve it like a browser does. Some anti-bot systems check header order along with the values, so a perfect Chrome User-Agent can still look off. httpx or curl_cffi give you more control here, and curl_cffi’s impersonate="chrome" also handles the TLS/HTTP2 fingerprint at the same time.

  3. SOCKS5 and DNS leaks. If you swap to SOCKS5 proxies as the article mentions, socks5:// makes requests resolve DNS locally, which can expose your real resolver and cause geo mismatches. Use socks5h:// so the proxy resolves the hostname.

  4. Reuse a Session per proxy. Creating a new connection for every request through a proxy adds latency and a new TLS handshake each time. A session per proxy keeps cookies consistent and makes the traffic pattern look more natural.

Have you measured how much a sticky session reduces CAPTCHA frequency compared to per-request rotation? I’d be curious about the numbers.

🏆 ยอดนิยม: devstackhub/debugging and captcha errors with proxies i - Kapook Update 2025
 
rafidbottler profile image
Rafid Bottler •

Solid guide, and the point about logging outcomes per proxy, URL and time is what makes debugging possible instead of guesswork.

A few things that helped me once this pattern was in production:

  1. Fingerprint block pages by hash. Soft-block markers work until the site changes its wording. Hashing a normalized version of the response body (strip digits, timestamps and tokens first) lets you spot when many “successful” responses are actually the same page. If 40 different product URLs return an identical hash, you’re being served a challenge, even if no marker matches.

  2. Pick proxies by success rate, not random.choice. ProxyPool.get() treats every healthy IP equally. Tracking a rolling success rate (say, the last 50 requests) and weighting selection toward better performers means good IPs get used more and mediocre ones fade out before they even hit the bench threshold.

  3. Reduce requests before optimizing evasion. Conditional requests (If-None-Match with the stored ETag, or If-Modified-Since) return a cheap 304 for unchanged pages. For recurring scrapes, this can cut request volume dramatically, and fewer requests means fewer 429s. Many sites that rate-limit aggressively still honor conditional headers.

  4. Apply a per-domain rate limit when you go async. If you move from requests to asyncio with httpx or aiohttp, a plain semaphore caps total concurrency but not the rate per target. A token bucket per domain (for example, 2 requests per second regardless of how many workers or proxies you have) keeps pacing predictable.

  5. Respect Crawl-delay in robots.txt as a free hint. Some sites publish it, and it’s a good starting point for your base delay, since it’s roughly what the site owner considers acceptable.

Do you also track latency per proxy? I’ve found a sudden jump in response time often shows up a few minutes before blocks start, which makes it a useful early warning.