Почему idempotency key не доказывает доставку

В интеграции с внешним сервисом легко получить аккуратный внутренний статус и всё равно не знать, появился ли внешний объект. Особенно опасна ситуация, когда запрос ушёл, ответ потерялся по таймауту, а повтор может создать дубликат.
Idempotency key помогает, но отвечает только на часть вопроса. Он связывает повторные попытки одной и той же мутации. Сам по себе ключ не подтверждает, что провайдер принял запрос, создал объект и сделал его доступным по каноническому адресу.
Ниже разберём модель состояний, в которой попытка, разрешение на повтор, результат провайдера и подтверждённый факт доставки хранятся отдельно.
Четыре сущности вместо одного статуса
У публикационного конвейера есть как минимум четыре разных объекта учёта.
1. Release
Release фиксирует точный пакет, который разрешено отправить: ревизию текста, хэш тела, выбранный визуал, канал, профиль и время.
Это граница полномочий. Она отвечает на вопрос: какой именно пакет получил разрешение на отправку?
Release не доказывает, что отправка началась.
2. Attempt
Attempt появляется в момент контакта с провайдером. В нём нужны идентификатор операции, idempotency key, время старта и текущее состояние.
Он отвечает на вопрос: предпринималась ли попытка и можно ли отличить её от другой попытки?
Attempt не равен доставке. Даже статус started говорит только о том, что локальная сторона начала работу.
3. Provider result
Это подтверждённый ответ внешней системы: созданный object ID, permalink, явная ошибка или результат чтения состояния.
Он отвечает на вопрос: что сообщил или показал провайдер?
Сетевой таймаут оставляет ответ неизвестным. Он не превращает попытку в failed и не разрешает повтор автоматически.
4. Canonical fact
Факт доставки связывает принятый пакет с подтверждённым внешним объектом. В нём есть публичный URL или иной канонический идентификатор, фактическое время и способ подтверждения.
Он отвечает на вопрос: какой внешний объект мы считаем результатом этой задачи и на каком основании?
Без такого факта внутренний зелёный статус остаётся обещанием системы самой себе.
Что реально даёт idempotency key
Ключ идемпотентности полезен, когда провайдер или собственный адаптер хранит связь «ключ → результат операции». Тогда повтор с тем же ключом может вернуть уже созданный объект вместо создания второго.
Но гарантии зависят от конкретной границы.
Если ключ учитывается только внутри очереди, а внешний вызов выполняется вне транзакции, локальная дедупликация не доказывает, что объект у провайдера единственный.
Если провайдер принимает ключ, но срок его хранения неизвестен, поздний повтор тоже нельзя считать безопасным без проверки контракта.
Если первый вызов завершился таймаутом после отправки тела, новый ключ особенно опасен: он маскирует связь с первой попыткой и может создать дубликат.
Поэтому ключ должен сопровождаться зафиксированным состоянием попытки и процедурой readback.
UNKNOWN является рабочим состоянием
После таймаута хочется записать failed. Это удобно для интерфейса, но логически неверно.
Возможны три сценария:
запрос не дошёл до провайдера;
запрос дошёл, но провайдер не создал объект;
объект создан, а ответ не вернулся.
Локальная система не может выбрать один сценарий без новых данных. Значит, состояние результата остаётся UNKNOWN.
Для UNKNOWN полезно заморозить автоматические повторы и передать задачу в reconciliation. Это не аварийная пауза «на всякий случай», а защита от второй мутации при неизвестном результате первой.
Как устроить reconciliation
Reconciliation начинается с чтения, а не с нового POST.
Минимальная процедура выглядит так:
зафиксировать точный пакет первой попытки: ревизию, хэш тела, asset и target identity;
сохранить время, idempotency key и известную фазу операции;
проверить канонический факт в собственной системе;
прочитать доступное состояние провайдера;
сопоставить внешний объект с точным пакетом;
либо записать подтверждённый факт, либо доказать отсутствие совпадения в проверенной области;
только после этого принимать отдельное решение о повторе.
Совпадение по одному заголовку недостаточно. Нужны признаки, которые действительно связывают объект с задачей: профиль, время, текст или его хэш, asset, parent/reply relationship и provider object ID.
Если чтение провайдера неполное, результат reconciliation тоже остаётся ограниченным. «Не нашли в первых десяти объектах» не равно «объекта нет».
Где нужны жёсткие запреты
Есть несколько правил, которые лучше кодировать как инварианты.
Не менять пакет после release. Любая правка текста или визуала создаёт новую ревизию и требует нового допуска.
Не выдавать started за published. Начало вызова не создаёт публикационный факт.
Не повторять UNKNOWN новым ключом. Сначала нужен readback или отдельное решение с явно принятым риском дубликата.
Не считать provider receipt бизнес-результатом. Receipt может подтвердить внешний объект. Он не доказывает, что материал принят аудиторией или дал продуктовый эффект.
Не использовать локальную запись как доказательство внешнего состояния. Внешний объект подтверждается чтением внешней системы или каноническим provider response.
Практическая схема данных
Для небольшого конвейера достаточно пяти связанных записей:
immutable package: точная ревизия и хэши;
release: кто и когда разрешил этот пакет;
attempt: идентификатор вызова и его состояние;
provider evidence: ответ или readback;
publication fact: канонический внешний объект.
Такая схема кажется тяжелее одного поля status. Зато она позволяет ответить на вопросы, которые возникают именно в сбоях:
можно ли повторять;
какой пакет пытались отправить;
был ли контакт с провайдером;
есть ли внешний объект;
кто подтвердил результат;
от какого времени считать измерения.
Пример переходов
Нормальный путь:
prepared → released → attempt started → provider confirmed → fact recorded.
Путь с таймаутом:
prepared → released → attempt started → result unknown → retry frozen → provider readback → fact recorded или retry explicitly authorized.
Критично, что ветка unknown не перескакивает напрямую в failed и не возвращается сама в очередь.
Что проверять в реализации
Перед запуском полезно пройти короткий список.
Release привязан к точной ревизии и target identity.
У попытки есть устойчивый idempotency key.
Неопределённый результат блокирует автоматический повтор.
Readback умеет искать точный внешний объект.
Fact записывается только после подтверждения.
Метрики считаются от фактического времени публикации.
Повтор после UNKNOWN требует отдельного решения.
Эти пункты не гарантируют надёжность конкретного провайдера. Они делают видимым, что система знает, а что пока остаётся неизвестным.
Idempotency key защищает повтор одной операции в пределах заданного контракта. Доставку доказывает подтверждённый внешний объект и записанный канонический факт. Между ними находится отдельная работа: сохранить попытку, остановиться при UNKNOWN и выполнить readback до следующей мутации.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.