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.HTTPErrortimeout=обязателен у каждого вызова - без него запрос может зависнуть навсегдаTimeout- не дождались ответаConnectionError- не подключилис ь вообще
BeautifulSoup- для разбора HTML, когда JSON-API нет- свою иерархию исключений можно и нужно накладывать поверх сетевых ошибок, а не пробрасывать
requests.exceptions.*наружу как есть