You've already forked The-API-Book
mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-08-10 21:51:42 +02:00
continue errors
This commit is contained in:
@@ -72,7 +72,13 @@ There is also an important question regarding the default behavior of the server
|
||||
|
||||
#### Evaluating the Risks of Switching to Eventual Consistency
|
||||
|
||||
Let us state an important assertion: the methods of solving architectural problems we're discussing in this section *are probabilistic*. Abolishing strict consistency means that, even if all components of the system work perfectly, client errors will still occur — and we may only try to lessen their numbers for typical usage profiles.
|
||||
Let us state an important assertion: the methods of solving architectural problems we're discussing in this section are probabilistic. Abolishing strict consistency means that even if all components of the system work perfectly, client errors will still occur. It might appear that they could be simply ignored, but in reality, doing so means introducing risks.
|
||||
|
||||
Imagine that because of eventual consistency, users of our API sometimes cannot create orders with their first attempt. For example, a customer adds a new payment method in the application, but their subsequent order creation request is routed to a replica that hasn't yet received the information regarding the newest payment method. As these two actions (adding a bank card and making an order) often go in conjunction, there will be a noticeable percentage of errors — let's say, 1%. At this stage, we could disregard the situation as it appears harmless: in the worst-case scenario, the client will repeat the request.
|
||||
|
||||
But let's go a bit further and imagine there is an error in a new version of the application, and 0.1% of end users cannot make an order at all because the client sends a wrong payment method identifier. In the absence of this 1% background noise of consistency-bound errors, we would find the issue very quickly. However, amidst this constant inflow of errors, identifying problems like this one could be very challenging as it requires configuring monitoring systems to reliably exclude the data consistency errors, and this could be very complicated or even impossible. The author of this book, in his job, has seen several situations when critical mistakes that affect a small percentage of users were not noticed for months.
|
||||
|
||||
Therefore, the task of proactively lowering the number of these background errors is crucially important. We may try to reduce their occurrence for typical usage profiles.
|
||||
|
||||
**NB**: the “typical usage profile” stipulation is important: an API implies the variability of client scenarios, and API usage cases might fall into several groups, each featuring quite different error profiles. The classical example is client APIs (where it's an end user who makes actions and waits for results) versus server APIs (where the execution time is per se not so important — but let's say mass parallel execution might be). If this happens, it's a strong signal to make a family of API products covering different usage scenarios, as we will discuss in “[The API Services Range](#api-product-range)” chapter of “The API Product” section of this book.
|
||||
|
||||
|
@@ -15,7 +15,7 @@ HTTP/1.1 200 OK
|
||||
}
|
||||
```
|
||||
|
||||
the server could have simply responded with `400 Bad Request`, passing the request identifier as a custom header like `X-OurCoffeeAPI-Request-Id`. Nevertheless, protocol designers decided to introduce their own custom format.
|
||||
the server could have simply responded with `400 Bad Request`, passing the request identifier as a custom header like `X-OurCoffeeAPI-RequestId`. Nevertheless, protocol designers decided to introduce their own custom format.
|
||||
|
||||
This situation (not only with JSON-RPC but with essentially every high-level protocol built on top of HTTP) has developed due to various reasons. Some of them are historical (such as the inability to use many HTTP protocol features in early implementations of the `XMLHttpRequest` functionality in web browsers). However, new RPC protocols relying on the bare minimum of HTTP capabilities continue to emerge today.
|
||||
|
||||
|
@@ -18,7 +18,7 @@ This convention allows for reflecting almost any API's entity nomenclature decen
|
||||
* A header:
|
||||
```
|
||||
GET /orders/{id} HTTP/1.1
|
||||
X-OurCoffeeApi-Version: 1
|
||||
X-OurCoffeeAPI-Version: 1
|
||||
```
|
||||
|
||||
Even more exotic options can be added here, such as specifying the schema in a customized media type or request protocol.
|
||||
|
@@ -59,4 +59,126 @@ Even if we choose this approach, there are very few status codes that can reflec
|
||||
* `410 Gone` if the resource was deleted.
|
||||
* `429 Too Many Requests` if some quotas are exceeded.
|
||||
|
||||
The editors of the specification are very well aware of this problem as they state that “the server SHOULD send a representation containing an explanation of the error situation, and whether it is a temporary or permanent condition.” This, however, contradicts the entire idea of a uniform interface. (Let us additionally emphasize that this lack of standard tools to describe business logic-bound errors is one of the reasons we consider the REST architectural style as described by Fielding in his 2008 article non-viable. The client *must* possess prior knowledge of error formats and how to work with them. Otherwise, it could restore its state after an error only by restarting the application.)
|
||||
The editors of the specification are very well aware of this problem as they state that “the server SHOULD send a representation containing an explanation of the error situation, and whether it is a temporary or permanent condition.” This, however, contradicts the entire idea of a uniform machine-readable interface (and so does the idea of using arbitrary status codes). (Let us additionally emphasize that this lack of standard tools to describe business logic-bound errors is one of the reasons we consider the REST architectural style as described by Fielding in his 2008 article non-viable. The client *must* possess prior knowledge of error formats and how to work with them. Otherwise, it could restore its state after an error only by restarting the application.)
|
||||
|
||||
Additionally, there is a third dimension of this problem in the form of webserver software for monitoring system health that often relies on status codes to plot charts and emit notifications. However, two errors represented with the same status code — let's say, wrong password and expired token — might be very different. The increased rate of the former might indicate brute-forcing of accounts, while an unusually high frequency of the latter could be a result of a client error if a new version of an application wrongly caches authorization tokens.
|
||||
|
||||
All these observations naturally lead us to the following conclusion: if we want to use error for diagnostics and (possibly) helping clients to recover, we need to include machine-readable metadata about the error subtype and, possibly, additional properties to the error body with a detailed description of an error. For example, as we proposed in the “[Describing Final Interfaces](#api-design-describing-interfaces)” chapter:
|
||||
|
||||
```
|
||||
POST /v1/coffee-machines/search HTTP/1.1
|
||||
|
||||
{
|
||||
"recipes": ["lngo"],
|
||||
"position": {
|
||||
"latitude": 110,
|
||||
"longitude": 55
|
||||
}
|
||||
}
|
||||
→
|
||||
HTTP/1.1 400 Bad Request
|
||||
X-OurCoffeeAPI-Error-Kind:⮠
|
||||
wrong_parameter_value
|
||||
|
||||
{
|
||||
"reason": "wrong_parameter_value",
|
||||
"localized_message":
|
||||
"Something is wrong.⮠
|
||||
Contact the developer of the app."
|
||||
"details": {
|
||||
"checks_failed": [
|
||||
{
|
||||
"field": "recipe",
|
||||
"error_type": "wrong_value",
|
||||
"message":
|
||||
"Unknown value: 'lngo'.⮠
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Let us also remind the reader that client must treat unknown `4xx` status codes as a `400 Bad Request` error, therefor the (meta)data format for the `400` error must be as general as possible.
|
||||
|
||||
#### Server Errors
|
||||
|
||||
**`5xx` errors** indicate that the client did everything right, and the problem is server-bound. For the client, the only important thing about the server error is whether it makes sense to repeat the request (and if yes, then when). Keeping in mind that in publicly available APIs, the real reason of the error is usually not exposed, having just the `500 Internal Server Error` and `503 Service Unavailable` codes is enough for most of the subject areas. (The latter is needed for indicating that the denial of service state is temporary. Might be replaced with just a `Retry-After` header to the `500` error.)
|
||||
|
||||
For internal systems, however, this argumentation is wrong. To build proper monitoring and notification systems, server errors must contain machine-readable error subtypes, just as the client ones. The same approaches are applicable (either using arbitrary status codes and/or passing error kind as a header), however, this data must be cut off by a gateway that marks the border between external and internal systems and replaced with general instructions for both developers and end users, describing actions that need to be performed upon receiving an error.
|
||||
|
||||
```
|
||||
POST /v1/orders/?user_id=<user id> HTTP/1.1
|
||||
If-Match: <revision>
|
||||
|
||||
{ parameters }
|
||||
→
|
||||
// The response the gateway received
|
||||
// from the server, the metadata
|
||||
// of which will be used for
|
||||
// monitoring and diagnostics
|
||||
HTTP/1.1 500 Internal Server Error
|
||||
// Error kind: timeout from the DB
|
||||
X-OurCoffeAPI-Error-Kind: db_timeout
|
||||
{ /*
|
||||
* Additional data, such as
|
||||
* which host returned an error
|
||||
*/ }
|
||||
```
|
||||
```
|
||||
// The response as returned to
|
||||
// the client. The details regarding
|
||||
// the server error are removed
|
||||
// and replace with instructions
|
||||
// for the client. As at the gateway
|
||||
// level it is unknown whether
|
||||
// order creation succeeded, the client
|
||||
// is advised to repeat the request
|
||||
// and/or retrieve the actual state.
|
||||
HTTP/1.1 500 Internal Server Error
|
||||
Retry-After: 5
|
||||
|
||||
{
|
||||
"reason": "internal_server_error",
|
||||
"localized_message": "Cannot get⮠
|
||||
a response from the server.⮠
|
||||
Please try repeating the operation
|
||||
or reload the page.",
|
||||
"details": {
|
||||
"can_be_retried": true,
|
||||
"is_operation_failed": "unknown"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
However, here we go on a slippery slope. The contemporary practice of implementing HTTP API clients allows for repeating safe requests (e.g., `GET`, `HEAD`, and `OPTIONS` methods). In case of unsafe methods, *developers are to write code* to repeat the request — and to do so they need to read the docs very carefully to check if is the desired behavior and it is actually safe.
|
||||
|
||||
*Theoretically*, with idempotent `PUT` and `DELETE` it should be more convenient. Practically, as many developers let this knowledge pass them, frameworks for working HTTP APIs will likely not repeat these requests. Still, we can get *some* benefit from following the standards as the signature itself indicates the request can be retried.
|
||||
|
||||
As for more complex operations, to make developers aware they can repeat a potentially unsafe operation, we could introduce a format describing the possible actions in the error response itself… However, developers seldom expect to find such instructions in the error body — probably, because programmers rarely see `5xx` errors during development, unlike their `4xx` counterparts, and testing environments usually do not provide capabilities to emulate server errors. All in all, you will have to describe the desirable actions in the documentation. (Be aware that this instruction will likely be ignored. This is the way.)
|
||||
|
||||
#### Organizing HTTP API Error SNomenclature in Practice
|
||||
|
||||
As it is obvious from what was discussed above, there are essentially three approaches to working with errors in HTTP APIs:
|
||||
|
||||
1. Applying an “extended interpretation” to the status code nomenclature, or in plain words, select or invent new status code each type new type of error is introduced. (The author or this book several times observed an approach to API development that included choosing status code by wording resembling the error cause, disregarding its description in the standard completely.)
|
||||
|
||||
2. Abolishing using status codes and develop a format for errors enveloped in a `200` HTTP response. Most RPC frameworks choose this direction.
|
||||
* 2a. A subvariant of this strategy is using just two status codes (`400` for every client error, `500` for every server error), optionally complemented by a third one (`404` to indicate situations of uncertainty).
|
||||
|
||||
3. Employing a mixed approach, i.e., using status codes in accordance to their semantics to indicate an *error family* with additional (meta)data being passed in a specially developed format (akin to code sample we gave above).
|
||||
|
||||
Obviously, only approach \#3 could be considered compliant to the standard. Let us be hones and say that the benefits of following it (especially compared to option \#2a) are not very significant and only comprise better readability of logs and transparency for intermediate proxies.
|
@@ -74,9 +74,15 @@ const pendingOrders = await api.
|
||||
|
||||
#### Риски перехода к событийной консистентности
|
||||
|
||||
Прежде всего, давайте зафиксируем один важный тезис: все обсуждаемые в настоящем разделе техники решения архитектурных проблем — вероятностные. Отказ от строгой консистентности означает, что даже при идеальной работе компонентов системы клиентские ошибки все равно будут возникать — мы только можем постараться сделать так, чтобы при типичном профиле использования системы ошибок было меньше.
|
||||
Прежде всего, давайте зафиксируем один важный тезис: все обсуждаемые в настоящем разделе техники решения архитектурных проблем — вероятностные. Отказ от строгой консистентности означает, что даже при идеальной работе компонентов системы клиентские ошибки все равно будут возникать. Может показаться, что этот фон ошибок можно просто проигнорировать, но это весьма рискованно.
|
||||
|
||||
Оговорка про «типичный профиль важна»: API предполагает вариативность сценариев его применения, и вполне может оказаться так, что кейсы использования API делятся на несколько сильно отличающихся с точки зрения толерантности к ошибкам групп (классический пример — это клиентские API, где завершения операций ждёт реальный пользователь, против серверных API, где время исполнения само по себе менее важно, но может оказаться важным, например, массовый параллелизм операций). Если такое происходит — это сильный сигнал для того, чтобы выделить API для различных типовых сценариев в отдельные продукты в семействе API, о чём мы поговорим в главе [«Линейка сервисов API»](#api-product-range) раздела «API как продукт».
|
||||
Представим, что в нашей системе из-за событийной консистентности клиенты с какой-то вероятностью не могут сделать заказ с первой попытки. Например, пользователь добавляет в приложении новый метод оплаты, но при создании заказа попадает на реплику, которая ещё не получила данные о новом способе оплаты. Так как пользователи довольно часто совершают эти две операции (добавление банковской карты и заказ) подряд, фон ошибок будет довольно значительным — пусть для примера 1% — но нас это пока не беспокоит: в худшем случае клиент выполнит автоматический перезапрос.
|
||||
|
||||
Предположим теперь, однако, что в новой версии приложения была допущена ошибка, и 0.1% пользователей не могут выполнить заказ вовсе по причине того, что клиент отсылает неправильный идентификатор метода оплаты. В отсутствие 1% ошибок консистентности данных эта проблема была бы выявлена очень быстро; но на фоне имеющихся ошибок найти её весьма непросто: для этого требуется настроить мониторинги так, чтобы они точно исключали ошибки, вызванные нестрогой консистентностью данных, а это может быть весьма непросто, а то и вообще невозможно. Автор этой книги сталкивался с такими ситуациями в своей работе: ошибку, затрагивающую небольшой процент пользователей, можно не замечать месяцами.
|
||||
|
||||
Таким образом, задача проактивного снижения фона ошибок критически важна. Мы можем постараться сделать так, чтобы при типичном профиле использования системы ошибок было меньше.
|
||||
|
||||
**NB**: оговорка про «типичный профиль» здесь не просто так: API предполагает вариативность сценариев его применения, и вполне может оказаться так, что кейсы использования API делятся на несколько сильно отличающихся с точки зрения толерантности к ошибкам групп (классический пример — это клиентские API, где завершения операций ждёт реальный пользователь, против серверных API, где время исполнения само по себе менее важно, но может оказаться важным, например, массовый параллелизм операций). Если такое происходит — это сильный сигнал для того, чтобы выделить API для различных типовых сценариев в отдельные продукты в семействе API, о чём мы поговорим в главе [«Линейка сервисов API»](#api-product-range) раздела «API как продукт».
|
||||
|
||||
Проиллюстрируем этот принцип на нашем примере с заказом кофе. Предположим, что мы реализуем следующую схему:
|
||||
* оптимистичное управление синхронизацией (скажем, через идентификатор последнего заказа);
|
||||
|
@@ -15,7 +15,7 @@ HTTP/1.1 200 OK
|
||||
}
|
||||
```
|
||||
|
||||
сервер мог бы ответить просто `400 Bad Request` (с передачей идентификатора запроса, ну скажем, в заголовке `X-OurCoffeeAPI-Request-Id`). Тем не менее, разработчики протокола посчитали нужным разработать свой собственный формат.
|
||||
сервер мог бы ответить просто `400 Bad Request` (с передачей идентификатора запроса, ну скажем, в заголовке `X-OurCoffeeAPI-RequestId`). Тем не менее, разработчики протокола посчитали нужным разработать свой собственный формат.
|
||||
|
||||
Такая ситуация (не только конкретно с JSON-RPC, а почти со всеми высокоуровневыми протоколами поверх HTTP) сложилась по множеству причин, включая разнообразные исторические (например, невозможность использовать многие возможности HTTP из ранних реализаций `XMLHttpRequest` в браузерах). Однако, новые варианты RPC-протоколов, использующих абсолютный минимум возможностей HTTP, продолжают появляться и сегодня.
|
||||
|
||||
|
@@ -18,7 +18,7 @@
|
||||
* как заголовок:
|
||||
```
|
||||
GET /orders/{id} HTTP/1.1
|
||||
X-OurCoffeeApi-Version: 1
|
||||
X-OurCoffeeAPI-Version: 1
|
||||
```
|
||||
|
||||
Сюда можно приплюсовать и более экзотические варианты, такие как указание схемы в кастомизированном медиатипе или протоколе запроса.
|
||||
|
@@ -59,9 +59,9 @@ If-Match: <ревизия>
|
||||
|
||||
Разработчики стандарта 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 года невозможной; клиент *должен* обладать априорным знанием о том, как работать с метаинформацией об ошибке, иначе он сможет восстанавливать своё состояние после ошибки только перезагрузкой.)
|
||||
|
||||
Дополнительно, у проблемы есть и третье измерение в виде серверного ПО мониторинга состояния системы, которое часто полагается на статус-коды ответов при построении графиков и уведомлений. Между тем, ошибка, возникающая при вводе неправильного пароля, и ошибка, возникающая при истечении срока действия токена — это две очень разные ошибки; повышенный фон первой ошибки может говорить о потенциальной попытке взлома путём перебора паролей, а второй — о потенциальных ошибках в новой версии приложения, которая может неверно кэшировать токены авторизации.
|
||||
Дополнительно, у проблемы есть и третье измерение в виде серверного ПО мониторинга состояния системы, которое часто полагается на статус-коды ответов при построении графиков и уведомлений. Между тем, ошибки, скрывающиеся под одним статус кодом — например ввод неправильного пароля и истёкший срок жизни токена — могут быть очень разными по смыслу; повышенный фон первой ошибки может говорить о потенциальной попытке взлома путём перебора паролей, а второй — о потенциальных ошибках в новой версии приложения, которая может неверно кэшировать токены авторизации.
|
||||
|
||||
Всё это естественным образом подводит нас к следующему выводу: если мы хотим использовать ошибки для диагностики и (возможно) восстановления состояния клиента, нам необходимо добавить метаинформацию о подвиде ошибки и, возможно, тело ошибки с указанием подробной информации о проблемах — например, как мы предлагали в главе «Описание конечных интерфейсов»:
|
||||
Всё это естественным образом подводит нас к следующему выводу: если мы хотим использовать ошибки для диагностики и (возможно) восстановления состояния клиента, нам необходимо добавить машиночитаемую метаинформацию о подвиде ошибки и, возможно, тело ошибки с указанием подробной информации о проблемах — например, как мы предлагали в главе «[Описание конечных интерфейсов](#api-design-describing-interfaces)»:
|
||||
|
||||
```
|
||||
POST /v1/coffee-machines/search HTTP/1.1
|
||||
@@ -75,7 +75,8 @@ POST /v1/coffee-machines/search HTTP/1.1
|
||||
}
|
||||
→
|
||||
HTTP/1.1 400 Bad Request
|
||||
X-OurApi-Error-Kind: wrong_parameter_value
|
||||
X-OurCoffeeAPI-Error-Kind:⮠
|
||||
wrong_parameter_value
|
||||
|
||||
{
|
||||
"reason": "wrong_parameter_value",
|
||||
@@ -114,13 +115,56 @@ X-OurApi-Error-Kind: wrong_parameter_value
|
||||
|
||||
**Ошибки `5xx`** индицируют, что клиент, со своей стороны, выполнил запрос правильно, и проблема заключается в сервере. Для клиента, по большому счёту, важно только то, имеет ли смысл повторять запрос и, если да, то через какое время. Если учесть, что в любых публично доступных API причины серверных ошибок, как правило, не раскрывают — в абсолютном большинстве кодов `500 Internal Server Error` и `503 Service Unavailable` достаточно для индикации серверных ошибок (второй код указывает, что отказ в обслуживании имеет разовый характер и есть смысл автоматически повторить запрос), или можно вовсе ограничиться одним из них с опциональным заголовком `Retry-After`.
|
||||
|
||||
Разумеется, серверные ошибки также должны содержать информацию для разработчика и для конечного пользователя системы с описанием действий, которые необходимо выполнить при получении ошибки, и вот здесь мы вступаем на очень скользкую территорию.
|
||||
Для внутренних систем, вообще говоря, такое рассуждение неверно. Для построения правильных мониторингов и системы оповещений необходимо, чтобы серверные ошибки, точно так же, как и клиентские, содержали подтип ошибки в машиночитаемом виде. Здесь по-прежнему применимы те же подходы — использование широкой номенклатуры кодов и/или передача типа ошибки заголовком — однако эта информация должна быть вырезана гейтвеем на границе внешней и внутренней систем, и заменена на общую информацию для разработчика и для конечного пользователя системы с описанием действий, которые необходимо выполнить при получении ошибки.
|
||||
|
||||
Современная практика реализации HTTP-клиентов такова, что безусловно повторяются только немодифицирующие (`GET`, `HEAD`, `OPTIONS`) запросы. В случае модифицирующих запросов *разработчик должен написать код*, который повторит запрос — и для этого разработчику нужно очень внимательно прочитать документацию к API.
|
||||
```
|
||||
POST /v1/orders/?user_id=<user id> HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
|
||||
{ parameters }
|
||||
→
|
||||
// Ответ, полученный гейтвеем
|
||||
// от сервиса обработки заказов,
|
||||
// метаданные которого будут
|
||||
// использованы для мониторинга
|
||||
HTTP/1.1 500 Internal Server Error
|
||||
// Тип ошибки: получен таймаут от БД
|
||||
X-OurCoffeAPI-Error-Kind: db_timeout
|
||||
{ /*
|
||||
* Дополнительные данные, например,
|
||||
* какой хост ответил таймаутом
|
||||
*/ }
|
||||
```
|
||||
```
|
||||
// Ответ, передаваемый клиенту.
|
||||
// Детали серверной ошибки удалены
|
||||
// и заменены на инструкцию клиенту.
|
||||
// Поскольку гейтвей не знает, был
|
||||
// ли в действительности сделан заказ,
|
||||
// клиенту рекомендуется попробовать
|
||||
// повторить запрос и/или попытаться
|
||||
// получить актуальное состояние
|
||||
HTTP/1.1 500 Internal Server Error
|
||||
Retry-After: 5
|
||||
|
||||
{
|
||||
"reason": "internal_server_error",
|
||||
"localized_message": "Не удалось⮠
|
||||
получить ответ от сервера.⮠
|
||||
Попробуйте повторить операцию
|
||||
или обновить страницу.",
|
||||
"details": {
|
||||
"can_be_retried": true,
|
||||
"is_operation_failed": "unknown"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Вот здесь мы, однако, вступаем на очень скользкую территорию. Современная практика реализации HTTP-клиентов такова, что безусловно повторяются только немодифицирующие (`GET`, `HEAD`, `OPTIONS`) запросы. В случае модифицирующих запросов *разработчик должен написать код*, который повторит запрос — и для этого разработчику нужно очень внимательно прочитать документацию к API, чтобы убедиться, что это поведение допустимо и не приведёт к побочным эффектам.
|
||||
|
||||
*Теоретически* идемпотентные методы `PUT` и `DELETE` можно вызывать повторно. Практически, однако, ввиду того, что многие разработчики упускают требование идемпотентности этих методов, фреймворки работы с HTTP API по умолчанию перезапросов модифицирующих методов, как правило, не делают, но некоторую выгоду из следования стандарту мы всё же можем извлечь — по крайней мере, сама сигнатура индицирует, что запрос *можно* повторять.
|
||||
|
||||
Что касается более сложных ситуаций, когда мы хотим указать разработчику, что он может безопасно повторить потенциально неидемпотентную операцию, то мы могли бы предложить формат описания доступных действий в теле ошибки… но практически никто не ожидает найти такое описание в самой ошибке. Возможно, потому, что с ошибками `5xx`, в отличие от `4xx`, программисты практически не сталкиваются при написании клиентского кода, и мало какие тестовые среды позволяют такие ошибки эмулировать. Так или иначе, описывать необходимые действия при получении серверной ошибки вам придётся в документации (и эти инструкции с большой долей вероятности будут проигнорированы).
|
||||
Что касается более сложных ситуаций, когда мы хотим указать разработчику, что он может безопасно повторить потенциально неидемпотентную операцию, то мы могли бы предложить формат описания доступных действий в теле ошибки… но практически никто не ожидает найти такое описание в самой ошибке. Возможно, потому, что с ошибками `5xx`, в отличие от `4xx`, программисты практически не сталкиваются при написании клиентского кода, и мало какие тестовые среды позволяют такие ошибки эмулировать. Так или иначе, описывать необходимые действия при получении серверной ошибки вам придётся в документации. (Имейте в виду, что эти инструкции с большой долей вероятности будут проигнорированы. Таков путь.)
|
||||
|
||||
#### Организация системы ошибок в HTTP API на практике
|
||||
|
||||
@@ -128,9 +172,9 @@ X-OurApi-Error-Kind: wrong_parameter_value
|
||||
|
||||
1. Расширительно трактовать номенклатуру статус-кодов и использовать новый код каждый раз, когда требуется индицировать новый вид ошибки. (Автор этой книги неоднократно встречал ситуации, когда при разработке API просто выбирался «похоже выглядящий» статус безо всякой оглядки на его описание в стандарте.)
|
||||
|
||||
2. Полностью отказаться от использования статусов и вкладывать описание ошибки в тело и/или метаданные ответа с кодом `200`. Этим путём идут почти все RPC-фреймворки.
|
||||
2. Полностью отказаться от использования статус-кодов и вкладывать описание ошибки в тело и/или метаданные ответа с кодом `200`. Этим путём идут почти все RPC-фреймворки.
|
||||
* 2а. Вариантом этой стратегии можно считать использование всего двух статус-кодов ошибок (`400` для любой клиентской ошибки, `500` для любой серверной), опционально трёх (те же плюс `404` для статуса неопределённости).
|
||||
|
||||
3. Применить смешанный подход, то есть использовать статус-код согласно его семантике для индикации *рода* ошибки и вложенные данные в специально разработанном формате для детализации (подобно фрагменту кода, предложенному нами выше в параграфе «Клиентские ошибки»).
|
||||
* Вариантом этой стратегии можно считать использование всего двух статус-кодов (`400` для любой клиентской ошибки, `500` для любой серверной), опционально трёх (те же плюс `404` для статуса неопределённости), что фактически очень мало отличается от подхода (1).
|
||||
3. Применить смешанный подход, то есть использовать статус-код согласно его семантике для индикации *рода* ошибки и вложенные (мета)данные в специально разработанном формате для детализации (подобно фрагментам кода, предложенным нами в настоящей главе).
|
||||
|
||||
Как нетрудно заметить, считать соответствующим стандарту можно только подход (3), пусть и выгода от такого соответствия невелика.
|
||||
Как нетрудно заметить, считать соответствующим стандарту можно только подход (3). Будем честны и скажем, что выгоды следования ему, особенно по сравнению с вариантом (2а), не очень велики и состоят в основном в чуть лучшей читабельности логов и большей прозрачности для промежуточных прокси.
|
@@ -1,121 +0,0 @@
|
||||
### Работа с ошибками в HTTP API
|
||||
|
||||
Рассмотренный в предыдущей главе пример организации 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 API применяется множество инструментов, самым главным из которых является статус-код ошибки.
|
||||
|
||||
Для определения, на чьей стороне произошла ошибка, используется первая цифра статус-кода: `4xx` — клиентские ошибки (за исключением состояния неопределённости, см. ниже), `5xx` — серверные.
|
||||
|
||||
#### Клиентские ошибки
|
||||
|
||||
**Ошибки `4xx`** повторять бессмысленно — если не предпринять дополнительных действий по изменению состояния сервиса, этот запрос не будет выполнен успешно никогда. Однако из этого правила есть исключения, самые важные из которых — `429 Too Many Requests` и `404 Not Found`. Последняя по стандарту имеет смысл «состояния неопределённости»: сервер имеет право использовать её, если не желает раскрывать причины ошибки. После получения ошибки `404`, можно сделать повторный запрос, и он вполне может отработать успешно. для индикации *персистентной* ошибки «ресурс не найден» используется отдельный статус `410 Gone`.
|
||||
|
||||
Более интересный вопрос — а что клиент может (или должен) сделать, получив такую ошибку. Как мы указывали в главе «Разграничение областей ответственности», если ошибка может быть исправлена программно, необходимо в машиночитаемом виде индицировать это клиенту; если ошибка не может быть исправлена, необходимо включить человекочитаемые сообщения для пользователя (фактически, вы можете предложить только два вида таких сообщений — «попробуйте повторить операцию позднее» и «попробуйте начать сначала / перезагрузить приложение») и для разработчика, который будет разбираться с проблемой.
|
||||
|
||||
С восстановимыми ошибками в 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 по умолчанию перезапросов модифицирующих методов, как правило, не делают; перезапросов потенциально неидемпотентных `POST` и `PATCH` (а также частичного `PUT`) фреймворки не допускают почти никогда, и эту логику приходится реализовывать разработчикам (клиента или гейтвея). Однако сама эта возможность — автоматически повторять идемпотентные запросы в случае серверной / сетевой ошибки — очень большое преимущество, которое даёт нам следование парадигме HTTP API, поскольку в альтернативных парадигмах (RPC, в частности) промежуточный агент (например, гейтвей) обычно никак не может узнать, идемпотентна ли операция.
|
@@ -1,158 +0,0 @@
|
||||
### Организация URL ресурсов и операций над ними в HTTP API
|
||||
|
||||
Как мы поняли из предыдущих глав, разработка номенклатуры URL и применимых к этим URL методов (вкупе с предоставлением метаинформации и кодификацией ошибок) — важнейший аспект проектирования HTTP API. Однако, если мы взглянем на *технические* требования к URL, выдвигаемые разработчиками стандарта, то обнаружим, что они сводятся к следующему:
|
||||
* URL должны быть ключами кэширования и идемпотентности там, где это применимо;
|
||||
* должна быть поддержана ссылочная целостность там, где этого требует стандарт (например, при получении статус-кода `201 Created` URL вновь созданного ресурса считается равным заголовку `Location`, если он есть, или самому исходному URL, если заголовка нет).
|
||||
|
||||
Требований к URL иметь определённую *структуру* ни стандарт HTTP, ни ограничения REST не содержат — более того, разработчики стандарта намеренно уклоняются от определений path и query, не предоставляя никаких рекомендаций по их семантике и организации.
|
||||
|
||||
Важный вывод из вышесказанного, который мы хотели бы подчеркнуть, следующий: **правила организации URL в API существуют *только* для читабельности кода и удобства разработчика**. С точки зрения буквы стандарта (а также диссертации и последующих разъяснений Филдинга) URL вообще могут быть просто случайными строками, лишь бы пресловутая ссылочная целостность была сохранена. Многочисленные советы по разработке HTTP API, которые вы можете найти в Интернете — типа «URL не должен содержать глаголов» или «используйте вложенные path для организации иерархических данных» — не более чем явочным порядком сложившийся стиль кодирования, который теперь навязывается разработчикам под видом best practice разработки «REST API».
|
||||
|
||||
Мы, тем не менее, совершенно не склонны утверждать, что организация URL — это что-то неважное. Напротив, URL в HTTP API являются средством выразить уровни абстракции и области ответственности объектов; поэтому разработка иерархия сущностей API должна напрямую отражаться в иерархии URL. Традиционно частям URL приписывается следующая семантика:
|
||||
|
||||
* части path (фрагменты пути между символами `/`) используются для организации вложенных сущностей вида `/partner/{id}/coffee-machines/{id}`; при этом путь часто может наращиваться, т.е. к конкретному пути продолжают приписываться новые суффиксы, указывающие на подчинённые ресурсы;
|
||||
* query используется для организации нестрогой иерархии (отношений «многие ко многим», например `/recipes/?partner=<partner_id>`) либо как способ передать параметры операции (`/search/?recipe=lungo`).
|
||||
|
||||
Подобная конвенция достаточно хорошо подходит для того, чтобы отразить номенклатуру сущностей почти любого API, поэтому следовать ей вполне разумно (и, наоборот, демонстративное нарушение этого устоявшегося соглашения чревато тем, что разработчики вас просто неправильно поймут). Однако подобная некодифицированная и размытая концепция неизбежно вызывает множество разночтений в конкретных моментах:
|
||||
|
||||
1. Каким образом организовывать эндпойнты, связывающие две сущности, между которыми нет явных отношений подчинения? Скажем, каким должен быть URL запуска приготовления лунго на конкретной кофе-машине?
|
||||
* `/coffee-machines/{id}/recipes/lungo/prepare`
|
||||
* `/recipes/lungo/coffee-machines/{id}/prepare`
|
||||
* `/coffee-machines/{id}/prepare?recipe=lungo`
|
||||
* `/recipes/lungo/prepare?coffee_machine_id=<id>`
|
||||
* `/prepare?coffee_machine_id=<id>&recipe=lungo`
|
||||
* `/action=prepare&coffee_machine_id=<id>&recipe=lungo`
|
||||
|
||||
Все эти варианты семантически вполне допустимы и в общем-то равноправны. Более того, можно даже поддерживать несколько из них одновременно.
|
||||
|
||||
2. Насколько строго должна выдерживаться читабельность операции `ГЛАГОЛ ресурс`? Многие учебники по REST API безапелляционно требуют, чтобы `ресурс` был существительным (ведь странно применять глагол к глаголу), и, таким образом, в примерах выше должно быть не `prepare`, а `preparator` (а вариант `/action=prepare&coffee_machine_id=<id>&recipe=lungo` вовсе недопустим, так как нет объекта действия). С нашей точки зрения единственная выгода от соблюдения этого требования только одна — становится интуитивно понятным требование «URL = ключ кэширования»; но мы, естественно, выступаем за то, чтобы разработчик *понимал* основы протокола, а не интуитивно догадывался о них.
|
||||
|
||||
3. Если сигнатура вызова по умолчанию модифицирующая или неидемпотентная, означает ли это, что операция *обязана* быть модифицирующей / идемпотентной? Двойственность смысловой нагрузки глаголов (семантика vs побочные действия) порождает неопределённость в вопросах организации таких API. Рассмотрим, например, ресурс `/v1/search`, осуществляющий поиск предложений кофе в нашем учебном API. С каким глаголом мы должны к нему обращаться?
|
||||
* С одной стороны, `GET /v1/search?query=<поисковый запрос>` позволяет явно продекларировать, что никаких посторонних эффектов у этого запроса нет (никакие данные не перезаписываются) и результаты его можно кэшировать (при условии, что все значимые параметры передаются в URL, т.е. в виде path и query-параметров).
|
||||
* С другой стороны, согласно семантике операции, `GET /v1/search` должен возвращать *представление ресурса* `search`. Но разве результаты поиска являются представлением ресурса-поисковика? Смысл операции «поиск» гораздо точнее описывается фразой «обработка запроса в соответствии с внутренней семантикой ресурса», т.е. соответствует методу `POST`. Кроме того, можем ли мы вообще говорить о кэшировании поисковых запросов? Страница результатов поиска формируется динамически из множества источников, и повторный запрос с той же поисковой фразой почти наверняка выдаст другой список результатов.
|
||||
|
||||
Иными словами, для любых операций, результат которых представляет собой результат работы какого-то алгоритма (например, список релевантных предложений по запросу) мы всегда будем сталкиваться с выбором, что важнее: семантика глагола или отсутствие побочных эффектов; кэширование запроса или индикация его сложности с вычислительной точки зрения.
|
||||
|
||||
Простых ответов на вопросы выше у нас, к сожалению, нет (особенно если мы добавим к ним механики логирования и построения мониторингов по URL запроса). В рамках настоящей книги мы придерживаемся следующего подхода:
|
||||
|
||||
* сигнатура вызова в первую очередь должна быть лаконична и читабельна; усложнение сигнатур в угоду абстрактным концепциям нежелательно;
|
||||
* иерархия ресурсов выдерживается там, где она однозначна (т.е., если сущность низшего уровня абстракции однозначно подчинена сущности высшего уровня абстракции, то отношения между ними будут выражены в виде вложенных путей);
|
||||
* если есть сомнения в том, что иерархия в ходе дальнейшего развития API останется неизменной, лучше завести новый верхнеуровневый префикс, а не вкладывать новые сущности в уже существующие;
|
||||
* семантика HTTP-глагола приоритетнее ложного предупреждения о небезопасности/неидемпотентности (в частности, если операция является безопасной, но ресурсозатратной, с нашей точки зрения вполне разумно использовать метод `POST` для индикации этого факта);
|
||||
* для выполнения кросс-доменных операций предпочтительнее завести специальный ресурс, выполняющий операцию (т.е. в примере с кофе-машинами и рецептами автор этой книги выбрал бы вариант `/prepare?coffee_machine_id=<id>&recipe=lungo`).
|
||||
|
||||
**NB**: отметим, что передача параметров в виде пути или query-параметра в URL влияет не только на читабельность. Если представить, что гейтвей D реализован в виде stateless прокси с декларативной конфигурацией, то получать от клиента запрос в виде:
|
||||
* `GET /v1/state?user_id=<user_id>`
|
||||
|
||||
и преобразовывать в пару вложенных запросов
|
||||
|
||||
* `GET /v1/profiles?user_id=<user_id>`
|
||||
* `GET /v1/orders?user_id=<user_id>`
|
||||
|
||||
гораздо удобнее, чем извлекать идентификатор из path и преобразовывать его в query-параметр. Первую операцию [замена одного path целиком на другой] достаточно просто описать декларативно, и в большинстве ПО для веб-серверов она поддерживается из коробки. Напротив, извлечение данных из разных компонентов и полная пересборка запроса — достаточно сложная функциональность, которая, скорее всего, потребует от гейтвея поддержки скриптового языка программирования и/или написания специального модуля для таких манипуляций. Аналогично, автоматическое построение мониторинговых панелей в популярных сервисах типа связки Prometheus+Grafana гораздо проще организовать по path, чем вычленять из данных запроса какой-то синтетический ключ группировки запросов.
|
||||
|
||||
Всё это приводит нас к соображению, что поддержание одинаковой структуры URL, в которой меняется только путь или домен, а параметры всегда находятся в query и именуются одинаково, приводит к ещё более унифицированному интерфейсу, хотя бы и в ущерб читабельности и семантичности URL. Во многих внутренних системах выбор в пользу удобства выглядит самоочевидным, хотя во внешних API мы бы такой подход не рекомендовали.
|
||||
|
||||
**NB**: passing variables as either query parameters or path fragments affects not only readability. If gateway D is implemented as a stateless proxy with a declarative configuration, then receiving a request like:
|
||||
* `GET /v1/state?user_id=<user_id>`
|
||||
|
||||
and transforming it into a pair of nested sub-requests:
|
||||
|
||||
* `GET /v1/profiles?user_id=<user_id>`
|
||||
* `GET /v1/orders?user_id=<user_id>`
|
||||
|
||||
would be much more convenient than extracting identifiers from the path or some header and putting them into query parameters. The former operation [replacing one path with another] is easily described declaratively and is supported by most server software out of the box. On the other hand, retrieving data from various components and rebuilding requests is a complex functionality that most likely requires a gateway supporting scripting languages and/or plugins for such manipulations. Conversely, automated creation of monitoring panels in services like Prometheus+Grafana bundle is much easier to organize by path prefix than by a synthetic key computed from request parameters.
|
||||
|
||||
All this leads us to the conclusion than maintaining an identical URL structure when only the path changes while custom parameters are passed in queries will lead to an even more uniform interface, although less readable and semantic. In internal systems, preferring convenience of usage over readability is sometimes an obvious decision. In public APIs, we would rather discourage implementing this approach.
|
||||
|
||||
#### CRUD-операции
|
||||
|
||||
Одно из самых популярных приложений HTTP API — это реализация CRUD-интерфейсов. Акроним CRUD (**C**reate, **R**ead, **U**pdate, **D**elete) был популяризирован ещё в 1983 году Джеймсом Мартином, но с развитием HTTP API обрёл второе дыхание. Ключевая идея соответствия CRUD и HTTP заключается в том, что каждой из CRUD-операций соответствует один из глаголов HTTP:
|
||||
* операции создания — создание ресурса через метод `POST`;
|
||||
* операции чтения — возврат представления ресурса через метод `GET`;
|
||||
* операции редактирования — перезапись ресурса через метод `PUT` или редактирование через `PATCH`;
|
||||
* операции удаления — удаление ресурса через метод `DELETE`.
|
||||
|
||||
Фактически, подобное соответствие — это просто мнемоническое правило, позволяющее определить, какой глагол следует использовать к какой операции. Мы, однако, должны предостеречь читателя: глагол следует выбирать по его семантике согласно стандарту, а не по мнемоническим правилам. Может показаться, что, например, операцию удаления 3-го элемента списка нужно реализовать через `DELETE`:
|
||||
* `DELETE /v1/list/{list_id}/?position=3 HTTP 1.1`
|
||||
но, как мы помним, делать так категорически нельзя: во-первых, такой вызов неидемпотентен; во-вторых, нарушает требование консистентности `GET` и `DELETE`.
|
||||
|
||||
С точки зрения удобства разработки концепция выглядит очень удобной — каждому виду ресурсов соответствует свой URL, каждой операции — свой глагол. При пристальном рассмотрении, однако, оказывается, что отношение CRUD-операция / HTTP-глагол — очень упрощённое представление о манипуляции ресурсами, и, что самое неприятное, плохо расширяемое.
|
||||
|
||||
##### Создание
|
||||
|
||||
Начнём с операции создания ресурса. Как мы помним из предыдущих глав, операция создания в любой сколько-нибудь ответственной предметной области обязана быть идемпотентной и, очень желательно, ещё и позволять управлять параллелизмом. В рамках парадигмы HTTP API идемпотентное создание можно организовать одним из трёх способов:
|
||||
|
||||
1. Через метод `POST` с передачей токена идемпотентности (им может выступать, в частности, ревизия ресурса):
|
||||
```
|
||||
POST /v1/orders/?user_id=<user_id> HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
|
||||
{ … }
|
||||
```
|
||||
|
||||
2. Через метод `PUT`, предполагая, что идентификатор заказа сгенерирован клиентом (ревизия при этом всё ещё может использоваться для управления параллелизмом, но токеном идемпотентности является сам URL):
|
||||
```
|
||||
PUT /v1/orders/{order_id} HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
|
||||
{ … }
|
||||
```
|
||||
|
||||
3. Через схему создания черновика методом `POST` и его подтверждения методом `PUT`:
|
||||
```
|
||||
POST /v1/drafts HTTP/1.1
|
||||
|
||||
{ … }
|
||||
→
|
||||
HTTP/1.1 201 Created
|
||||
Location: /v1/drafts/{id}
|
||||
```
|
||||
```
|
||||
PUT /v1/drafts/{id}/status
|
||||
If-Match: <ревизия>
|
||||
|
||||
{"status": "confirmed"}
|
||||
→
|
||||
HTTP/1.1 200 Ok
|
||||
Location: /v1/orders/{id}
|
||||
```
|
||||
|
||||
Метод (2) в современных системах используется редко, так как вынуждает доверять правильности генерации идентификатора заказа клиентом. Если же рассматривать варианты (1) и (3), то необходимо отметить, что семантике протокола вариант (3) соответствует лучше, так как `POST`-запросы по умолчанию считаются неидемпотентными, и их автоматический повтор в случае получения сетевого таймаута или ошибки сервера будет выглядеть для постороннего наблюдателя опасной операцией (которой запрос и правда может стать, если сервер изменит политику проверки заголовка `If-Match` на более мягкую). Повтор `PUT`-запроса (а мы предполагаем, что таймауты и серверные ошибки на «тяжёлой» операции создания заказа намного более вероятны, чем на «лёгкой» операции создания черновика) вполне может быть автоматизирован, и не будет создавать дубликаты заказа, даже если проверка ревизии будет отключена вообще. Однако теперь вместо двух URL и двух операций (`POST /v1/orders` — `GET /v1/orders/{id}`) мы имеем четыре URL и пять операций:
|
||||
|
||||
1. URL создания черновика(`POST /v1/drafts`), который дополнительно потребует существования URL самого черновика и/или списка черновиков пользователя (`GET /v1/drafts/?user_id=<user_id>` или что-то аналогичное).
|
||||
2. URL подтверждения черновика (`PUT /v1/drafts/{id}/status`) и симметричную операцию чтения статуса черновика (через которую клиент должен будет получать актуальную ревизию для подтверждения черновика).
|
||||
3. URL заказа (`GET /v1/orders/{id}`).
|
||||
|
||||
##### Чтение
|
||||
|
||||
Идём дальше. Операция чтения на первый взгляд не вызывает сомнений:
|
||||
* `GET /v1/orders/{id}`
|
||||
…но это только на первый взгляд. Клиент как минимум должен обладать способом выяснить, какие заказы сейчас выполняются от его имени, что требует создания отдельного ресурса-поисковика:
|
||||
* `GET /v1/orders/?user_id=<user_id>`
|
||||
…но передача списков без ограничений по их длине — потенциально плохая идея, а значит необходимо ввести поддержку пагинации:
|
||||
* `GET /v1/orders/?user_id=<user_id>&cursor=<cursor>`
|
||||
…но если заказов много, наверняка пользователю понадобятся фильтры, скажем, по названию напитка:
|
||||
* `GET /v1/orders/?user_id=<user_id>&recipe=lungo`
|
||||
…но пользователь может захотеть видеть в одном списке латте и лунго:
|
||||
* ???
|
||||
* общепринятого стандарта передачи в URL более сложных структур, чем пары ключ-значение, не существует.
|
||||
|
||||
Довольно скоро мы придём к тому, что, наряду с доступом по идентификатору заказа потребуется ещё и, во-первых, способ строго перебрать все заказы и способ искать по нестрогому совпадению.
|
||||
|
||||
Кроме того, если к заказу можно прикладывать какие-то медиа-данные (скажем, фотографии), то для доступа к ним придётся разработать отдельные URL:
|
||||
* `GET /v1/orders/{order_id}/attachements/{id}`
|
||||
|
||||
##### Редактирование
|
||||
|
||||
Вопросы частичного редактирования мы подробно разбирали в соответствующей главе раздела «Паттерны API». Идея полной перезаписи ресурса методом `PUT` быстро разбивается о необходимость работать с вычисляемыми и неизменяемыми полями, необходимость совместного редактирования и/или большой объём передаваемых данных. Работа через метод `PATCH` возможна, но, так как этот метод по умолчанию считается неидемпотентным, для него справедливо всё то же соображение об опасности автоматических перезапросов. Достаточно быстро мы придём к одному из двух вариантов:
|
||||
* либо `PUT` декомпозирован на множество составных `PUT /v1/orders/{id}/address`, `PUT /v1/orders/{id}/volume` и т.д. — по ресурсу для каждой частной операции;
|
||||
* либо существует отдельный ресурс, принимающий список изменений, причём, вероятнее всего, через схему черновик-подтверждение.
|
||||
|
||||
Если к сущности прилагаются медиаданные, для их редактирования также придётся разработать отдельные эндпойнты.
|
||||
|
||||
##### Удаление
|
||||
|
||||
С удалением ситуация проще всего: никакие данные в современных сервисах не удаляются моментально, а лишь архивируются или помечаются удалёнными. Таким образом, вместо `DELETE /v1/orders/{id}` необходимо разработать эндпойнт типа `PUT /v1/orders/{id}/archive` или `PUT /v1/archive?order=<order_id>`.
|
||||
|
||||
Таким образом, идея CRUD как способ минимальным набором операций описать типовые действия над ресурсом в при столкновении с реальностью быстро эволюционирует в сторону семейства эндпойнтов, каждый из которых описывает отдельный аспект взаимодействия с сущностью в течение её жизненного цикла. Изложенные выше соображения следует считать не критикой концепции CRUD как таковой, а скорее призывом не лениться и разрабатывать номенклатуру ресурсов и операций над ними исходя из конкретной предметной области, а не абстрактных мнемонических правил, к которым является эта концепция. Если вы всё же хотите разработать типовой API для манипуляции типовыми сущностями, стоит изначально разработать его гораздо более гибким, чем предлагает CRUD-HTTP методология.
|
Reference in New Issue
Block a user