Два инструмента, один интерфейс
В прошлом уроке мы разобрались, как устроен HTTP. Теперь — чем на нём говорить из Python. Стандартный stdlib-модуль urllib.request есть, но писать на нём в 2026 году никто не пишет — у него многословный API и неудобные ошибки. Все ходят в один из двух пакетов.
Starlette/uvicorn. Совместимый с requests синхронный API + полноценный асинхронный + HTTP/2 + строгие timeouts по умолчанию + аккуратные типы.
Правило выбора для DE 2026 года:
- Новый проект — берите
httpx. - Существующая кодовая база на requests — оставляйте requests, не переписывайте «потому что круче».
- Нужен async (FastAPI, параллельные запросы, тысячи URL одновременно) —
httpxбез альтернатив.
API похожи почти посимвольно, поэтому 90% уроки про httpx — это и про requests тоже. Различия отметим явно.
Первый запрос
import httpx
response = httpx.get("https://api.github.com/repos/python/cpython")
print(response.status_code) # 200
print(response.headers["content-type"]) # application/json; charset=utf-8
print(response.json()["name"]) # "cpython"
То же самое на requests — буква в букву:
import requests
response = requests.get("https://api.github.com/repos/python/cpython")
response — это объект с полезными атрибутами:
response.status_code— int, статус ответа.response.text— тело как строка (декодированное поContent-Type).response.content— тело какbytes(если скачиваете бинарь — Parquet, картинку).response.json()— распарсенный JSON. КидаетJSONDecodeError, если тело не JSON.response.headers— словаре-подобный объект с заголовками (case-insensitive).response.url— итоговый URL после редиректов.response.elapsed—timedelta, сколько занял запрос.
POST, PUT, DELETE и параметры
Базовые HTTP-методы — отдельные функции:
# GET с query-параметрами: ?per_page=50&state=open
response = httpx.get(
"https://api.github.com/repos/python/cpython/issues",
params={"per_page": 50, "state": "open"},
)
# POST с JSON-телом: body будет {"name": "...", "ref": "..."}
response = httpx.post(
"https://api.github.com/repos/me/test/git/refs",
json={"ref": "refs/heads/feature", "sha": "abc123"},
headers={"Authorization": "Bearer ghp_xxx"},
)
# PUT с JSON-телом
response = httpx.put(url, json={"state": "closed"})
# DELETE
response = httpx.delete(url)
Разница между data=, json= и params= — частая ловушка:
params={...}— это query string в URL (?a=1&b=2). Используется почти всегда с GET.json={...}— клиент сам сериализует в JSON и проставитContent-Type: application/json. Используется с POST/PUT/PATCH в REST API.data={...}— это, как HTML-форма. Для REST API обычно НЕ нужен.application/x-www-form-urlencoded
99% REST API хотят json=. Если вместо JSON ушёл form-encoded — API ответит 400 или странной ошибкой парсинга.
params в URL, json/data в теле, headers — отдельной секцией. Не путайте.
Timeouts: самое важное правило
# ПЛОХО — может зависнуть навсегда
response = requests.get(url)
# Чуть лучше — но всё ещё опасно
response = requests.get(url, timeout=10)
# Правильно — раздельные таймауты
response = httpx.get(url, timeout=httpx.Timeout(10.0, connect=5.0))
Без timeout запрос может зависнуть на минуты или дольше — TCP-соединение установлено, сервер ничего не пишет, клиент послушно ждёт. Если у вас Airflow-таск с 1000 таких запросов — DAG зависнет, scheduler положит вам всё.
В requests timeout — одно число (секунды) или кортеж (connect, read). По умолчанию None (бесконечно). Всегда задавайте явно.
В httpx timeout по умолчанию 5 секунд (есть!) и поддерживает четыре отдельных канала:
connect— время на установку TCP+TLS.read— время между байтами от сервера.write— время на отправку байт серверу.pool— время ожидания свободного коннекта из пула.
Для DE-задач разумно: connect=5s, read=30s (или больше для тяжёлых endpoints).
client = httpx.Client(timeout=httpx.Timeout(30.0, connect=5.0))
Если API регулярно отвечает дольше вашего timeout — это не повод увеличивать timeout до 5 минут. Это повод посмотреть, не нужна ли в API пагинация / асинхронный режим / другой endpoint. Бесконечно растущий timeout — это маскировка проблемы.
raise_for_status: не молча
По умолчанию requests/httpx не кидают исключение на 4xx/5xx. Они просто возвращают response с этим статусом. Это значит, что наивный код пропустит ошибку и побежит парсить тело:
# Эта функция вернёт мусор при 404, если сервер прислал HTML-страницу
def get_repo(name: str) -> dict:
response = httpx.get(f"https://api.github.com/repos/{name}")
return response.json() # может бросить JSONDecodeError или вернуть error-объект
Правильно — явно проверять статус сразу после получения ответа:
def get_repo(name: str) -> dict:
response = httpx.get(f"https://api.github.com/repos/{name}")
response.raise_for_status() # бросит HTTPStatusError на 4xx/5xx
return response.json()
raise_for_status() кидает специальное исключение, которое содержит весь response — потом можно его поймать и достать body/headers для логирования.
import httpx
try:
response = httpx.get("https://api.github.com/repos/does/not/exist")
response.raise_for_status()
except httpx.HTTPStatusError as exc:
print(f"status: {exc.response.status_code}")
print(f"body: {exc.response.text[:200]}")
Запомните: raise_for_status() после каждого запроса, который не должен молча падать. Это основа любого вменяемого клиента.
Session / Client: переиспользование коннектов
Каждый httpx.get(...) под капотом открывает новый TCP-коннект, делает TLS-handshake, отправляет запрос, закрывает коннект. TLS-handshake — это 1-2 round-trip к серверу, на медленной сети это 100-300 мс. Если у вас 1000 запросов к одному API — это +1000 handshake’ей, +5 минут потраченного времени.
Решение — переиспользовать одно соединение для всех запросов. В requests это
requests.Sessionhttpx.Client:
import httpx
with httpx.Client(
base_url="https://api.github.com",
headers={
"Authorization": "Bearer ghp_xxx",
"Accept": "application/vnd.github+json",
"User-Agent": "my-etl/1.0",
},
timeout=httpx.Timeout(30.0, connect=5.0),
) as client:
repo = client.get("/repos/python/cpython").json()
issues = client.get("/repos/python/cpython/issues", params={"per_page": 50}).json()
user = client.get("/users/gvanrossum").json()
Что мы получили:
- Один TLS-handshake на всё. На 1000 запросов — экономия в десятки секунд.
- Общие headers не повторяются в каждом вызове. Авторизация задана раз.
- base_url убирает повторение домена.
with-блок гарантирует закрытие коннекта при выходе.
Правило железобетонное: в DE-коде, который делает больше одного HTTP-запроса, всегда используйте Session/Client. Создание клиента дёшево, но коннект — нет.
В requests то же самое:
import requests
session = requests.Session()
session.headers.update({"Authorization": "Bearer ghp_xxx"})
session.get("https://api.github.com/repos/python/cpython", timeout=10)
requests не поддерживает base_url без сторонних хаков — это одно из небольших удобств httpx.
Один TLS-handshake против N. На большом числе запросов экономия — десятки секунд.
DE-кейс: клиент к GitHub API
Соберём всё, что мы видели, в один маленький класс — каркас, который пойдёт в каждый проект, ходящий в REST API:
import httpx
from typing import Any
class GitHubClient:
"""Минимальный клиент к GitHub API: Session, headers, timeouts, raise_for_status."""
def __init__(self, token: str, *, timeout: float = 30.0) -> None:
self._client = httpx.Client(
base_url="https://api.github.com",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"User-Agent": "my-etl/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout=httpx.Timeout(timeout, connect=5.0),
)
def close(self) -> None:
self._client.close()
def __enter__(self) -> "GitHubClient":
return self
def __exit__(self, *args: object) -> None:
self.close()
def get_repo(self, owner: str, name: str) -> dict[str, Any]:
response = self._client.get(f"/repos/{owner}/{name}")
response.raise_for_status()
return response.json()
def list_issues(self, owner: str, name: str, *, state: str = "open") -> list[dict[str, Any]]:
response = self._client.get(
f"/repos/{owner}/{name}/issues",
params={"state": state, "per_page": 100},
)
response.raise_for_status()
return response.json()
# Использование
with GitHubClient(token="ghp_xxx") as gh:
repo = gh.get_repo("python", "cpython")
issues = gh.list_issues("python", "cpython", state="open")
print(repo["stargazers_count"], len(issues))
Заметьте:
- Класс — обёртка вокруг одного
httpx.Client. Никакой магии. __enter__/__exit__сделаны вручную черезhttpx.Client.close(). С контекст-менеджером гарантировано освобождение коннекта (вспомните урок 02-context-managers).raise_for_status()после каждого вызова. Никаких «может, json вернёт error-объект, посмотрим».- Timeouts заданы явно.
Это — каркас. В следующих уроках добавим pagination, retries и обработку rate-limit’ов. На прод-проектах поверх этого ещё кладут логирование, метрики, кастомные исключения по типам ошибок API.
Streaming большого ответа
Если API отдаёт огромный JSON / CSV / Parquet, грузить его весь в response.content — это OOM. httpx/requests умеют отдавать тело по кускам:
with httpx.stream("GET", "https://example.com/big-file.csv") as response:
response.raise_for_status()
with open("big.csv", "wb") as f:
for chunk in response.iter_bytes(chunk_size=64 * 1024):
f.write(chunk)
Это сочетается с подходом из урока про генераторы — данные не материализуются в памяти, идут потоком.
httpx async (awareness)
httpx.AsyncClient позволяет делать сотни параллельных запросов в одном event-loop. Глубоко в async лезть не будем (это Python 02), но базовая идея:
import httpx
import asyncio
async def fetch_all(urls: list[str]) -> list[dict]:
async with httpx.AsyncClient(timeout=30) as client:
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks)
return [r.json() for r in responses]
В DE это бывает нужно, когда у вас 10000 URL для скачивания и синхронный код будет идти часами. Но 99% задач отлично решаются синхронно с одним Session — не лезьте в async без необходимости.
requests vs httpx — сводная
| Аспект | requests | httpx |
|---|---|---|
| Возраст | 2011 | 2019 |
| Sync API | да | да (совместимый) |
| Async API | нет | да |
| HTTP/2 | нет | опционально |
| Default timeout | None (бесконечно) | 5 секунд |
| base_url у Session/Client | нет (есть хаки) | да |
| Type hints | минимальные | полные |
| Развивается | очень медленно | активно |
Для нового кода в курсе — везде httpx. Если попадёте на legacy с requests — увидите, что 95% знаний переносится без изменений.
Что мы получили
httpxдля новых проектов,requestsесли уже стоит. API совместим.params=,json=,data=,headers=— четыре разных слота, не путайте.- Всегда
timeout— без него запрос может висеть вечно. - Всегда
raise_for_status()после запроса, который не должен молча падать. - Всегда
Session/Clientпри больше чем одном запросе к API. - Skeleton DE-клиента — это класс с
httpx.Clientвнутри,__enter__/__exit__, методами, каждый делаетraise_for_status().
В следующем уроке — пагинация: как тянуть из API не одну страницу, а все.