Попробовать API
</>
5 правил API-клиента, который не сломается в проде
api-dizaynПроблема
14 мин

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, без альтернатив. Опциональная идемпотентность — это идемпотентность только для дисциплинированных, остальные забывают, и через год у них неконтролируемый поток дублей в БД.

python
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 отдаёт структурированную ошибку:

json
{
    "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_EXCEEDEDretry_after_seconds и limit, для RESOURCE_NOT_FOUNDresource_type и resource_id. request_id — по нему поддержка за секунды поднимает полную трассировку; в BOTIX он есть во всех ответах, успешных и ошибочных, в теле и в заголовке X-Request-ID. Парсер пишется один раз:

python
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,
    )

Использование:

python
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 записей» клиент говорит «дай записи после такого-то значения», где значение — непрозрачный курсор из предыдущего ответа (клиент относится к нему как к чёрному ящику). Внутри обычно закодированная пара (значение_сортировки, идентификатор). Сервер декодирует и строит запрос:

sql
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 не уникален — иначе две записи с одинаковым временем разъехались бы по стыку страниц. Клиентский обход:

python
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-соединение не закрыто, данные не идут), запрос висит часами.

python
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.

python
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.