← Назад к блогу

5 ошибок в Scrapy proxy middleware, которые сжигают прокси-трафик впустую

Пять типовых ошибок в middleware Scrapy, из-за которых прокси-трафик расходуется впустую, а парсер получает баны. С примерами кода и готовыми решениями.

📅29 сентября 2026 г.

Парсер на Scrapy падает по таймауту, прокси-пул расходуется в разы быстрее ожидаемого, а логи забиты 407 и 403 ответами — знакомая картина? В 90% случаев проблема не в самих прокси, а в том, как написан DownloaderMiddleware. Разбираем пять самых частых ошибок в middleware, которые превращают дорогой трафик в мусорные запросы, и показываем, как их исправить с кодом.

Ошибка 1: примитивная ротация без учёта состояния прокси

Самая частая конструкция, которую можно найти в туториалах — это random.choice(PROXY_LIST) внутри process_request. Проблема в том, что такая ротация не знает, какой прокси только что получил бан, а какой всё ещё жив. В итоге парсер продолжает слать запросы через уже заблокированный IP, получает 403/429, делает retry — и снова выбирает тот же адрес, потому что выбор случайный и не исключает «плохие» узлы.

Правильный подход — вести состояние каждого прокси: количество успешных запросов, количество ошибок, время последнего использования. Вот минимальный рабочий вариант:

import random
import time

class ProxyPool:
    def __init__(self, proxies):
        self.proxies = {p: {"fails": 0, "last_used": 0, "banned_until": 0} for p in proxies}

    def get_proxy(self):
        now = time.time()
        available = [
            p for p, state in self.proxies.items()
            if state["banned_until"] < now
        ]
        if not available:
            # если все забанены, сбрасываем самый "старый" бан
            available = list(self.proxies.keys())
        return random.choice(available)

    def mark_fail(self, proxy, cooldown=300):
        self.proxies[proxy]["fails"] += 1
        self.proxies[proxy]["banned_until"] = time.time() + cooldown

    def mark_success(self, proxy):
        self.proxies[proxy]["fails"] = 0
        self.proxies[proxy]["last_used"] = time.time()

Такой пул исключает забаненные IP на время «охлаждения» (cooldown) и возвращает их в оборот позже. Это уже снижает расход трафика в разы, потому что вы не долбите один и тот же заблокированный узел по кругу.

Ошибка 2: неправильная обработка retry и статус-кодов

Вторая типичная ошибка — использовать стандартный RetryMiddleware из Scrapy без модификации. По умолчанию он ретраит запрос на том же прокси, который его провалил, если вы явно не подмешали смену прокси в process_exception. В результате получаем классическую картину: 3 retry, 3 бана, запрос всё равно падает, а трафик уже потрачен.

Второй момент — не все статус-коды нужно ретраить одинаково. 429 (Too Many Requests) требует паузы и смены IP, 403 обычно означает бан конкретного прокси (нужна немедленная замена), а 5xx — это часто временная проблема на стороне сервера, ретраить можно на том же прокси. Если валить всё в одну кучу, middleware либо слишком агрессивно жжёт прокси, либо слишком долго ждёт там, где нужно было сразу сменить IP.

class SmartRetryMiddleware:
    def __init__(self, pool):
        self.pool = pool

    def process_response(self, request, response, spider):
        proxy = request.meta.get("proxy")
        if response.status in (403, 407):
            if proxy:
                self.pool.mark_fail(proxy, cooldown=600)
            new_request = request.copy()
            new_request.meta["proxy"] = self.pool.get_proxy()
            new_request.dont_filter = True
            return new_request

        if response.status == 429:
            if proxy:
                self.pool.mark_fail(proxy, cooldown=120)
            new_request = request.copy()
            new_request.meta["proxy"] = self.pool.get_proxy()
            new_request.dont_filter = True
            return new_request

        if proxy:
            self.pool.mark_success(proxy)
        return response

Здесь важно разделять cooldown по типу ошибки: жёсткий бан (403/407) — долгая пауза, лимит частоты (429) — короткая. Это экономит десятки процентов трафика на длинных прогонах.

Ошибка 3: отсутствие sticky-сессий для сайтов с авторизацией

Если парсер работает с сайтом, где есть логин, корзина, пагинация с сохранением состояния или капча с проверкой по IP — смена прокси на каждый запрос ломает сессию. Сайт видит, что запрос №1 пришёл с одного IP, а запрос №2 (в рамках той же cookie-сессии) — с другого, и это триггерит защиту от бота мгновенно, даже если оба IP «чистые».

Решение — привязывать один прокси к одной логической сессии (например, к конкретному аккаунту или к цепочке запросов внутри одного домена) на фиксированное время, а не менять его на каждый запрос. Это называется sticky-сессия.

class StickySessionMiddleware:
    def __init__(self, pool, ttl=600):
        self.pool = pool
        self.ttl = ttl
        self.sessions = {}  # session_id -> (proxy, expires_at)

    def process_request(self, request, spider):
        session_id = request.meta.get("session_id")
        if not session_id:
            return

        now = time.time()
        session = self.sessions.get(session_id)

        if session and session[1] > now:
            request.meta["proxy"] = session[0]
        else:
            proxy = self.pool.get_proxy()
            self.sessions[session_id] = (proxy, now + self.ttl)
            request.meta["proxy"] = proxy

Для задач, где нужна стабильность IP на протяжении всей сессии — авторизация, работа с личным кабинетом, многошаговые формы — лучше всего подходят резидентные прокси с поддержкой сессий: они позволяют держать один и тот же исходящий IP несколько минут или часов, а затем менять его контролируемо, а не случайным образом на каждый запрос.

Ошибка 4: неверная передача авторизации прокси

Четвёртая ошибка — техническая, но встречается почти в каждом втором проекте. Разработчики передают логин и пароль прокси прямо в URL вида http://user:pass@ip:port через request.meta["proxy"]. Это работает в большинстве случаев, но при использовании прокси через HTTPS-туннель или при работе с некоторыми провайдерами такой способ авторизации некорректно обрабатывается стандартным HttpProxyMiddleware, и запросы падают с 407 Proxy Authentication Required, хотя учётные данные верные.

Более надёжный способ — передавать заголовок Proxy-Authorization явно, закодированный в base64:

import base64

class ProxyAuthMiddleware:
    def process_request(self, request, spider):
        proxy = request.meta.get("proxy")
        if not proxy:
            return

        # proxy без учётных данных в URL
        request.meta["proxy"] = proxy

        user = request.meta.get("proxy_user")
        password = request.meta.get("proxy_pass")
        if user and password:
            credentials = f"{user}:{password}"
            encoded = base64.b64encode(credentials.encode()).decode()
            request.headers["Proxy-Authorization"] = f"Basic {encoded}"

Такой подход стабильнее работает при масштабировании до сотен параллельных запросов и не зависит от того, как конкретная версия библиотеки парсит URL с embedded credentials. Особенно это критично при работе с мобильными прокси, где аутентификация часто завязана на IP whitelist или на строгую проверку заголовков.

Ошибка 5: нет мониторинга и логирования банов

Последняя и, возможно, самая дорогая по последствиям ошибка — отсутствие логирования того, какие прокси банятся, как часто и на каких доменах. Без этих данных невозможно понять, что именно жрёт трафик: то ли пул прокси исчерпал себя на конкретном сайте, то ли проблема в самом парсере (слишком частые запросы, отсутствие задержек, подозрительные заголовки).

Минимальный набор метрик, который стоит логировать в middleware:

  • Количество запросов на каждый прокси за сессию парсинга
  • Количество и коды ошибок (403, 407, 429, 5xx) по каждому прокси
  • Время жизни прокси до первого бана
  • Домены, на которых баны случаются чаще всего
import logging
import json

logger = logging.getLogger("proxy_stats")

class ProxyStatsMiddleware:
    def __init__(self):
        self.stats = {}

    def process_response(self, request, response, spider):
        proxy = request.meta.get("proxy", "unknown")
        domain = request.url.split("/")[2]
        key = f"{proxy}|{domain}"

        entry = self.stats.setdefault(key, {"requests": 0, "errors": 0})
        entry["requests"] += 1
        if response.status in (403, 407, 429):
            entry["errors"] += 1

        if entry["requests"] % 50 == 0:
            logger.info(json.dumps(self.stats))

        return response

Без такой статистики любые попытки «оптимизировать» middleware сводятся к гаданию. С ней вы точно видите: если, например, конкретный домен банит 80% прокси-пула за первые 10 запросов — проблема не в прокси, а в паттерне запросов (нет ротации User-Agent, слишком высокая частота, отсутствие задержек между запросами).

Рабочий пример middleware целиком

Собираем всё вместе в settings.py — порядок middleware критичен, потому что от него зависит, в каком порядке применяются проверки:

DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.ProxyAuthMiddleware": 350,
    "myproject.middlewares.StickySessionMiddleware": 400,
    "myproject.middlewares.SmartRetryMiddleware": 550,
    "myproject.middlewares.ProxyStatsMiddleware": 900,
}

RETRY_ENABLED = False  # отключаем стандартный retry, используем свой
DOWNLOAD_TIMEOUT = 15
CONCURRENT_REQUESTS_PER_DOMAIN = 8

Обратите внимание на RETRY_ENABLED = False — это принципиально, иначе встроенный retry-механизм Scrapy будет конфликтовать с вашей логикой смены прокси, и запросы начнут дублироваться или ретраиться дважды. Также важно ограничивать CONCURRENT_REQUESTS_PER_DOMAIN — слишком высокая параллельность на один домен даже с ротацией прокси выглядит подозрительно для антибот-систем.

Какой тип прокси выбрать для Scrapy

Middleware решает половину проблемы, вторая половина — правильный выбор самого прокси-пула под задачу. Ниже сравнение по типичным сценариям парсинга.

Тип прокси Когда использовать Плюсы Минусы
Прокси дата-центров Массовый парсинг открытых страниц без строгой антибот-защиты Высокая скорость, низкая стоимость трафика Легко детектируются, часто в blacklist
Резидентные прокси Парсинг маркетплейсов, сайтов с JS-защитой, авторизация Реальные IP, низкий процент банов, поддержка sticky-сессий Ниже скорость по сравнению с DC
Мобильные прокси Парсинг мобильных версий сайтов и API с жёсткой антибот-защитой Максимальное доверие со стороны целевых сайтов Самая высокая стоимость трафика

Практическое правило: для парсинга статичных страниц без сильной защиты подойдут дешёвые датацентровые прокси в связке с грамотным middleware из этой статьи. Если сайт использует Cloudflare, PerimeterX, DataDome или похожие системы — датацентровые IP будут банится почти сразу, и тут уже выгоднее сразу переходить на резидентный пул, экономя время на дебаге middleware.

Чек-лист перед запуском парсера в продакшн

  • Middleware исключает забаненные прокси на cooldown, а не выбирает их случайно
  • Retry-логика различает типы ошибок (403/407 vs 429 vs 5xx) с разным cooldown
  • Для сессионных задач используется sticky-привязка прокси, а не смена на каждый запрос
  • Авторизация прокси передаётся через заголовок Proxy-Authorization, а не только через URL
  • Ведётся статистика банов по доменам и прокси для диагностики проблем
  • Стандартный RetryMiddleware Scrapy отключён, чтобы не конфликтовать с кастомной логикой
  • Concurrency ограничена разумными значениями, а не выставлена на максимум

Заключение

Большинство проблем с расходом прокси-трафика в Scrapy решаются не покупкой большего пула IP, а исправлением логики middleware: разделением ошибок по типам, sticky-сессиями для сложных сценариев, корректной авторизацией и постоянным мониторингом банов. Код из этой статьи можно взять как основу и адаптировать под конкретный проект — структура остаётся рабочей и для небольших парсеров, и для распределённых Scrapy-Cluster-инсталляций.

Если middleware уже настроен правильно, а баны всё равно происходят слишком часто — вероятно, дело в качестве самого IP-пула. Для парсинга сайтов с продвинутой антибот-защитой стоит попробовать резидентные прокси с поддержкой сессий — они заметно снижают процент срабатывания защиты по сравнению с датацентровыми адресами при той же логике middleware.