1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-07-12 22:50:21 +02:00

HTTP errors, beginning

This commit is contained in:
Sergey Konstantinov
2023-06-27 22:46:31 +03:00
parent 60a2ca7eea
commit 0070e6368b
3 changed files with 189 additions and 3 deletions

View File

@ -1 +1,52 @@
### Working with HTTP API Errors ### [Working with HTTP API Errors][http-api-errors]
The examples of organizing HTTP APIs discussed in the previous chapters were mostly about “happy paths,” i.e., the direct path of working with an API in the absence of obstacles. It's now time to talk about the opposite case: how HTTP APIs should work with errors and how the standard and the REST architectural principles can help us.
Imagine that some actor (a client or a gateway) tries to create a new order:
```
POST /v1/orders?user_id=<user_id> HTTP/1.1
Authorization: Bearer <token>
If-Match: <revision>
{ /* order parameters */ }
```
What problems could potentially happen while handling the request? Of of the top of the mind, it might be:
1. The request cannot be parsed (invalid symbols, syntax violation, etc.)
2. The authorization token is missing
3. The authorization token is invalid
4. The token is valid, but the user is not permitted to create new orders
5. The user is deleted or deactivated
6. The user identifier is invalid or does not exist
7. The revision is missing
8. The revision does not match the actual one
9. Some required fields are missing in the request body
10. A value of a field exceeds the allowed boundaries
11. The limit for the number of requests reached
12. The server is overloaded and cannot respond
13. Unknown server error (i.e., the server is broken to the extent that it's impossible to understand why the error happened).
From general considerations, the natural idea is to assign a status code for each mistake. Obviously, the `403 Forbidden` code fits well for mistake \#4, and the `429 Too Many Requests`, for \#11. However, let's not be rash, and ask first *for which purpose* are we assigning codes to errors.
Generally speaking, there are three kinds of actors in the system: the user, the application (a client), and the server. Each of these actors needs to understand several important things about the error (and answers could actually differ for each of them):
1. Who made the mistake: the end user, the developer of the client, the backend developer, or another interim agent such as the network stack programmer?
* And let's not forget about the possibility of the mistake being *deliberately* made by either an end user or a client developer while trying to blunt-force hijack the account of another user.
2. Is it possible to fix the error by just repeating the request?
* If yes, then after what period of waiting?
3. If it is not the case, is it still possible to fix it by reformulating the request?
4. If the error cannot be resolved, what should be done about it?
One of these questions is easily answered in the HTTP API paradigm: the desired interval of repeating the request might be indicated in a `Retry-After` header. Also, HTTP helps with question \#1: to understand, which side is the cause of the error, the first digit in the HTTP status code is used (see below).
With other questions, the situation is unfortunately much more complicated.
#### Client Errors
Status codes that start with digit `4` indicate that it was the user or the client who made a mistake, or at least the server decided so. *Usually*, repeating a `4xx` is meaningless: the request will never be fulfilled unless some additional actions are performed. However, there are notable exceptions, most importantly `429 Too Many Requests` and `404 Not Found`. The letter implies some “uncertainty state” according to the standard: the server could use it if exposing the real cause of the error is undesirable. After receiving a `404`, the request might be repeated, possibly yielding a different outcome. To indicate the *persistent* non-existence of a resource, a separate `410 Gone` status is used.
A more interesting question is then what the client can (or must) do if such an error is received. As we discussed in the “[Isolating Responsibility Areas](#api-design-isolating-responsibility)” chapter, if the error can be resolved, there must be a machine-readable description for the client to interpret. In the case it cannot, a human-readable instructions should be provided for the user (even “Try restarting the application” is a better user experience than “Unknown error happened”) and for the client developer.
If we try to apply this principle to HTTP APIs, we will soon learn that the situation is complicated. On one hand, the protocol includes a lot of codes that indicate specific problems with using the protocol, such as `405 Method Not Allowed` (indicates that the verb in the request cannot be applied to the requested resource), `406 Not Acceptable` (the server cannot return a representation that satisfies the `Accept*` headers in the request), `411 Length Required`, `414 URI Too Long`, etc. The client code might process these errors and sometimes even perform some actions to mitigate them (for example, add a `Content-Length` header in case of a `411` error). However, this is hardly applicable to business logic. If the server returned a `429 Too Many Request` if some limit is exceeded, there are no standardized means of indicating *what exact limit* was hit.
Sometimes, the absence of a common approach to describing business logic errors is circumvented by using different codes with almost identical semantics (or just randomly chosen codes) to distinguish between different causes of the error. One notable example is the widely adopted usage of the `401 Authorized` status code to indicate the absence or the invalid value of authorization headers, which is a signal for an application to ask the user to log in. This usage contradicts the standard (which requires that a `401` response must contain the `WWW-Authenticate` header that describes the methods of authorization; we are unaware of a single API that follows this requirement), but it has become a *de facto* standard itself.

View File

@ -145,7 +145,7 @@
##### Редактирование ##### Редактирование
Проблемы частичного обновления ресурсов мы подробно разбирали в [соответствующей главе](#api-patterns-partial-updates) раздела «Паттерны дизайна API». Напомним, что полная перезапись ресурса методом `PUT` возможна, но быстро разбивается о необходимость работать с вычисляемыми и неизменяемыми полями, необходимость совместного редактирования и/или большой объём передаваемых данных. Работа через метод `PATCH` возможна, но, так как этот метод по умолчанию считается неидемпотентным (и часто нетразитивным), для него справедливо всё то же соображение об опасности автоматических перезапросов. Достаточно быстро мы придём к одному из двух вариантов: Проблемы частичного обновления ресурсов мы подробно разбирали в [соответствующей главе](#api-patterns-partial-updates) раздела «Паттерны дизайна API». Напомним, что полная перезапись ресурса методом `PUT` возможна, но быстро разбивается о необходимость работать с вычисляемыми и неизменяемыми полями, необходимость совместного редактирования и/или большой объём передаваемых данных. Работа через метод `PATCH` возможна, но, так как этот метод по умолчанию считается неидемпотентным (и часто нетранзитивным), для него справедливо всё то же соображение об опасности автоматических перезапросов. Достаточно быстро мы придём к одному из двух вариантов:
* либо `PUT` декомпозирован на множество составных `PUT /v1/orders/{id}/address`, `PUT /v1/orders/{id}/volume` и т.д. — по ресурсу для каждой частной операции; * либо `PUT` декомпозирован на множество составных `PUT /v1/orders/{id}/address`, `PUT /v1/orders/{id}/volume` и т.д. — по ресурсу для каждой частной операции;
* либо существует отдельный ресурс, принимающий список изменений, причём, вероятнее всего, через схему черновик-подтверждение в виде пары методов `POST` + `PUT`. * либо существует отдельный ресурс, принимающий список изменений, причём, вероятнее всего, через схему черновик-подтверждение в виде пары методов `POST` + `PUT`.

View File

@ -1 +1,136 @@
### Работа с ошибками в HTTP API ### [Работа с ошибками в HTTP API][http-api-errors]
Рассмотренные в предыдущих главах примеры организации API согласно стандарту HTTP и принципам REST покрывают т.н. «happy path», т.е. стандартный процесс работы с API в отсутствие ошибок. Конечно, нам не менее интересен и обратный кейс — каким образом HTTP API следует работать с ошибками, и чем стандарт и архитектурные принципы могут нам в этом помочь. Пусть какой-то агент в системе (неважно, клиент или гейтвей) пытается создать новый заказ:
```
POST /v1/orders?user_id=<user_id> HTTP/1.1
Authorization: Bearer <token>
If-Match: <ревизия>
{ /* параметры заказа */ }
```
Какие потенциальные неприятности могут ожидать нас при выполнении этого запроса? Навскидку, это:
1. Запрос не может быть прочитан (недопустимые символы, нарушение синтаксиса).
2. Токен авторизации отсутствует.
3. Токен авторизации невалиден.
4. Токен валиден, но пользователь не обладает правами создавать новый заказ.
5. Пользователь удалён или деактивирован.
6. Идентификатор пользователя неверен (не существует).
7. Ревизия не передана.
8. Ревизия не совпадает с последней актуальной.
9. В теле запроса отсутствуют обязательные поля.
10. Какое-то из полей запроса имеет недопустимое значение.
11. Превышены лимиты на допустимое количество запросов.
12. Сервер перегружен и не может ответить в настоящий момент.
13. Неизвестная серверная ошибка (т.е. сервер сломан настолько, что диагностика ошибки невозможна).
Исходя из общих соображений, соблазнительной кажется идея назначить каждой из ошибок свой статус-код. Скажем, для ошибки (4) напрашивается код `403`, а для ошибки (11) — `429`. Не будем, однако, торопиться, и прежде зададим себе вопрос *с какой целью* мы хотим назначить тот или иной код ошибки.
В нашей системе в общем случае присутствуют три агента: пользователь приложения, само приложение (клиент) и сервер. Каждому из этих акторов необходимо понимать ответ на три вопроса относительно ошибки (причём для каждого из акторов ответ может быть разным):
1. Кто допустил ошибку (конечный пользователь, разработчик клиента, разработчик сервера или какой-то промежуточный агент, например, программист сетевого стека).
* Не забудем учесть тот факт, что и конечный пользователь, и разработчик клиента могут допустить ошибку *намеренно*, например, пытаясь перебором подобрать пароль к чужому аккаунту.
2. Можно ли исправить ошибку, просто повторив запрос.
* Если да, то через какое время.
3. Если повтором запроса ошибку исправить нельзя, то можно ли её исправить, переформулировав запрос.
4. Если ошибку вообще нельзя исправить, то что с этим делать.
На один из этих вопрос в рамках стандарта HTTP ответить достаточно легко: регулировать желаемое время повтора запроса можно через параметры кэширования ответа и заголовок `Retry-After`. Также HTTP частично помогает с первым вопросом: для определения, на чьей стороне произошла ошибка, используется первая цифра статус-кода (см. ниже).
Со всеми остальными вопросами, увы, ситуация сильно сложнее.
#### Клиентские ошибки
Статус-коды, начинающиеся с цифры `4`, индицируют, что ошибка допущена пользователем или клиентом (или, по крайней мере, сервер так считает). *Обычно*, полученную `4xx` повторять бессмысленно — если не предпринять дополнительных действий по изменению состояния сервиса, этот запрос не будет выполнен успешно никогда. Однако из этого правила есть исключения, самые важные из которых — `429 Too Many Requests` и `404 Not Found`. Последняя по стандарту имеет смысл «состояния неопределённости»: сервер имеет право использовать её, если не желает раскрывать причины ошибки. После получения ошибки `404`, можно сделать повторный запрос, и он вполне может отработать успешно. Для индикации *персистентной* ошибки «ресурс не найден» используется отдельный статус `410 Gone`.
Более интересный вопрос — а что всё-таки клиент может (или должен) сделать, получив такую ошибку. Как мы указывали в главе «[Разграничение областей ответственности](#api-design-isolating-responsibility)», если ошибка может быть исправлена программно, необходимо в машиночитаемом виде индицировать это клиенту; если ошибка не может быть исправлена, необходимо включить человекочитаемые сообщения для пользователя (даже просто «попробуйте начать сначала / перезагрузить приложение» лучше с точки зрения UX, чем «неизвестная ошибка») и для разработчика, который будет разбираться с проблемой.
С восстановимыми ошибками в HTTP, к сожалению, ситуация достаточно сложная. С одной стороны, протокол включает в себя множество специальных кодов, которые индицируют проблемы с использованием самого протокола — такие как `405 Method Not Allowed` (данный глагол неприменим к указанному ресурсу), `406 Not Acceptable` (сервер не может вернуть ответ согласно `Accept`-заголовкам запроса), `411 Length Required`, `414 URI Too Long` и так далее. Код клиента может обработать данные ошибки и даже, возможно, предпринять какие-то действия по их устранению (например, добавить заголовок `Content-Length` в запрос после получения ошибки `411`), но все они очень плохо применимы к ошибкам в бизнес-логике. Например, мы можем вернуть `429 Too Many Request` при превышении лимитов запросов, но у нас нет никакого стандартного способа указать, *какой именно* лимит был превышен.
Частично проблему отсутствия стандартных подходов к возврату ошибок компенсируют использованием различных близких по смыслу статус-кодов для индикации разных состояний (либо и вовсе выбор произвольного кода ошибки и придания ему нового смысла в рамках конкретного API). В частности, сегодня де-факто стандартом является возврат кода `401 Unauthorized` при отсутствии заголовков авторизации или невалидном токене (получение этого кода, таким образом, является сигналом для приложения предложить пользователю залогиниться в системе), что противоречит стандарту (который требует при возврате `401` обязательно указать заголовок `WWW-Authenticate` с описанием способа аутентификации пользователя; нам неизвестны реальные API, которые выполняют это требованием).
Однако таких кодов, которые могут отражать нюансы одной и той же проблемы, в стандарте очень мало. Фактически, мы приходим к тому, что множество различных ошибок в логике приложения приходится возвращать под очень небольшим набором статус-кодов:
* `400 Bad Request` для всех ошибок валидации запроса (некоторые пуристы утверждают, что, вообще говоря, `400` соответствует нарушению формата запроса — невалидному JSON, например — а для логических ошибок следует использовать код `422 Unprocessable Content`; в постановке задачи это мало что меняет);
* `403 Forbidden` для любых проблем, связанных с авторизацией действий клиента;
* `404 Not Found` в случае, если какие-то из указанных в запросе сущностей не найдены *либо* раскрытие причин ошибки нежелательно;
* `409 Conflict` при нарушении целостности данных;
* `410 Gone` если ресурс был удалён;
* `429 Too Many Requests` при превышении лимитов.
Разработчики стандарта HTTP об этой проблеме вполне осведомлены, и отдельно отмечают, что для решения бизнес-сценариев необходимо передавать в метаданных либо теле ответа дополнительные данные для описания возникшей ситуации («the server SHOULD send a representation containing an explanation of the error situation, and whether it is a temporary or permanent condition»), что (как и введение новых специальных кодов ошибок) противоречит самой идее унифицированного машиночитаемого формата ошибок. (Отметим, что отсутствие стандартов описания ошибок в бизнес-логике — одна из основных причин, по которым мы считаем разработку REST API как его описал Филдинг в манифесте 2008 года невозможной; клиент *должен* обладать априорным знанием о том, как работать с метаинформацией об ошибке, иначе он сможет восстанавливать своё состояние после ошибки только перезагрузкой.)
Дополнительно, у проблемы есть и третье измерение в виде серверного ПО мониторинга состояния системы, которое часто полагается на статус-коды ответов при построении графиков и уведомлений. Между тем, ошибка, возникающая при вводе неправильного пароля, и ошибка, возникающая при истечении срока действия токена — это две очень разные ошибки; повышенный фон первой ошибки может говорить о потенциальной попытке взлома путём перебора паролей, а второй — о потенциальных ошибках в новой версии приложения, которая может неверно кэшировать токены авторизации.
Всё это естественным образом подводит нас к следующему выводу: если мы хотим использовать ошибки для диагностики и (возможно) восстановления состояния клиента, нам необходимо добавить метаинформацию о подвиде ошибки и, возможно, тело ошибки с указанием подробной информации о проблемах — например, как мы предлагали в главе «Описание конечных интерфейсов»:
```
POST /v1/coffee-machines/search HTTP/1.1
{
"recipes": ["lngo"],
"position": {
"latitude": 110,
"longitude": 55
}
}
HTTP/1.1 400 Bad Request
X-OurApi-Error-Kind: wrong_parameter_value
{
"reason": "wrong_parameter_value",
"localized_message":
"Что-то пошло не так.⮠
Обратитесь к разработчику приложения."
"details": {
"checks_failed": [
{
"field": "recipe",
"error_type": "wrong_value",
"message":
"Value 'lngo' unknown.⮠
Did you mean 'lungo'?"
},
{
"field": "position.latitude",
"error_type": "constraint_violation",
"constraints": {
"min": -90,
"max": 90
},
"message":
"'position.latitude' value⮠
must fall within⮠
the [-90, 90] interval"
}
]
}
}
```
Также напомним, что любые неизвестные `4xx`-статус-коды клиент должен трактовать как ошибку `400 Bad Request`, следовательно, формат (мета)данных ошибки `400` должен быть максимально общим.
#### Серверные ошибки
**Ошибки `5xx`** индицируют, что клиент, со своей стороны, выполнил запрос правильно, и проблема заключается в сервере. Для клиента, по большому счёту, важно только то, имеет ли смысл повторять запрос и, если да, то через какое время. Если учесть, что в любых публично доступных API причины серверных ошибок, как правило, не раскрывают — в абсолютном большинстве кодов `500 Internal Server Error` и `503 Service Unavailable` достаточно для индикации серверных ошибок (второй код указывает, что отказ в обслуживании имеет разовый характер и есть смысл автоматически повторить запрос), или можно вовсе ограничиться одним из них с опциональным заголовком `Retry-After`.
Разумеется, серверные ошибки также должны содержать информацию для разработчика и для конечного пользователя системы с описанием действий, которые необходимо выполнить при получении ошибки, и вот здесь мы вступаем на очень скользкую территорию.
Современная практика реализации HTTP-клиентов такова, что безусловно повторяются только немодифицирующие (`GET`, `HEAD`, `OPTIONS`) запросы. В случае модифицирующих запросов *разработчик должен написать код*, который повторит запрос — и для этого разработчику нужно очень внимательно прочитать документацию к API.
*Теоретически* идемпотентные методы `PUT` и `DELETE` можно вызывать повторно. Практически, однако, ввиду того, что многие разработчики упускают требование идемпотентности этих методов, фреймворки работы с HTTP API по умолчанию перезапросов модифицирующих методов, как правило, не делают, но некоторую выгоду из следования стандарту мы всё же можем извлечь — по крайней мере, сама сигнатура индицирует, что запрос *можно* повторять.
Что касается более сложных ситуаций, когда мы хотим указать разработчику, что он может безопасно повторить потенциально неидемпотентную операцию, то мы могли бы предложить формат описания доступных действий в теле ошибки… но практически никто не ожидает найти такое описание в самой ошибке. Возможно, потому, что с ошибками `5xx`, в отличие от `4xx`, программисты практически не сталкиваются при написании клиентского кода, и мало какие тестовые среды позволяют такие ошибки эмулировать. Так или иначе, описывать необходимые действия при получении серверной ошибки вам придётся в документации (и эти инструкции с большой долей вероятности будут проигнорированы).
#### Организация системы ошибок в HTTP API на практике
Как понятно из вышесказанного, фактически есть три способа работать с ошибками HTTP API:
1. Расширительно трактовать номенклатуру статус-кодов и использовать новый код каждый раз, когда требуется индицировать новый вид ошибки. (Автор этой книги неоднократно встречал ситуации, когда при разработке API просто выбирался «похоже выглядящий» статус безо всякой оглядки на его описание в стандарте.)
2. Полностью отказаться от использования статусов и вкладывать описание ошибки в тело и/или метаданные ответа с кодом `200`. Этим путём идут почти все RPC-фреймворки.
3. Применить смешанный подход, то есть использовать статус-код согласно его семантике для индикации *рода* ошибки и вложенные данные в специально разработанном формате для детализации (подобно фрагменту кода, предложенному нами выше в параграфе «Клиентские ошибки»).
* Вариантом этой стратегии можно считать использование всего двух статус-кодов (`400` для любой клиентской ошибки, `500` для любой серверной), опционально трёх (те же плюс `404` для статуса неопределённости), что фактически очень мало отличается от подхода (1).
Как нетрудно заметить, считать соответствующим стандарту можно только подход (3), пусть и выгода от такого соответствия невелика.