1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-04-17 11:06:25 +02:00

Asynchronicity

This commit is contained in:
Sergey Konstantinov 2023-04-30 22:20:06 +03:00
parent ac27918663
commit c7c4bff5d6
3 changed files with 202 additions and 105 deletions

View File

@ -1 +1,101 @@
### Asynchronicity and Time Management
### Asynchronicity and Time Management
Let's continue working with the previous example. Let's imagine that the application retrieves some system state upon start-up, perhaps not the most recent one. What else does the probability of collision depend on, and how can we lower it?
We remember that this probability is equal to the ratio of time periods: getting an actual state versus starting an app and making an order. The latter is almost out of our control (unless we deliberately introduce additional waiting periods in the API initialization function, which we consider an extreme measure). Let's then talk about the former.
Our usage scenario looks like this:
```
const pendingOrders = await api.
getOngoingOrders();
if (pendingOrders.length == 0) {
const order = await api
.createOrder(…);
}
// App restart happens here,
// and all the same requests
// are repeated
const pendingOrders = await api.
getOngoingOrders(); // → []
if (pendingOrders.length == 0) {
const order = await api
.createOrder(…);
}
```
Therefore, we're trying to minimize the following interval: network latency to deliver the `createOrder` call plus the time of executing the `createOrder` plus the time needed to propagate the newly created order to the replicas. We don't control the first summand (but we might expect the network latencies to be more or less constant during the session duration, so the next `getOngoingOrders` call will be delayed for roughly the same time period). The third summand depends on the infrastructure of the backend. Let's talk about the second one.
As we can see if the order creation itself takes a lot of time (meaning that it is comparable to the app restart time) then all our previous efforts were useless. The end user must wait until they get the server response back and might just restart the app to make a second `createOrder` call. It is in our best interest to ensure this never happens.
However, what we could do to improve this timing remains unclear. Creating an order might *indeed* take a lot of time as we need to carry out necessary checks and wait for the payment gateway response and confirmation from the coffee shop.
What could help us here is the asynchronous operations pattern. If our goal is to reduce the collision rate, there is no need to wait until the order is *actually* created as we need to quickly propagate the knowledge that the order is *accepted for creation*. We might employ the following technique: create *a task for order creation* and return its identifier, not the order itself.
```
const pendingOrders = await api.
getOngoingOrders();
if (pendingOrders.length == 0) {
// Instead of creating an order,
// put the task for the creation
const task = await api
.putOrderCreationTask(…);
}
// App restart happens here,
// and all the same requests
// are repeated
const pendingOrders = await api.
getOngoingOrders();
// → { tasks: [task] }
```
Here we assume that task creation requires minimum checks and doesn't wait for any lingering operations and therefore is created much faster. Furthermore, this operation (of creating an asynchronous task) might be isolated as a separate backend service for performing abstract asynchronous tasks. Meanwhile, by having the functionality of creating tasks and retrieving the list of ongoing tasks we might significantly narrow the “gray zones” when clients can't learn the actual system state precisely.
Thus we naturally came to the pattern of organizing asynchronous APIs through task queues. Here we use the term “asynchronous” logically meaning the absence of mutual *logical* locks: the party that makes a request gets a response immediately and does not wait until the requested procedure is carried out fully being able to continue to interact with the API. *Technically* in modern application environments, locking (of both the client and server) almost universally doesn't happen during long-responding calls. However, *logically* allowing users to work with the API while waiting for a response from a modifying endpoint is error-prone and leads to collisions like the one we described above.
The asynchronous call pattern is useful for solving other practical tasks as well:
* caching operation results and providing links to them (implying that if the client needs to reread the operation result or share it with another client, it might use the task identifier to do so)
* ensuring operation idempotency (through introducing the task confirmation step we will actually get the draft-commit system as discussed in the [“Describing Final Interfaces”](#api-design-describing-interfaces) chapter)
* naturally improving resilience to peak loads on the service as the new tasks will be queuing up (possibly prioritized) in fact implementing the [“token bucket”](https://en.wikipedia.org/wiki/Token_bucket) technique
* organizing interaction in the cases of very long-lasting operations that require more time than reasonable timeouts (which are tens of seconds in the case of network calls) or can take unpredictable time.
Also, asynchronous communication is more robust from a future API development point of view: request handling procedures might evolve towards prolonging and extending the asynchronous execution pipelines whereas synchronous handlers must retain reasonable execution times which puts certain restrictions on possible internal architecture.
**NB**: in some APIs, an ambivalent decision is implemented where endpoints feature a double interface that might either return a result or a link to a task. Although from the API developer's point of view, this might look logical (if the request was processed “quickly”, e.g., served from cache, the result is to be returned immediately; otherwise, the asynchronous task is created), for API consumers, this solution is quite inconvenient as it forces them to maintain two execution branches in their code. Sometimes, a concept of providing a double set of endpoints (synchronous and asynchronous ones) is implemented, but this simply shifts the burden of making decisions onto partners.
The popularity of the asynchronicity pattern is also driven by the fact that modern microservice architectures “under the hood” operate in asynchronous mode through event queues or pub/sub middleware. Implementing an analogous approach in external APIs is the simplest solution to the problems caused by asynchronous internal architectures (the unpredictable and sometimes very long latencies of propagating changes). Ultimately, some API vendors make all API methods asynchronous (including the read-only ones) even if there are no real reasons to do so.
However, we must stress that excessive asynchronicity, though appealing to API developers, implies several quite objectionable disadvantages:
1. If a single queue service is shared by all endpoints, it becomes a single point of failure for the system. If unpublished events are piling up and/or the event processing pipeline is overloaded, all the API endpoints start to suffer. Otherwise, if there is a separate queue service instance for every functional domain, the internal architecture becomes much more complex, making monitoring and troubleshooting increasingly costly.
2. For partners, writing code becomes more complicated. It is not only about the physical volume of code (creating a shared component to communicate with queues is not that complex of an engineering task) but also about anticipating every endpoint to possibly respond slowly. With synchronous endpoints, we assume by default that they respond within a reasonable time, less than a typical response timeout (which, for client applications, means that just a spinner might be shown to a user). With asynchronous endpoints, we don't have such a guarantee as it's simply impossible to provide one.
3. Employing task queues might lead to some problems specific to the queue technology itself, i.e., not related to the business logic of the request handler:
* tasks might be “lost” and never processed
* under the task identifier, wrong data might be published (corresponding to some other task) or the data might be corrupted.
These issues will be totally unexpected by developers and will lead to bugs in applications that are very hard to reproduce.
4. As a result of the above, the question of the viability of such an SLA level arises. With asynchronous tasks, it's rather easy to formally make the API uptime 100.00% — just some requests will be served in a couple of weeks when the maintenance team finds the root cause of the delay. Of course, that's not what API consumers want: their users need their problems solved *now* or at least *in a reasonable time*, not in two weeks.
Therefore, despite all the advantages of the approach, we tend to recommend applying this pattern only to those cases when they are really needed (as in the example we started with when we needed to lower the probability of collisions) and having separate queues for each case. The perfect task queue solution is the one that doesn't look like a task queue. For example, we might simply make the “order creation task is accepted and awaits execution” state a separate order status, and make its identifier the future identifier of the order itself:
```
const pendingOrders = await api.
getOngoingOrders();
if (pendingOrders.length == 0) {
// Don't call it a “task”,
// just create an order
const order = await api
.createOrder(…);
}
// App restart happens here,
// and all the same requests
// are repeated
const pendingOrders = await api.
getOngoingOrders();
/* → { orders: [{
order_id: <task identifier>,
status: "new"
}]} */
```
**NB**: let us also mention that in the asynchronous format, it's possible to provide not only binary status (task done or not) but also execution progress as a percentage if needed.

View File

@ -1 +1,101 @@
### Асинхронность и управление временем
### Асинхронность и управление временем
Продолжим рассматривать предыдущий пример. Пусть на старте приложение получает *какое-то* состояние системы, возможно, не самое актуальное. От чего ещё зависит вероятность коллизий и как мы можем её снизить?
Напомним, что вероятность эта равна она равна отношению периода времени, требуемого для получения актуального состояния к типичному периоду времени, за который пользователь перезапускает приложение и повторяет заказ. Повлиять на знаменатель этой дроби мы практически не можем (если только не будем преднамеренно вносить задержку инициализации API, что мы всё же считаем крайней мерой). Обратимся теперь к числителю.
Наш сценарий использования, напомним, выглядит так:
```
const pendingOrders = await api.
getOngoingOrders();
if (pendingOrder.length == 0) {
const order = await api
.createOrder(…);
}
// Здесь происходит крэш приложения,
// и те же операции выполняются
// повторно
const pendingOrders = await api.
getOngoingOrders(); // → []
if (pendingOrder.length == 0) {
const order = await api
.createOrder(…);
}
```
Таким образом, мы стремимся минимизировать следующий временной интервал: сетевая задержка передачи команды `createOrder` + время выполнения `createOrder` + время пропагации изменений до реплик. Первое мы вновь не контролируем (но, по счастью, мы можем надеяться на то, что сетевые задержки в пределах сессии величина плюс-минус постоянная, и, таким образом, последующий вызов `getOngoingOrders` будет задержан примерно на ту же величину); третье, скорее всего, будет обеспечиваться инфраструктурой нашего бэкенда. Поговорим теперь о втором времени.
Мы видим, что, если создание заказа само по себе происходит очень долго (здесь «очень долго» = «сопоставимо со временем запуска приложения»), то все наши усилия практически бесполезны. Пользователь может устать ждать исполнения вызова `createOrder`, выгрузить приложение и послать второй (и более) `createOrder`. В наших интересах сделать так, чтобы этого не происходило.
Но каким образом мы реально можем улучшить это время? Ведь создание заказа *действительно* может быть длительным — нам нужно выполнить множество проверок и дождаться ответа платёжного шлюза, подтверждения приёма заказа кофейней и т.д.
Здесь нам на помощь приходят асинхронные вызовы. Если наша цель — уменьшить число коллизий, то нам нет никакой нужды дожидаться, когда заказ будет *действительно* создан; наша цель — максимально быстро распространить по репликам знание о том, что заказ *принят к созданию*. Мы можем поступить следующим образом: создавать не заказ, а задание на создание заказа, и возвращать его идентификатор.
```
const pendingOrders = await api.
getOngoingOrders();
if (pendingOrder.length == 0) {
// Вместо создания заказа
// размещаем задание на создание
const task = await api
.putOrderCreationTask(…);
}
// Здесь происходит крэш приложения,
// и те же операции выполняются
// повторно
const pendingOrders = await api.
getOngoingOrders();
// → { tasks: [task] }
```
Здесь мы предполагаем, что создание задания требует минимальных проверок и не ожидает исполнения каких-то длительных операций, а потому происходит много быстрее. Кроме того, саму эту операцию — создание асинхронного задания — мы можем поручить отдельному сервису абстрактных заданий в составе бэкенда. Между тем, имея функциональность создания заданий и получения списка текущих заданий, мы значительно уменьшаем «серые зоны» состояния неопределённости, когда клиент не может узнать текущее состояние сервера точно.
Таким образом, мы естественным образом приходим к паттерну организации асинхронного API через очереди заданий. Мы используем здесь термин «асинхронность» логически — подразумевая отсутствие взаимных *логических* блокировок: посылающая сторона получает ответ на свой запрос сразу, не дожидаясь окончания исполнения запрошенной функциональности, и может продолжать взаимодействие с API, пока операция выполняется. При этом технически в современных системах блокировки клиента (и сервера) почти всегда не происходит и при обращении к синхронным эндпойнтам — однако логически продолжать работать с API, не дождавшись ответа на синхронный запрос, может быть чревато коллизиями подобно описанным выше.
Асинхронный подход может применяться не только для устранения коллизий и неопределённости, но и для решения других прикладных задач:
* организация ссылок на результаты операции и их кэширование (предполагается, что, если клиенту необходимо снова прочитать результат операции или же поделиться им с другим агентом, он может использовать для этого идентификатор задания);
* обеспечение идемпотентности операций (для этого необходимо ввести подтверждение задания, и мы фактически получим схему с черновиками операции, описанную в главе [«Описание конечных интерфейсов»](#api-design-describing-interfaces));
* нативное же обеспечение устойчивости к временному всплеску нагрузки на сервис — новые задачи встают в очередь (возможно, приоритизированную), фактически имплементируя [«маркерное ведро»](https://en.wikipedia.org/wiki/Token_bucket);
* организация взаимодействия в тех случаях, когда время исполнения операции превышает разумные значения (в случае сетевых API — типичное время срабатывания сетевых таймаутов, т.е. десятки секунд) либо является непредсказуемым.
Кроме того, асихнронное взаимодействие удобнее с точки зрения развития API в будущем: устройство системы, обрабатывающей такие запросы, может меняться в сторону усложнения и удлинения конвейера исполнения задачи, в то время как синхронным функциям придётся укладываться в разумные временные рамки, чтобы оставаться синхронными — что, конечно, ограничивает возможности рефакторинга внутренних механик.
**NB**: иногда можно встретить решение, при котором эндпойнт имеет двойной интерфейс и может вернуть как результат, так и ссылку на исполнение задания. Хотя для вас как разработчика API он может выглядеть логично (смогли «быстро» выполнить запрос, например, получить результат из кэша — вернули ответ; не смогли — вернули ссылку на задание), для пользователей API это решение крайне неудобно, поскольку заставляет поддерживать две ветки кода одновременно. Также встречается парадигма предоставления на выбор разработчику два набора эндпойнтов, синхронный и асинхронный, но по факту это просто перекладывание ответственности на партнёра.
Популярность данного паттерна также обусловлена тем, что многие современные микросервисные архитектуры «под капотом» также взаимодействуют асинхронно — либо через потоки событий, либо через асинхронную постановку заданий же. Имплементация аналогичной асинхронности во внешнем API является самым простым способом обойти возникающие проблемы (читай, те же непредсказуемые и возможно очень большие задержки выполнения операций). Доходит до того, что в некоторых API абсолютно все операции делаются асинхронными (включая чтение данных), даже если никакой необходимости в этом нет.
Мы, однако, не можем не отметить, что, несмотря на свою привлекательность, повсеместная асинхронность влечёт за собой ряд достаточно неприятных проблем.
1. Если используется единый сервис очередей на все эндпойнты, то она становится единой точкой отказа. Если события не успевают публиковаться и/или обрабатываться — возникает задержка исполнения во всех эндпойнтов. Если же, напротив, для каждого функционального домена организуется свой сервис очередей, то это приводит к кратному усложнению внутренней архитектуры и увеличению расходов на мониторинг и исправление проблем.
2. Написание кода для партнёра становится гораздо сложнее. Дело даже не в физическом объёме кода (в конце концов, создание общего компонента взаимодействия с очередью заданий — не такая уж и сложная задача), а в том, что теперь в отношении каждого вызова разработчик должен поставить себе вопрос: что произойдёт, если его обработка займёт длительное время. Если в случае с синхронными эндпойнтами мы по умолчанию полагаем, что они отрабатывают за какое-то разумное время, меньшее, чем типичный таймаут запросов (например, в клиентских приложения можно просто показать пользователю спиннер), то в случае асинхронных эндпойнтов такой гарантии у нас не просто нет — она не может быть дана.
3. Использование очередей заданий может повлечь за собой свои собственные проблемы, не связанные с собственно обработкой запроса:
* задание может быть «потеряно», т.е. никогда не быть обработанным;
* под идентификатором задания могут быть по ошибке размещены неправильные данные (соответствующие другому заданию) или же данные могут быть повреждены.
Эти ситуации могут оказаться совершенно неожиданными для разработчиков и приводить к крайне сложным в воспроизведении ошибкам в приложениях.
4. Как следствие вышесказанного, возникает вопрос осмысленности SLA такого сервиса. Через асинхронные задачи легко можно поднять аптайм API до 100% — просто некоторые запросы будут выполнены через пару недель, когда команда поддержки, наконец, найдёт причину задержки. Но такие гарантии пользователям вашего API, разумеется, совершенно не нужны: их пользователи обычно хотят выполнить задачу *сейчас* или хотя бы за разумное время, а не через две недели.
Поэтому, при всей привлекательности идеи, мы всё же склонны рекомендовать ограничиться асинхронными интерфейсами только там, где они действительно критически важны (как в примере выше, где они снижают вероятность коллизий), и при этом иметь отдельные очереди для каждого кейса. Идеальное решение с очередями — то, которое вписано в бизнес-логику и вообще не выглядит очередью. Например, ничто не мешает нам объявить состояние «задание на создание заказа принято и ожидает исполнения» просто отдельным статусом заказа, а его идентификатор сделать идентификатором будущего заказа:
```
const pendingOrders = await api.
getOngoingOrders();
if (pendingOrder.length == 0) {
// Не называем это «заданием» —
// просто создаём заказ
const order = await api
.createOrder(…);
}
// Здесь происходит крэш приложения,
// и те же операции выполняются
// повторно
const pendingOrders = await api.
getOngoingOrders();
/* → { orders: [{
order_id: <идентификатор задания>,
status: "new"
}]} */
```
**NB**: отметим также, что в формате асинхронного взаимодействия можно передавать не только бинарный статус (выполнено задание или нет), но и прогресс выполнения в процентах, если это возможно.

View File

@ -1,103 +0,0 @@
### Слабая консистентность
Описанный в предыдущей главе подход фактически представляет собой размен производительности API на «нормальный» (т.е. ожидаемый) фон ошибок при работе с ним путём изоляции компонента, отвечающего за строгую консистентность и управление параллелизмом внутри системы. Тем не менее, его пропускная способность всё равно ограничена, и снизить её мы можем единственным образом — убрав строгую консистентность из внешнего API:
```
// Получаем состояние,
// возможно, из реплики
const orderState =
await api.getOrderState();
const version =
orderState.latestVersion;
try {
// Обработчик запроса на
// создание заказа прочитает
// актуальную версию
// из мастер-данных
const task = await api
.createOrder(version, …);
} catch (e) {
}
```
Т.к. заказы создаются намного реже, нежели читаются, мы можем существенно повысить производительность системы, если откажемся от гарантии возврата всегда самого актуального состояния ресурса из операции на чтение. Версионирование же поможет нам избежать проблем: создать заказ, не получив актуальной версии, невозможно. Фактически мы пришли к модели [событийной консистентности](https://en.wikipedia.org/wiki/Consistency_model#Eventual_consistency) (т.н. «согласованность в конечном счёте»): клиент сможет выполнить свой запрос *когда-нибудь*, когда получит, наконец, актуальные данные. В самом деле, согласованность в конечном счёте — скорее норма жизни для современных микросервисных архитектур, в которой может оказаться очень сложно как раз добиться обратного, т.е. строгой консистентности.
**NB**: на всякий случай напомним, что выбирать подходящий подход вы можете только в случае разработки новых API. Если вы уже предоставляете эндпойнт, реализующий какую-то модель консистентности, вы не можете понизить её уровень (в частности, сменить строгую консистентность на слабую), даже если вы никогда не документировали текущее поведение явно (см. главу «О ватерлинии айсберга»).
Однако просто замена сильной консистентности на слабую влечёт за собой другую проблему. Да, мы можем потребовать от партнёров дождаться получения последнего актуального состояния ресурса перед внесением изменений. Но очень неочевидно (и в самом деле неудобно) требовать от партнёров быть готовыми к тому, что они должны дождаться появления в том числе и тех изменений, которые сами же внесли.
```
// Создаёт заказ
const api = await api
.createOrder(…)
// Возвращает список заказов
const pendingOrders = await api.
getOngoingOrders(); // → []
// список пуст
```
Если мы не гарантируем сильную консистентность, то второй вызов может запросто вернуть пустой результат, ведь при чтении из реплики новый заказ мог просто до неё ещё не дойти.
Важный паттерн, который поможет в этой ситуации — это имплементация модели [«read-your-writes»](https://en.wikipedia.org/wiki/Consistency_model#Read-your-writes_consistency), а именно гарантии, что клиент всегда «видит» те изменения, которые сам же и внёс. Поднять уровень слабой консистентности до read-your-writes можно, если предложить клиенту самому передать токен, описывающий его последние изменения.
```
const order = await api
.createOrder(…);
const pendingOrders = await api.
getOngoingOrders({
…,
// Передаём идентификатор
// последней операции
// совершённой клиентом
lastKnownOrderId: order.id
})
```
В качестве такого токена может выступать, например:
* идентификатор или идентификаторы последних модифицирующих операций, выполненных клиентом;
* последняя известная клиенту версия ресурса (дата изменения, ETag).
Получив такой токен, сервер должен проверить, что ответ (список текущих операций, который он возвращает) соответствует токену, т.е. консистентность «в конечном счёте» сошлась. Если же она не сошлась (клиент передал дату модификации / версию / идентификатор последнего заказа новее, чем известна в данном узле сети), то сервер может реализовать одну из трёх стратегий (или их произвольную комбинацию):
* запросить данные из нижележащего БД или другого хранилища повторно;
* вернуть клиенту ошибку, индицирующую необходимость повторить запрос через некоторое время;
* обратиться к основной реплике БД, если таковая имеется, либо иным образом инициировать запрос мастер-данных из хранилища.
Достоинством этого подхода является лучшая масштабируемость сервиса — вы можете добавлять read-only реплики и вообще перейти к событийной консистентности на бэкенде. Недостатков же здесь два:
* вам всё ещё нужно выбрать между масштабируемостью системы и постоянным фоном ошибок;
* если при несовпадении версий клиента и сервера вы обращаетесь к мастер-реплике или перезапрашиваете данные, то увеличиваете нагрузку на хранилище сложно прогнозируемым образом;
* если же вы генерируете ошибку для клиента, то в вашей системе всегда будет достаточно заметный фон таких ошибок, и, к тому же, партнёрам придётся написать клиентский код для их обработки;
* этот подход вероятностный и спасает только в части ситуаций — о чём мы расскажем в следующей главе.
Учитывая, что клиентское приложение может быть перезапущено или просто потерять токен, наиболее правильное (хотя не всегда приемлемое с точки зрения нагрузки) поведение сервера при отсутствии токена в запросе — форсировать возврат актуальных мастер-данных.
#### Риски перехода к событийной консистентности
Прежде всего, давайте зафиксируем один важный тезис: все обсуждаемые в настоящем разделе техники решения архитектурных проблем — вероятностные. В силу ненадёжности компонентов системы мы не можем разработать её так, чтобы исключительные ситуации в ходе работы с API не возникали совсем — но мы можем постараться сделать так, чтобы при типичном профиле использования системы их стало меньше.
**NB**: оговорка про «типичный профиль важна»: API предполагает вариативность сценариев его применения, и вполне может оказаться так, что кейсы использования API делятся на несколько сильно отличающихся с точки зрения толерантности к ошибкам групп (классический пример — это клиентские API, где завершения операций ждёт реальный пользователь, и его вниманием надо дорожить против серверных API, где время исполнения само по себе менее важно, но может оказаться важным, например, массовый параллелизм операций). Если такое происходит — это сильный сигнал для того, чтобы выделить API для различных типовых сценариев в отдельные продукты в семействе API, как мы описывали это в главе «Линейка сервисов API».
Вернёмся к нашему примеру с заказом кофе и предположим, что мы реализуем следующую схему:
* оптимистичное управление синхронизацией (скажем, через идентификатор последнего заказа);
* «read-your-writes»-политика чтения списка заказов;
* если токен не передан, клиент всегда получает актуальное состояние.
Тогда получить ошибку синхронизации (отправленный клиентом токен-идентификатор не совпадает с актуальным состоянием системы) можно только в одном из двух случаев:
* клиент неверно обращается с данными, т.е. шлёт устаревший токен;
* клиент создаёт заказы одновременно с двух разных экземпляров приложения, которые не разделяют между собой состояние.
В первом случае речь идёт об ошибке имплементации приложения партнёра; второй случай означает, что клиент намеренно пытается проверить систему на прочность, что вряд ли можно рассматривать как частотный кейс (либо, например, у пользователя сел телефон и он очень быстро продолжает работу с приложением с планшета — согласитесь, маловероятное развитие событий.)
Что же произойдёт, если в целях улучшения масштабируемости системы мы откажемся от третьего условия, т.е. возврата мастер-данных клиенту, не передающему токен? У нас появится третья ситуация, когда клиент получит ошибку, а именно:
* клиентское приложение потеряло часть данных (токен синхронизации), и пробует повторить последний запрос.
**NB**: важно, что перезапрос может случить и по совершенно не техническим причинам: конечному пользователю может просто надоесть ждать, он вручную перезапустит приложение и вручную создаст повторный заказ. Полагаться только на технические предохранители в этом вопросе нельзя.
Математически вероятность создания заказа выражается довольно просто: она равна отношению периода времени, требуемого для получения актуального состояния к типичному периоду времени, за который пользователь перезапускает приложение и повторяет заказ. (Следует, правда, отметить, что клиентское приложение может быть реализовано так, что даст вам ещё меньше времени, если оно пытается повторить несозданный заказ автоматически при запуске). Итого условие, когда вы можете относительно без проблем переходить к событийной консистентности при получении состояния приложения выглядит так: это допустимо, **если вы можете обеспечить задержку полчения актуального состояния много меньшую, чем время перезапуска приложения на целевой платформе**. Если мы говорим о современных мобильных ОС и веб-браузерах, то время перезапуска измеряется в них в секундах. (Вы можете сделать проблему менее острой, если потребуете — или хотя бы порекомендуете — партнёрам не повторять автоматически запросы на неидемпотентные операции, если токен синхронизации отсутствует. Но имейте в виду — далеко не факт, что партнёры последуют этой рекомендации.)
Однако если мы говорим не о клиентских, а о серверных приложениях, здесь ситуация совершенно иная:
* с одной стороны, серверный код, как правило, гораздо толерантнее к повышенному времени получения ответа — в отличие от живого человека, он не будет нервничать и перезапускать приложение, если спиннер крутится субъективно слишком долго;
* с другой стороны, если сервер решает повторить запрос, он сделает это моментально — задержка может составлять миллисекунды.
Таким образом, в случае серверных API не предоставлять мастер-данные на старте приложения — очень плохая идея. Даже преднамеренное внесение задержек в продолжительность старта предпочтительнее, чем риск создания повторных перезаказов в случае сетевых проблем.