Learning Platform
Урок 08.02 · 22 мин
Начальный
requestshttpxSessionTimeoutsHTTP clients
requests 2.34: HTTP for humans — полная документация httpx.AsyncClient: 100 параллельных запросов за время одного

Два инструмента, один интерфейс

В прошлом уроке мы разобрались, как устроен HTTP. Теперь — чем на нём говорить из Python. Стандартный stdlib-модуль urllib.request есть, но писать на нём в 2026 году никто не пишет — у него многословный API и неудобные ошибки. Все ходят в один из двух пакетов.

requests
— классика с 2011 года, ~50M скачиваний в месяц. Только синхронный, HTTP/1.1, никакого асинка. Развитие практически остановилось — это «закрытый» стабильный API.

httpx
— современная альтернатива (с 2019), от автора 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.elapsedtimedelta, сколько занял запрос.

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={...} — это
    application/x-www-form-urlencoded
    , как HTML-форма. Для REST API обычно НЕ нужен.

99% REST API хотят json=. Если вместо JSON ушёл form-encoded — API ответит 400 или странной ошибкой парсинга.

Куда едет каждый параметр в запросе

params в URL, json/data в теле, headers — отдельной секцией. Не путайте.

params={a:1}?a=1Query string в URL
URLhttps://.../issues?a=1
json={...}{"key":"val"}Body + Content-Type: application/json
BodyJSON payload
data={a:1}a=1&b=2Body как form-urlencoded
Bodyform-urlencoded
headers={...}Auth: BearerЗаголовки запроса
HeadersAuthorization, Accept, ...

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))
WARNING

Если 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.Session
, в httpx — httpx.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()

Что мы получили:

  1. Один TLS-handshake на всё. На 1000 запросов — экономия в десятки секунд.
  2. Общие headers не повторяются в каждом вызове. Авторизация задана раз.
  3. base_url убирает повторение домена.
  4. 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.

Session vs одиночные запросы

Один TLS-handshake против N. На большом числе запросов экономия — десятки секунд.

без SessionN запросов = N handshakesКаждый get открывает свой TCP/TLS, закрывает в конце
latencyN × (TCP + TLS + req)
с SessionN запросов = 1 handshakeОткрыли коннект один раз, переиспользуем для всех
latency(TCP + TLS) + N × req

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 — сводная

Аспектrequestshttpx
Возраст20112019
Sync APIдада (совместимый)
Async APIнетда
HTTP/2нетопционально
Default timeoutNone (бесконечно)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 не одну страницу, а все.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7