API
Проектирование и ревью публичного API от начала до конца
Авария начинается с существительного. Кто-то смоделировал POST /chargeCard вместо ресурса payments, и на вопрос «а это списание уже произошло?» API не мог ответить. Эндпоинт вернул 200 на дубликат. Мобильные клиенты, видя дёрганую сеть, ретраили — а 200 не безопасен для ретрая, поэтому карты списались дважды. OpenAPI-контракта не было, и QA не заметило дрифт формы ответа. Рейт-лимита не было, и шторм ретраев в 18:00 ударил по базе 40 тысячами дублирующих записей в секунду. Шесть плохих решений, одна цепная реакция. Каждое из них можно было отревьюить месяцем раньше.
Цепная реакция: как одно плохое решение становится аварией
Весь трек APIs учил одному с разных сторон: API — это контракт, и контракты ломаются на стыках между решениями, а не внутри какого-то одного. Сеньор, который ревьюит публичный API, не проверяет пункты по отдельности — он трассирует цепочку, потому что каждое слабое звено держит на себе следующее.
Начни с моделирования ресурсов. Моделируй существительные, а не глаголы: POST /payments, а не POST /chargeCard. Глагол — это HTTP-метод; URL — это сущность. Действие, которое не CRUD-глагол (publish, refund, retry), становится суб-ресурсом — POST /payments/{id}/refunds — а не query-параметром ?action=refund и не утёкшей колонкой базы. В тот момент, когда URL зеркалит схему базы (/user_accounts_v2), ты приварил клиентов к своему внутреннему хранилищу, и каждая миграция стала ломающим изменением.
Это решение о моделировании определяет, могут ли твои статус-коды вообще быть честными. Настоящий ресурс payments даёт вернуть 201 Created с Location на первой записи и 200/409 на повторе с тем же ключом идемпотентности. У RPC chargeCard не на что указать, поэтому он возвращает 200 на всё — а 200 говорит клиенту «готово, не ретрай», что ложь, когда сеть съела ответ.
Статус-коды — это контракт ретрая, а не украшение
Клиенты не читают твою документацию в 18:00 во время инцидента — они читают статус-код и заголовки и действуют по ним автоматически. Это делает статус-код машиночитаемым контрактом:
2xx→ готово, не ретрай.4xx→ запрос неверен; ретрай без изменений бессмыслен (400,404,422).429/503→ можно ретраить, но с бэкоффом — читайRetry-After.5xx→ может, и можно ретраить, но только если операция идемпотентна.
Опасный случай — неидемпотентный POST, который упал после записи, но до ответа. Фикс — Idempotency-Key: клиент шлёт UUID, сервер его запоминает, и ретрай с тем же ключом возвращает исходный результат вместо повторного списания. Поэтому платёжные API в стиле Stripe требуют этот заголовок — он превращает небезопасный ретрай в безопасный. Без него единственный корректный статус-код для «я не знаю, сработало ли» — это отсутствие статус-кода, потому что клиент угадает неверно.
| Слой | Сделай правильно | Ошибись → следующий сбой |
|---|---|---|
| Моделирование ресурсов | Существительные; не-CRUD действия как суб-ресурсы; без утечки схемы базы | RPC-глаголы без адреса → статус-коды не могут быть честными |
| Статус-коды | 201/409/429 с Idempotency-Key + Retry-After | 200 на всё → клиенты ретраят небезопасные записи |
| Пагинация | Курсор (keyset) для больших/живых данных | Глубокий OFFSET → полные сканы, дрифт при вставке |
| Контракт (OpenAPI) | Spec-first; дифф ломающих изменений в CI | Нет спеки → дрифт ответа уезжает молча |
| Версионирование | По умолчанию аддитивно; версия только на слом; заголовок Sunset | Мутировать /v1 на месте → клиенты ломаются за ночь |
| Рейт-лимит | 429 + Retry-After; задокументированная квота | Нет лимита → один шторм ретраев становится аварией |
Пагинация и контракт, который ловит дрифт
OFFSET 100000 LIMIT 20 заставляет базу посчитать и отбросить 100k строк на каждой глубокой странице — полный скан, который тем медленнее, чем дальше уходишь, и который молча пропускает или дублирует строки, когда элементы вставляются посреди пагинации. Курсорная (keyset) пагинация — WHERE id > :last_seen ORDER BY id LIMIT 20 — остаётся O(размер страницы) на любой глубине и стабильна при вставках, ценой отсутствия случайного «прыжка на страницу 500». Для больших или живых датасетов отгружай курсоры; offset нормален только для маленьких, стабильных, админских списков.
Ничто из этого не остаётся корректным без контракта. Spec-first OpenAPI значит, что схема — источник истины, генерируемые клиенты и серверные стабы оба выводятся из неё, и — то, что реально спасает — CI-джоб диффит новую спеку против старой и валит сборку на ломающем изменении: удалённое поле, ужатый enum, смена типа, переименованный конверт ошибки. Без этого диффа дрифт, который сломал QA в Hook, уезжает обычным деплоем с зелёными тестами, потому что тесты написаны против старой формы и их никто не обновил.
Почему это работает
«Обратно совместимо» имеет здесь точный смысл: старый клиент, работающий без изменений против нового API, всё ещё работает. Добавить опциональное поле, новый эндпоинт или новое значение enum, которое клиент может игнорировать, — аддитивно и безопасно. Удалить поле, переименовать его, ужесточить валидацию или сменить дефолт — ломающее, даже если «кажется мелким», потому что какой-то клиент где-то зависит от точного старого поведения. CI-дифф существует, потому что люди стабильно ошибаются, в какую корзину попадает изменение.
Версионирование — соединительная ткань; протокол — отдельная ось
Версионирование — это то, что даёт всей системе эволюционировать без цепной реакции. Дефолт сеньора — аддитивное изменение без бампа версии: новые опциональные поля и эндпоинты идут прямо в /v1. Новую мажорную версию (/v2 или media-type Accept: application/vnd.api.v2+json) ты режешь только на настоящие сломы и держишь старую живой. URL-версионирование (/v1/...) выигрывает для публичных API — видимо, дружелюбно к кэшу, тривиально маршрутится — а версионирование через заголовок/media-type держит URL чистыми для внутренних потребителей. В любом случае депрекейшн — это опубликованная политика, а не сюрприз: анонсируй заголовки Deprecation и Sunset (RFC 8594/9745), дай клиентам 6–12 месяцев и следи за метриками использования, прежде чем реально удалять старую версию.
Выбор протокола — REST vs gRPC — это ортогональная ось, а не часть лестницы версий, и именно тут сеньоры чаще всего переусложняют. gRPC реально быстрее: примерно на 77% ниже latency на маленьких payload, ~10x меньше сериализованные сообщения (protobuf vs JSON) и ~50k req/s против ~20k у REST в синтетических бенчмарках, со стримингом как фичей первого класса. Но эти цифры — ответ на «внутренний service-to-service на высоком объёме», а не на «должен ли мой публичный API быть на gRPC». Публичные API живут или умирают на discoverability, доступности из браузера/curl и эргономике для внешних разработчиков — ровно сильные стороны REST + OpenAPI. Ход сеньора — REST на краю, gRPC между своими сервисами, если объём оправдывает, и никогда не давать бенчмарку выбирать твой публичный контракт.
Два твоих сервиса обмениваются ~40k запросов/сек внутри с крошечными payload и стримящим фидом; ты также отдаёшь публичный API сторонним разработчикам. Выбери раздел протоколов.
POST мобильного клиента на создание платежа упал по таймауту. Сервер мог записать, а мог и нет. Какой дизайн сеньора делает ретрай клиента безопасным?
Твой API возвращает список, который теперь 2M строк и растёт, и его листают вживую. Эндпоинт использует OFFSET/LIMIT и тормозит на глубоких страницах. Лучший фикс?
Расставь сквозной чек-лист ревью публичного API (каждый шаг де-рискует следующий):
- 1 Ресурсы — существительные; не-CRUD действия — суб-ресурсы; схема базы не утекает в URL
- 2 Статус-коды честные: 201/409/429, Idempotency-Key на небезопасных записях, Retry-After на троттлинге
- 3 Списки используют курсорную пагинацию для больших/живых данных, а не глубокий OFFSET
- 4 Spec-first OpenAPI — источник истины, с диффом ломающих изменений в CI
- 5 Изменения по умолчанию аддитивны; настоящие сломы бампят версию с опубликованной политикой Sunset
- 6 Рейт-лимиты возвращают 429 + Retry-After с задокументированной квотой, чтобы нагрузка не стала аварией
- 01Пройди по тому, как одна ошибка моделирования ресурса перерастает в продакшен-аварию, называя каждое звено цепочки.
- 02Коллега хочет сделать публичный API на gRPC, потому что бенчмарки показывают, что он сильно быстрее. Как рассуждать, верно ли это решение?
Трек APIs всегда был одним уроком, видимым со многих сторон: API — это связный контракт, и он ломается на стыках между решениями. Моделируй существительные и превращай не-CRUD действия в суб-ресурсы, потому что именно это даёт статус-кодам быть честными. Делай статус-коды машиночитаемым контрактом ретрая — 201/409/429, Idempotency-Key на небезопасных записях, Retry-After на троттлинге — потому что во время инцидента клиенты действуют по кодам, а не по докам. Пагинируй большие или живые списки курсором, а не глубоким OFFSET, чтобы стоимость оставалась плоской, а страницы стабильными. Фиксируй всю форму в spec-first OpenAPI с CI-диффом ломающих изменений, чтобы дрифт не мог уехать молча. Эволюционируй аддитивно и версионируй только на настоящие сломы, с опубликованной политикой Deprecation/Sunset и 6–12 месяцами разбега. Гейти всё задокументированными рейт-лимитами, возвращающими 429 + Retry-After, чтобы шторм ретраев не превратился в аварию. Держи протокол на своей оси: REST + OpenAPI на публичном крае, gRPC между внутренними сервисами, когда объём реально оправдывает. Ревьюй это как цепную реакцию, а не как чек-лист.