Учебник веб-разработки
Разделы учебника
На этой странице

VII. HTTP и API

Повторы, идемпотентность и неизвестный результат

Оглавление · HTTP · Переходы состояния

Задача: объяснить, когда повтор сохраняет смысл операции и почему timeout не доказывает отсутствие эффекта. Нужны функции, состояние и асинхронность.

Математическая модель

Для фиксированной команды c рассмотрим переход F_c: S → S. Идемпотентность означает F_c(F_c(s)) = F_c(s) для допустимых состояний. Сравниваем состояние в выбранной модели: если забыть отправленное письмо, можно ошибочно объявить весь процесс идемпотентным.

Установка заголовка в X идемпотентна; увеличение счётчика — нет. Это свойство конкретной операции. Команда «создать новую статью» и «обеспечить существование статьи с заданным идентификатором» имеют разные контракты.

export type Article = Readonly<{ title: string; views: number }>;
export const setTitle = (s: Article, title: string): Article => ({ ...s, title });
export const incrementViews = (s: Article): Article => ({ ...s, views: s.views + 1 });

const initial: Article = { title: "Старый", views: 0 };
console.log(setTitle(setTitle(initial, "Новый"), "Новый"));
console.log(incrementViews(incrementViews(initial)));

Повтор может вернуть другое тело или статус, сохранив предполагаемый эффект. HTTP-определение. Равенство в примере структурное, не ===: функции создают новые объекты. Закон не утверждает, что два разных изменения можно переставить местами.

Потерянный ответ

Исходник схемы
sequenceDiagram
  participant Client as Клиент
  participant Server as Сервер
  participant DB as Хранилище
  Client->>Server: Создать статью
  Server->>DB: Запись
  DB-->>Server: Зафиксирована
  Server--xClient: Ответ потерян
  Client->>Client: Timeout: результат неизвестен
  Client->>Server: Повтор той же операции?

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

Retry требует причины, ограниченных попыток и общей границы времени. Backoff увеличивает паузу, jitter рассеивает синхронные повторы клиентов. Неверную схему нельзя исправить повтором того же payload. При неизвестном результате записи сначала нужна политика повторов, а не универсальный retry wrapper.

Ключ прикладной операции

Представим будущий редактор: клиент создаёт идентификатор одной операции и сохраняет его для всех её попыток. Новый ключ на каждый retry не объединяет повторы. Заголовок с ключом ничего не гарантирует без серверного контракта; в Atmanki такой механизм не добавлен.

Полезная модель записи обработки:

(principal, operation, key) → (requestFingerprint, state, result).

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

СитуацияРешение в проектируемом контракте
Новый ключНачать одну обработку
Тот же ключ и тот же вход, результат сохранёнВернуть сохранённый результат
Тот же ключ, другая командаОтклонить конфликт
Тот же ключ уже обрабатываетсяОжидать или дать явно определённый ответ
Запись обработки удалена по срокуГарантия дедупликации больше не действует

Это вариант дизайна, не стандарт универсального заголовка и не описание Strapi. Аутентификация и авторизация нужны также при повторном чтении результата. Не позволяйте чужому ключу раскрывать чужой ответ.

Атомарность важнее Map

Исходник схемы
flowchart LR
  Begin["Начало транзакции"] --> Claim["Уникальная область и ключ"]
  Claim --> Effect["Изменение в той же БД"]
  Effect --> Result["Запись результата обработки"]
  Result --> Commit["Commit"]

Схема задаёт желаемую границу для эффекта внутри одной БД. Уникальное ограничение и транзакция должны согласовать конкурентные попытки; одной проверки if (!seen.has(key)) недостаточно. Ошибка между эффектом и записью результата вне общей транзакции оставляет возможность повторного эффекта.

Локальный Map теряет историю при рестарте и не объединяет экземпляры сервера. Redis не делает запись в PostgreSQL и отправку письма единой транзакцией. Для внешней системы нужны её контракт повторов, сверка состояния либо процесс доставки с журналом и восстановлением. Outbox будет разобран позже в хранении; он помогает фиксировать намерение доставки, но не устраняет дубли у получателя.

Не называйте дедупликацию exactly-once без обозначенной области гарантии, устойчивого хранения и поведения после сбоя. Формула закона перехода и протокол доставки — разные уровни доказательства.

Что делает текущий webhook

Обработчик не создаёт статью и не отправляет письмо. Он просрочивает тег cms и инвалидирует корневой layout. Повторное уведомление снова разрешает получить актуальные данные из CMS, не добавляя повторную редакторскую запись. Нагрузка и число обновлений при этом могут отличаться.

Собственного журнала доставок, дедупликации по event ID и очереди здесь нет. Payload не обязан содержать идентификатор записи для общего инвалидирования. trigger-test проверяет приём, не меняя кеша. Уведомления не применяют старый payload как новую версию статьи: чтение идёт из источника при обновлении. Это не гарантия доставки, порядка событий или мгновенной свежести.

Практика и контрпримеры

Проверьте закон setTitle на нескольких заголовках и счётчиках. Для incrementViews постройте контрпример. Заморозьте исходный объект и убедитесь, что функции его не меняют. Отдельно включите число отправленных писем в состояние: «повторно установить заголовок и отправить письмо» перестанет быть идемпотентным.

На бумаге разберите два одновременных запроса с одним ключом, конфликт входа, рестарт после commit и повтор после удаления истории. Укажите, где известен результат, а где нужна сверка. Не запускайте операции изменения на живой CMS ради учебного эксперимента.