diff --git a/src/en/clean-copy/01-Introduction/08.md b/src/en/clean-copy/01-Introduction/08.md index 21c5267..65b6ad0 100644 --- a/src/en/clean-copy/01-Introduction/08.md +++ b/src/en/clean-copy/01-Introduction/08.md @@ -50,4 +50,4 @@ Simplified notation might be used to avoid redundancies, like `POST /some-resour We will use sentences like “`POST /v1/bucket/{id}/some-resource` method” (or simply “`bucket/some-resource` method,” “`some-resource`” method — if there are no other `some-resource`s in the chapter, so there is no ambiguity) to refer to such endpoint definitions. -Apart from HTTP API notation, we will employ C-style pseudocode, or, to be more precise, JavaScript-like or Python-like one since types are omitted. We assume such imperative structures are readable enough to skip detailed grammar explanations. \ No newline at end of file +Apart from HTTP API notation, we will employ C-style pseudocode, or, to be more precise, JavaScript-like or Python-like one since types are omitted. We assume such imperative structures are readable enough to skip detailed grammar explanations. HTTP API-like samples intend to illustrate the *contract*, i.e., how we would design an API. Samples in pseudocode are intended to illustrate how developers might work with the API in their code, or how we would implement SDKs based on the contract. \ No newline at end of file diff --git a/src/en/clean-copy/02-Section I. The API Design/05.md b/src/en/clean-copy/02-Section I. The API Design/05.md index dcfecbb..6ebd334 100644 --- a/src/en/clean-copy/02-Section I. The API Design/05.md +++ b/src/en/clean-copy/02-Section I. The API Design/05.md @@ -293,7 +293,7 @@ POST /v1/users } ``` -**NB**: the contradiction with the previous rule lies in the necessity of introducing “negative” flags (the “no limit” flag), which we had to rename to `abolish_spending_limit`. Though it's a decent name for a negative flag, its semantics is still unobvious, and developers will have to read the docs. That's the way. +**NB**: the contradiction with the previous rule lies in the necessity of introducing “negative” flags (the “no limit” flag), which we had to rename to `abolish_spending_limit`. Though it's a decent name for a negative flag, its semantics is still unobvious, and developers will have to read the docs. This is the way. ##### Declare Technical Restrictions Explicitly diff --git a/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/08.md b/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/08.md index 44fe73c..0319b00 100644 --- a/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/08.md +++ b/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/08.md @@ -83,7 +83,7 @@ Which option to select depends on the subject area (and on the allowed message s **NB**: the approach \#3 (and partly \#2) naturally leads us to the scheme that is typical for client-server integration: the push message itself contains almost no data and is only a trigger for ahead-of-time polling. -The technique of sending only essential data in the notification has one important disadvantage, apart from more complicated data flows and increased request rate. With option \#1 implemented (i.e., the message contains all the data), we might assume that returning a success response by the subscriber is equivalent to successfully processing the state change by the partner (although it's not guaranteed if the partner uses asynchronous techniques). With options \#2 and \#3, this is certainly not the case: the partner must carry out additional actions (starting from retrieving the actual order state) to fully process the message. This implies that two separate statuses might be needed: “message received” and “message processed.” Ideally, the latter should follow the logic of the API work cycle, i.e., the partner should carry out some action upon processing the event, and this action might be treated as the “message processed” signal. In our coffee example, we can expect that the partner will either accept or reject an order after receiving the “new order” message. Then the full message processing flow will look like this: +The technique of sending only essential data in the notification has one important disadvantage, apart from more complicated data flows and increased request rate. With option \#1 implemented (i.e., the message contains all the data), we might assume that returning a success response by the subscriber is equivalent to successfully processing the state change by the partner (although it's not guaranteed if the partner uses asynchronous techniques). With options \#2 and \#3, this is certainly not the case: the partner must carry out additional actions (starting from retrieving the actual order state) to fully process the message. This implies that two separate statuses might be needed: “message received” and “message processed.” Ideally, the latter should follow the logic of the API work cycle, i.e., the partner should carry out some follow-up action upon processing the event, and this action might be treated as the “message processed” signal. In our coffee example, we can expect that the partner will either accept or reject an order after receiving the “new order” message. Then the full message processing flow will look like this: ``` // The API vendor diff --git a/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/09.md b/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/09.md index ecc0e38..41b8814 100644 --- a/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/09.md +++ b/src/en/clean-copy/03-[Work in Progress] Section II. The API Patterns/09.md @@ -1 +1,42 @@ -### Atomicity \ No newline at end of file +### [Atomicity of Bulk Changes][api-patterns-atomicity] + +Let's return from *webhooks* back to developing direct-call APIs. The `orders/bulk-status-change` endpoint design, described in the previous chapter, raises an interesting question: what should we do if some changes were successfully processed by our backend and some were not? + +Let's imagine the partner notifies us about status changes that happened with two orders: + +``` +POST /v1/orders/bulk-status-change +{ + "status_changes": [{ + "order_id": "1", + "new_status": "accepted", + // Other relevant data, + // let's say, estimated + // preparation time + … + }, { + "order_id": "2", + "new_status": "rejected", + "reason" + }] +} +→ +500 Internal Server Error +``` + +The question is how to organize this “umbrella” endpoint (which is in fact a proxy to process a list of nested sub-requests) if changing one of the two orders emits an error. We might propose at least four different options: + * A. Guarantee atomicity and idempotency. If any of the sub-requests is unsuccessful, other changes are not applied as well. + * B. Guarantee idempotency but not atomicity. If some sub-requests fail, repeating the call with the same idempotency key results in no action and leaves the system exactly in the same state (i.e., the unsuccessful calls will never be carried out, even if all the obstacles were removed, until a new call with a new idempotency key is performed). + * C. Guarantee neither idempotency nor atomicity and process the sub-requests fully independently. + * D. Do not guarantee atomicity and forbid retries completely by requiring passing the actual resource revision (see the “[Synchronization Strategies](#api-patterns-sync-strategies)” chapter). + +From general considerations, it looks like the first option suits public APIs best: if you can guarantee atomicity (which might be challenging from the scalability point of view), do it. In the first revision of this book, we recommended sticking to this solution unconditionally. + +However, if we take a look at the situation from the partner's perspective, we learn it is not as straightforward as one might decide at first glance. Let us imagine that the partner implemented the following functionality: + 1. Partner's backend processes notifications about incoming orders through a *webhook*. + 2. The backend makes inquiries to coffee shops whether they are ready to fulfill the orders. + 3. Periodically, let's say once every 10 seconds, the partner collects all the status changes (i.e., all responses from the coffee shops) and calls the `bulk-status-change` endpoint with the list of the changes. + +Imagine that in the third step, the partner got an error from the API endpoint. What would developers do about it? Most probably, one of the following solutions might be realized in the partner's code: + + 1. Unconditional retry of the request: \ No newline at end of file diff --git a/src/en/clean-copy/07-Section VI. The API Product/08.md b/src/en/clean-copy/07-Section VI. The API Product/08.md index b9ed34d..f6b93e7 100644 --- a/src/en/clean-copy/07-Section VI. The API Product/08.md +++ b/src/en/clean-copy/07-Section VI. The API Product/08.md @@ -71,6 +71,6 @@ Usually, you can put forward some requirements for self-identifying of partners, 2. Additional means of tracking are users' unique identifiers, most notably cookies. However, most recently this method of gathering data got attacked from several directions: browser makers restrict third-party cookies, users are employing anti-tracker software, and lawmakers started to roll out legal requirements against data collection. In the current situation, it's much easier to drop cookie usage than to be compliant with all the regulations. - All this leads to a situation when public APIs (especially those installed on free-to-use sites and applications) are very limited in the means of collecting statistics and analyzing user behavior. And that impacts not only fighting all kinds of fraud but analyzing use cases as well. That's the way. + All this leads to a situation when public APIs (especially those installed on free-to-use sites and applications) are very limited in the means of collecting statistics and analyzing user behavior. And that impacts not only fighting all kinds of fraud but analyzing use cases as well. This is the way. **NB**. In some jurisdictions, IP addresses are considered personal data, and collecting them is prohibited as well. We don't dare to advise on how an API vendor might at the same time be able to fight prohibited content on the platform and don't have access to users' IP addresses. We presume that complying with such legislation implies storing statistics by IP address hashes. (And just in case we won't mention that building a rainbow table for SHA-256 hashes covering the entire 4-billion range of IPv4 addresses would take several hours on a regular office-grade computer.) diff --git a/src/en/clean-copy/07-Section VI. The API Product/11.md b/src/en/clean-copy/07-Section VI. The API Product/11.md index c034e3b..199b7c8 100644 --- a/src/en/clean-copy/07-Section VI. The API Product/11.md +++ b/src/en/clean-copy/07-Section VI. The API Product/11.md @@ -13,7 +13,7 @@ In fact, newcomers (i.e., those developers who are not familiar with the API) us Documentation frequently suffers from being excessively clerical; it's being written using formal terminology (which often requires reading the glossary before the actual docs) and is frequently unreasonably inflated. So instead of a two-word answer to a user's question, a couple of paragraphs is conceived — a practice we strongly disapprove of. The perfect documentation must be simple and laconic, and all the terms must be either explained in the text or given a reference to such an explanation. However, “simple” doesn't mean “illiterate”: remember, the documentation is the face of your product, so grammar errors and improper usage of terms are unacceptable. -Also, keep in mind that documentation will be used for searching as well, so every page should contain all the keywords required to be properly ranked by search engines. Unfortunately, this requirement contradicts the simple-and-laconic principle; that's the way. +Also, keep in mind that documentation will be used for searching as well, so every page should contain all the keywords required to be properly ranked by search engines. Unfortunately, this requirement contradicts the simple-and-laconic principle; this is the way. #### Documentation Content Types diff --git a/src/ru/clean-copy/01-Введение/08.md b/src/ru/clean-copy/01-Введение/08.md index f0eb666..16adcaf 100644 --- a/src/ru/clean-copy/01-Введение/08.md +++ b/src/ru/clean-copy/01-Введение/08.md @@ -48,4 +48,4 @@ Cache-Control: no-cache Чтобы сослаться на это описание будут использоваться выражения типа «метод `POST /v1/bucket/{id}/some-resource`» или, для простоты, «метод `some-resource`» или «метод `bucket/some-resource`» (если никаких других `some-resource` в контексте главы не упоминается и перепутать не с чем). -Помимо HTTP API-нотации мы будем активно использовать C-подобный псевдокод — точнее будет сказать, JavaScript или Python-подобный, поскольку нотации типов мы будем опускать. Мы предполагаем, что подобного рода императивные конструкции достаточно читабельны, и не будем здесь описывать грамматику подробно. +Помимо HTTP API-нотации мы будем активно использовать C-подобный псевдокод — точнее будет сказать, JavaScript или Python-подобный, поскольку нотации типов мы будем опускать. Мы предполагаем, что подобного рода императивные конструкции достаточно читабельны, и не будем здесь описывать грамматику подробно. Примеры в виде HTTP API-нотации призваны иллюстрировать дизайн *контрактов*, т.е. показывать, как мы разрабатываем API. Примеры в псевдокоде обычно отражают, какой код напишут разработчики для работы с API, или как бы мы сами написали SDK на основе такого контракта. diff --git a/src/ru/clean-copy/03-[В разработке] Раздел II. Паттерны дизайна API/09.md b/src/ru/clean-copy/03-[В разработке] Раздел II. Паттерны дизайна API/09.md index 69cb3e8..309ade6 100644 --- a/src/ru/clean-copy/03-[В разработке] Раздел II. Паттерны дизайна API/09.md +++ b/src/ru/clean-copy/03-[В разработке] Раздел II. Паттерны дизайна API/09.md @@ -1 +1,218 @@ -### Атомарность \ No newline at end of file +### [Атомарность массовых изменений][api-patterns-atomicity] + +Вернёмся теперь от webhook-ов обратно к разработке API прямого вызова. Дизайн эндпойнта `orders/bulk-status-change`, описанный в предыдущей главе, ставит перед нами интересный вопрос: что делать, если наш бэкенд часть изменений смог обработать, а часть — нет? + +Пусть партнёр уведомляет нас об изменении статусов двух заказов: + +``` +POST /v1/orders/bulk-status-change +{ + "status_changes": [{ + "order_id": "1", + "new_status": "accepted", + // Иная релевантная информация, + // например, время готовности + … + }, { + "order_id": "2", + "new_status": "rejected", + "reason" + }] +} +→ +500 Internal Server Error +``` + +Возникает вопрос, каким образом должен быть организован данный «зонтичный» эндпойнт, который фактически представляет собой прокси для выполнения списка вложенных подзапросов, если при изменении статуса одного из двух заказов возникла ошибка. Мы можем предложить по крайней мере четыре варианта: + * A. Гарантировать идемпотентность и атомарность: если хотя бы один из подзапросов не был выполнен, все остальные изменения также не применяются. + * B. Гарантировать идемпотентность, но не атомарность: если какие-то подзапросы не были успешны, то повтор запроса (с тем же ключом идемпотентности) не выполняет никаких действий и оставляет систему точно в том же состоянии (т.е. неуспешные запросы никогда не выполнятся, даже если этому ничего не препятствует, пока не будет отправлен запрос с новым ключом идемпотентности). + * C. Не гарантировать ни идемпотентность, ни атомарность: применять подзапросы полностью независимо. + * D. Не гарантировать атомарность и вообще запретить перезапросы через требование указания актуальной ревизии ресурса (см. главу «[Стратегии синхронизации](#api-patterns-sync-strategies)»). + +Из общих соображений кажется, что первый вариант для публичных API подходит лучше сего: если вы можете гарантировать атомарность (что не всегда легко с т.з. производительности), сделайте это. В первой редакции настоящей книги мы рекомендовали безусловно придерживаться этого правила. + +Однако, если мы взглянем на ситуацию со стороны партнёра, она окажется далеко не такой простой. Предположим, что на стороне партнёра реализована следующая функциональность: + 1. Через webhook на бэкенд поступают уведомления о новых заказах. + 2. Партнёр опрашивает свои кофейни, готовы ли они принять заказ. + 3. Периодически, скажем, раз в 10 секунд, партнёр собирает все изменения статуса (т.е. все новые ответы кофеен) и вызывает наш эндпойнт `bulk-status-change`, передавая список изменений. + +Предположим, что на шаге (3) партнёр получил от сервера API ошибку. Что разработчик должен в этой ситуации сделать? Вероятнее всего, в коде партнёра будет реализован один из трёх вариантов: + + 1. Безусловный повтор запроса: + ``` + // Получаем все текущие заказы + const pendingOrders = await api + .getPendingOrders(); + // Партнёр проверяет статус + // каждого из них в своей + // системе и готовит + // необходимые изменения + const changes = + await prepareStatusChanges( + pendingOrders + ); + + let result; + let tryNo = 0; + let timeout = + DEFAULT_RETRY_TIMEOUT; + while ( + result && + tryNo++ < MAX_RETRIES + ) { + try { + // Отправляем массив + // изменений статусов + result = await api + .bulkStatusChange( + changes, + // Указываем новейшую + // известную ревизию + pendingOrders.revision + ); + } catch (e) { + // если получена + // ошибка, повторяем + // операцию отправки + logger.error(e); + await wait(timeout); + timeout = min( + timeout * 2, + MAX_TIMEOUT + ); + } + } + ``` + **NB**: в примере выше мы приводим «правильную» политику перезапросов (с экспоненциально растущим периодом ожидания и лимитом на количество попыток), как это следует реализовать в SDK. Следует, однако, иметь в виду, что в реальном коде партнёров с большой долей вероятности ничего подобного реализовано не будет. В дальнейших примерах эту громоздкую конструкцию мы также будем опускать, чтобы упростить чтение кода. + + 2. Повтор только неудавшихся подзапросов + ``` + const pendingOrders = await api + .getPendingOrders(); + let changes = + await prepareStatusChanges( + pendingOrders + ); + + let result; + while (changes.length) { + let failedChanges = []; + try { + result = await api + .bulkStatusChange( + changes, + pendingOrders.revision + ); + } catch (e) { + let i = 0; + // Предполагаем, что + // поле e.changes + // содержит разбивку + // подзапросов по + // статусу исполнения + for ( + i < e.changes.length; i++ + ) { + if (e.changes[i].status == + 'failed') { + failedChanges.push( + changes[i] + ); + } + } + } + // Формируем новый запрос, + // состоящий только + // из неуспешных подзапросов + changes = failedChanges; + } + ``` + + 3. Рестарт всей операции, т.е. в нашем случае — перезапрос всех новых заказов и формирование нового запроса на изменение: + ``` + do { + const pendingOrders = await api + .getPendingOrders(); + const changes = + await prepareStatusChanges( + pendingOrders + ); + // Отсылаем изменения, + // если они есть + if (changes.length) { + await api.bulkStatusChange( + changes, + pendingOrders.revision + ); + } + } while (pendingOrders.length); + ``` + +Если предположить, что операция `bulkStatusChange` может с какой-то вероятностью окончиться частичной неудачей (ну или частичным успехом, если вы оптимист), который может быть исправлен перезапросом, то, как мы видим, варианты организации сервера (B) и (D) не годятся, т.к. безусловный повтор частично неудачного запроса никогда не будет успешным. + +Теперь добавим к постановке задачи ещё одно важное условие: предположим, что иногда ошибка подзапроса не может быть устранена его повторением. Например, партнёр пытается подтвердить заказ, который был отменён пользователем: если такой подзапрос есть (фактически — в коде партнёра есть какая-то не часто происходящая ошибка в логике, о которой он пока не осведомлён), то атомарный сервер, реализованный по схеме (A), моментально партнёра «накажет»: сколько бы он запрос ни повторял, *валидные подзапросы не будут выполнены, если есть хоть один невалидный*. В то время как неатомарный сервер, по крайней мере, продолжит подтверждать валидные запросы. + +Это приводит нас к парадоксальному умозаключению: гарантировать, что партнёрский код будет *как-то* работать можно только реализовав максимально нестрогий неидемпотентный неатомарный подход к операции массовых изменений. Однако и этот вывод мы считаем ошибочным, и вот почему: описанный нами «зоопарк» возможных имплементаций клиента и сервера очень хорошо демонстрирует *нежелательность* эндпойнтов массовых изменений как таковых. Такие эндпойнты требуют реализации дополнительного уровня логики и на клиенте, и на сервере, причём логики весьма неочевидной. Функциональность неатомарных массовых изменений очень быстро приведёт нас к крайне неприятным ситуациям. + +``` +// Партнёр делает рефанд +// и отменяет заказ +POST /v1/bulk-status-change +{ + "changes": [{ + "operation": "refund", + "order_id" + }, { + "operation": "cancel", + "order_id" + }] +} +→ +// Пока длилась операция, +// пользователь успел дойти +// до кофейни и забрать заказ +{ + "changes": [{ + // Рефанд проведён успешно + "status": "success" + }, { + // А отмена заказа нет + "status": "fail", + "reason": "already_served" + }] +} +``` + +Если операции в списке как-то зависят одна от другой (как в примере выше — партнёру нужно *и* сделать рефанд, *и* отменить заказ, выполнение только одной из этих операций бессмысленно) либо важен порядок исполнения операций, неатомарные эндпойнты будут постоянно приводить к проблемам. И даже если вам кажется, что в вашей предметной области таких проблем нет, в какой-то момент может оказаться, что вы чего-то не учли. + +Поэтому наши рекомендации по организации эндпойнтов массовых изменений таковы: + 1. Если вы можете обойтись без таких эндпойнтов — обойдитесь. В server-to-server интеграциях экономия копеечная, в современных сетях с поддержкой протокола [QUIC](https://datatracker.ietf.org/doc/html/rfc9000) и мультиплексирования запросов тоже весьма сомнительная. + 2. Если такой эндпойнт всё же нужен, лучше реализовать его атомарно и предоставить SDK, которые помогут партнёрам не допускать типичные ошибки. + 3. Если реализовать атомарный эндпойнт невозможно, тщательно продумайте дизайн API, чтобы не допустить ошибок, подобных описанным выше. + +Один из подходов, позволяющих минимизировать возможные проблемы — разработать смешанный эндпойнт, в котором потенциально зависящие друг от друга операции группированы, например, вот так: + +``` +POST /v1/bulk-status-change +{ + "changes": [{ + "order_id": <первый заказ> + // Операции по одному + // заказу группируются + // в одну структуру + // и выполняются атомарно + "operations": [ + "refund", + "cancel" + ] + }, { + // Операции по разным + // заказам могут выполняться + // параллельно и неатомарно + "order_id": <второй заказ> + … + }] +} +``` + +На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует каким-то детерминированным образом сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности (в простейшем случае — считать токен идемпотентности внутренних запросов равным токену идемпотентости внешнего запроса, если это допустимо в рамках предметной области; иначе придётся использовать составные токены — в нашем случае, например, в виде `:`). diff --git a/src/ru/drafts/03-Раздел II. Паттерны дизайна API/08.md b/src/ru/drafts/03-Раздел II. Паттерны дизайна API/08.md deleted file mode 100644 index e877086..0000000 --- a/src/ru/drafts/03-Раздел II. Паттерны дизайна API/08.md +++ /dev/null @@ -1,123 +0,0 @@ -### Варианты организации системы нотификаций - -В отличие от интеграции через push-уведомления, webhook-и менее чувствительны к размеру тела сообщения, однако посылка больших сообщений в запросе — скорее, антипаттерн. Большинство веб-серверов в мире настроены отсекать слишком большие тела запросов — как и большинство средств организации очередей сообщений. Это приводит нас к необходимости сделать два технических выбора: - * содержит ли тело сообщения все данные необходимые для его обработки, или только уведомляет о факте изменения состояния; - * если второе, то содержит ли один вызов одно сообщение, или может уведомлять сразу о нескольких изменениях. - -Рассмотрим на примере нашего кофейного API: - -``` -// Вариант 1: тело сообщения -// содержит все данные о заказе -POST /partner/webhook -Host: partners.host -{ - "event_id", - "occurred_at", - "order": { - "id", - "status", - "recipe_id", - "volume", - // Все прочие детали заказа - } -} -``` -``` -// Вариант 2: тело сообщения -// содержит только информацию -// о самом событии -POST /partner/webhook -Host: partners.host -{ - "event_id", - // Тип сообщения: нотификация - // о появлении нового заказа - "event_type": "new_order", - "occurred_at", - "order_id" -} -// При обработке сообщения, -// возможно, отложенной, -// партнёр должен обратиться -// к нашему API -GET /v1/orders/{id} -→ -{ /* все детали заказа */ } -``` -``` -// Вариант 3: мы уведомляем -// партнёра, что его реакции -// ожидают три новых заказа -POST /partner/webhook -Host: partners.host -{ - // Здесь может быть версия - // состояния системы или курсор - "occurred_at", - "pending_order_count": - <число новых заказов> -} -// В ответ партнёр должен вызвать -// эндпойнт получения списка заказов, -// организованный любым из способов, -// описанных в предыдущих главах -GET /v1/orders/pending -→ -{ - "orders", - "cursor" -} -``` - -Выбор подходящей модели зависит от предметной области и того, каким образом партнёр будет обрабатывать сообщение. В нашем конкретном случае, когда партнёр должен каждый новый заказ обработать отдельно, при этом на один заказ не может приходить больше одного-двух уведомлений, естественным выбором является вариант 1 (если тело запроса не содержит никаких непредсказуемо больших данных) или 2. Если же мы нотифицируем партнёра о каких-то часто изменяющихся параметрах системы и/или партнёра интересуют только наиболее свежие изменения, то естественным выбором будет третий подход. - -Отметим, что третий (и отчасти второй) варианты естественным образом приводят нас к схеме, характерной для клиентских устройств: push-уведомление само по себе не почти содержит полезной информации и только является сигналом для внеочередного поллинга. - -#### Гарантии прочтения и обработки сообщений - -Если в варианте 1 (сообщение содержит в себе все релевантные данные) мы можем рассчитывать на то, что возврат кода успеха из webhook-а эквивалентен успешной обработке сообщения партнёром (что, вообще говоря, тоже не гарантировано, т.к. партнёр может использовать асинхронные схемы), то для вариантов 2 и особенно 3 это заведомо не так: для обработки сообщений необходимо выполнить дополнительные действия. В этом случае нам необходимо иметь раздельные статусы — сообщение доставлено и сообщение обработано; в идеале, второе должно вытекать из логики работы API, т.е. сигналом о том, что сообщение обработано, является какое-то действие, совершаемое партнёром. В нашем кофейном примере это может быть перевод заказа партнёром из статуса `"new"` (заказ создан пользователем) в статус `"accepted"` или `"rejected"` (кофейня партнёра приняла или отклонила заказ). Тогда полный цикл работы будет выглядеть так: - -``` -// Уведомляем партнёра о том, -// что его реакции -// ожидают три новых заказа -POST /partner/webhook -Host: partners.host -{ - "occurred_at", - "pending_order_count": - <число новых заказов> -} -``` -``` -// В ответ партнёр вызывает -// эндпойнт получения списка заказов -GET /v1/orders/pending -→ -{ - "orders", - "cursor" -} -``` -``` -// После того, как заказы обработаны, -// партнёр уведомляет нас об -// изменениях статуса -POST /v1/orders/bulk-status-change -{ - "status_changes": [{ - "order_id", - "new_status": "accepted", - // Иная релевантная информация, - // например, время готовности - … - }, { - "order_id", - "new_status": "rejected", - "reason" - }, …] -} -``` - -Если такого нативного способа оповестить об успешной обработке события схема работы нашего API не предполагает, мы можем ввести эндпойнт который явно помечает сообщения прочитанными. Этот шаг, вообще говоря, необязательный (мы можем просто договориться о том, что это ответственность партнёра обрабатывать события и мы не ждём от него никаких подтверждений), но это лишает нас полезного инструмента мониторинга — что происходит на стороне партнёра, успевает ли он обрабатывать события — что в свою очередь затрудняет разработку упомянутых в предыдущей главе механизмов деградации и аварийного отключения интеграции. \ No newline at end of file diff --git a/src/ru/drafts/03-Раздел II. Паттерны дизайна API/09.md b/src/ru/drafts/03-Раздел II. Паттерны дизайна API/09.md index 14b11c2..e69de29 100644 --- a/src/ru/drafts/03-Раздел II. Паттерны дизайна API/09.md +++ b/src/ru/drafts/03-Раздел II. Паттерны дизайна API/09.md @@ -1,187 +0,0 @@ -### Атомарность - -Вернёмся теперь от webhook-ов обратно к разработке API прямого вызова. Дизайн эндпойнта `orders/bulk-status-change`, описанный в предыдущей главе, ставит перед нами интересный вопрос: что делать, если наш бэкенд часть изменений смог обработать, а часть — нет? - -Правило по умолчанию очень простое: если вы можете обеспечить атомарность, т.е. выполнить либо все изменения сразу, либо ни одно из них — сделайте это. - -**Плохо**: -``` -// Партнёр уведомляет нас об -// изменениях статуса -POST /v1/orders/bulk-status-change -{ - "status_changes": [{ - "order_id": "1", - "new_status": "accepted", - // Иная релевантная информация, - // например, время готовности - … - }, { - "order_id": "2", - "new_status": "rejected", - "reason" - }] -} -→ -500 Internal Server Error -``` -``` -// Партнёр вычитывает список заказов -GET /v1/orders/last?limit=2 -→ -{ - "orders": [{ - // Статус заказа с id="1" - // не изменился - "order_id": "1", - "status": "new" - }, { - // Но статус заказа id="2" - // был успешно изменён - "order_id": "2", - "status": "rejected" - }], - "cursor" -} -``` - -Почему это плохо: партнёр никак не может узнать, что операция, которую он посчитал ошибочной, на самом деле частично применена. Для этого нужно разрабатывать сложную систему синхронизации, которая после получения ошибок будет пытаться восстановить состояние всех заказов. - -Если способа обеспечить атомарность выполнения операции нет, следует очень хорошо подумать над её обработкой. Следует предоставить способ получения статуса каждого изменения отдельно. - -**Лучше**: -``` -// Партнёр уведомляет нас об -// изменениях статуса -POST /v1/orders/bulk-status-change -{ - "status_changes": [{ - "order_id": "1", - "new_status": "accepted", - // Иная релевантная информация, - // например, время готовности - … - }, { - "order_id": "2", - "new_status": "rejected", - "reason" - }] -} -→ -// Можно воспользоваться статусом -// «частичного успеха», -// если он предусмотрен протоколом -→ -{ - "changes": [{ - "change_id", - "occurred_at", - "order_id": "1", - "operation_status": "success" - }, { - "change_id", - "occurred_at", - "order_id": "2", - "operation_status": "fail", - "error" - }] -} -``` - -Здесь: - * `change_id` — уникальный идентификатор каждого атомарного изменения; - * `occurred_at` — время проведения каждого изменения; - * `error` — информация по ошибке для каждого изменения, если она возникла. - -Однако внимательный читатель может подметить, что это решение лишь уменьшает вероятность возникновения ситуации неопределённости для партнёра, но не снимает проблемы восстановления состояния после сбоя — так как сбой может произойти на сетевом уровне (или же в коде нашего эндпойнта может произойти настолько критическая ошибка, что мы не сможем сформировать ответ с разбивкой успех/неуспех). Поэтому массовые неатомарные операции должны сопровождаться эндпойнтом для восстановления состояния (возможно также серверными нотификациями) - -``` -GET /v1/order-status-changes/history⮠ - limit=2 -→ -{ - "order_status_changes": [{ - "change_id", - "occurred_at", - "order_id": "1", - "operation_status": "success" - }, { - "change_id", - "occurred_at", - "order_id": "2", - "operation_status": "fail", - "error" - }], - "cursor" -} -``` - -Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример: - -``` -// Партнёр уведомляет нас об -// изменениях статуса -POST /v1/orders/bulk-status-change -X-Idempotency-Token: <токен> -{ - "status_changes": [{ - "order_id": "1", - "new_status": "accepted", - … - }, { - "order_id": "2", - "new_status": "rejected", - … - }] -} -→ -{ - "changes": [{ - "order_id": "1", - "operation_status": "success" - … - }, { - "order_id": "2", - "operation_status": "fail", - "error" - }] -} - -``` - -Допустим, клиент не смог получить наш частично-успешный ответ и повторил запрос с тем же токеном идемпотентности. - -``` -// Партнёр повторно -// уведомляет нас об -// изменениях статуса -POST /v1/orders/bulk-status-change -X-Idempotency-Token: <токен> -{ - "status_changes": [{ - "order_id": "1", - "new_status": "accepted", - }, { - "order_id": "2", - "new_status": "rejected", - "reason" - }] -} -→ -{ - "changes": [{ - "order_id": "1", - "operation_status": "success" - … - }, { - "order_id": "2", - "operation_status": "success", - }] -} -``` - -По сути, для клиента всё произошло ожидаемым образом: изменения были внесены, и последний полученный ответ корректен. Однако по сути состояние ресурса после первого запроса отличалось от состояния ресурса после второго запроса, что противоречит самому определению идемпотентности. - -Более корректно было бы при получении повторного запроса с тем же токеном ничего не делать и возвращать ту же разбивку ошибок, что была дана на первый запрос — для чего нам вновь необходимо хранение истории изменений. - -На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует каким-то детерминированным образом сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности (в простейшем случае — считать токен идемпотентности внутренних запросов равным токену идемпотентости внешнего запроса, если это допустимо в рамках предметной области; иначе придётся использовать составные токены — в нашем случае, например, в виде `:`). diff --git a/src/ru/drafts/03-Раздел II. Паттерны дизайна API/10.md b/src/ru/drafts/03-Раздел II. Паттерны дизайна API/10.md index 5cc774f..b0e9992 100644 --- a/src/ru/drafts/03-Раздел II. Паттерны дизайна API/10.md +++ b/src/ru/drafts/03-Раздел II. Паттерны дизайна API/10.md @@ -12,7 +12,7 @@ X-Idempotency-Token: <токен> "recipe": "lungo", }, { "recipe": "latte", - "milk_type": "oats" + "milk_type": "oat" }] } → @@ -185,4 +185,4 @@ X-Idempotency-Token: <токен> Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает возможные конфликты, основываясь на истории ревизий. -**NB**: один из подходов к этой задаче — разработка такой номенклатуры операций над данными (например, [CRDT](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type)), в которой любые действия коммутативны (т.е. конечное состояние системы не зависит от того, в каком порядке они были применены). Мы, однако, склонны считать такой подход применимым только к весьма ограниченным предметным областям — поскольку в реальной жизни некоммутативные действия находятся почти всегда. Если один пользователь ввёл в документ новый текст, а другой пользователь удалил документ — никакого разумного (т.е. удовлетворительного с точки зрения обоих агентов) способа автоматического разрешения конфликта здесь нет, необходимо явно спросить пользователей, что бы они хотели сделать с возникшим конфликтом. +**NB**: один из подходов к этой задаче — разработка такой номенклатуры операций над данными (например, [CRDT](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type)), в которой любые действия коммутативны (т.е. конечное состояние системы не зависит от того, в каком порядке они были применены). Мы, однако, склонны считать такой подход применимым только к весьма ограниченным предметным областям — поскольку в реальной жизни некоммутативные действия находятся почти всегда. Если один пользователь ввёл в документ новый текст, а другой пользователь удалил документ — никакого разумного (т.е. удовлетворительного с точки зрения обоих акторов) способа автоматического разрешения конфликта здесь нет, необходимо явно спросить пользователей, что бы они хотели сделать с возникшим конфликтом.