Это глава 23 моей книги «API». v2 будет содержать три новых раздела: «Паттерны API», «HTTP API и REST», «SDK и UI‑библиотеки». Если эта работа была для вас полезна, пожалуйста, оцените книгу на GitHub, Amazon или GoodReads. English version on Substack.

Вернёмся теперь от 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 подходит лучше сего: если вы можете гарантировать атомарность (что не всегда легко с т.з. производительности), сделайте это. В первой редакции настоящей книги мы рекомендовали безусловно придерживаться этого правила.

Однако, если мы взглянем на ситуацию со стороны партнёра, она окажется далеко не такой простой. Предположим, что на стороне партнёра реализована следующая функциональность:

  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: в примере выше мы приводим «правильную» политику перезапросов (с экспоненциально растущим периодом ожидания и лимитом на количество попыток), как мы ранее рекомендовали в главе «Описание конечных интерфейсов». Следует, однако, иметь в виду, что в реальном коде партнёров с большой долей вероятности ничего подобного реализовано не будет. В дальнейших примерах эту громоздкую конструкцию мы также будем опускать, чтобы упростить чтение кода.

  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);
    

Если мы проанализируем комбинации возможных реализаций клиента и сервера, то увидим, что подходы (B) и (D) не работают с решением (1), поскольку клиент будет пытаться повторять заведомо неисполнимый запрос, пока не исчерпает лимит попыток.

Теперь добавим к постановке задачи ещё одно важное условие: предположим, что иногда ошибка подзапроса не может быть устранена его повторением — например, партнёр пытается подтвердить заказ, который был отменён пользователем. Если в составе массового вызова есть такой подзапрос, то атомарный сервер, реализованный по схеме (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 и мультиплексирования запросов тоже весьма сомнительная.

  2. Если такой эндпойнт всё же нужен, лучше реализовать его атомарно и предоставить SDK, которые помогут партнёрам не допускать типичные ошибки.

  3. Если реализовать атомарный эндпойнт невозможно, тщательно продумайте дизайн API, чтобы не допустить ошибок, подобных описанным выше.

Один из подходов, позволяющих минимизировать возможные проблемы — разработать смешанный эндпойнт, в котором потенциально зависящие друг от друга операции группированы, например, вот так:

POST /v1/bulk-status-change
{
  "changes": [{
    "order_id": <первый заказ>
    // Операции по одному
    // заказу группируются
    // в одну структуру
    // и выполняются атомарно
    "operations": [
      "refund",
      "cancel"
    ]
  }, {
    // Группы операции по разным
    // заказам могут выполняться
    // параллельно и неатомарно
    "order_id": <второй заказ>
    …
  }]
}

На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует каким-то детерминированным образом сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности (в простейшем случае — считать токен идемпотентности внутренних запросов равным токену идемпотентости внешнего запроса, если это допустимо в рамках предметной области; иначе придётся использовать составные токены — в нашем случае, например, в виде <order_id>:<external_token>).

Комментарии (0)