httpx python - это то, на что я перехожу каждый раз, когда парсер начинает буксовать на requests. Синхронный requests тянет страницы по очереди, и когда в очереди двести-триста URL, скрипт работает пятнадцать-двадцать минут вместо тридцати секунд. httpx даёт синхронный и асинхронный клиент в одной библиотеке, поддержку HTTP/2 и API, почти один в один похожий на requests, поэтому переезд занимает час, а не день переписывания кода. Дальше показываю, как настроить AsyncClient под реальный парсинг: ограничение параллельности, таймауты, повторные попытки и работу с прокси.
Чем httpx отличается от requests и aiohttp для парсинга
requests умеет только синхронные запросы. Чтобы собрать сто страниц, скрипт делает сто последовательных HTTP-вызовов, и пока не пришёл ответ на текущий запрос, следующий не отправляется. Для парсинга нескольких страниц в день это не проблема, но для мониторинга каталога из тысяч товаров ожидание растягивается на десятки минут. У requests есть Session с пулом соединений, но она остаётся синхронной: пока ответ на один запрос не пришёл, следующий не уйдёт, как ни оборачивай код в Session.
aiohttp решает эту проблему асинхронностью, но у него своя модель: ClientSession, отдельный коннектор с настройками SSL, свой синтаксис для заголовков и параметров запроса. Переписывать парсер с requests на aiohttp означает менять почти весь код, а не только импорт.
httpx закрывает разрыв между ними. Он поддерживает и синхронный Client, и асинхронный AsyncClient, а методы get, post, params, headers, json называются так же, как в requests. Плюс опционально доступен HTTP/2 (нужен дополнительный пакет и явное включение у клиента) и полная типизация, которая в IDE подсказывает параметры методов ещё на этапе написания кода.
| Библиотека | Асинхронный режим | HTTP/2 | API похож на requests |
|---|---|---|---|
| requests | нет | нет | - |
| aiohttp | есть | нет | нет, своя модель ClientSession |
| httpx | есть | есть, опционально | да, почти идентичный |
Для несложного скрипта, который раз в сутки проверяет пару десятков ссылок, разница не критична, requests справится. Но если нужно опрашивать сотни или тысячи URL, обновлять цены из каталога поставщика или следить за появлением новых объявлений, асинхронный клиент экономит часы работы скрипта.
Установка httpx и первый асинхронный запрос
Библиотека ставится одной командой:
pip install httpx
Если понадобится HTTP/2, ставится версия с дополнительным пакетом h2:
pip install "httpx[http2]"
Одной установки пакета мало: у клиента HTTP/2 по умолчанию выключен, поддержку нужно включить параметром при создании клиента.
client = httpx.AsyncClient(http2=True)
Дальше базовый пример: один асинхронный запрос через AsyncClient как контекстный менеджер.
import asyncio
import httpx
async def main():
async with httpx.AsyncClient() as client:
response = await client.get("https://example.com")
print(response.status_code)
print(response.text[:200])
asyncio.run(main())
Клиент стоит создавать один раз и переиспользовать для всех запросов внутри одной задачи парсинга, а не открывать новый AsyncClient на каждый URL. Внутри он держит пул соединений, и повторное использование клиента экономит время на TCP- и TLS-хендшейках к одному и тому же хосту.
AsyncClient и ограничение параллельности через семафор
Соблазн собрать список из тысячи URL и запустить их все разом через asyncio.gather понятен, но на практике это почти всегда заканчивается плохо: сайт отвечает 429 или 503, соединения обрываются по таймауту, а часть ответов приходит битыми. Нужно ограничивать число одновременных запросов, и для этого в asyncio есть Semaphore.
import asyncio
import httpx
urls = [
"https://example.com/catalog/1",
"https://example.com/catalog/2",
"https://example.com/catalog/3",
]
async def fetch(client, url, semaphore):
async with semaphore:
response = await client.get(url)
response.raise_for_status()
return response.text
async def main():
semaphore = asyncio.Semaphore(10)
async with httpx.AsyncClient() as client:
tasks = [fetch(client, url, semaphore) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
asyncio.run(main())
По моей практике семафор на 5-10 одновременных запросов держит большинство сайтов без банов и без роста числа ошибок. Для внутренних API или собственной инфраструктуры заказчика можно поднимать значение до 20-30, а для чувствительных к нагрузке сайтов иногда приходится опускать до 2-3 и добавлять паузу между запросами через asyncio.sleep. Если нужно видеть прогресс по мере готовности ответов, а не ждать весь список разом, вместо gather подойдёт asyncio.as_completed, он отдаёт результаты по мере завершения задач, а не когда готовы все сразу.
Отдельно стоит настроить пул соединений через httpx.Limits, если запросов действительно много:
limits = httpx.Limits(max_connections=20, max_keepalive_connections=10)
client = httpx.AsyncClient(limits=limits)
max_connections ограничивает общее число открытых соединений, max_keepalive_connections задаёт, сколько из них держать открытыми между запросами для переиспользования.
Бесплатный материал
🎁 Полезный скрипт в подарок
Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.
Без спама. Отписка в 1 клик.
Таймауты и повторные попытки при сетевых сбоях
Если не задать timeout явно, httpx ждёт не дольше пяти секунд на каждом этапе: подключение, отправку запроса, получение ответа и ожидание свободного соединения в пуле. Для парсинга с сотнями URL этого обычно мало, часть сайтов отвечает медленнее, особенно под нагрузкой. Таймаут задаётся отдельно по фазам:
timeout = httpx.Timeout(10.0, connect=5.0)
client = httpx.AsyncClient(timeout=timeout)
Сетевые обрывы, разрывы соединения и временные блокировки по частоте запросов лучше не превращать в падение всего скрипта, а обрабатывать повторной попыткой с задержкой:
import asyncio
import httpx
async def fetch_with_retries(client, url, retries=3):
for attempt in range(1, retries + 1):
try:
response = await client.get(url)
response.raise_for_status()
return response.text
except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.ConnectError):
if attempt == retries:
raise
await asyncio.sleep(2 * attempt)
Здесь важно не путать HTTPStatusError с кодом 404 (страницы правда нет, повторять запрос бессмысленно) и с кодом 429 или 503 (сервер просит подождать, повтор с задержкой уместен). Я обычно проверяю response.status_code перед retry и для 404 сразу пропускаю URL, а не гоняю его по кругу три раза впустую. Для продакшен-скрипта, который гоняется по расписанию, вместо ручного цикла retry часто беру библиотеку tenacity: она умеет экспоненциальную задержку с джиттером и навешивается декоратором на функцию fetch без дублирования кода в каждом месте, где нужен повтор.
Заголовки, прокси и снижение числа блокировок
Запрос без заголовков или с одним и тем же User-Agent на все тысячу URL выглядит подозрительно для любой системы защиты от нагрузки. Базовое снижение числа ложных блокировок - ротация заголовков и соблюдение rate-limit:
import random
import httpx
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
]
async def fetch(client, url):
headers = {"User-Agent": random.choice(USER_AGENTS)}
response = await client.get(url, headers=headers)
return response.text
Если парсинг идёт через прокси, адрес передаётся клиенту параметром proxy:
client = httpx.AsyncClient(proxy="http://user:password@proxy-host:8080")
Для ротации IP на каждый запрос проще брать прокси со стороны пула (список прокси-провайдера или собственный ротатор) и создавать клиент с новым адресом на каждую партию запросов, чем городить это руками внутри одного клиента. Дальше остаётся соблюдать паузы между запросами к одному хосту и не игнорировать robots.txt, если сайт явно ограничивает частоту обращений к разделу. Речь именно о сборе открытых данных: цен, наличия товаров, характеристик, которые видны любому посетителю сайта без авторизации.
Асинхронный httpx пригождается не только в отдельных скриптах парсинга. В aiogram-ботах он же используется для вызова внешних API из хендлера, чтобы не блокировать event loop бота на время ответа стороннего сервиса. А в сценариях n8n парсер на httpx часто запускается по вебхуку: workflow дёргает HTTP-ноду, которая обращается к развёрнутому скрипту, тот отрабатывает и возвращает JSON с результатом обратно в n8n для дальнейшей обработки. Если нужна разработка парсера на Python под конкретный сайт и его особенности защиты, такую задачу я беру в работу отдельно от типовых доработок сайта.
Собираю асинхронный парсер на httpx целиком
Ниже пример, где всё из предыдущих разделов работает вместе: семафор ограничивает параллельность, таймаут задан явно, ошибки сети обрабатываются повторной попыткой, а результат сохраняется в JSON.
import asyncio
import json
import random
import httpx
URLS = [
"https://example.com/catalog/1",
"https://example.com/catalog/2",
"https://example.com/catalog/3",
]
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
]
async def fetch(client, url, semaphore, retries=3):
headers = {"User-Agent": random.choice(USER_AGENTS)}
async with semaphore:
for attempt in range(1, retries + 1):
try:
response = await client.get(url, headers=headers)
response.raise_for_status()
return {"url": url, "status": response.status_code, "html": response.text}
except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.ConnectError):
if attempt == retries:
return {"url": url, "status": None, "html": None}
await asyncio.sleep(2 * attempt)
async def main():
semaphore = asyncio.Semaphore(10)
timeout = httpx.Timeout(10.0, connect=5.0)
limits = httpx.Limits(max_connections=20, max_keepalive_connections=10)
async with httpx.AsyncClient(timeout=timeout, limits=limits) as client:
tasks = [fetch(client, url, semaphore) for url in URLS]
results = await asyncio.gather(*tasks)
with open("results.json", "w", encoding="utf-8") as file:
json.dump(results, file, ensure_ascii=False, indent=2)
asyncio.run(main())
Для больших HTML-страниц, которые не нужно грузить в память целиком, у httpx есть потоковый режим через client.stream, он читает ответ по частям и подходит для скачивания файлов или очень тяжёлых страниц:
async with client.stream("GET", url) as response:
async for chunk in response.aiter_text():
process(chunk)
На практике для парсинга обычного HTML-каталога это не требуется, но пригождается при скачивании прайс-листов в CSV или XML большого размера. Для отладки на этапе разработки удобно смотреть каждый запрос и ответ через event_hooks клиента: туда передаются асинхронные функции, которые вызываются автоматически перед отправкой запроса и после получения ответа, и в них можно просто логировать URL, статус и время выполнения без правки основного кода парсера.
Python регулярно обновляется, поэтому перед внедрением я сверяюсь с актуальной документацией.
Частые вопросы
Чем httpx лучше aiohttp для парсинга
Не лучше по производительности, они сопоставимы. Разница в удобстве: httpx повторяет API requests, поэтому если парсер уже написан на requests, переезд на асинхронный httpx требует минимальных правок кода. С aiohttp пришлось бы переписывать логику запросов под ClientSession и его собственный синтаксис, что на действующем проекте занимает заметно больше времени.
Нужен ли asyncio, если сайтов немного
Если парсер обращается к десятку URL раз в сутки, синхронного requests или синхронного httpx.Client достаточно, асинхронность не даст ощутимого выигрыша, а код усложнит. Смысл в AsyncClient появляется, когда счёт идёт на сотни и тысячи запросов за один прогон, и именно там разница по времени становится заметной.
Как httpx работает с прокси и ротацией IP
Прокси передаётся клиенту параметром proxy при создании AsyncClient, одна строка вида http://user:password@host:port. Для ротации IP на каждый запрос проще менять прокси между партиями запросов или брать готовый ротатор от прокси-провайдера, чем реализовывать переключение внутри одного клиента вручную.
Можно ли парсить httpx сайты с рендерингом на JavaScript
httpx получает тот HTML, который отдаёт сервер до выполнения скриптов в браузере. Если контент подгружается через JavaScript после загрузки страницы, httpx его не увидит, и нужен браузерный движок вроде Playwright. Часто данные, которые кажутся сгенерированными JS, на деле приходят отдельным запросом к API сайта, и его можно вызвать напрямую через httpx, если найти этот эндпоинт в вкладке Network браузера.