Перейти к основному содержимому

HTTP и requests

Почти любой сервис в интернете - это HTTP-сервер, который отвечает на запросы, и почти любая программа рано или поздно обращается к чужому API
requests - самая распространённая библиотека для этого в Python: она превращает HTTP-запрос в пару строк кода вместо ручной сборки сокетов и заголовков

Теория​

HTTP в двух словах​

Запрос состоит из метода (GET - получить, POST - создать, PUT/PATCH - изменить, DELETE

  • удалить), адреса и, если нужно, тела с данными
    Ответ - это код статуса и тело:

  • 2xx - успех (200 OK, 201 Created)

  • 4xx - ошибка на стороне клиента (404 Not Found, 401 Unauthorized)

  • 5xx - ошибка на стороне сервера (500 Internal Server Error)

Тело чаще всего - JSON, поэтому requests умеет разбирать его в словарь или список одним вызовом

requests - базовый клиент​

import requests

response = requests.get("https://api.example.com/users/1", timeout=5)
print(response.status_code) # 200
data = response.json() # словарь, разобранный из JSON-тела ответа
print(data["name"])

Для запроса с параметрами и для POST с телом:

response = requests.get(
"https://api.example.com/users",
params={"role": "admin"}, # ?role=admin в URL
timeout=5,
)

response = requests.post(
"https://api.example.com/users",
json={"name": "Alex", "role": "admin"}, # тело запроса как JSON
timeout=5,
)

У объекта Response из обоих случаев одинаковый набор полей: .status_code, .headers, .text (сырой текст ответа) и .json() (тот же текст, разобранный как JSON)

Проверка ответа - raise_for_status​

requests не поднимает исключение сам по себе, если сервер ответил 404 или 500 - код статуса нужно проверять явно, либо позвать response.raise_for_status(), который поднимет requests.exceptions.HTTPError, если статус 4xx/5xx:

response = requests.get("https://api.example.com/users/999", timeout=5)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
print(f"сервер ответил ошибкой: {e}")

Это тот же try/except, что и в статье про исключения - разница только в том, что исключение поднимает не ваш код, а библиотека, разобрав код статуса за вас

Таймауты обязательны​

Без timeout= requests будет ждать ответа сколько угодно - если сервер завис или "потерял" соединение, программа зависнет вместе с ним
Поэтому timeout= должен быть у каждого вызова requests.get/.post/... - ruff в этом курсе даже проверяет это отдельным правилом S113 (flake8-bandit), которое прямо так и называется: "Probable use of requests call without timeout"

Сетевые сбои приходят как отдельные исключения:

  • requests.exceptions.Timeout - сервер не ответил за отведённое время (частные случаи - ConnectTimeout, если не удалось даже установить соединение, и ReadTimeout, если соединение установилось, а ответ не пришёл)
  • requests.exceptions.ConnectionError - не удалось подключиться вообще (сервер недоступен, DNS не резолвится, порт закрыт)
  • requests.exceptions.HTTPError - соединение и ответ есть, но статус 4xx/5xx (только после raise_for_status())

Если конкретный вызов может упасть по сетевой причине, а не по логической (сервис правда бывает недоступен на секунду-две), для повтора не нужно писать цикл руками - декоратор retry из статьи про декораторы ровно для этого и был написан

Разбор HTML - BeautifulSoup​

Когда у сервиса нет JSON-API и есть только HTML-страница, распарсить её напрямую регулярными выражениями - плохая идея, потому что HTML не такой регулярный, каким кажется на первый взгляд
BeautifulSoup строит из HTML дерево тегов, по которому можно искать через CSS-подобные селекторы:

from bs4 import BeautifulSoup

html = response.text
soup = BeautifulSoup(html, "html.parser")

for item in soup.select("li.book"):
title = item.select_one(".title").get_text()
print(title)

.select(...) возвращает список совпадений, .select_one(...) - первое совпадение или None, .get_text() достаёт текст из тега без разметки
Прежде чем парсить чужую страницу, стоит свериться с robots.txt сайта и условиями использования - не все сайты разрешают автоматический сбор данных

Примеры​

Ниже - маленький клиент к сервису каталога книг: запрашивает книгу по ISBN и оборачивает сетевые и логические ошибки в свою иерархию исключений, ту же идею, что и в статье про исключения, только источник ошибки теперь - не локальная функция, а чужой сервис:

import requests


class BookLookupError(Exception):
"""Базовая ошибка похода в сервис каталога."""


class BookNotFoundError(BookLookupError):
"""Такого ISBN в каталоге нет."""


def fetch_book(isbn: str) -> dict:
url = f"https://catalog.example.com/books/{isbn}"
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
if response.status_code == 404:
raise BookNotFoundError(f"книга {isbn} не найдена") from e
raise BookLookupError(f"каталог ответил ошибкой: {e}") from e
except requests.exceptions.Timeout as e:
raise BookLookupError("каталог не ответил вовремя") from e
except requests.exceptions.ConnectionError as e:
raise BookLookupError("не удалось подключиться к каталогу") from e
return response.json()

Вызывающий код ловит именно то, что ему нужно, не заботясь о том, была ли причина сетевой или логической:

try:
book = fetch_book("9780140449136")
print(book["title"])
except BookNotFoundError:
print("такой книги нет в каталоге")
except BookLookupError as e:
print(f"каталог сейчас недоступен: {e}")

Если у каталога вместо JSON-API есть только HTML-страница со списком книг, тот же список можно получить парсингом:

from bs4 import BeautifulSoup

response = requests.get("https://catalog.example.com/search?q=odyssey", timeout=5)
soup = BeautifulSoup(response.text, "html.parser")

results = [
{
"title": item.select_one(".title").get_text(),
"isbn": item.select_one(".isbn").get_text(),
}
for item in soup.select("li.book")
]

Ровно эта связка - HTTP-запрос, разбор ответа, своя иерархия исключений на сетевые и логические ошибки - и есть то, что нужно для автогрейдер-задачи этой недели поверх модели курсов и студентов
Разница лишь в том, что там источник данных - не каталог книг, а реальный сервис, к которому обращается ваш Registrar

Проверь себя​

1. Что случится, если вызвать requests.get(url) без timeout=, а сервер завис и не отвечает?

Вызов будет ждать ответа неограниченно долго - программа зависнет на этом месте
Поэтому timeout= нужен у каждого сетевого вызова, а ruff в этом курсе проверяет это правилом S113

2. В чём разница между response.text и response.json()?

.text - сырое тело ответа как строка, без какой-либо обработки
.json() - та же строка, разобранная как JSON, в словарь или список - упадёт с ошибкой, если тело не является валидным JSON

3. requests.get на несуществующий адрес (404 Not Found) сам поднимет исключение?

Нет
requests не считает 4xx/5xx ошибкой сам по себе - нужно явно вызвать response.raise_for_status(), который и поднимет HTTPError, если статус неуспешный

4. Чем requests.exceptions.ConnectionError отличается от requests.exceptions.Timeout?

ConnectionError - не получилось подключиться вообще (сервер недоступен, DNS не резолвится)
Timeout - подключение прошло (или ещё идёт), но ответ не пришёл за отведённое время

5. Зачем в примере с каталогом книг две ошибки - BookLookupError и BookNotFoundError - а не одна?

Вызывающему коду обычно нужно по-разному реагировать на "книги нет" (ожидаемый случай, можно показать пользователю) и на "каталог сейчас недоступен" (сбой, стоит попробовать позже)
BookNotFoundError наследуется от BookLookupError, так что код, которому не важна разница, может ловить один только базовый класс

6. Почему raise BookNotFoundError(...) from e лучше, чем просто raise BookNotFoundError(...)?

from e явно сохраняет исходное исключение как __cause__ - в трейсбеке будет видно, что BookNotFoundError появился из-за конкретного HTTPError, а не взялся ниоткуда
Без from e Python всё равно покажет исходное исключение как контекст, но неявно - явная связь читается прямее

7. Когда стоит парсить HTML через BeautifulSoup, а не через re?

Почти всегда, если источник - HTML, а не простой текст с регулярной структурой
HTML допускает вложенность, разное форматирование одного и того же смысла и множество исключений, которые регулярное выражение не учитывает - BeautifulSoup строит дерево тегов и даёт искать по нему предсказуемо, а не гадать шаблоном

8. Если сетевой вызов иногда падает по временной причине (сервис моргнул на секунду), а не по логической, что стоит сделать до похода в except?

Обернуть вызов декоратором retry (см. статью про декораторы) - он повторит попытку несколько раз перед тем, как ошибка дойдёт до вызывающего кода
Это не отменяет try/except - retry спасает от временных сбоев, а обработка исключений всё равно нужна на случай, если сбой не временный

Итог / чек-лист​

  • HTTP-запрос - метод + адрес + (опционально) тело, ответ - код статуса + тело
  • requests.get/.post/... с params=/json=, ответ - .status_code, .text, .json()
  • raise_for_status() превращает код статуса 4xx/5xx в requests.exceptions.HTTPError
  • timeout= обязателен у каждого вызова - без него запрос может зависнуть навсегда
    • Timeout - не дождались ответа
    • ConnectionError - не подключились вообще
  • BeautifulSoup - для разбора HTML, когда JSON-API нет
  • свою иерархию исключений можно и нужно накладывать поверх сетевых ошибок, а не пробрасывать requests.exceptions.* наружу как есть

Дополнительно​