Каждый backend-разработчик рано или поздно проектирует API. И каждый рано или поздно находит в нём что-то, от чего хочется закрыть ноутбук и пойти гладить траву и восстанавливать душевное здоровье. Формат “10 ошибок” судя по прошлой статье зашел аудитории, поэтому я собрал 10 ошибок проектирования и реализации API. Они все из реальных проектов. С некоторыми из них проекты жили годами пока не стрельнуло на проде или не пришел ИБ, с некоторыми им предстоит встретить свою старость как с камнем в ботинке, с которым больно идти, но вынимать некогда тк надо пилить фичи.

Что вы узнаете:
Почему 200 OK на ошибку - невидимый баг, который живёт месяцами
Как 200 OK с пустым телом вместо 400 съедает десятки часов отладки
Как три формата ошибок в одном API сводят с ума клиента
Чем опасен exception.getMessage в ответе
Почему 500 на бизнес-ошибку - это бесконечный retry
Как отсутствие пагинации роняет мобильных клиентов
Когда версионирование уже поздно добавлять
Почему зоопарк URL растёт сам по себе
Как String вместо enum превращает отладку в квест
Как повторный POST создаёт дубли там, где не ждали
1. 200 OK с ошибкой в теле запроса
У нас был сервис, который полгода работал «без ошибок». Мониторинг зелёный, алерты молчат, графики ровные. Красота.
А потом один клиент пожаловался, что у него не создаются заказы. Полезли смотреть. Endpoint возвращает 200 OK. Тело:
{"error": "insufficient_balance", "message": "Недостаточно средств"}
Двести. OK. Ошибка.
Как так вышло: обработчик ошибки конструировал HttpResponse с JSON-телом, но без явного указания статуса. Фреймворк подставил 200 по умолчанию. Тест на этот сценарий проверял только тело ответа, статус - нет.
Мониторинг считает HTTP-статусы. Если 200 - всё хорошо. Retry-механизмы на клиенте срабатывают по 5xx, а тут 200. Балансировщик не пометит инстанс как unhealthy. Вся инфраструктура построена на том, что статус-код не врёт. А он врал.
2. 200 OK с пустым телом вместо 400 Bad Request
Интеграция с крупной и сложной банковской системой. Одна из подсистем интегрируется через внутреннее api, к которому совсем нет доступа для дебага и тестирования. Код писался по документации. По логам все отлично, все показатели в норме, но данные не доходят до целевой подсистемы.
После долгих “боев” между отделами и множества сломанных копий путем обкладывания разных участков кода тоннами debug логов выясняется, что одна из подсистем молча отдает “200 ОК” вместо “400 Bad Request” при некорректных входных данных.

Формат входа поправить дело 5 минут, но выяснить что в принципе дело в этом, а не например в нескольких проксирующих серверах или правах доступа или еще сотне других причин - вот это настоящее испытание стойкости.
На эту ошибку лично при мне было убито больше нескольких десятков человеко-часов нескольких разработчиков.
3. Зоопарк форматов ошибок
Открываю API одного сервиса. Делаю невалидный запрос на создание сущности - получаю:
{"code": -1, "description": "Invalid request body"}
Ок, запомнил формат. Делаю запрос на загрузку файла с ошибкой:
{"error": "File too large"}
Другой формат. Ладно. Дёргаю ещё один endpoint, получаю ошибку:
ok
Просто строка ok. Это не шутка. Это реальный ответ на ошибку. Потому что кто-то когда-то решил, что success-ответ - это plain text "ok", а ошибку забыл обработать.

А ещё code: -1. Всегда минус один. Не нашли пользователя - -1. Невалидный JSON - -1. Сервер упал - -1. Клиент не может различить типы ошибок программно. Вся обработка сводится к if code == -1 then показать description.
Route-файлы писали разные разработчики без единого соглашения по этому поводу. Единого middleware не было. Утилитная функция createError существовала, но половина endpoint’ов её не использовала.
Лечится и просто и сложно одновременно: договоренность о единой модели ошибки, контроль на ревью и в конце концов middleware, который оборачивает все ответы в единый формат. Но чем дольше откладываешь - тем больнее мигрировать. Клиенты уже парсят три формата.
4. Проброс exception.getMessage клиенту
Коротко. В одном из endpoint’ов:
case Failure(ex) => complete(ex.getMessage)
Клиент получает: org.postgresql.util.PSQLException: ERROR: duplicate key value violates unique constraint "users_login_key". С именем таблицы, именем constraint’а и движком БД в придачу.
В другом месте - полный stack trace. Имена внутренних классов, номера строк, версия библиотеки.
Я нашёл это, когда проверял API на утечку информации перед security-аудитом. За пару часов собрал имена таблиц, структуру пакетов, версии библиотек и одинесколько SQL-запросов целиком. Всё из сообщений об ошибках.
5. Неверные HTTP-статусы
Один endpoint возвращал 400 Bad Request, когда не мог прочитать свой конфигурационный файл. Четырёхсотка. Клиент виноват, да? Нет. Сервер не смог прочитать свой собственный конфиг. Это 500, очевидно. Но в коде стояло BadRequest - и клиенты не ретраили, потому что 4xx означает «ты ошибся, повтори с другими данными».
В обратную сторону тоже бывает: 500 Internal Server Error на бизнес-правило «отправка сообщений запрещена». Это не серверная ошибка, это 403 или 409. Но клиент настроил retry на 5xx, получает 500 и ретраит. Ретрай. Ещё ретрай. Ещё. Бизнес-правило не изменится от повторного запроса, но клиент честно долбит пять раз с экспоненциальным backoff’ом.
Ещё был случай: case Failure(ex) => complete(StatusCodes.UnprocessableEntity, ...). Любой Failure - 422. NullPointerException - 422. Timeout к базе - 422. Клиент: «я что-то не так отправил?» Нет, у нас просто база легла, но мы стесняемся сказать 500.
Стандарный набор джентельмена:
Ситуация | Статус |
|---|---|
Клиент прислал невалидные данные | 400 |
Клиент не авторизован | 401 |
Клиент авторизован, но нет прав | 403 |
Ресурс не найден | 404 |
Бизнес-правило запрещает операцию | 409 или 422 |
Сервер сломался | 500 |
Зависимость не отвечает | 502 или 504 |
Не rocket science. Но я видел нарушения этих принципов во многих проектах.
6. Нет пагинации
У нас был endpoint /api/equipment - список техники. Когда писали, у типичного клиента было 50–100 единиц. JSON на 100 записей - ну, килобайт 30. Норм.
Через два года пришёл клиент с 40 000+ единиц техники. Endpoint честно сделал SELECT * FROM equipment WHERE service_id = ?, собрал всё в список, сериализовал в JSON и отправил. Семь мегабайт JSON в одном ответе. Response time - 14 секунд. На мобильном клиенте - OOM crash.

А самое обидное: фронтенд показывал таблицу с пагинацией. 20 записей на странице. Но пагинация была клиентская - сначала скачай все 40 000+, потом нарежь по 20. Бэкенд был не в курсе.
Когда я полез смотреть другие list-endpoint’ы - везде то же самое. Геозоны - без лимита. Пользователи - без лимита. На одном внутреннем endpoint стоял hardcoded LIMIT 500, клиент не мог запросить следующую страницу - если записей больше 500, остальные просто не существуют.
Добавлять пагинацию в существующий API - это breaking change. Клиенты уже рассчитывают получить полный список. Надо или вводить новую версию endpoint’а, или добавлять пагинацию так, чтобы без параметров limit/offset поведение не менялось (и гонять все 40 000+ для старых клиентов).
Мы в итоге добавили default limit = 50 и max limit = 200. Старых клиентов пришлось уведомлять и давать три месяца на миграцию. Если бы сделали пагинацию в первый день - сэкономили бы эти три месяца, два инцидента с OOM и один неприятный разговор с крупным клиентом.
7. Нет версионирования
Когда ты пишешь MVP и у тебя два клиента - версионирование API кажется overengineering’ом. «Зачем /v1/, если мы ещё не знаем, будет ли /v2/?» Справедливо. Я сам так думал. И в каком-то смысле это правда: не надо проектировать то, чего не будет.
Проблема в том, что ты не знаешь, когда перешёл черту. Один клиент, два, пять - всё ещё можно позвонить каждому и сказать «мы меняем формат, обновитесь». Двадцать клиентов - уже нет. А момент перехода от пяти к двадцати ты пропускаешь, потому что в это время занят фичами.
У нас было так: внешний API версионирован - /api/external/v3/. Внутренний API для фронтенда - /api/services, /api/users, без версии. «Ну это же внутренний, кому он нужен». А потом оказалось, что «внутренним» API пользуются три внешних интеграции, которые нашли его в dev-tools браузера и решили, что это публичный контракт.
Добавляем поле в ответ /api/services - клиент, который десериализует в strict mode, ломается. Удаляем deprecated-поле - ломаются все. Механизма сказать «это v1, вот v2 с другим контрактом» просто нет.
Нет смысла версионировать с первого дня, но как только у вас появился хотя бы один внешний потребитель, которого вы не контролируете, то уже пора. Дальше с каждым месяцем будет только дороже.
8. Зоопарк именования URL
Открываю файл с route’ами. Вижу:
GET /equipment - существительное, REST GET /geozone/{id}/check - единственное число + глагол GET /equipmentInfo/{id} - camelCase POST /loadUsers - глагол, RPC-стиль GET /geozonesAsync - суффикс Async, зачем? POST /api/internal/equipment - POST для чтения данных
Шесть endpoint’ов - шесть разных конвенций. Каждый route-файл писал свой человек, и каждый принёс свой стиль. Кто-то пришёл из мира RPC и назвал /loadUsers. Кто-то писал REST и сделал /equipment. Кто-то любил camelCase, кто-то - нет.
К слову, POST /api/internal/equipment - это не создание единицы техники. Это получение списка техники с фильтрами в теле запроса. Семантически GET, но «фильтр сложный, в query-параметры не влезет». POST для чтения не кэшируется CDN, не идемпотентен по спецификации HTTP, ломает предположения клиента.
API невозможно «выучить». Нет паттерна, который подскажет, как называется следующий endpoint. Каждый раз - в документацию (если она есть) или в код.
Договориться - 30 минут на встрече. Не договориться - годы жизни с зоопарком.
9. String вместо enum
{"login": "john", "status": "actve", "roles": ["admn"]}
Заметили? actve вместо active. admn вместо admin. Запрос прошёл валидацию, пользователь создался. С невалидным статусом и ролью, которая ни на что не влияет, потому что в коде проверяется role == "admin", а у нас "admn".
Нашли через две недели, когда пользователь не мог зайти в админку. Поле status и roles в API были объявлены как String и Seq[String]. Какие значения допустимы - знал только backend-код. Ни автокомплита на клиенте, ни ошибки валидации, ни документации по допустимым значениям.
Enum, sealed trait, JSON Schema с enum - неважно что, лишь бы ограниченное множество значений валидировалось на входе в API.
10. Нет идемпотентности на POST
Клиент отправляет POST на создание заявки на ремонт техники. Сеть моргнула, таймаут. Клиент не знает - запрос дошёл или нет. Ретраит. Запрос дошёл оба раза. Две заявки, два наряда на выезд бригады, два комплекта запчастей зарезервировано на складе.
В другом сервисе - POST на отправку уведомления пользователю. Retry при таймауте. Пользователь получает два одинаковых сообщения. Хорошо если это push. Плохо если это SMS, за каждую из которых платят деньги.
Страшнее если это связано с финансами, например POST на создание платежа. Таймаут на клиенте, retry, два списания. Клиент обнаружил через три дня, когда пришла выписка.
HTTP чётко говорит: GET, PUT, DELETE идемпотентны. POST - нет. Если API принимает POST, который нельзя безопасно повторить - это мина замедленного действия.
Решение добавить заголовок Idempotency-Key. Клиент генерирует UUID, отправляет в заголовке. Сервер сохраняет результат по этому ключу на N минут. Повторный запрос с тем же ключом возвращает сохранённый результат без повторного выполнения.
Для операций с известным ID - ещё проще: PUT вместо POST. PUT /orders/123 - идемпотентен по спецификации. Повторный вызов перезапишет ту же сущность тем же значением.
Ну и конечно, можно сделать операцию идемпотентной на уровне бизнес-логики: перед созданием заявки проверить, нет ли уже заявки с таким же содержимым за последние N секунд. Но это работает только для простых случаев. Для сложных - Idempotency-Key надёжнее.
Итого
Ни одна из этих ошибок не выглядит страшной по отдельности. Ну вернули 200 вместо 500 - поправим. Ну String вместо enum - ну да, надо бы поправить.
Засада в том, что они накапливаются. И каждая из них обнаруживается не сразу, а через полгода-год, когда у API уже есть клиенты и интеграции. Чинить больно. Не чинить еще больнее.
Три вещи, которые спасают чаще всего:
Единый middleware для ответов - один формат ошибки, один способ обернуть ответ, статус-код выставляется явно
Валидация на входе - enum’ы, пагинация, идемпотентность проверяются до того, как запрос попал в бизнес-логику
Code review с чеклистом по API - статус-код указан явно? Ошибка не содержит внутренних деталей? POST идемпотентен?
Огромная благодарность тем, кто дочитал статью до конца. Полагаю, раз вы добрались до этого места, то как минимум тема Вас заинтересовала, а как максимум понравилась, поэтому предложу зайти ко мне в канал в Telegram или в канал Max (кому где удобнее) о разработке в стартапах. В них рассказываю ещё больше интересного и делюсь опытом, заходите, обязательно найдете полезные кейсы!
Удачного проектирования!
