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
HTTP errors
This commit is contained in:
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
Третье важное подготовительное упражнение, которое мы должны сделать — это дать описание формата HTTP-запросов и ответов и прояснить базовые понятия. Многое из написанного ниже может показаться читателю самоочевидным, но, увы, специфика протокола такова, что даже базовые сведения о нём, без которых мы не сможем двигаться дальше, разбросаны по обширной и фрагментированной документации. Ниже мы попытаемся дать структурированный обзор протокола в том объёме, который необходим нам для проектирования HTTP API.
|
Третье важное подготовительное упражнение, которое мы должны сделать — это дать описание формата HTTP-запросов и ответов и прояснить базовые понятия. Многое из написанного ниже может показаться читателю самоочевидным, но, увы, специфика протокола такова, что даже базовые сведения о нём, без которых мы не сможем двигаться дальше, разбросаны по обширной и фрагментированной документации. Ниже мы попытаемся дать структурированный обзор протокола в том объёме, который необходим нам для проектирования HTTP API.
|
||||||
|
|
||||||
В описании семантики и формата протокола мы будем руководствоваться свежевышедшим [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html), который заменил аж девять предыдущих спецификаций, описывавших разные аспекты технологии (при этом большое количество различной дополнительной функциональности всё ещё покрывается отдельными стандартами, например, метод `PATCH` так и не вошёл в основной RFC и регулируется [RFC 5789](https://www.rfc-editor.org/rfc/rfc5789)).
|
В описании семантики и формата протокола мы будем руководствоваться свежевышедшим [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html), который заменил аж девять предыдущих спецификаций, описывавших разные аспекты технологии (при этом большое количество различной дополнительной функциональности всё ещё покрывается отдельными стандартами. В частности, принципы HTTP-кэширования описаны в отдельном [RFC 9111](https://www.rfc-editor.org/rfc/rfc9111.html), а широко используемый в API метод `PATCH` так и не вошёл в основной RFC и регулируется [RFC 5789](https://www.rfc-editor.org/rfc/rfc5789.html)).
|
||||||
|
|
||||||
HTTP-запрос представляет собой применение определённого глагола к URL с указанием версии протокола и передачей дополнительной мета-информации в заголовках и, возможно, каких-то данных в теле запроса:
|
HTTP-запрос представляет собой применение определённого глагола к URL с указанием версии протокола и передачей дополнительной мета-информации в заголовках и, возможно, каких-то данных в теле запроса:
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /v1/orders HTTP/2
|
POST /v1/orders HTTP/1.1
|
||||||
Host: our-api-host.tld
|
Host: our-api-host.tld
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
@@ -21,10 +21,10 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Ответом на HTTP-запрос будет являться конструкция, состоящая из статус-кода ответа, сообщения, заголовков и, возможно, тела ответа.
|
Ответом на HTTP-запрос будет являться конструкция, состоящая из версии протокола, статус-кода ответа, сообщения, заголовков и, возможно, тела ответа.
|
||||||
|
|
||||||
```
|
```
|
||||||
201 Created
|
HTTP/1.1 201 Created
|
||||||
Location: /v1/orders/123
|
Location: /v1/orders/123
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
@@ -33,6 +33,8 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**NB**: в HTTP/2 (и будущем HTTP/3) вместо единого текстового формата используются отдельные бинарные фреймы для передачи заголовков и данных. Этот факт не влияет на излагаемые архитектурные принципы, но во избежание двусмысленности мы будем давать примеры в формате HTTP/1.1. Подробнее о формате HTTP/2 можно прочитать [здесь](https://hpbn.co/http2/).
|
||||||
|
|
||||||
##### URL
|
##### URL
|
||||||
|
|
||||||
URL — единица адресации в HTTP API (некоторые евангелисты технологии даже используют термин «пространство URL» как синоним для Мировой паутины). Предполагается, что API в парадигме REST должно использовать систему адресов столь же гранулярную, как и предметная область; иными словами, у любых сущностей, которыми мы можем манипулировать независимо, должен быть свой URL.
|
URL — единица адресации в HTTP API (некоторые евангелисты технологии даже используют термин «пространство URL» как синоним для Мировой паутины). Предполагается, что API в парадигме REST должно использовать систему адресов столь же гранулярную, как и предметная область; иными словами, у любых сущностей, которыми мы можем манипулировать независимо, должен быть свой URL.
|
||||||
@@ -83,9 +85,9 @@ HTTP-глагол определяет два важных свойства HTTP
|
|||||||
* является ли запрос модифицирующим (и можно ли кэшировать ответ);
|
* является ли запрос модифицирующим (и можно ли кэшировать ответ);
|
||||||
* является ли запрос идемпотентным.
|
* является ли запрос идемпотентным.
|
||||||
|
|
||||||
| Глагол | Семантика | Модифицирующий | Идемпотентный | Имеет тело |
|
| Глагол | Семантика | Безопасный (немодифицирующий) | Идемпотентный | Имеет тело |
|
||||||
|--------|----------------------------------|----------------|---------------|------------|
|
|--------|----------------------------------|---------------|---------------|------------|
|
||||||
| GET | Возвращает представление ресурса | нет | да | нет |
|
| GET | Возвращает представление ресурса | да | да | нет |
|
||||||
| PUT | Заменяет (полностью перезаписывает) ресурс согласно данным, переданным в теле запроса | да | да | да |
|
| PUT | Заменяет (полностью перезаписывает) ресурс согласно данным, переданным в теле запроса | да | да | да |
|
||||||
| DELETE | Удаляет ресурс | да | да | нет |
|
| DELETE | Удаляет ресурс | да | да | нет |
|
||||||
| POST | Обрабатывает запрос в соответствии со своим внутренним устройством | да | нет | да |
|
| POST | Обрабатывает запрос в соответствии со своим внутренним устройством | да | нет | да |
|
@@ -5,7 +5,8 @@
|
|||||||
У читателя может возникнуть резонный вопрос — а почему вообще существует такая дихотомия: какие-то API полагаются на стандартную семантику HTTP, а какие-то полностью от неё отказываются в пользу новоизобретённых стандартов. Например, если мы посмотрим на [формат ответа в JSON-RPC](https://www.jsonrpc.org/specification#response_object), то мы обнаружим, что он легко мог бы быть заменён на стандартные средства протокола HTTP. Вместо
|
У читателя может возникнуть резонный вопрос — а почему вообще существует такая дихотомия: какие-то API полагаются на стандартную семантику HTTP, а какие-то полностью от неё отказываются в пользу новоизобретённых стандартов. Например, если мы посмотрим на [формат ответа в JSON-RPC](https://www.jsonrpc.org/specification#response_object), то мы обнаружим, что он легко мог бы быть заменён на стандартные средства протокола HTTP. Вместо
|
||||||
|
|
||||||
```
|
```
|
||||||
200 OK
|
HTTP/1.1 200 OK
|
||||||
|
|
||||||
{
|
{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id",
|
"id",
|
@@ -1,15 +1,47 @@
|
|||||||
### Принципы организации HTTP API
|
### Принципы организации HTTP API
|
||||||
|
|
||||||
Перейдём теперь к конкретике: как разрабатывать HTTP API так, чтобы извлекать выгоду из следования протоколу. Представим себе, например, процедуру старта приложения. Как правило, на старте требуется, используя сохранённый токен аутентификации, получить профиль текущего пользователя и важную информацию о нём (в нашем случае — текущие заказы). Мы можем достаточно очевидным образом предложить для этого эндпойнт:
|
Перейдём теперь к конкретике: что конкретно означает «следовать семантике протокола» и «разрабатывать приложение в соответствии с архитектурным стилем REST». Напомним, речь идёт о следующих принципах:
|
||||||
|
* операции должны быть stateless;
|
||||||
|
* данные должны размечаться как кэшируемые или некэшируемые;
|
||||||
|
* интерфейсы взаимодействия между компонентами должны быть стандартизированы;
|
||||||
|
* сетевые системы многослойны;
|
||||||
|
|
||||||
|
эти принципы мы должны применить к протоколу HTTP, соблюдая дух и букву стандарта:
|
||||||
|
|
||||||
|
* URL операции должен идентифицировать ресурс, к которому применяется действие, и быть ключом кэширования для `GET` и ключом идемпотентности — для `PUT` и `DELETE`;
|
||||||
|
* HTTP-глаголы должны использоваться в соответствии с их семантикой;
|
||||||
|
* свойства операции (безопасность, кэшируемость, идемпотентность, а также симметрия `GET` / `PUT` / `DELETE`-методов), заголовки запросов и ответов, статус-коды ответов должны соответствовать спецификации.
|
||||||
|
|
||||||
|
**NB**: мы намеренно опускаем многие тонкости стандарта:
|
||||||
|
* ключ кэширования фактически является составным [включает в себя заголовки запроса], если в ответе содержится заголовок `Vary`;
|
||||||
|
* ключ идемпотентности также может быть составным, если в запросе содержится заголовок `Range`;
|
||||||
|
* политика кэширования в отсутствие явных заголовков кэширования определяется не только глаголом, но и статус-кодом и другими заголовками запроса и ответа, а также политиками платформы
|
||||||
|
|
||||||
|
В рамках HTTP API использование подобных техник является скорее экзотикой, поэтому в целях сохранения размеров глав в рамках разумного касаться этих вопросов мы не будем.
|
||||||
|
|
||||||
|
Рассмотрим построение HTTP API на конкретном примере. Представим себе, например, процедуру старта приложения. Как правило, на старте требуется, используя сохранённый токен аутентификации, получить профиль текущего пользователя и важную информацию о нём (в нашем случае — текущие заказы). Мы можем достаточно очевидным образом предложить для этого эндпойнт:
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /v1/state
|
GET /v1/state HTTP/1.1
|
||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
|
→
|
||||||
|
HTTP/1.1 200 Ok
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"profile",
|
||||||
|
"orders"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Получив такой запрос, сервер проверит валидность токена, получит идентификатор пользователя `user_id`, обратится к базе данных и вернёт профиль пользователя и список его заказов.
|
Получив такой запрос, сервер проверит валидность токена, получит идентификатор пользователя `user_id`, обратится к базе данных и вернёт профиль пользователя и список его заказов.
|
||||||
|
|
||||||
Пока вопросы производительности нас не волнуют, подобная схема прекрасно работает. Однако, с ростом количества пользователей, мы рано или поздно столкнёмся с тем, что подобная монолитная архитектура нам слишком дорого обходится, и принимаем решение декомпозировать единый бэкенд на четыре микросервиса:
|
Подобный простой API нарушает сразу несколько архитектурных принципов REST:
|
||||||
|
* нет кэширования (и оно вряд ли возможно, так как в одном ответе совмещены разнородные данные);
|
||||||
|
* операция является stateful, т.к. сервер не знает идентификатор клиента (к которому привязаны запрошенные данные), пока не проверит токен;
|
||||||
|
* система однослойна (и таким образом вопрос об унифицированном интерфейсе бессмыслен).
|
||||||
|
|
||||||
|
Пока вопросы производительности нас не волнуют, подобная схема прекрасно работает. Однако, с ростом количества пользователей, мы рано или поздно столкнёмся с тем, что подобная монолитная архитектура нам слишком дорого обходится. Допустим, мы приняли решение декомпозировать единый бэкенд на четыре микросервиса:
|
||||||
* сервис A, проверяющий авторизационные токены;
|
* сервис A, проверяющий авторизационные токены;
|
||||||
* сервис B, хранящий профили пользователей;
|
* сервис B, хранящий профили пользователей;
|
||||||
* сервис C, хранящий заказы пользователей;
|
* сервис C, хранящий заказы пользователей;
|
||||||
@@ -36,20 +68,20 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
**NB**: альтернативно мы могли бы закодировать имя пользователя в самом токене согласно, например, [стандарту JWT](https://www.rfc-editor.org/rfc/rfc7519) — для данного кейса это неважно, поскольку `user_id` всё равно остаётся частью HTTP-заголовка.
|
**NB**: альтернативно мы могли бы закодировать имя пользователя в самом токене согласно, например, [стандарту JWT](https://www.rfc-editor.org/rfc/rfc7519) — для данного кейса это неважно, поскольку `user_id` всё равно остаётся частью HTTP-заголовка.
|
||||||
|
|
||||||
Теперь сервисы B и C получают запрос в таком виде, что им не требуется выполнение дополнительных действий (идентификации пользователя через сервис А) для получения результата. Тем самым мы переформулировали запрос так, что он не требует от (микро)сервиса обращаться за данными за пределами его области ответственности. Этот принцип скрывается под буквой S в аббревиатуре REST: «stateless».
|
Теперь сервисы B и C получают запрос в таком виде, что им не требуется выполнение дополнительных действий (идентификации пользователя через сервис А) для получения результата. Тем самым мы переформулировали запрос так, что он не требует от (микро)сервиса обращаться за данными за пределами его области ответственности, добившись соответствия stateless-принципу.
|
||||||
|
|
||||||
Пойдём теперь чуть дальше и подметим, что профиль пользователя меняется достаточно редко, и нет никакой нужды каждый раз получать его заново — мы могли бы закэшировать его на стороне гейтвея D. Для этого нам нужно сформировать ключ кэша, которым фактически является идентификатор клиента. Мы можем пойти длинным путём:
|
Пойдём теперь чуть дальше и подметим, что профиль пользователя меняется достаточно редко, и нет никакой нужды каждый раз получать его заново — мы могли бы закэшировать его на стороне гейтвея D. Для этого нам нужно сформировать ключ кэша, которым фактически является идентификатор клиента. Мы можем пойти длинным путём:
|
||||||
* перед обращением в сервис B составить ключ и обратиться к кэшу;
|
* перед обращением в сервис B составить ключ и обратиться к кэшу;
|
||||||
* если данные имеются в кэше, ответить клиенту из кэша; иначе обратиться к сервису B и сохранить полученные данные в кэш.
|
* если данные имеются в кэше, ответить клиенту из кэша; иначе обратиться к сервису B и сохранить полученные данные в кэш.
|
||||||
|
|
||||||
А можем срезать пару углов: если мы добавим идентификатор пользователя непосредственно в запрос, то можем положиться на HTTP-кэширование, которое наверняка или реализовано в нашем фреймворке, или добавляется в качестве плагина за пять минут. Тогда гейтвей D обратится к ресурсу `service-c.tld/profiles/<user-id>` и получит данные либо из кэша, либо непосредственно из сервиса.
|
А можем срезать пару углов: если мы добавим идентификатор пользователя непосредственно в запрос, то можем положиться на HTTP-кэширование, которое наверняка или реализовано в нашем фреймворке, или добавляется в качестве плагина за пять минут. Тогда гейтвей D обратится к ресурсу `/v1/profiles/<user-id>` в сервисе B и получит данные либо из кэша, либо непосредственно из сервиса.
|
||||||
|
|
||||||
Теперь рассмотрим сервис C. Результат его работы мы тоже могли бы кэшировать, однако состояние текущего заказа меняется гораздо чаще, чем список завершённых заказов. Вспомним, однако, описанное нами в разделе «Паттерны API» оптимистичное управление параллелизмом: для корректной работы сервиса нам нужна ревизия состояния ресурса, и ничто не мешает нам воспользоваться этой ревизией как ключом кэша. Пусть сервис С возвращает нам токен, соответствующий текущему состоянию заказов пользователя:
|
Теперь рассмотрим сервис C. Результат его работы мы тоже могли бы кэшировать, однако состояние текущего заказа меняется гораздо чаще, чем список завершённых заказов. Вспомним, однако, описанное нами в разделе «Паттерны API» оптимистичное управление параллелизмом: для корректной работы сервиса нам нужна ревизия состояния ресурса, и ничто не мешает нам воспользоваться этой ревизией как ключом кэша. Пусть сервис С возвращает нам токен, соответствующий текущему состоянию заказов пользователя:
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /v1/orders?user_id=<user_id>
|
GET /v1/orders?user_id=<user_id> HTTP/1.1
|
||||||
→
|
→
|
||||||
200 Ok
|
HTTP/1.1 200 Ok
|
||||||
ETag: w/<ревизия>
|
ETag: w/<ревизия>
|
||||||
…
|
…
|
||||||
```
|
```
|
||||||
@@ -71,26 +103,26 @@ ETag: w/<ревизия>
|
|||||||
* сервис текущих заказов хранит заказы, а не пользователи — логично если URL будет это отражать;
|
* сервис текущих заказов хранит заказы, а не пользователи — логично если URL будет это отражать;
|
||||||
* если нам потребуется в будущем позволить нескольким пользователям делать общий заказ, нотация `/v1/orders?user_id` будет лучше отражать отношения между сущностями (напомним, путь традиционно используется для индикации строгой иерархии).
|
* если нам потребуется в будущем позволить нескольким пользователям делать общий заказ, нотация `/v1/orders?user_id` будет лучше отражать отношения между сущностями (напомним, путь традиционно используется для индикации строгой иерархии).
|
||||||
|
|
||||||
Впрочем, мы не настаиваем на этом решении как на единственно верном: в первую нам важно, чтобы URL был ключом кэширования и идемпотентности, а для этого подходят обе нотации.
|
Впрочем, мы не настаиваем на этом решении как на единственно верном: в первую нам важно, чтобы URL был ключом кэширования и идемпотентности, а для этого подходят обе нотации.
|
||||||
|
|
||||||
Использовав такое решение, мы автоматически получаем ещё один приятный бонус: эти же данные пригодятся нам, если пользователь попытается создать новый заказ. Допустим, пользователь выполняет запрос вида:
|
Использовав такое решение, мы автоматически получаем ещё один приятный бонус: эти же данные пригодятся нам, если пользователь попытается создать новый заказ. Допустим, пользователь выполняет запрос вида:
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /v1/orders
|
POST /v1/orders HTTP/1.1
|
||||||
If-Match: w/<ревизия>
|
If-Match: w/<ревизия>
|
||||||
```
|
```
|
||||||
|
|
||||||
Гейтвей D подставляет в запрос идентификатор пользователя и формирует запрос к сервису B:
|
Гейтвей D подставляет в запрос идентификатор пользователя и формирует запрос к сервису B:
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /v1/orders?user_id=<user_id>
|
POST /v1/orders?user_id=<user_id> HTTP/1.1
|
||||||
If-Match: w/<ревизия>
|
If-Match: w/<ревизия>
|
||||||
```
|
```
|
||||||
|
|
||||||
Если ревизия правильная, гейтвей D может сразу же получить в ответе сервиса B:
|
Если ревизия правильная, гейтвей D может сразу же получить в ответе сервиса B:
|
||||||
|
|
||||||
```
|
```
|
||||||
200 OK
|
HTTP/1.1 200 OK
|
||||||
Content-Location: /v1/orders?user_id<user_id>
|
Content-Location: /v1/orders?user_id<user_id>
|
||||||
ETag: w/<новая ревизия>
|
ETag: w/<новая ревизия>
|
||||||
|
|
||||||
@@ -106,7 +138,7 @@ ETag: w/<новая ревизия>
|
|||||||
|
|
||||||
С точки зрения реализации сервисов B и C наличие или отсутствие гейтвея перед ними ни на что не влияет (особенно если мы используем stateless-токены). Мы также можем добавить и второй гейтвей в цепочку, если, скажем, мы захотим разделить хранение заказов на «горячее» и «холодное» хранилища.
|
С точки зрения реализации сервисов B и C наличие или отсутствие гейтвея перед ними ни на что не влияет (особенно если мы используем stateless-токены). Мы также можем добавить и второй гейтвей в цепочку, если, скажем, мы захотим разделить хранение заказов на «горячее» и «холодное» хранилища.
|
||||||
|
|
||||||
Если мы теперь обратимся к описанию REST как он дан в диссертации Филдинга, мы обнаружим, что мы построили систему, полностью соответствующую требованиям REST:
|
Если мы теперь обратимся к началу главы, мы обнаружим, что мы построили систему, полностью соответствующую требованиям REST:
|
||||||
* запросы к сервисам уже несут в себе все данные, которые необходимы для выполнения запроса;
|
* запросы к сервисам уже несут в себе все данные, которые необходимы для выполнения запроса;
|
||||||
* интерфейс взаимодействия настолько унифицирован, что мы можем передавать функции гейтвея клиенту и обратно;
|
* интерфейс взаимодействия настолько унифицирован, что мы можем передавать функции гейтвея клиенту и обратно;
|
||||||
* политика кэширования каждого вида данных размечена.
|
* политика кэширования каждого вида данных размечена.
|
109
src/ru/drafts/04-Раздел IV. HTTP API и REST/06.md
Normal file
109
src/ru/drafts/04-Раздел IV. HTTP API и REST/06.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
### Организация клиентских ошибок в 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: w/<ревизия>
|
||||||
|
|
||||||
|
{ /* параметры заказа */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Какие потенциальные неприятности могут ожидать нас при выполнении этого запроса? Навскидку, это:
|
||||||
|
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` должен быть максимально общим.
|
9
src/ru/drafts/04-Раздел IV. HTTP API и REST/07.md
Normal file
9
src/ru/drafts/04-Раздел IV. HTTP API и REST/07.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
### Работа с серверными ошибками в HTTP API
|
||||||
|
|
||||||
|
**Ошибки `5xx`** индицируют, что клиент, со своей стороны, выполнил запрос правильно, и проблема заключается в сервере. Для клиента, по большому счёту, важно только то, имеет ли смысл повторять запрос и, если да, то через какое время. Таким образом, кодов `500 Internal Server Error` и `503 Service Unavailable` было бы достаточно (второй код указывает, что отказ в обслуживании имеет разовый характер и есть смысл автоматически повторить запрос) — или можно вовсе ограничиться одним из них с опциональным заголовком `Retry-After`. Для целей мониторинга состояния системы имеет определённый смысл использовать другие коды (`502` и `504` для индикации проблем с гейтвеями) но, из общих соображений, унифицированный интерфейс (когда внутренняя информация о серверных ошибках передаётся в виде метаданных и используется для диагностики так же, как и для клиентских ошибок) был бы более консистентным и удобным в настройке — с точностью до того, что внешний гейтвей должен её удалить, чтобы не выдавать внутренние ошибки внешнему наблюдателю.
|
||||||
|
|
||||||
|
Разумеется, серверные ошибки также должны содержать информацию для разработчика и для конечного пользователя системы с описанием действий, которые необходимо выполнить при получении ошибки, и вот здесь мы вступаем на очень скользкую территорию.
|
||||||
|
|
||||||
|
Современная практика реализации HTTP-клиентов такова, что безусловно повторяются только немодифицирующие (`GET`, `HEAD`, `OPTIONS`) запросы. В случае модифицирующих запросов *разработчик должен написать код*, который повторит запрос — и для этого разработчику нужно очень внимательно прочитать документацию к API.
|
||||||
|
|
||||||
|
*Теоретически* идемпотентные методы `PUT` и `DELETE` можно вызывать повторно. Практически, однако, ввиду того, что многие разработчики упускают требование идемпотентности этих методов, фреймворки работы с HTTP API по умолчанию перезапросов модифицирующих методов, как правило, не делают; перезапросов потенциально неидемпотентных `POST` и `PATCH` (а также частичного `PUT`) фреймворки не допускают почти никогда.
|
@@ -26,7 +26,7 @@
|
|||||||
* ошибка для пользователя vs ошибка для разработчика
|
* ошибка для пользователя vs ошибка для разработчика
|
||||||
* деградация
|
* деградация
|
||||||
* мониторинг состояния
|
* мониторинг состояния
|
||||||
Раздел III
|
Раздел IV
|
||||||
* о терминологии
|
* о терминологии
|
||||||
* введение в HTTP
|
* введение в HTTP
|
||||||
* REST, определение и реальность
|
* REST, определение и реальность
|
||||||
@@ -36,8 +36,15 @@
|
|||||||
* CRUD
|
* CRUD
|
||||||
* кодстайл
|
* кодстайл
|
||||||
* domain/path/query/body
|
* domain/path/query/body
|
||||||
Раздел IV
|
Раздел V
|
||||||
* постановка проблемы
|
* SDK как клиент поверх другой платформы
|
||||||
|
* политика перезапросов
|
||||||
|
* консистентность
|
||||||
|
* типы данных
|
||||||
|
* кодстайл
|
||||||
|
* паттерн «кодогенерация»
|
||||||
|
* UI lib: постановка проблемы
|
||||||
|
* общий UX vs платформенный UX
|
||||||
* реюз кода vs реюз поведения
|
* реюз кода vs реюз поведения
|
||||||
* трехстороннее взаимодействие
|
* трехстороннее взаимодействие
|
||||||
* асинхронность, разделяемые ресурсы
|
* асинхронность, разделяемые ресурсы
|
Reference in New Issue
Block a user