Стили API: REST, GraphQL, gRPC, вебхуки и версионирование
API — это единственная часть системы, которую вы не можете отрефакторить в одиночку. Внутренний код принадлежит вам: переименовали класс, прогнали тесты, поехали. Опубликованный контракт принадлежит всем, кто его вызывает, и стоимость его изменения растёт вместе с числом клиентов — линейно по количеству интеграций и почти неограниченно по времени, если среди клиентов есть мобильные приложения, которые пользователь обновит когда захочет (или никогда).
Отсюда два практических следствия, которые задают структуру этой статьи. Первое: выбор стиля API — это выбор формы связанности между вашей системой и её потребителями, и менять его потом дорого. Второе: умение эволюционировать контракт важнее умения спроектировать его идеально с первого раза, потому что идеально не получится ни у кого.
Предыдущие статьи трека дали контекст: микросервисы объяснили, почему сетевые контракты вообще появляются, а событийная архитектура — как выглядит асинхронная альтернатива вызову. Здесь мы разбираем синхронную границу и push-уведомления наружу.
1. Оси выбора: чем стили реально отличаются
Спор «REST против gRPC против GraphQL» почти всегда бесплоден, потому что стили сравнивают по популярности, а не по свойствам. Полезнее разложить их по четырём независимым осям.
Ось 1. Кто инициирует. Запрос-ответ (клиент дёргает сервер) или push (сервер уведомляет клиента). Вебхуки, SSE и WebSocket живут на второй половине оси, REST/gRPC/GraphQL — на первой.
Ось 2. Что является единицей контракта. Ресурс (существительное: «заказ», «пользователь»), процедура (глагол: «перевести деньги»), или граф с выборкой (клиент сам описывает нужную форму). Это самое глубокое различие, и оно определяет, куда попадёт бизнес-логика.
Ось 3. Кто владеет формой ответа. Сервер (REST, gRPC) или клиент (GraphQL, OData,
частично — ?fields= в REST). Владение формой на клиенте убирает лишние раунд-трипы, но
переносит на сервер задачу произвольных запросов, включая их стоимость и защиту.
Ось 4. Насколько строг контракт. От «JSON как получится» через OpenAPI-описание к
кодогенерации из .proto, где несовместимая схема не соберётся. Строгость — это не всегда
хорошо: жёсткая схема отлично работает внутри организации и плохо — на публичной границе,
где нельзя заставить всех перегенерировать клиент.
или чужая система?"} C -->|"Наш фронтенд, поток обновлений"| D["SSE или WebSocket"] C -->|"Чужая система"| E["Вебхуки + polling-фолбэк"] B -->|"Клиент запрашивает"| F{"Кто по ту сторону?"} F -->|"Публичные/партнёрские
интеграции"| G["REST + OpenAPI
JSON, кэшируемость, низкий порог входа"] F -->|"Наши сервисы
между собой"| H{"Профиль трафика?"} H -->|"Много мелких вызовов,
важна латентность"| I["gRPC + protobuf
кодогенерация, HTTP/2, стриминг"] H -->|"Редкие вызовы,
важна отладка"| G F -->|"Много разных UI
с разными выборками"| J{"Есть команда, готовая
владеть графом?"} J -->|"Да"| K["GraphQL
клиент владеет формой"] J -->|"Нет"| L["BFF на REST
по эндпоинту на экран"]
Обратите внимание на нижнюю ветку: BFF (Backend for Frontend) — полноценная альтернатива GraphQL, а не запасной вариант. Если у вас два-три фронтенда и стабильные экраны, эндпоинт на экран решает ту же задачу за десятую часть операционной сложности.
2. REST: не «HTTP с JSON», а ресурсная модель
Термин ввёл Рой Филдинг в диссертации 2000 года, и описывал он не формат, а набор ограничений: клиент-сервер, отсутствие состояния сессии на сервере, кэшируемость, единообразный интерфейс, слоистая система. Практическая ценность здесь не в чистоте терминологии, а в том, что каждое ограничение покупает конкретное свойство.
Statelessness покупает масштабирование. Раз запрос самодостаточен, любой инстанс может его обслужить: балансировщик не обязан помнить, куда ходил клиент, а падение инстанса не теряет сессию. Всё, что вы кладёте в память процесса между запросами, — это отказ от этого свойства.
Единообразный интерфейс покупает инфраструктуру бесплатно. Если GET действительно
безопасен, а PUT действительно идемпотентен, то CDN, прокси, ретраи в клиентских библиотеках
и браузерный кэш работают корректно без вашего участия. Если вы делаете GET /orders/42/cancel,
вы отменяете заказ каждый раз, когда прокси решит префетчнуть ссылку.
Семантика методов: таблица, которую стоит помнить наизусть
| Метод | Безопасен | Идемпотентен | Кэшируем | Типичный ответ |
|---|---|---|---|---|
GET |
да | да | да | 200 + тело, 304 при валидном ETag |
HEAD |
да | да | да | 200 без тела |
PUT |
нет | да | нет | 200/204, 201 при создании |
DELETE |
нет | да | нет | 204, повторный — тоже 204 или 404 |
POST |
нет | нет | почти нет | 201 + Location, 202 для асинхронной обработки |
PATCH |
нет | зависит | нет | 200/204 |
Идемпотентность здесь — свойство эффекта на сервере, а не одинаковости ответа. DELETE
идемпотентен, потому что второе удаление не меняет состояние, даже если код ответа отличается.
Это ровно то свойство, на которое опираются ретраи: клиент, не получивший ответа, не знает,
дошёл ли запрос, и может повторить безопасно только идемпотентную операцию.
Модель зрелости Ричардсона: где остановиться
Леонард Ричардсон предложил четыре уровня:
- Уровень 0 — один URL, один метод
POST, всё различие в теле. Это RPC поверх HTTP; ничего постыдного, но и никаких бонусов от HTTP. - Уровень 1 — появились ресурсы:
/orders/42,/customers/7. - Уровень 2 — заработали глаголы HTTP и коды статусов. Здесь находится 95% промышленных API, и это осознанная точка остановки.
- Уровень 3 — HATEOAS: ответ содержит ссылки на доступные переходы.
Про уровень 3 стоит быть честным. Идея красива: клиент не хардкодит URL, а идёт по ссылкам, и
сервер свободно меняет адресацию. На практике это почти нигде не окупается, потому что клиенты
всё равно пишутся под конкретные переходы, а обход по ссылкам добавляет раунд-трипы. Но одна
часть идеи работает всегда и стоит копейки: отдавайте в ответе список доступных сейчас
действий. Не URL-навигацию ради навигации, а "allowed_actions": ["cancel", "refund"] — тогда
UI не дублирует у себя правила предметной области и не показывает кнопку «отменить» на уже
отгруженном заказе.
Действия, которые не ложатся на CRUD
Главная боль ресурсного стиля: бизнес полон глаголов. «Отменить заказ», «провести инвентаризацию», «пересчитать бонусы». Три рабочих приёма, в порядке предпочтения:
- Овеществить действие в ресурс. Отмена — это не мутация заказа, а сущность:
POST /orders/42/cancellationsс телом-причиной. Бонус: у вас появляется история отмен, идентификатор для идемпотентности и место для аудита. - Подресурс-состояние.
PUT /orders/42/statusс телом{"value": "cancelled"}— годится, когда переход тривиален и не имеет собственных атрибутов. - Явный «командный» эндпоинт.
POST /orders/42:cancel— так делает Google AIP-136 для custom methods. Прагматично; не стесняйтесь, если первые два варианта натягиваются на глобус.
Чего делать не надо — прятать команду в PATCH общего вида. PATCH /orders/42 с телом
{"status": "cancelled"} выглядит невинно, но означает, что вся логика переходов состояния
теперь размазана по проверкам «а что именно нам прислали», а клиент имеет формальное право
прислать любое поле.
Ошибки: RFC 9457 и почему свой формат — плохая идея
Каждая компания однажды изобретает свой конверт ошибки. Их уже стандартизировали — RFC 9457 Problem Details for HTTP APIs (заменил RFC 7807):
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Недостаточно средств",
"status": 409,
"detail": "На счёте 320.50 RUB, требуется 1200.00 RUB",
"instance": "/orders/42/payments/9",
"balance": "320.50",
"required": "1200.00"
}
Ключевое поле — type: это стабильный машиночитаемый идентификатор, на который клиент
может ветвиться. title и detail — для человека, их можно менять и локализовать.
Расширяющие поля (balance, required) добавляются свободно. Правило: код HTTP говорит
классу проблемы, type — конкретной причине, и никогда не кодируйте ошибку в теле при
200 OK — это ломает ретраи, метрики и все инструменты, которые смотрят на статус.
Пагинация: почему offset ломается на проде
Классический ?page=5&size=20 транслируется в OFFSET 80 LIMIT 20. Две беды. Первая —
сложность: чтобы отдать 20 строк со смещением N, база обязана прочитать и отбросить N строк,
то есть запрос стоит O(N + limit) и деградирует линейно по глубине. Пятитысячная страница
кладёт базу. Вторая — корректность: между запросами данные меняются, и вставка новой строки
сдвигает окно так, что элемент показывается дважды или пропускается.
Правильный ответ — keyset (cursor) пагинация: вместо «пропусти N» говорим «дай, что идёт после этого ключа».
-- Курсорная пагинация: O(log N + limit) при индексе (created_at DESC, id DESC).
-- Кортежное сравнение обязательно — иначе строки с одинаковым created_at теряются.
SELECT id, created_at, total
FROM orders
WHERE tenant_id = $1
AND (created_at, id) < ($2, $3) -- значения из курсора предыдущей страницы
ORDER BY created_at DESC, id DESC
LIMIT 21; -- на одну больше: есть ли следующая страница
Курсор отдавайте клиенту как непрозрачную строку (base64 от кортежа плюс версия формата и, если нужно, подпись). Непрозрачность — не косметика: она даёт право поменять внутреннюю схему сортировки, не ломая клиентов, и не позволяет подобрать чужой курсор.
Ограничение метода честное: произвольного перехода на страницу 137 не будет — только «вперёд/назад». Для бесконечных лент и синхронизации это ровно то, что нужно; для административной таблицы с нумерацией страниц придётся оставить offset и ограничить глубину.
Условные запросы и идемпотентность записи
Два механизма, которые в REST-API чаще всего забывают, а стоят они дёшево.
ETag + If-Match даёт оптимистическую блокировку. Клиент получает ETag: "v7", при
записи присылает If-Match: "v7", сервер отвечает 412 Precondition Failed, если версия
устарела. Это решает потерянное обновление без единой строчки в бизнес-логике клиента.
Idempotency-Key спасает POST. Клиент генерирует UUID, сервер запоминает пару
(ключ → результат) и при повторе возвращает сохранённый ответ вместо второго списания.
Так работает Stripe; детальный разбор
семантики есть в черновике IETF по Idempotency-Key.
# FastAPI: идемпотентный POST с постоянным хранением результата.
# Критично: запись ключа и бизнес-эффект должны попасть в ОДНУ транзакцию,
# иначе при падении между ними получим либо двойное списание, либо вечный 409.
from fastapi import APIRouter, Header, HTTPException, Response
from sqlalchemy.exc import IntegrityError
router = APIRouter()
@router.post("/payments", status_code=201)
async def create_payment(body: PaymentIn, response: Response,
idempotency_key: str | None = Header(default=None)):
if idempotency_key is None:
raise HTTPException(400, "Требуется заголовок Idempotency-Key")
async with db.begin() as tx: # одна транзакция на всё
saved = await tx.fetch_idempotent(idempotency_key)
if saved is not None:
# Повтор с тем же ключом, но другим телом — почти всегда баг клиента.
if saved.request_hash != body.stable_hash():
raise HTTPException(422, "Idempotency-Key переиспользован с другим телом")
response.status_code = saved.status
return saved.response_body # тот же ответ, эффект не повторён
try:
payment = await tx.charge(body) # собственно бизнес-эффект
await tx.store_idempotent(idempotency_key, body.stable_hash(),
status=201, response_body=payment.as_dict())
except IntegrityError:
# Гонка: параллельный запрос с тем же ключом уже вставил строку.
# 409 корректен — клиенту достаточно повторить и получить сохранённый ответ.
raise HTTPException(409, "Запрос с этим ключом уже выполняется")
return payment.as_dict()
Три детали, которые отличают рабочую реализацию от учебной: (1) хэш тела, чтобы поймать переиспользование ключа; (2) одна транзакция на эффект и запись ключа; (3) TTL на записи — хранить ключи вечно не нужно, 24–72 часа перекрывают любые разумные ретраи.
3. gRPC: контракт как код
gRPC разворачивает модель на 180°: единицей контракта становится процедура, схема
описывается в .proto, из неё генерируются клиент и сервер, а транспорт — HTTP/2 с бинарным
protobuf вместо JSON.
syntax = "proto3";
package orders.v1; // версия — в имени пакета, это важно
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
// Серверный стриминг: одна подписка — поток обновлений статуса.
rpc WatchOrder(WatchOrderRequest) returns (stream OrderEvent);
}
message Order {
string id = 1;
string customer_id = 2;
Money total = 3;
Status status = 4;
reserved 5; // номер удалённого поля НИКОГДА не переиспользуем
reserved "legacy_discount";
enum Status {
STATUS_UNSPECIFIED = 0; // нулевое значение всегда «не задано»
STATUS_PENDING = 1;
STATUS_PAID = 2;
STATUS_CANCELLED = 3;
}
}
Что даёт эта схема на практике:
- Дешёвая сериализация. Полевые номера вместо имён, varint-кодирование, отсутствие парсинга текста. На типичном сообщении это 3–10× по размеру относительно JSON и заметно меньше CPU — на трафике «сервис-сервис» в десятки тысяч RPS разница видна в счёте за железо.
- Мультиплексирование HTTP/2. Много одновременных вызовов в одном TCP-соединении без head-of-line blocking на уровне HTTP, без пула из сотни соединений.
- Четыре режима вызова: унарный, серверный стриминг, клиентский стриминг, двунаправленный. Стриминг здесь — не экзотика, а штатный способ отдать длинную выборку или подписку.
- Правила совместимости, встроенные в формат. Добавление поля с новым номером совместимо
в обе стороны; удаление поля требует
reserved; смена типа или номера — ломающее изменение. Это дисциплина, которой в JSON-API приходится добиваться ревью и договорённостями.
Дедлайны — главное, что стоит перенести из gRPC в любой API
В gRPC дедлайн — часть вызова и распространяется по цепочке: если у входящего запроса осталось 80 мс, вызов вниз получит не больше 80 мс. Это лечит классическую патологию распределённых систем, когда верхний уровень давно отдал таймаут, а сервисы под ним продолжают жечь ресурсы на никому не нужный ответ.
// Go-клиент gRPC: дедлайн наследуется из контекста входящего запроса.
func (h *Handler) Checkout(ctx context.Context, req *pb.CheckoutRequest) (*pb.CheckoutResponse, error) {
// Оставляем себе бюджет на сборку ответа: вниз отдаём не весь остаток.
ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
order, err := h.orders.GetOrder(ctx, &pb.GetOrderRequest{Id: req.OrderId})
if err != nil {
switch status.Code(err) {
case codes.NotFound: // ожидаемый бизнес-исход, не инцидент
return nil, status.Error(codes.NotFound, "заказ не найден")
case codes.DeadlineExceeded: // бюджет исчерпан — ретрай бессмысленен
return nil, status.Error(codes.DeadlineExceeded, "не успели за бюджет")
default:
return nil, err
}
}
return h.buildResponse(order), nil
}
Про ретраи: gRPC умеет их сам через service config — с экспоненциальной задержкой и, что важнее, с retry throttling, который глобально отключает повторы, когда доля ошибок превышает порог. Без такого предохранителя ретраи превращают деградацию в лавину; подробно это разбирается в статье про устойчивость.
Чем платят за gRPC
Честный список: браузер напрямую не умеет (нужен gRPC-Web и прокси); трафик не прочитать
глазами в tcpdump и не потрогать curl без grpcurl; балансировка сложнее, потому что L4
балансировщик распределяет соединения, а не запросы, и долгоживущее HTTP/2-соединение
прилипает к одному бэкенду (лечится клиентской балансировкой или service mesh); порог входа
для внешних интеграторов заметно выше JSON. Вывод простой и почти универсальный: gRPC внутрь,
REST наружу.
4. GraphQL: клиент владеет формой ответа
GraphQL (спецификация) решает конкретную боль: много разных клиентов с разными потребностями против одного набора эндпоинтов. Клиент присылает запрос, описывающий нужное дерево, сервер отдаёт ровно его.
type Query {
order(id: ID!): Order
}
type Order {
id: ID!
totalAmount: Money!
customer: Customer!
items: [OrderItem!]!
}
Ценность реальна: исчезает и over-fetching, и водопад запросов, а фронтенд перестаёт ждать бэкенд ради нового поля. Но у модели есть три структурные проблемы, и все три надо решать осознанно.
Проблема 1: N+1 запросов. Резолвер items вызывается для каждого заказа отдельно. Запрос
на 50 заказов даёт 51 обращение к базе. Лечение — DataLoader:
батчинг обращений в пределах одного тика event loop плюс кэш на время запроса.
// DataLoader: собирает вызовы одного тика в один батч-запрос.
// Порядок результата ДОЛЖЕН соответствовать порядку ключей — иначе данные разъедутся.
import DataLoader from "dataloader";
const makeItemsLoader = (db: Db) =>
new DataLoader<string, OrderItem[]>(async (orderIds) => {
const rows = await db.query(
`SELECT * FROM order_items WHERE order_id = ANY($1)`,
[orderIds as string[]],
);
const byOrder = new Map<string, OrderItem[]>();
for (const r of rows) {
const list = byOrder.get(r.order_id) ?? [];
list.push(r);
byOrder.set(r.order_id, list);
}
return orderIds.map((id) => byOrder.get(id) ?? []);
});
// Лоадер создаётся НА КАЖДЫЙ запрос, а не глобально:
// глобальный кэш переживёт мутацию и отдаст устаревшие данные другому пользователю.
export const buildContext = (db: Db, user: User) => ({
user,
loaders: { items: makeItemsLoader(db) },
});
Проблема 2: неограниченная стоимость запроса. Клиент может запросить дерево глубиной 15 с перекрёстными связями и положить базу одним HTTP-запросом. Рейт-лимит по числу запросов бесполезен — запросы неравноценны. Нужны: ограничение глубины, анализ стоимости (каждому полю назначается вес, бюджет списывается до выполнения) и в идеале persisted queries — режим, когда сервер принимает только заранее зарегистрированные запросы по хэшу. Последнее убирает почти весь класс проблем разом и дополнительно экономит трафик.
Проблема 3: HTTP-кэширование не работает. Всё идёт POST-ом на один URL, поэтому CDN и
браузер отдыхают. Кэш переезжает внутрь: на уровень сущностей в клиенте (Apollo, Relay) и на
уровень резолверов на сервере. Для read-heavy публичного API это серьёзный аргумент против:
REST с ETag и CDN обслужит такой трафик радикально дешевле.
Отдельно про федерацию: Apollo Federation
позволяет собрать один граф из подграфов разных команд. Это работающая технология и одновременно
организационное обязательство — общий граф требует владельца, иначе он превращается в свалку
несогласованных типов, где User означает три разные вещи.
5. Вебхуки: push наружу и его подводные камни
Вебхук — HTTP-callback: происходит событие, вы делаете POST на URL, который зарегистрировал
клиент. Модель кажется тривиальной ровно до первого прода. Разница между наивной и рабочей
реализацией — примерно вся эта глава.
что и бизнес-изменение Note over D,O: без этого возможно «изменили, но не отправили» W->>O: выбрать неотправленные (FOR UPDATE SKIP LOCKED) W->>C: POST /hook + подпись HMAC + Idempotency-Key C-->>W: 200 OK за 1,2 с W->>O: пометить доставленным W->>C: POST /hook (следующее событие) C-->>W: 500 Internal Server Error W->>W: backoff с джиттером: 10с → 1м → 10м → 1ч → 6ч W->>C: повтор C-->>W: таймаут (клиент лежит) W->>Q: после 8 попыток — в DLQ Q-->>C: письмо владельцу интеграции + ручной replay
Что здесь принципиально:
Транзакционный outbox. Событие пишется в БД в той же транзакции, что и изменение состояния, а отправкой занимается отдельный воркер. Иначе получаете dual-write: изменение зафиксировано, процесс упал до отправки, событие потеряно навсегда. Паттерн подробно разобран в статье про saga и outbox.
At-least-once — это данность, а не выбор. Клиент обязан быть готов к дублям: сеть теряет
ответы, воркер падает после доставки, но до отметки. Поэтому в каждом вебхуке отправляйте
стабильный event_id, а в документации прямым текстом требуйте дедупликации по нему.
Порядок не гарантирован. Ретраи и параллелизм переставляют события: order.updated может
прийти раньше order.created. Два лечения: (а) включать в payload версию/timestamp, чтобы
клиент отбросил устаревшее; (б) отправлять «тонкие» события — только идентификатор и тип, —
а клиент сам запрашивает актуальное состояние через REST. Второй вариант заодно решает вопрос
утечки данных в логи посредников.
Подпись обязательна. URL клиента публичен, значит на него может постучаться кто угодно.
# Проверка подписи вебхука на стороне получателя.
# Три обязательных элемента: HMAC от СЫРОГО тела, timestamp против replay,
# сравнение за постоянное время против timing-атак.
import hashlib, hmac, time
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str, secrets: list[str]) -> bool:
# Формат заголовка: "t=1771171200,v1=5257a8...,v1=9f2b..." — версий может быть
# несколько во время ротации ключа: старый и новый действуют одновременно.
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
ts = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > TOLERANCE_SECONDS:
return False # старый перехваченный запрос не пройдёт
signed_payload = f"{ts}.".encode() + raw_body
provided = [v for k, v in
(p.split("=", 1) for p in header.split(",")) if k == "v1"]
for secret in secrets: # перебор нужен только на время ротации
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected, p) for p in provided):
return True
return False
Тонкость, на которой ошибаются чаще всего: подписывать нужно сырое тело до парсинга JSON. Если фреймворк десериализовал и вы сериализуете обратно, порядок ключей и пробелы изменятся, и подпись развалится на ровном месте.
Дайте способ пережить простой. Клиент неизбежно полежит дольше вашего окна ретраев. Поэтому
рядом с вебхуками нужен REST-эндпоинт вида GET /events?since=<cursor> — тогда после починки
интеграция догоняет пропущенное сама, без вашего участия и без ручного replay из DLQ.
Чем ещё бывает push
| Механизм | Направление | Держит соединение | Когда уместно |
|---|---|---|---|
| Вебхуки | сервер → чужой сервер | нет | интеграции с внешними системами |
| SSE | сервер → браузер | да, однонаправленно | ленты, прогресс задач, уведомления |
| WebSocket | двунаправленно | да | чат, совместное редактирование, игры |
| Long polling | клиент инициирует | почти | фолбэк там, где остальное не проходит |
| Очередь (Kafka/SQS) | сервер → сервер | нет | внутренний обмен, свои сервисы |
Практическое правило: для браузера начинайте с SSE. Он работает поверх обычного HTTP,
переживает прокси, умеет автопереподключение с Last-Event-ID из коробки. WebSocket берите,
когда действительно нужен канал в обе стороны, — за него платят отдельным протоколом,
собственным heartbeat и невозможностью использовать HTTP-кэш и стандартную авторизацию.
6. Сравнение по свойствам
Сводка по типовым решениям, которую можно использовать как чек-лист:
- Публичный API для внешних разработчиков → REST + OpenAPI. Порог входа, документация,
curl, кэшируемость, отсутствие обязательной кодогенерации. - Обмен между своими сервисами, чувствительный к латентности → gRPC. Дедлайны, стриминг, схема, дешёвая сериализация.
- Много разнородных UI над общей моделью → GraphQL, если есть команда-владелец графа; иначе BFF.
- Уведомления во внешние системы → вебхуки + outbox + подпись + догоняющий эндпоинт.
- Внутренние асинхронные события → брокер, а не HTTP: см. событийную архитектуру.
И отдельно: эти стили сосуществуют. Нормальная зрелая система имеет gRPC внутри, REST на внешнем периметре, GraphQL или BFF для фронтенда и вебхуки для партнёров. Выбор делается на каждой границе отдельно, а не один раз на всю компанию.
7. Версионирование: центральная тема
Здесь ломается больше всего систем, поэтому начнём с определений, а не с приёмов.
Обратная совместимость (backward compatible) — новый сервер корректно обслуживает старого клиента. Прямая совместимость (forward compatible) — старый клиент не ломается о новый ответ. Прямая совместимость требует дисциплины от клиента: игнорировать неизвестные поля, не падать на новом значении enum, не валидировать ответ по closed-schema. Если ваши клиенты этого не делают, вы не сможете добавить ни одного поля — и это ограничение вашей системы, даже если формально виноват клиент.
Что не ломает совместимость: добавление опционального поля в ответ; добавление
опционального параметра запроса со значением по умолчанию; добавление нового эндпоинта;
добавление нового значения enum — но только если клиенты умеют его игнорировать
(в protobuf для этого есть _UNSPECIFIED = 0).
Что ломает: удаление или переименование поля; смена типа ("42" → 42, число → строка
для больших ID); ужесточение валидации; смена значения по умолчанию; смена кода ответа с 200
на 202; смена семантики при том же имени — самое коварное, потому что тесты зелёные, а
клиенты считают деньги иначе.
(новые поля, эндпоинты) Active --> Deprecated: вышла новая версия
заголовок Deprecation Deprecated --> Sunset: назначена дата отключения
заголовок Sunset + Link на миграцию Sunset --> Retired: трафик = 0
или дата наступила Retired --> [*]: 410 Gone Deprecated --> Active: откат, если нашлись
критичные потребители note right of Deprecated Выход из Deprecated определяется телеметрией по клиентам, а не календарём end note
Где размещать номер версии
- В пути:
/v1/orders. Очевидно, кэшируется, видно в логах, тривиально маршрутизируется. Формально «нересурсно» (один ресурс — два URL), практически — доминирующий вариант в индустрии. - В заголовке:
Accept: application/vnd.example.v2+json. Чище по духу HTTP, но невидимо в логах и браузерной адресной строке, легко теряется в прокси и путает интеграторов. - В имени пакета (gRPC/protobuf):
package orders.v2— версия становится частью типа, две версии сосуществуют в одном бинаре без конфликтов. - По дате, закреплённой за клиентом: подход Stripe — клиент фиксируется на версии в момент первого вызова, а сервер держит цепочку трансформаций между версиями. Внутри всегда одна модель, наружу — много. Мощно и дорого: нужна инфраструктура и тесты на каждый переход, зато клиентов не приходится гнать на миграцию.
Главное правило: мажорную версию поднимайте как можно реже. Каждая новая версия — это удвоение эксплуатации, документации и тестов до тех пор, пока старая жива. А живёт она годами.
Expand / Migrate / Contract
Рабочий способ вносить ломающее изменение без ломающего релиза. Тот же приём, что и для миграций схемы БД: расширить, мигрировать, сузить.
- Expand. Добавляем новое поле рядом со старым. Сервер поддерживает оба и синхронизирует их в обе стороны: пришло старое — заполнить новое, пришло новое — заполнить старое.
- Migrate. Помечаем старое
Deprecation(RFC 9745) иSunset(RFC 8594), считаем использование по клиентам, а не суммарно, и адресно пишем тем, кто ещё не мигрировал. - Contract. Удаляем, когда счётчик держит ноль. Не раньше и, что важнее, — не «когда вспомним».
Второй пункт — тот, который пропускают. Без per-client метрики использования поля вы не узнаете, кого сломаете, и поэтому не удалите старое поле никогда. Минимальная реализация:
# Счётчик использования устаревших полей: без него Contract не наступает никогда.
# Метку client_id берём из токена, а не из User-Agent — UA подделывается и врёт.
DEPRECATED_FIELDS = {"name": "2026-10-01", "shipping_address": "2026-12-15"}
def track_deprecated_usage(requested_fields: set[str], client_id: str, response) -> None:
used = requested_fields & DEPRECATED_FIELDS.keys()
if not used:
return
for field in used:
metrics.counter("api.deprecated_field.used",
tags={"field": field, "client": client_id}).inc()
# Клиент узнаёт о проблеме из ответа, а не из рассылки, которую не прочитал.
earliest = min(DEPRECATED_FIELDS[f] for f in used)
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = to_http_date(earliest)
response.headers["Link"] = '</docs/migrations/v2>; rel="deprecation"'
Контрактные тесты вместо надежды
Совместимость надо проверять машиной, а не ревью. Три уровня, по возрастанию цены:
- Линтер схемы в CI. Для protobuf —
buf breaking, который сравнивает.protoс базовой веткой и валит сборку на несовместимом изменении. Для OpenAPI —oasdiffили аналог. Это самый дешёвый и самый эффективный контроль: пять минут настройки закрывают большую часть класса ошибок. - Consumer-driven contracts. Потребитель публикует свои ожидания (Pact, см. также описание паттерна у Фаулера), провайдер прогоняет их в своём CI. Ценность в том, что провайдер видит, какая часть контракта реально используется, — и может смело менять всё остальное.
- Канареечный прогон реального трафика. Зеркалируем часть продовых запросов на новую версию и сравниваем ответы. Дорого, но ловит семантические расхождения, которые не видит ни один анализ схемы.
8. Типичные ошибки
200 OKс ошибкой в теле. Ломает ретраи, метрики, алерты и любой инструмент, который смотрит на статус. Код ответа — это часть контракта, а не украшение.- Утечка внутренней модели наружу. Отдаёте ORM-сущность через сериализатор — и любое переименование колонки становится ломающим изменением API. DTO на границе не бюрократия, а изоляция; см. гексагональную архитектуру.
- Нет ограничения на размер выборки. Эндпоинт без обязательного
limitрано или поздно получит запрос на весь датасет. Всегда ставьте максимум на стороне сервера. POSTбез идемпотентности в денежных операциях. Первый же сетевой таймаут даст двойное списание. Правило: любая операция с внешним эффектом принимаетIdempotency-Key.- Ретраи без бюджета и джиттера. Синхронизированные повторы после сбоя добивают систему при восстановлении. Нужны экспоненциальный backoff, джиттер и retry budget.
- Версия в URL, но код не разделён.
/v1/и/v2/ведут в одну функцию с ветвлениямиif version == 2. Через год это нечитаемо. Разделяйте на уровне слоя представления, держа одну доменную модель под ним. - Enum без «неизвестного» значения. Клиент падает на значении, которого не знал.
В protobuf
_UNSPECIFIED = 0обязателен; в JSON-API документируйте, что список открыт. - Вебхуки без outbox. «Сохранили и отправили» двумя действиями — значит теряете события при падении между ними.
- Тайм-ауты по умолчанию. Клиент без явного таймаута ждёт вечно и держит поток/соединение. Таймаут должен быть у каждого исходящего вызова, и он должен быть меньше входящего.
- Пагинация через offset на больших таблицах. Работает на демо, деградирует линейно на проде, теряет и дублирует строки при конкурентных вставках.
9. Как это выглядит в проде
Дизайн-гайд, а не вкусовщина. Крупные компании публикуют внутренние стандарты API и проверяют их линтером в CI: Google API Improvement Proposals, Microsoft REST API Guidelines, Zalando RESTful API Guidelines. Ценность не в конкретных правилах, а в том, что они одинаковы — интегратор, освоивший один ваш эндпоинт, понимает остальные.
Schema-first и реестр схем. .proto и OpenAPI лежат в отдельном репозитории, из них
генерируются клиенты и публикуются в реестр (Buf Schema Registry
или внутренний артефакторий). Проверка совместимости — обязательный шаг пайплайна, а не
ответственность ревьюера.
Шлюз на периметре. API gateway снимает с сервисов сквозные задачи: аутентификация, рейт-лимит, TLS, квоты, трансформация версий, логирование. Важно не перегрузить его бизнес-логикой, иначе он превращается в ESB — централизованный компонент, изменение которого требует координации всех команд, то есть ровно то, от чего уходили в микросервисах.
Наблюдаемость по клиентам, а не только по эндпоинтам. Метрики размечаются client_id и
версией API. Это единственный способ ответить на вопросы «кого сломает удаление поля»,
«кто генерирует 80% нагрузки» и «кому звонить перед отключением v1».
Явные лимиты и их коммуникация. Рейт-лимит отдаётся заголовками
(RFC 9331 / RateLimit-заголовки),
превышение — 429 с Retry-After. Клиент, который видит остаток бюджета, ведёт себя
кооперативно; клиент, который узнаёт о лимите только по отказу, ретраит и делает хуже.
Бюджет ошибок и SLA на депрекацию. Зрелые платформы публикуют политику: минимум 12 месяцев
между Deprecation и Sunset для публичных API, минимум один квартал — для внутренних.
Предсказуемость правил ценнее их мягкости.
10. Мини-итог
- Стиль API — это выбор формы связанности, а не формата данных. Решайте по осям: кто инициирует, что является единицей контракта, кто владеет формой ответа, насколько строга схема.
- REST полезен ограничениями, а не буквой: stateless даёт масштабирование, честная семантика методов даёт бесплатную инфраструктуру ретраев и кэша. Уровень 2 по Ричардсону — нормальная точка остановки.
- gRPC — правильный выбор для внутреннего обмена: схема, кодогенерация, стриминг и, главное, распространяемые дедлайны. Наружу — почти всегда REST.
- GraphQL решает проблему разнородных клиентов и создаёт три новые: N+1, неограниченную стоимость запроса и потерю HTTP-кэша. Все три решаемы, но требуют владельца графа.
- Вебхуки без outbox, подписи, ретраев с backoff и догоняющего эндпоинта — не интеграция, а источник инцидентов.
- Версионирование начинается с определений совместимости и заканчивается телеметрией по
клиентам. Expand / Migrate / Contract плюс
buf breakingв CI закрывают большую часть рисков. - Мажорных версий должно быть мало. Аддитивных изменений — сколько угодно.
Источники
- Roy T. Fielding. Architectural Styles and the Design of Network-based Software Architectures (2000) — первоисточник REST.
- Mark Masse. REST API Design Rulebook — практикум по ресурсному дизайну.
- Mike Amundsen. RESTful Web API Patterns and Practices Cookbook — эволюция и совместимость контрактов.
- RFC 9110 — HTTP Semantics — актуальная спецификация методов, статусов и условных запросов.
- RFC 9457 — Problem Details for HTTP APIs.
- RFC 8594 — The Sunset HTTP Header Field и RFC 9745 — The Deprecation HTTP Response Header Field.
- gRPC documentation и Protocol Buffers: правила совместимости.
- GraphQL Specification и Production Ready GraphQL Марка-Андре Жиру.
- Google AIP — самый детальный публичный свод правил дизайна API.
- Martin Fowler. Richardson Maturity Model и Consumer-Driven Contracts.
- Stripe API Versioning — эталонный разбор версий, закреплённых за клиентом.
Что дальше
Мы разобрали, как выглядит граница системы снаружи. Следующий вопрос — что происходит, когда через эту границу идёт на порядок больше трафика, чем рассчитывали: где ставить кэш, как его инвалидировать и когда пора резать данные по шардам.
Кэширование и масштабирование: уровни, инвалидация, шардирование