Универсальный протокол ошибок для REST-API
Передача ошибок - не такая простая штука как может показаться. Рассмотрим пример с запросом на создание юзера с двумя полями:
PUT /v01/user
{
"email": "john@test.com",
"password": "123456"
}
Какие гипотетические ошибки тут могут возникнуть?
- Ошибка поля email: такой email уже используется, email не корректен, email находится в запрещенной зоне
- Ошибка поля password: слишком короткий, слишком длинный, слишком простой и т.д.
- Ошибка создания: вам разрешено создавать не более 5 юзеров, а вы пытаетесь создать 6й
- Ресурс
/v01/user
уже устарел, используйте/v02/user
(может возникнуть в мобильном приложении которое давно не обновлялось; для веба не актуально) - У вас недостаточно прав для обращения к этому ресурсу (может возникнуть при работе с устаревшими клиентами в мобильных приложениях; т.е. ресурс был открыт, но потом закрыли)
- Auth-токен под которым вы совершаете запрос - устарел, невалиден, вас забанили, юзера удалили и все остальные ошибки аутентификации
- На сервере ведутся технические работы
- Какие-то непредусмотренные клиентом ошибки
Как видно, на один запрос мы можем получить самые разные ошибки, или комбинацию из них.
Например, вместе получить ошибки 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 типа ошибок:
type="global"
содержат поля (id
,message
) глобальные ошибки, которые могут прилететь на любой запрос. Контекст - всё приложение, т.е. это ошибки уровня приложения.type="form"
как и global содержат поля (id
,message
) Контекст - эндпоинт. Ошибки самих форм или эндпоинтов.
Q: Можно ли объединить form и global?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 можно прийти когда:
- access-токен протух: клиент должен его обновить и после заново послать запрос
- нет токена: клиент должен показать попап сообщение о том, что “Функционал недоступен для гостей, залогиньтесь”
- access-токен принадлежит несуществующему юзеру: клиенту надо забыть невалидный токен и попросить юзера залогиниться
- пользователь забанен: показать соответствующее сообщение
Гибкости 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 Deprecated503 Service unavailable
На сервере ведутся работы422 Unprocessable Entity
Для списка полиморфных ошибок
Любые другие статусы считаются как непредвиденное поведение, и на них нужно реагировать тревогой и репортами в багтрекер.
Минималистично, детерминировано.