Python requests post: отправка данных на сервер

POST-запрос нужен, когда клиент передает данные серверу: отправляет форму, создает запись через API, загружает файл. В Python для этого почти всегда используют библиотеку requests, и отправить простой запрос можно одной строкой. Сложности начинаются дальше: чем data отличается от json, почему запрос "висит" без таймаута, как повторять запрос после сбоя и не создать при этом дубль заказа. Все это разберем по порядку на рабочих примерах.
Установка и первый запрос
Библиотека не входит в стандартную поставку, ее ставят через pip в виртуальное окружение проекта:
pip install requests
Минимальный POST-запрос выглядит так:
import requests
response = requests.post(
"https://api.example.com/v1/notes",
json={"title": "Купить хлеб", "done": False},
timeout=10,
)
print(response.status_code) # -> 201
print(response.json()) # -> {'id': 42, 'title': 'Купить хлеб', 'done': False}
Функция requests.post() возвращает объект Response. Самые нужные его атрибуты: status_code (число), ok (True для кодов меньше 400), headers (заголовки ответа, регистронезависимый словарь), text (тело как строка), content (тело как байты) и метод json(). Общий обзор библиотеки, включая GET-запросы и параметры URL, есть в статье про модуль requests.
Параметры data и json: в чем разница
Это самый частый источник ошибок при работе с API. Оба параметра отправляют тело запроса, но в разном формате и с разным заголовком Content-Type.
| Параметр | Что передать | Формат тела | Content-Type |
|---|---|---|---|
data=dict | словарь | title=abc&done=0 | application/x-www-form-urlencoded |
data=str или bytes | готовое тело | как есть | не ставится, задайте сами |
json=obj | словарь, список, число | JSON-строка | application/json |
files=dict | файлы и поля | multipart | multipart/form-data; boundary=... |
HTML-форма на сайте отправляет данные как x-www-form-urlencoded, поэтому для имитации формы используйте data. Современные REST API почти всегда ждут JSON, для них используйте json.
payload = {"login": "anna", "tags": ["python", "web"]}
# форма: tags уйдет как tags=python&tags=web
r1 = requests.post(url, data=payload, timeout=10)
# JSON: {"login": "anna", "tags": ["python", "web"]}
r2 = requests.post(url, json=payload, timeout=10)
Типичная ошибка с вложенными данными
Если передать вложенный словарь в data, requests не превратит его в JSON. Сервер получит что-то вроде user=name&user=age, то есть только ключи вложенного словаря. Признак проблемы: API отвечает 400 или 415 (Unsupported Media Type), хотя данные "правильные". Решение: использовать json=.
Если по каким-то причинам JSON нужно сериализовать вручную (например, со своими настройками), передайте строку в data и выставьте заголовок сами:
import json
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
r = requests.post(url, data=body,
headers={"Content-Type": "application/json; charset=utf-8"},
timeout=10)
Параметры ensure_ascii и работа с кириллицей в JSON подробно описаны в статье о том, как записать и прочитать JSON-файл.
Заголовки запроса
Заголовки передают словарем в параметре headers. Чаще всего это токен авторизации, тип принимаемого ответа и идентификатор клиента.
headers = {
"Authorization": "Bearer <ваш_токен>",
"Accept": "application/json",
"User-Agent": "my-app/1.4",
}
r = requests.post(url, json=payload, headers=headers, timeout=10)
print(r.request.headers["Content-Type"]) # -> application/json
Несколько практических правил:
- Не выставляйте
Content-Typeвручную при использованииjson=иfiles=. Библиотека делает это сама, а для multipart еще и добавляетboundary, без которого сервер не разберет тело. - Токены не храните в коде. Читайте их из переменных окружения:
os.environ["API_TOKEN"]. - Для Basic-авторизации есть готовый параметр:
auth=("user", "password"). - Чтобы посмотреть, что реально ушло на сервер, используйте
r.request.headersиr.request.body.
Отправка файлов
Файлы отправляют через параметр files. Ключ словаря это имя поля формы, значение это открытый в бинарном режиме файл или кортеж с подробностями.
from pathlib import Path
path = Path("report.pdf")
with path.open("rb") as f:
r = requests.post(
"https://api.example.com/v1/upload",
files={"document": (path.name, f, "application/pdf")},
data={"comment": "отчет за квартал"}, # обычные поля формы рядом с файлом
timeout=(5, 60),
)
r.raise_for_status()
Формат кортежа: (имя_файла, файловый_объект_или_байты, content_type). Можно добавить четвертый элемент, словарь дополнительных заголовков части. Несколько файлов в одном поле передаются списком пар:
files = [
("images", ("a.png", open("a.png", "rb"), "image/png")),
("images", ("b.png", open("b.png", "rb"), "image/png")),
]
Файл всегда открывайте в режиме "rb". В текстовом режиме на Windows могут исказиться переводы строк, а размер тела не совпадет с реальным. Requests собирает multipart-тело в памяти целиком, поэтому для файлов в сотни мегабайт нужен потоковый вариант: data=f без multipart, если API это поддерживает, или пакет requests-toolbelt с его MultipartEncoder.
Статус-коды и разбор ответа
Requests не выбрасывает исключение на ответы 4xx и 5xx: для библиотеки ответ 500 тоже успешно полученный ответ. Проверку нужно делать явно.
| Код | Значение для POST | Что делать |
|---|---|---|
| 200 OK | запрос выполнен, в теле результат | читать r.json() |
| 201 Created | ресурс создан | взять id из тела или заголовок Location |
| 202 Accepted | принято в обработку | опрашивать статус задачи |
| 204 No Content | успех без тела | не вызывать r.json() |
| 400, 422 | ошибка в данных | показать текст ошибки, не повторять |
| 401, 403 | нет доступа | проверить токен, не повторять |
| 409 | конфликт, запись уже есть | обработать как бизнес-ошибку |
| 429 | слишком много запросов | подождать Retry-After и повторить |
| 500, 502, 503, 504 | сбой сервера или шлюза | повторить с паузой |
r = requests.post(url, json=payload, timeout=10)
if r.status_code == 204:
result = None
elif r.ok:
result = r.json()
else:
print(r.status_code, r.text[:500]) # сервер обычно объясняет ошибку в теле
r.raise_for_status() # HTTPError для 4xx и 5xx
Метод r.json() выбрасывает requests.exceptions.JSONDecodeError, если тело пустое или не является JSON. Так бывает, когда прокси вместо JSON отдает HTML-страницу с ошибкой 502. Перед разбором полезно проверить заголовок: r.headers.get("Content-Type", "").startswith("application/json").
Таймауты: обязательный параметр
По умолчанию у requests нет таймаута. Если сервер принял соединение и замолчал, программа будет ждать бесконечно. Поэтому timeout указывают в каждом запросе без исключений.
# одно число: и на соединение, и на ожидание каждого блока ответа
requests.post(url, json=payload, timeout=10)
# кортеж: (connect, read)
requests.post(url, json=payload, timeout=(3.05, 30))
Таймаут чтения это не время всего запроса, а максимальная пауза между байтами ответа. Медленный, но живой поток данных может длиться дольше 30 секунд. Таймаут соединения стоит делать небольшим (3-5 секунд): если сервер недоступен, лучше узнать это быстро. Для долгих операций на сервере (генерация отчета) увеличивайте таймаут чтения, а не соединения.
Обработка ошибок сети
Все исключения библиотеки наследуются от requests.exceptions.RequestException. Основные из них:
ConnectionError: не удалось установить соединение (DNS, отказ, обрыв).Timeoutи его подклассыConnectTimeout,ReadTimeout.HTTPError: выбрасывается методомraise_for_status().TooManyRedirects,InvalidURL,SSLError(подклассConnectionError).
import requests
from requests.exceptions import ConnectTimeout, ReadTimeout, ConnectionError, HTTPError, RequestException
def create_note(title):
try:
r = requests.post(URL, json={"title": title}, timeout=(3, 15))
r.raise_for_status()
return r.json()
except ConnectTimeout:
print("сервер не отвечает на подключение")
except ReadTimeout:
print("сервер подключился, но не прислал ответ вовремя")
except HTTPError as e:
print("ошибка HTTP", e.response.status_code, e.response.text[:200])
except ConnectionError:
print("сетевая ошибка")
except RequestException as e:
print("прочая ошибка requests:", e)
return None
Порядок блоков важен: более узкие исключения ставят выше. Особенно опасен ReadTimeout для POST: запрос мог дойти до сервера и выполниться, а ответ просто не успел вернуться. Слепой повтор в этом случае создаст вторую запись. Общие принципы построения блоков try описаны в материале про обработку исключений.
Сессии: общие заголовки и переиспользование соединений
requests.Session хранит заголовки, cookies и пул соединений между запросами. Если вы делаете больше одного запроса к одному хосту, сессия ускоряет работу (не нужно заново устанавливать TCP и TLS) и убирает дублирование кода.
import os
import requests
with requests.Session() as s:
s.headers.update({
"Authorization": f"Bearer {os.environ['API_TOKEN']}",
"Accept": "application/json",
})
for title in ["хлеб", "молоко", "сыр"]:
r = s.post("https://api.example.com/v1/notes", json={"title": title}, timeout=10)
r.raise_for_status()
Сессия также автоматически сохраняет cookies, которые сервер установил при логине через форму, и подставляет их в следующие запросы. Таймаут на уровне сессии задать нельзя, его по-прежнему указывают в каждом вызове.
Вход через форму и последующий POST
with requests.Session() as s:
s.post("https://example.com/login",
data={"username": "anna", "password": os.environ["APP_PASSWORD"]},
timeout=10).raise_for_status()
r = s.post("https://example.com/profile",
data={"city": "Казань"}, timeout=10)
print(r.status_code)
Повторные попытки (retry)
Встроенного параметра retries у requests.post нет, но к сессии можно подключить адаптер с политикой повторов из urllib3. Важная деталь: по умолчанию Retry не повторяет POST, потому что этот метод не идемпотентен. Его нужно явно добавить в allowed_methods.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry = Retry(
total=4,
backoff_factor=0.5, # паузы растут экспоненциально
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=frozenset(["GET", "POST"]),
respect_retry_after_header=True,
raise_on_status=False, # вернуть последний ответ, а не RetryError
)
session = requests.Session()
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
r = session.post(url, json=payload, timeout=(3, 15))
Повторять POST безопасно только если сервер умеет распознавать повтор. Многие платежные и учетные API принимают заголовок Idempotency-Key: клиент генерирует уникальный ключ (например, uuid.uuid4()) и отправляет его со всеми попытками одного и того же действия. Сервер, увидев знакомый ключ, вернет результат первого запроса вместо создания дубля.
import uuid
key = str(uuid.uuid4())
r = session.post(url, json=payload,
headers={"Idempotency-Key": key}, timeout=(3, 15))
Если API такого не поддерживает, ограничьте повторы ситуациями, когда запрос точно не дошел до сервера: ошибка соединения и коды 429, 503. Параметр Retry(connect=3, read=0, ...) разрешает повторы при сбое подключения и запрещает при таймауте чтения.
Готовый шаблон для работы с API
import os
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class ApiClient:
def __init__(self, base_url, token, timeout=(3, 20)):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.s = requests.Session()
self.s.headers.update({"Authorization": f"Bearer {token}",
"Accept": "application/json"})
retry = Retry(total=3, connect=3, read=0, backoff_factor=0.5,
status_forcelist=[429, 502, 503, 504],
allowed_methods=frozenset(["GET", "POST"]),
raise_on_status=False)
self.s.mount("https://", HTTPAdapter(max_retries=retry))
def post(self, path, payload):
r = self.s.post(f"{self.base_url}/{path.lstrip('/')}",
json=payload, timeout=self.timeout)
if r.status_code == 204:
return None
r.raise_for_status()
return r.json()
api = ApiClient("https://api.example.com/v1", os.environ["API_TOKEN"])
note = api.post("/notes", {"title": "позвонить"})
Если нужна асинхронность или HTTP/2, посмотрите на HTTP-клиент httpx: его API почти совпадает с requests, так что перенос кода занимает минуты.
Частые ошибки при отправке POST
Ниже собраны ошибки, которые чаще всего встречаются при разборе чужого кода, работающего с API. Каждая из них приводит либо к зависаниям, либо к трудноуловимым сбоям в продакшене.
- Нет таймаута. Скрипт работает неделями, а потом однажды зависает навсегда, потому что удаленный сервер принял соединение и перестал отвечать. Таймаут нужен в каждом вызове.
- Нет проверки статуса. Код вызывает
r.json()сразу после запроса, и при ответе 502 с HTML-страницей падает с непонятной ошибкой разбора JSON. Сначала проверьтеr.okили вызовитеraise_for_status(). - Ручной заголовок Content-Type для multipart. Заголовок без параметра
boundaryделает тело нечитаемым для сервера. При использованииfilesзаголовок должна выставлять библиотека. - Новое соединение на каждый запрос. Тысяча вызовов
requests.postподряд означает тысячу TLS-рукопожатий. Сессия сокращает время в разы. - Логирование секретов. Вывод
r.request.headersцеликом в лог раскрывает токен авторизации. Маскируйте заголовокAuthorizationперед записью. - Повтор POST без защиты от дублей. Автоматический retry при таймауте чтения может создать два заказа или два платежа. Используйте ключ идемпотентности или повторяйте только ошибки соединения.
Как отлаживать POST-запросы
Когда сервер отвечает ошибкой, первым делом нужно увидеть, что именно ушло по сети. Объект r.request хранит подготовленный запрос: метод, итоговый URL с параметрами, заголовки и тело. Сравните его с примером из документации API: чаще всего расходится тип содержимого, имя поля или формат даты.
Второй шаг: прочитать тело ответа с ошибкой. Хорошие API возвращают в нем описание проблемы, например список полей, не прошедших проверку. Выводите первые несколько сотен символов r.text, а не только код статуса.
Третий шаг: включить подробный журнал библиотеки urllib3 через модуль logging на уровне DEBUG. В журнале видны установка соединений, повторные попытки и редиректы. Это помогает понять, почему запрос выполняется дольше ожидаемого или почему сработал retry.
Наконец, если запрос из браузера работает, а из Python нет, сравните заголовки. Некоторые серверы требуют определенный User-Agent, заголовок Referer или CSRF-токен из cookies, который браузер передает автоматически, а скрипт нет. В этом случае сначала выполните GET-запрос страницы с формой в рамках сессии, извлеките токен и передайте его вместе с данными формы.
Частые вопросы
Почему кириллица уходит на сервер escape-последовательностями?
Параметр json= сериализует данные с ensure_ascii=True, поэтому русские буквы превращаются в escape-последовательности. Это корректный JSON, и любой нормальный сервер его разберет. Если API требует именно UTF-8 символы, сериализуйте вручную через json.dumps(..., ensure_ascii=False).encode("utf-8") и передайте в data.
Как добавить параметры в URL у POST-запроса?
Через параметр params, как в GET: requests.post(url, params={"lang": "ru"}, json=payload). Тело и строка запроса передаются независимо.
Что делать с ошибкой SSLError?
Обычно у сервера самоподписанный сертификат или в системе устарели корневые сертификаты. Правильное решение: указать путь к сертификату через verify="/path/ca.pem" или обновить пакет certifi. Параметр verify=False отключает проверку полностью, его допустимо использовать только для локальной отладки.
Почему после редиректа POST превратился в GET?
Так требует стандарт для кодов 301, 302 и 303, и браузеры ведут себя так же. Коды 307 и 308 сохраняют метод и тело. Если API отвечает 301 на POST, скорее всего, вы забыли слеш в конце URL или используете http вместо https.
Как протестировать код с requests без реального сервера?
Подмените сетевой слой: пакеты responses и requests-mock перехватывают вызовы и возвращают заданные ответы. В pytest это удобно оформить фикстурой.