Передача ошибок - не такая простая штука как может показаться. Рассмотрим пример с запросом на создание юзера с двумя полями:

PUT /v01/user

{
  "email": "john@test.com",
  "password": "123456"
}

Какие гипотетические ошибки тут могут возникнуть?

  1. Ошибка поля email: такой email уже используется, email не корректен, email находится в запрещенной зоне
  2. Ошибка поля password: слишком короткий, слишком длинный, слишком простой и т.д.
  3. Ошибка создания: вам разрешено создавать не более 5 юзеров, а вы пытаетесь создать 6й
  4. Ресурс /v01/user уже устарел, используйте /v02/user (может возникнуть в мобильном приложении которое давно не обновлялось; для веба не актуально)
  5. У вас недостаточно прав для обращения к этому ресурсу (может возникнуть при работе с устаревшими клиентами в мобильных приложениях; т.е. ресурс был открыт, но потом закрыли)
  6. Auth-токен под которым вы совершаете запрос - устарел, невалиден, вас забанили, юзера удалили и все остальные ошибки аутентификации
  7. На сервере ведутся технические работы
  8. Какие-то непредусмотренные клиентом ошибки

Как видно, на один запрос мы можем получить самые разные ошибки, или комбинацию из них.

Например, вместе получить ошибки 1, 2, 3, 8 в одном ответе - вполне реальная ситуация.
На клиентах этот набор ошибок будет также обрабатываться в разных местах. Ошибки 1-3 - это валидационные ошибки, они должны быть обработаны недалеко от формы. Ошибки 4-8 в некотором смысле глобальные, т.е. могут прийти в ответ на любой запрос, и обработчик на них также должен быть один глобальный.

Решение - полиморфный список ошибок

В ответ на любой запрос мы можем получить одну или несколько ошибок. Поэтому ошибки надо передавать списком (массивом).

Ошибки бывают разных типов. Чтобы отличить один тип от другой - используем специальное поле type.

У каждой ошибки должен быть message с описанием, что произошло. И id на который можно завязываться, если мы хотим использовать свои собственные тексты ошибок.

У ошибок могут быть еще другие поля, в зависимости от типа.

Ошибки из примера в одном ответе могли бы выглядеть так:

HTTP/1.1 422 Unprocessable Entity

[
    {
        "type": "field",
        "field": "email",
        "id": "email-already-exists",
        "message": "Such email already exists"
    },
    {
        "type": "field",
        "field": "password",
        "id": "password-too-weak",
        "message": "Password too weak"
    },
    {
        "type": "form",
        "id": "too-many-users",
        "message": "Unable to create one more user."
    },
    {
        "type": "global",
        "id": "jwt-token-expired",
        "message": "Your auth-token expired. Please, login again"
    }
]

3 типа ошибок

В зависимости от контекста, где происходит ошибка я выделил 3 типа ошибок:

  1. type="global" содержат поля (id, message) глобальные ошибки, которые могут прилететь на любой запрос. Контекст - всё приложение, т.е. это ошибки уровня приложения.
  2. type="form" как и global содержат поля (id, message) Контекст - эндпоинт. Ошибки самих форм или эндпоинтов.
    Q: Можно ли объединить form и global?
  3. type="field" содержат поля (id, field, message) ошибки валидации полей. Контекст - поле.

Глобальные ошибки с отдельным статусом

Хотя я и стараюсь минимизировать использование отдельных HTTP-статусов для ошибок, но некоторые ошибки просто необходимо выносить в отдельный статус.

API Deprecated

Эта ошибка на бэкенде может генерироваться как в рантайме, так и в веб-сервере (устарел весь сервер или домен). Поэтому эту ошибку лучше вынести в отдельный статус, например:

410 Gone

Server under maintenance

Для проведения работ на сервере неплохо бы также иметь отдельный статус, чтобы веб-сервер отдавал его клиентам и не пропускал запросы в сам бэкенд. При этом на клиенте важно отличать ситуации “ведутся технические работы” от “сервер недоступен”. В первом случае можно просто попросить пользователя подождать и попробовать чуть позже. Во втором - нужно бить тревогу, слать репорты и вообще это непредусмотренное поведение, таких ситуаций нужно избегать. Поэтому для этого кейса используем:

503 Service unavailable

Состояние Сервер недоступен в данном случае будет выражаться либо TCP-ошибкой Сonnection refused, либо 502 Bad Gateway, когда веб-сервер не видит бэкенд.

FAQ

Q: Почему бы просто не использовать разные http-статусы для ошибок?

Возможностей http-статусов недостаточно.

Http-статусов ограниченное количество, сто клиентских (400-499) и сто серверных (500-599). У этих статусов уже есть предопределенный смысл, который не всегда может подойти.

401 Unauthorized можно прийти когда:

  1. access-токен протух: клиент должен его обновить и после заново послать запрос
  2. нет токена: клиент должен показать попап сообщение о том, что “Функционал недоступен для гостей, залогиньтесь”
  3. access-токен принадлежит несуществующему юзеру: клиенту надо забыть невалидный токен и попросить юзера залогиниться
  4. пользователь забанен: показать соответствующее сообщение

Гибкости http-статусов недостаточно чтобы выразить эти кейсы. Поэтому лучше от них нужно отказаться, и использовать свои идентификаторы ошибок, без привязки к статусам.

Q: Как передавать кастомные параметры в ошибке?

Просто класть в тело ошибки. Но желательно гарантировать, что для одного id ошибки всегда приходит один и тот же набор полей.

Пример ошибки со смыслом: “Нельзя еще раз выслать код, т.к. он уже был отправлен, попробуйте через 35 секунд”.

[
  {
      "type": "form",
      "id": "sms-already-sent",
      "message": "Sms code already sent",
      "tryAfter": 35
  }
]

Q: Чем отличается form от global?

Отличаются контекстом. Структурно они одинаковые.

global - это глобальные ошибки для всего приложения, они могут прийти в ответ на любой запрос, любого эндпоинта. Контекст - все приложение.

form - это ошибка только одного эндпоинта. Контекст - один эндпоинт.

В документации к эндпоинту вы будете описывать все field и form, которые он может вернуть. А global по умолчанию присутствуют в каждом эндпоинте, так что они описываются в документации ко всему API, а не отдельному эндпоинту.

На клиенте form и global обрабатываются в разных местах. form - должны обрабатываться там, откуда был сделан запрос. А для global должны быть общие глобальные обработчики ошибок.

Q: Можно ли объединить form и global?

Технически - да. Каких-то больших проблем это не должно повлечь. Но я за смысловую строгость, поэтому не советую так делать.

Q: Зачем надо закладываться на непредусмотренные ошибки? Я же всегда могу посмотреть список ошибок в документации, и все их обработать.

Потому что есть мобильные клиенты, которые не хотят обновляться. И нужно стараться добиваться корректного поведения всех мобильных клиентов, включая всех устаревших.

Под корректным я имею в виду - если у вас приложение устарело, оно должно показывать сообщение, что оно устарело, а не падать.

Мобильные клиенты особенны тем, что как только вы их релизнули - это пожизненно ваш клиент (потому что он может не обновиться никогда). И если вы не хотите, чтобы ваши клиенты получили падающий апп, потому что вы после релиза добавили еще одну проверку на бэкенде - то нужно закладываться на ошибки в будущем, нужен гибкий протокол передачи ошибок и гибкий обработчик.

Q: Зачем 2 поля type и id? Нельзя ли использовать одно поле?

type - нужен для маппинга ошибок в классы. Т.е. сейчас нам нужно всего 3 класса (по одному для каждого type). Если мапить по id, то кол-во классов будет слишком большим.

Q: Message в ошибке на английском, как его перевести на язык клиента?

Передавать в заголовке язык клиента, чтобы message переводил бэкенд.

Q: Что отвечать бэкенду, если клиент прислал некорректный запрос или произошла ошибка?

То же, что и раньше - 400 Bad Request или 500 Internal Server Error.

Через полиморфный список нужно выражать только предусмотренные штатные ошибки. Нештатные ситуации и ошибки нужно выражать привычным способом.

Подразумевается, что если клиент прислал некорректный запрос и сервер ответил 400 Bad Request, то это нештатная ситуация, ее не должно было возникнуть, это ошибка разработчиков. Клиент должен на нее показать ошибку и отправить репорт в багтрекер.

Q: Что такое штатные и нештатные ошибки?

Штатные - предусмотренные; считаются допустимым поведением; на штатные ошибки не нужно реагировать; не считаются багом.

Нештатные - непредусмотренные; недопустимые; возникают в следствии ошибки разработчиков; считаются багом; подразумевается, что в правильно работающем приложении не должно возникать нештатных ситуаций; обязательно пишутся в багтрекер.

Оба типа ошибок могут возникать как на сервере, так и на клиенте.

Штатные Нештатные
JWT-токен expired Подпись JWT-токена некорректная
Поле firstName слишком короткое Поле firstName не пришло вообще, или пришел number, boolean вместо string.
410 API Deprecated Сервер прислал статус 459, на который клиент не знает как реагировать
Сервер недоступен, т.к. на нем ведутся технические работы
Сервер недоступен, потому что упала база данных

Итого

  • 200 OK если все ок
  • 410 Gone API Deprecated
  • 503 Service unavailable На сервере ведутся работы
  • 422 Unprocessable Entity Для списка полиморфных ошибок

Любые другие статусы считаются как непредвиденное поведение, и на них нужно реагировать тревогой и репортами в багтрекер.

Минималистично, детерминировано.