awesome-everything EN
↑ Обратно к восхождению

API

Проектирование и ревью публичного API от начала до конца

Суть Каждое решение в API связано: плохое моделирование ресурсов вынуждает злоупотреблять статус-кодами, из-за чего клиенты ретраят неправильно, что скрывает отсутствие контракта, что ломается молча, что без рейт-лимита превращается в аварию. Это сквозное ревью сеньора.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 17 min

Авария начинается с существительного. Кто-то смоделировал 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-After200 на всё → клиенты ретраят небезопасные записи
ПагинацияКурсор (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. 1 Ресурсы — существительные; не-CRUD действия — суб-ресурсы; схема базы не утекает в URL
  2. 2 Статус-коды честные: 201/409/429, Idempotency-Key на небезопасных записях, Retry-After на троттлинге
  3. 3 Списки используют курсорную пагинацию для больших/живых данных, а не глубокий OFFSET
  4. 4 Spec-first OpenAPI — источник истины, с диффом ломающих изменений в CI
  5. 5 Изменения по умолчанию аддитивны; настоящие сломы бампят версию с опубликованной политикой Sunset
  6. 6 Рейт-лимиты возвращают 429 + Retry-After с задокументированной квотой, чтобы нагрузка не стала аварией
Вспомните перед уходом
  1. 01
    Пройди по тому, как одна ошибка моделирования ресурса перерастает в продакшен-аварию, называя каждое звено цепочки.
  2. 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 между внутренними сервисами, когда объём реально оправдывает. Ревьюй это как цепную реакцию, а не как чек-лист.

Продолжить восхождение ↑APIs: review с выбором ответа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.