5 правил API-клиента, который не сломается в проде
TL;DR: любой клиент к чужому API рано или поздно ловит таймаут, 500-ку и сбой пагинации. Сеть — среда, где пакеты теряются, балансировщики перезагружаются, апстримы отдают 502, — и каждое событие не if, а when. Пять правил закрывают пять классов сбоев: `Idempotency-Key` на POST, разбор стандартного error response, cursor вместо offset, таймауты и корректный retry. Каждое — с кодом на Python и на реальных эндпоинтах `api.botix.pro/public/v1/*`.
Коротко
- Идемпотентность — на сервере, ключ — от клиента. Retry без
Idempotency-Key= генератор дублей в проде. UUIDv4 на логическую операцию, тот же ключ на все retry-попытки; сервер кеширует ответ на 24 часа. - Контракт ошибок — такой же контракт, как успешный ответ. Ветвим логику по машиночитаемому
code, а не по текстуmessage;request_idкладём в логи,details— в UI. 16 кодов зафиксированы публично. - Cursor вместо offset, таймаут на каждый вызов, retry только на нужные коды.
offsetумирает на миллионных коллекциях и ловит race condition;timeout=(5, 30)спасает от вечного hang; ретраим 502/503/504/429 (поRetry-After) с jitter, но не 4xx.
Большинство интеграций пишутся за день. Работают на тестовом запросе, работают первую неделю в проде — потом ломаются. Пять правил — минимальный набор, без которого клиент живёт до первого инцидента. Примеры на Python с requests, логика идентична для PHP, Node, Go. Эндпоинты — реальные api.botix.pro/public/v1/*.
Правило 1. Idempotency-Key на каждый POST
Сценарий воспроизводится в любой интеграции с платёжным шлюзом или API доставки сообщений. Клиент отправил POST, в сети сбой — TCP-сброс, таймаут балансировщика, обрыв на стороне провайдера. Ответа не пришло, но запрос дошёл до сервера и был успешно обработан. Клиент, не зная этого, делает retry. На сервере — второй заказ, второе списание, второе исходящее сообщение. Дубль в проде.
Идемпотентность — свойство операции, при котором повторное выполнение с теми же входными данными даёт тот же результат без побочных эффектов. Для GET/PUT/DELETE это встроено в семантику HTTP (теоретически). Для POST — нет: POST по определению создаёт ресурс, и без защиты повторный вызов создаст второй. Индустриальный стандарт — заголовок Idempotency-Key с уникальным UUID.
Механизм на сервере: клиент перед POST генерирует UUID (назовём его K), кладёт его в заголовок и, если нужен retry, повторяет тот же запрос с тем же K. Сервер при первом запросе с ключом K выполняет операцию и сохраняет ответ на 24 часа (в BOTIX — Redis, ключ вида papi:idem:{apiKeyId}:{key}). При retry с тем же K — возвращает кешированный ответ без повторного выполнения и добавляет заголовок Idempotent-Replayed: 1. В BOTIX заголовок обязателен на мутирующих эндпоинтах: создание контакта, отправка сообщения, импорт каталога, создание сценария. Без него — 400 IDEMPOTENCY_KEY_REQUIRED, без альтернатив. Опциональная идемпотентность — это идемпотентность только для дисциплинированных, остальные забывают, и через год у них неконтролируемый поток дублей в БД.
import uuid
import requests
def create_contact(phone: str, name: str) -> dict:
# UUID на каждую логическую операцию, retry использует тот же
idempotency_key = str(uuid.uuid4())
response = requests.post(
"https://api.botix.pro/public/v1/contacts",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
json={"phone": phone, "name": name},
timeout=(5, 30),
)
response.raise_for_status()
return response.json()Idempotency-Key привязан к логической операции, не к одному HTTP-вызову. Цикл отправки 100 контактов — каждому свой UUID. Retry одного при таймауте — UUID тот же. Новый UUID на каждую попытку = потеря идемпотентности, и мы возвращаемся к исходной проблеме дублей. Что не работает в качестве ключа: auto-increment ID (не уникален между процессами и средами), timestamp (два одновременных процесса получат один и тот же), хеш от тела (совпадёт для двух разных логических операций с одинаковыми параметрами — два повторных списания одной суммы это две операции, не одна). Только UUIDv4 или ULID.
Три детали серверной реализации, которые часто упускают. Первое — что именно кешируется: не только тело, а полная репрезентация ответа (status code + headers + body), иначе при первом 409 Conflict retry вернёт из кеша 200 OK. Второе — конфликт ключа: если один K случайно ушёл на две разные операции, сервер сравнивает хеш входного запроса с сохранённым и на расхождении отдаёт 409 IDEMPOTENCY_KEY_REUSE_CONFLICT. Третье — состояние «в процессе»: если retry с тем же K пришёл, пока первая попытка ещё выполняется, сервер атомарно захватывает ключ и на параллельный retry отвечает 409 IDEMPOTENCY_KEY_IN_PROGRESS с Retry-After: 5 — клиент подождёт, и ответ придёт из кеша. Всё это — часть контракта, разобранного в production-ready POST: idempotency, retry, rate-limit.
Правило 2. Парсинг стандартного error response
500 с пустым телом — это несколько часов дебага вслепую: что произошло, какое поле невалидно, на чьей стороне проблема, имеет ли смысл retry — ничего не понятно. Форма 400 с телом {"error": "validation failed"} чуть лучше, но всё равно не отвечает «какое поле, какое значение, что менять». Контракт ошибок — часть публичного API, продуманная так же тщательно, как контракт успешных ответов. Зрелый API отдаёт структурированную ошибку:
{
"code": "VALIDATION_ERROR",
"message": "One or more fields failed validation",
"details": {
"fields": {
"email": "must be a valid email address",
"phone": "must start with +"
}
},
"request_id": "req_01HXY8Z2K5VQGN3PMAB7DEFGHJ"
}Разбор по полям. code — машиночитаемый идентификатор, на который клиент ветвит логику (if error.code == "CONTACT_LIMIT_EXCEEDED"); полагаться на message нельзя — текст правят, и всё сыплется. message — человекочитаемое сервисное описание для логов, локализовать не нужно (это задача клиентского приложения, не API). details — контекст, схема которого зависит от code: для VALIDATION_ERROR — карта fields, для RATE_LIMIT_EXCEEDED — retry_after_seconds и limit, для RESOURCE_NOT_FOUND — resource_type и resource_id. request_id — по нему поддержка за секунды поднимает полную трассировку; в BOTIX он есть во всех ответах, успешных и ошибочных, в теле и в заголовке X-Request-ID. Парсер пишется один раз:
class ApiError(Exception):
def __init__(self, code, message, details, request_id, status):
self.code = code
self.message = message
self.details = details or {}
self.request_id = request_id
self.status = status
super().__init__(f"{code}: {message}")
def call(method, url, **kwargs):
response = requests.request(method, url, **kwargs)
if response.status_code < 400:
return response.json()
body = response.json()
raise ApiError(
code=body.get("code", "UNKNOWN"),
message=body.get("message", ""),
details=body.get("details"),
request_id=body.get("request_id"),
status=response.status_code,
)Использование:
try:
contact = call("POST", url, json=payload, headers=headers)
except ApiError as e:
if e.code == "VALIDATION_ERROR":
# показать пользователю e.details['fields']
...
elif e.code == "CONTACT_LIMIT_EXCEEDED":
# лимит тарифа исчерпан
...
elif e.status >= 500:
# серверная ошибка, e.request_id в поддержку
...Чего в error response быть не должно: стектрейсов и имён внутренних классов (утечка деталей реализации и вектор атаки), SQL-запросов и имён колонок БД (column users.password_hash does not exist — катастрофа с точки зрения безопасности), расплывчатых Something went wrong (бесполезно — лучше INTERNAL_ERROR + request_id), плавающего формата ({"error"} / {"message"} / {"errors":[...]} на разных эндпоинтах заставляют писать три парсера вместо одного). Отдельный антипаттерн — 200 OK с полем error в теле: requests, axios, Guzzle трактуют 200 как успех и не зовут error-handler. HTTP-статус должен соответствовать результату: 4xx — ошибка клиента, 5xx — сервера, 2xx — успех. Различие 400 и 422: 400 — запрос не понятен серверу, 422 — понятен, но семантически невыполним.
В BOTIX зафиксированы 16 кодов на developers.botix.pro/error-codes. Список — часть контракта: удаление или переименование = breaking change. Это позволяет писать exhaustive switch без страха получить новый код в проде. Полный разбор контракта с cursor и rate-limit заголовками — в контракте ошибок, cursor и rate-limit headers.
Правило 3. Cursor вместо offset на больших коллекциях
?limit=20&offset=100 выглядит просто — и поддерживается ORM из коробки. Пока коллекция не перевалит за миллион записей. SQL SELECT * FROM contacts WHERE project_id = ? ORDER BY created_at DESC LIMIT 20 OFFSET 1000000 означает «отсортируй миллион, отбрось их и верни следующие 20» — даже с правильным индексом по (project_id, created_at) БД проходит миллион строк, чтобы их пропустить. На 10 млн записей запрос отваливается по таймауту, съедает память или блокирует читающие реплики. Второй провал — race condition: между первой и второй страницей в начало добавились новые записи; часть старых попадёт на стыке страниц дважды, часть пропадёт совсем.
Cursor решает обе проблемы. Вместо «пропусти N записей» клиент говорит «дай записи после такого-то значения», где значение — непрозрачный курсор из предыдущего ответа (клиент относится к нему как к чёрному ящику). Внутри обычно закодированная пара (значение_сортировки, идентификатор). Сервер декодирует и строит запрос:
SELECT * FROM contacts
WHERE project_id = ?
AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20Критичны две вещи. Индекс по (project_id, created_at, id) делает выборку O(log N) независимо от страницы — сервер не «пропускает» записи, а сразу прыгает в нужную точку индекса. Пара (created_at, id) гарантирует уникальность позиции, даже когда created_at не уникален — иначе две записи с одинаковым временем разъехались бы по стыку страниц. Клиентский обход:
def fetch_all_contacts():
cursor = None
while True:
params = {"cursor_limit": 100}
if cursor:
params["cursor"] = cursor
response = call("GET", "https://api.botix.pro/public/v1/contacts",
params=params, headers=headers)
for contact in response["data"]:
yield contact
if not response["has_more"]:
return
cursor = response["next_cursor"]В BOTIX все list-эндпоинты с 1.1.0 отдают data + next_cursor + has_more. cursor_limit — размер страницы (default 20, max 200). Фильтры применяются до курсора: меняете фильтры посреди обхода — курсор от предыдущей выборки становится невалидным (он относился к другому множеству), сервер обязан поймать это и вернуть 400 INVALID_CURSOR, а не выдать произвольный кусок. Про race condition при cursor: новые записи с более свежим created_at имеют меньший курсор-приоритет и просто не попадут в текущий обход — клиент получает предсказуемый снимок коллекции на момент старта; удаление записи с позиции курсора тоже не ломает следующий запрос.
Минусы перечислю честно. Нельзя прыгнуть на «страницу 47» — только последовательный обход (для UI-листингов с номерами страниц это проблема; решение — «бесконечная лента» или offset для UI с пониманием его границ). Нельзя дёшево показать «всего 12 354 записи» — COUNT(*) на больших таблицах сам тяжёлый. Реализация на сервере сложнее: курсор надо кодировать (base64 от JSON), валидировать и подписывать HMAC, чтобы клиент не подменил его и не дотянулся до чужих данных. В BOTIX так и вышло: версия 1.0.0 использовала offset, после стресс-теста на миллионе контактов стало ясно, что таймауты неизбежны, и 1.1.0 перешла на cursor — это был breaking change, поэтому миграционный гайд 1.0 → 1.1 включает раздел «было/стало» по пагинации. Урок: закладывайте cursor сразу, чтобы не платить за миграцию потом. Как это связывается с версионированием и совместимостью эндпоинтов — в версионировании публичного API без breaking change.
Правило 4. Таймауты на каждом вызове
requests.get(url) без таймаута — потенциально вечный hang. Апстрим лёг в zombie-режиме (TCP-соединение не закрыто, данные не идут), запрос висит часами.
response = requests.post(url, json=payload, headers=headers,
timeout=(5, 30))connect=5 — максимум на TCP handshake. read=30 — максимум между двумя пакетами в ответе. Не выставлять таймаут — доверять всему стеку между вами и API. Любое звено может зависнуть. Таймаут — единственная защита.
Правило 5. Retry только на правильные коды
Самая частая ошибка — ретраить всё подряд. 401 — ретрай бесполезен (ключ невалиден). 422 — бесполезен (запрос семантически некорректен). 429 — ретрай немедленно даст второй 429, потом третий, лавину. Что ретраить: connect/read timeout (сетевая проблема), 502/503/504 (апстрим временно недоступен), 429 — но через Retry-After из заголовка. Что не ретраить: 400, 401, 403, 404, 422.
import time
import random
def call_with_retry(method, url, max_retries=5, **kwargs):
for attempt in range(max_retries):
try:
response = requests.request(method, url, **kwargs)
except (requests.ConnectionError, requests.Timeout):
wait = (2 ** attempt) + random.uniform(0, 1)
time.sleep(min(wait, 30))
continue
if response.status_code == 429:
wait = int(response.headers.get("Retry-After", 5))
time.sleep(wait + random.uniform(0, wait * 0.3))
continue
if response.status_code in (502, 503, 504):
wait = (2 ** attempt) + random.uniform(0, 1)
time.sleep(min(wait, 30))
continue
return response
raise RuntimeError(f"Retries exhausted for {method} {url}")jitter критичен при параллельных воркерах с одним ключом. Без jitter все воркеры разом получают 429, разом просыпаются после Retry-After и разом дают залп — thundering herd, который убивает rate-limiter. В BOTIX все три официальных SDK (composer require botix-pro/sdk, pip install botix, npm install @botix/sdk) делают это автоматически, а заодно автогенерируют Idempotency-Key из Правила 1 через uuid4 на каждый мутирующий метод — используя SDK, вы получаете идемпотентность и корректный retry бесплатно.
Что ещё стоит знать
X-RateLimit-*в каждом ответе. BOTIX отдаётX-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset; на 429 — дополнительноRetry-After. ЕслиX-RateLimit-Remaining < 5— проактивно тормозите доX-RateLimit-Reset, не доводя до 429.request_idв свои логи. Каждый ответ содержитrequest_idв JSON и заголовокX-Request-ID. Поддержка по нему за секунды поднимет трассировку.- Idempotency-Key живёт 24 часа. Для новой попытки запроса в новый календарный день используйте новый ключ — старый уже мог вытесниться из кеша.
- Один SDK-инстанс на процесс, не на запрос. Инициализация клиента — парсинг конфига и подготовка connection pool; на каждый запрос это бессмысленная нагрузка.
Пять правил закрывают пять классов сбоев: идемпотентность — от дублей при сетевых сбоях, парсер ошибок — от дебага вслепую, cursor — от таймаутов на больших коллекциях, таймауты — от вечного hang, корректный retry — от лавины ретраев в адрес уже легшего сервера. Ни одно не «улучшение надёжности на потом»: каждое закрывает сбой, который в реальной сети — не if, а when.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Production-ready POST: idempotency, retry и rate-limit
«Мелочи» публичного API: ошибки, cursor, rate-limit
Cursor-пагинация, polling vs webhook, audit-логи: три паттерна чтения из публичного API
Как надёжно доставлять webhook: подпись, retry, симулятор
