REST, versioning, backward compatibility, pagination
Внутренний код можно переписать в любой момент. Опубликованный контракт — нет: у него есть потребители, о существовании половины которых вы не знаете.
Вы научитесь определять, что в диффе является ломающим изменением, даже когда сигнатура формально не изменилась; проверять пагинацию на устойчивость к вставкам; читать формат ошибок с точки зрения клиента; и задавать про новый эндпоинт три вопроса, после которых половина проблем всплывает сама.
Прежде чем оценивать форму контракта, надо понять его цену. Она полностью определяется тем, кто им пользуется.
| Кто потребитель | Цена изменения |
|---|---|
| Один фронтенд, выкатывается вместе с бэкендом | Почти ноль: меняйте что угодно |
| Мобильное приложение | Высокая: старая версия живёт в проде месяцами |
| Другая команда внутри компании | Средняя: нужен согласованный переход |
| Внешние интеграторы | Максимальная: изменение — публичное событие |
Из этого следует практический вывод: одно и то же изменение может быть nit и blocker в зависимости от ответа. Поэтому первый комментарий к PR, меняющему API, — вопрос: кто это вызывает.
Второй вопрос — как потребитель узнает об изменении. Если ответ «мы им скажем в чате», значит, механизма нет, и однажды не скажут.
Проверьте себя. Для главного эндпоинта вашего проекта перечислите всех известных потребителей. Уверены, что список полный?
Частая ошибка. Считать API внутренним, потому что он не задокументирован публично. Внутренний он или нет, определяют потребители, а не документация.
Явные случаи — удаление поля, переименование, новый обязательный параметр — знают все. Опаснее те, что не меняют схему.
Сужение допустимых значений. Поле принимало любую строку, стало принимать значения из перечисления. Схема не изменилась, старые клиенты сломались.
Изменение семантики при том же имени. Поле total включало доставку, стало не включать. Типы совпадают, тесты зелёные, у клиента неверные суммы — и он об этом даже не узнает.
Изменение кода ответа. Возвращали 200 с {"error": ...}, стали возвращать 422. Клиент, проверяющий response.ok, начнёт падать.
Ужесточение валидации. Раньше лишние поля в теле игнорировались, теперь отвергаются. Клиент, отправлявший отладочное поле, сломан.
Изменение порядка в списке без явной сортировки. Клиент полагался на порядок, потому что он был стабилен, — а порядок никто не обещал. Формально виноват клиент, практически — сломались вы.
Изменение точности или формата. Дата была 2026-07-28, стала 2026-07-28T00:00:00Z. Цена была числом, стала строкой ради точности — правильное решение, ломающее всех.
class OrderResponse(BaseModel):
- total: float
+ total: Decimal # сериализуется как строка "1234.56"Две строки в диффе, полностью сломанный клиент, который делает total * quantity.
Проверьте себя. Найдите в истории проекта изменение, которое сломало клиента, хотя схема не менялась.
Частая ошибка. Проверять совместимость сравнением схем. Инструмент увидит удаление поля и не увидит изменение смысла.
Работающая стратегия — добавлять, а не менять. Новое опциональное поле в ответе безопасно почти всегда; новое опциональное поле в запросе — тоже.
Про «никогда не удаляйте поля» надо уточнить: это не значит «копите вечно». Схема с дублирующими парами name/full_name, age/years быстро становится нечитаемой, и новые клиенты не понимают, что использовать.
Работающий порядок — с фиксированным сроком:
Deprecation, в логах — с указанием даты снятия.Ревьюеру важен третий пункт: если в PR добавляется поле-замена, разумный вопрос — «как мы узнаем, что старое можно убрать?».
Версионирование целого API — тяжёлый инструмент. Он оправдан при внешних интеграторах и почти всегда избыточен для внутреннего фронтенда: две версии означают двойную поддержку, а не свободу.
Проверьте себя. Есть ли в проекте поле, помеченное deprecated больше года назад? Почему оно всё ещё здесь?
Частая ошибка. Заводить /v2 из-за одного изменённого поля. Дешевле добавить поле, чем поддерживать две версии всего API.
Пагинация со смещением проста и имеет два свойства, которые надо проверять в ревью.
GET /orders?limit=20&offset=40Первое: на больших смещениях база всё равно читает и отбрасывает все предыдущие строки — offset=100000 медленный независимо от индексов. Второе, менее известное: при вставках и удалениях между запросами страницы съезжают, и клиент видит одну запись дважды или пропускает её.
GET /orders?limit=20&cursor=eyJpZCI6MTIzfQКурсорная пагинация от этого свободна: курсор кодирует позицию в упорядоченном наборе, и вставка новых записей её не сдвигает. Цена — нельзя перейти на произвольную страницу.
Практический критерий для ревью: если данные упорядочены по времени и активно дописываются (лента, журнал, история), нужен курсор. Если это административная таблица с номерами страниц и объём заведомо небольшой — смещение допустимо.
Обязательные вопросы к любому списочному эндпоинту:
limit однажды отдаст всю таблицу.limit=1000000 не должен приниматься.ORDER BY пагинация не имеет смысла: без гарантированного порядка страницы не согласованы между собой.created_at при одинаковых значениях даёт нестабильный порядок — нужен тай-брейк по id.Проверьте себя. Запросите у своего API страницу со смещением 100 000 и посмотрите на время ответа.
Частая ошибка. Курсор, содержащий смещение. Это пагинация со смещением с лишним шагом кодирования и всеми её недостатками.
Ответ об ошибке — тоже контракт, и его форма определяет, сможет ли клиент реагировать осмысленно.
// ❌ клиент может только показать текст пользователю
{"error": "Something went wrong"}
// ❌ хуже: 200 с ошибкой внутри — клиент проверяет response.ok и не замечает
{"status": "error", "message": "Insufficient funds"}// ✅ машиночитаемый код, человекочитаемое сообщение, детали по полям
{
"error": {
"code": "insufficient_funds",
"message": "На счёте недостаточно средств",
"details": {"required": "1500.00", "available": "820.50"},
"request_id": "01J2K..."
}
}Ключевое — стабильный code, по которому клиент ветвится. Текст сообщения меняется и переводится, код — нет. Если клиенту приходится разбирать message строкой, чтобы понять, что случилось, контракт спроектирован неверно.
request_id в теле ошибки экономит часы поддержки: пользователь присылает его вместе с жалобой, и запрос находится в логах мгновенно.
Что ещё проверять: коды состояния соответствуют смыслу (404 для отсутствия, 403 для запрета, 409 для конфликта, 422 для невалидных данных); ошибка не раскрывает внутренности (текст SQL-исключения, трассировка стека); частичный успех обрабатывается явно, а не молча.
Проверьте себя. Может ли ваш клиент отличить «недостаточно средств» от «карта заблокирована», не разбирая текст сообщения?
Частая ошибка. Один код 400 на все ошибки валидации без указания поля. Клиент не может подсветить проблемное поле в форме.
Любой клиент повторяет запрос по таймауту — это встроено в HTTP-клиенты, мобильные библиотеки и балансировщики. Значит, каждый небезопасный метод должен либо выдерживать повтор, либо иметь ключ идемпотентности.
POST /payments
Idempotency-Key: 8f1c...Правило: повтор с тем же ключом возвращает тот же результат, а не создаёт второй объект и не отдаёт ошибку. Возврат 409 Conflict на повтор — распространённая, но неудачная реализация: клиент не может отличить «мой повтор прошёл» от «кто-то другой занял ключ».
Для GET, PUT и DELETE идемпотентность обеспечивается семантикой. Опасен именно POST, и особенно всё, что связано с деньгами и отправкой сообщений.
Проверьте себя. Найдите в API метод POST, создающий что-то важное. Что произойдёт при двойной отправке?
Частая ошибка. Полагаться на кнопку, заблокированную на фронтенде. Повтор приходит не от кнопки, а от сетевого слоя.
Мобильное приложение в проде, релизный цикл в магазинах — до трёх недель. Дифф на 40 строк.
class ProfileResponse(BaseModel):
- name: str
- balance: float
- created: str # "2026-07-28"
+ full_name: str
+ balance: Decimal
+ created_at: datetime
+ tier: str # "basic" | "gold" | "platinum"
@app.get("/api/profile")
def get_profile(user=Depends(current_user)):
- return ProfileResponse(
- name=user.name, balance=user.balance, created=str(user.created.date())
- )
+ return ProfileResponse(
+ full_name=user.full_name,
+ balance=user.balance,
+ created_at=user.created,
+ tier=compute_tier(user),
+ )
@app.get("/api/orders")
-def list_orders(user=Depends(current_user), limit: int = 20, offset: int = 0):
- return Order.objects.filter(user=user)[offset:offset + limit]
+def list_orders(user=Depends(current_user), limit: int = 20, cursor: str | None = None):
+ qs = Order.objects.filter(user=user).order_by("-created_at")
+ if cursor:
+ qs = qs.filter(created_at__lt=decode_cursor(cursor))
+ return qs[:limit]Что видит ревьюер. Каждое изменение по отдельности — улучшение. Decimal вместо float для денег правильно, datetime информативнее строки, курсорная пагинация лучше смещения. И при этом весь дифф — набор ломающих изменений, выпускаемых одновременно, при клиенте, который обновится через три недели.
name → full_name: экран профиля покажет пустое имя. balance: float → Decimal сериализуется в строку — арифметика на клиенте сломается. created → created_at с другим форматом — разбор даты упадёт. Смена offset на cursor: старое приложение отправит offset, он будет проигнорирован, и пользователь увидит первую страницу вместо второй — то есть бесконечную прокрутку, зацикленную на первых двадцати заказах.
Отдельно: новое поле tier со значениями-строками. Что произойдёт, когда добавится четвёртый уровень? Старый клиент со switch по трём значениям попадёт в непредусмотренную ветку.
И тай-брейк в курсорной пагинации: created_at не уникален, у заказов, созданных в одну секунду, порядок не определён — часть записей может пропасть между страницами.
Комментарии в PR:
schemas.py:2· blocker Четыре ломающих изменения в одном релизе при мобильном клиенте, который обновится через три недели. Каждое по отдельности верное, но вместе они выведут из строя экраны профиля и списка заказов у всех текущих пользователей.Предлагаю: новые поля добавить рядом со старыми, старые оставить и заполнять, пометить
deprecatedс датой снятия — например, через два месяца. Удалять после того, как телеметрия покажет, что старые поля больше не читают.
schemas.py:4· blockerbalance: float → Decimalсериализуется как строка"820.50". Клиент делает арифметику с этим значением — сломается молча, без исключения, с неверными суммами на экране. Если переход нужен (а он нужен), добавьтеbalance_amount: Decimalрядом и оставьтеbalanceдо снятия.
orders.py:2· blocker Заменаoffsetнаcursorбез переходного периода: старый клиент отправитoffset, параметр будет молча проигнорирован, и пользователь получит первую страницу на каждый запрос — бесконечная прокрутка зациклится. Поддержите оба параметра:cursorв приоритете,offsetработает как раньше.
orders.py:5· major Курсор поcreated_atбез тай-брейка: значение не уникально, у заказов из одной секунды порядок не определён, и записи будут пропадать между страницами. Сортируйте по(created_at, id)и кодируйте в курсор оба значения.
schemas.py:6· majortierкак строка из трёх значений — на клиенте этоswitch, который попадёт в непредусмотренную ветку при добавлении четвёртого уровня. Опишите в документации, что список расширяемый, и попросите клиент иметь ветку по умолчанию. Иначе следующий уровень станет ломающим изменением.
orders.py:7· majorlimitбез верхней границы:limit=1000000примется. Ограничьте сотней.
schemas.py:1· question Есть ли у нас телеметрия по чтению полей? Без неё «удалим, когда никто не читает» превращается в «не удалим никогда». Если нет — стоит завести хотя бы логирование версии клиента.
Чем закончилось. PR разделили на два. В первый вошли добавления: новые поля рядом со старыми, cursor рядом с offset, тай-брейк в сортировке, предел на limit. Во второй, запланированный через два месяца, — удаление старых полей.
Заголовок Deprecation с датой добавили в ответы, а логирование версии клиента по полям завели отдельным тикетом — без него срок снятия было бы невозможно обосновать.
Через два месяца выяснилось, что created всё ещё читает внутренний отчётный сервис, о котором никто не помнил. Именно для таких случаев нужен третий шаг с телеметрией.
ПОТРЕБИТЕЛИ перечислены; известно, как они узнают об изменении
СЛОМ проверены не только схема, но и семантика, коды, форматы, валидация
ПЕРЕХОД новое добавлено рядом со старым; у устаревшего есть дата снятия
ТЕЛЕМЕТРИЯ известно, кто читает поле, которое планируется удалить
СПИСКИ предел по умолчанию и максимум; порядок задан явно и уникален
КУРСОР для дописываемых наборов; с тай-брейком по уникальному ключу
ОШИБКИ стабильный код, детали по полям, request_id, честный HTTP-статус
ИДЕМПОТЕНТНОСТЬ у POST с побочными эффектами есть ключ; повтор возвращает тот же результат
РАСШИРЯЕМОСТЬ перечисления описаны как расширяемыеАвторизация на уровне эндпоинтов — Безопасность: глубокий разбор. Пределы, повторы и деградация под нагрузкой — Масштабируемость. Документирование контракта и changelog — Рецензирование документации.
Ключевая мысль: ломающим изменение делает не дифф, а срок жизни старого клиента. Один и тот же PR безопасен при синхронной выкатке и катастрофичен при мобильном приложении в проде.
Далее: Глубокая проверка безопасности