Skip to content
Automation

Scrapy Proxies: Middleware, Rotation and Retry Logic That Holds Up

Scrapy routes traffic through a proxy by reading `request.meta['proxy']`, which its built-in `HttpProxyMiddleware` turns into a connection. What that middleware will not do is authenticate you unless the credentials are in the URL at the moment the request is created, and it will not rotate anything. Both jobs belong in middleware of your own — and getting them right matters more in Scrapy than elsewhere, because its default concurrency will find a target's rate limit within seconds.

seamless Team11 min readAugust 9, 2026
  • scrapy
  • python
  • automation
  • web scraping
A wide fan of parallel routing lines passing through evenly spaced throttle gates, one line lit crimson along its full path

The minimal configuration is on the Scrapy integration page. This article is about what to build on top of it once a spider is running against a target that pushes back.

How Scrapy sees a proxy

python
# The entire built-in mechanism, in one line.
yield scrapy.Request(url, meta={"proxy": "http://USERNAME:PASSWORD@PROXY_HOST:PROXY_PORT"})

HttpProxyMiddleware is enabled by default and picks that key up. It also reads http_proxy and https_proxy from the environment, which is worth knowing when a spider mysteriously routes somewhere you did not configure.

Setting the proxy in the spider works until the first retry. Retries, redirects and requests generated by other middleware do not automatically carry your meta unless you propagate it, so identity assignment belongs in one downloader middleware that every request passes through.

A proxy middleware worth having

python
# middlewares.py
import uuid
from urllib.parse import quote
from w3lib.http import basic_auth_header


class SeamlessProxyMiddleware:
    """Assigns an exit identity to every outgoing request."""

    def __init__(self, host, user, password):
        self.host = host
        self.user = quote(user, safe="")
        self.password = quote(password, safe="")

    @classmethod
    def from_crawler(cls, crawler):
        s = crawler.settings
        return cls(
            s.get("PROXY_HOST"),
            s.get("PROXY_USER"),
            s.get("PROXY_PASSWORD"),
        )

    def process_request(self, request, spider):
        if "proxy" in request.meta:
            return  # a retry keeping its original exit

        # A session tag per domain keeps one identity per site.
        tag = request.meta.get("session_tag") or uuid.uuid4().hex[:8]
        user = f"{self.user}-session-{tag}"

        request.meta["proxy"] = f"http://{self.host}"
        request.meta["session_tag"] = tag
        request.headers["Proxy-Authorization"] = basic_auth_header(
            user, self.password
        )
Credentials go in the Proxy-Authorization header, which survives redirects more reliably than userinfo in the URL.

The if "proxy" in request.meta guard matters. Without it a retry gets a brand-new exit, and you lose the ability to tell 'this IP is blocked' apart from 'this request is malformed'.

python
# settings.py
DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.SeamlessProxyMiddleware": 350,
    "scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": 400,
}
Lower numbers run earlier on the request path — yours must run before the built-in middleware.

Concurrency is the real problem

Scrapy's defaults are tuned for throughput against infrastructure you own. Pointed at somebody else's site they generate a burst that trips rate limiting almost immediately, and the resulting 429 looks like a proxy fault when it is a pacing fault.

python
# settings.py
CONCURRENT_REQUESTS = 32
CONCURRENT_REQUESTS_PER_DOMAIN = 8      # the number that actually matters

AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 1.0
AUTOTHROTTLE_MAX_DELAY = 30.0
AUTOTHROTTLE_TARGET_CONCURRENCY = 4.0
AUTOTHROTTLE_DEBUG = False

DOWNLOAD_TIMEOUT = 45                    # residential exits add latency
RANDOMIZE_DOWNLOAD_DELAY = True

AutoThrottle measures the target's response latency and adjusts the delay to keep concurrency near your target figure. It is strictly better than a fixed DOWNLOAD_DELAY, which is either too slow when the site is healthy or too fast when it is struggling.

SettingDefaultSensible for a third-party target
CONCURRENT_REQUESTS_PER_DOMAIN82–8, lower while tuning
DOWNLOAD_TIMEOUT18045 through residential exits
RETRY_TIMES23–5 with backoff
AUTOTHROTTLE_ENABLEDFalseTrue, always
ROBOTSTXT_OBEYTrueLeave it on unless you have a reason

Retries that distinguish causes

python
# settings.py
RETRY_ENABLED = True
RETRY_TIMES = 4
# 403 and 407 are deliberately absent: neither improves on repetition.
RETRY_HTTP_CODES = [429, 500, 502, 503, 504, 522, 524]

A 403 is a decision about your address or your request, and a 407 is a credentials fault. Retrying either burns budget and, in the 403 case, deepens the block. Handle 403 explicitly instead: retry once on a new exit to find out which of the two it is, then stop.

python
class RetryOnNewExitMiddleware:
    """One retry for a 403, on a fresh identity, to isolate IP from request."""

    def process_response(self, request, response, spider):
        if response.status != 403 or request.meta.get("exit_retried"):
            return response

        new = request.copy()
        new.meta.pop("proxy", None)
        new.meta.pop("session_tag", None)
        new.meta["exit_retried"] = True
        new.dont_filter = True
        spider.logger.info("403 on %s — retrying on a new exit", request.url)
        return new

Exits that survive a real crawl

Rotating residential from €1.20/GB, static ISP from €1.80/IP. Sticky sessions and country targeting are set in the username — no extra API.

See pricing

Bandwidth is the bill

Residential proxies are billed per gigabyte, and a Scrapy crawl left unattended will happily download every asset the parser ignores.

  • Enable HttpCompressionMiddleware — it is on by default; make sure nothing disabled it.
  • Turn on HTTPCACHE_ENABLED while developing so re-runs cost nothing.
  • Never crawl images, PDFs or video unless the pipeline consumes them.
  • Prefer JSON endpoints over HTML pages when the site offers both — usually a tenth of the bytes.
  • Set DEPTH_LIMIT on any spider following links, or one bad selector will crawl the whole site.

Logging that makes failures diagnosable

The distribution of failures across exits and hosts is what identifies a cause; a single error tells you almost nothing. Record the identity alongside the outcome.

python
def process_response(self, request, response, spider):
    if response.status >= 400:
        spider.logger.warning(
            "%s %s session=%s",
            response.status,
            request.url,
            request.meta.get("session_tag"),
        )
    return response

With that in place, the difference between 'this target blocks us' and 'three exits in our rotation are burnt' becomes a two-minute query instead of an afternoon of guessing.

Frequently asked questions

How do I use a proxy in Scrapy?

Set request.meta['proxy'] to the proxy URL and Scrapy's built-in HttpProxyMiddleware routes the request through it. For anything beyond a single fixed proxy, assign it in your own downloader middleware so retries and redirects inherit the setting.

How do I authenticate a proxy in Scrapy?

Either embed credentials in the proxy URL, or set the Proxy-Authorization header explicitly with w3lib's basic_auth_header. The header approach is more reliable because it survives redirects and is easier to inspect when something goes wrong.

How do I rotate proxies in Scrapy?

Vary the session identifier in the proxy username per request or per domain in your middleware. The gateway picks the exit, so your code only changes a string. Keep the identity stable across retries or you cannot tell a blocked IP from a bad request.

Why does Scrapy get blocked so quickly?

Its default concurrency sends a burst that few public sites tolerate. Lower CONCURRENT_REQUESTS_PER_DOMAIN, enable AutoThrottle so the delay adapts to the site's own latency, and randomise the download delay.

Should Scrapy retry 403 responses?

Not through the standard retry list. Handle it separately with a single retry on a fresh exit, which tells you whether the IP or the request was refused, then stop. Blind retries deepen the block and consume bandwidth.

How do I reduce bandwidth costs in a Scrapy crawl?

Skip assets the pipeline never reads, keep compression enabled, cache responses during development, prefer JSON endpoints over rendered HTML, and set a depth limit so a bad selector cannot crawl the entire site.

SE
seamless Team
Proxy infrastructure

The seamless team runs residential, ISP and datacenter proxy infrastructure and writes these guides from day-to-day operational experience.

Ready to try seamless proxies?

Residential, ISP and datacenter proxies with no data expiry.

Browse Plans