В автоматизации ashikov.ru подготовленный текст для социальной сети проходит отдельный путь после публикации статьи. Планировщик определяет, что можно отправить, а адаптер выполняет запрос к внешнему API. Сейчас автоматические запуски только строят план. Реальная отправка ограничена отдельно разрешённым пилотным сценарием.

В адаптере LinkedIn нет автоматического повтора POST после таймаута. Это не забытая обработка временной ошибки. Повторный запуск не должен создавать второй пост, когда результат первого запроса неизвестен.

Ниже — разбор реализованного протокола и тестовых сценариев сбоев, а не история произошедшего дублирования публикаций.

Таймаут не сообщает, был ли создан пост

Представим последовательность:

адаптер отправил POST
→ внешний сервис создал пост
→ ответ потерялся
→ адаптер получил таймаут

Для вызывающей стороны такой результат может выглядеть так же, как запрос, который не дошёл до сервиса. Но повторить эти две операции — не одно и то же. В первом случае можно создать дубль, во втором повтор мог бы завершить исходную работу.

HTTP не обещает идемпотентность произвольного POST. RFC 9110 рекомендует не повторять неидемпотентные запросы автоматически без основания считать их семантику идемпотентной или способа установить, что исходное действие не было применено.1

Поэтому «не получили подтверждение» и «ничего не произошло» — разные состояния. Увеличение задержки перед повтором не устраняет эту неопределённость.

Журнала успешных отправок недостаточно

Первое очевидное решение — записывать идентификатор поста после успешного ответа и при следующем запуске пропускать уже выполненную доставку.

Такой журнал нужен. Но он не закрывает два окна отказа.

В первом пост создан, а ответ потерян. Идентификатора у нас нет. Во втором ответ получен, но процесс завершился или не смог сохранить подтверждение. Пост есть, а следующему запуску снова нечего прочитать в журнале успехов.

В обоих случаях проверка «нет записи об успехе — можно отправлять» разрешает потенциальный дубль. Ошибка находится не в HTTP-клиенте, а в трактовке отсутствующей записи.

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

Сначала сохранить намерение, потом отправить запрос

Порядок действий выглядит так:

проверить план, текст и состояние доставки
→ сохранить намерение в удалённом Git-репозитории
→ выполнить один POST
→ получить успешный ответ с идентификатором поста
→ сохранить подтверждение в удалённом Git-репозитории

Намерение содержит конкретное действие, хеш подготовленного текста и ревизию, из которой взят план. Его недостаточно записать в файл на временном runner: после завершения задания этот файл может исчезнуть. До POST адаптер требует успешного сохранения состояния в удалённой ветке.

В CI для этого создаётся коммит только с файлами состояния и выполняется обычный push. Если сохранить намерение не удалось, до POST выполнение не доходит. Если push мог пройти, но подтверждение не вернулось, остановка остаётся безопасным исходом: пост ещё не отправлялся, а следующий запуск перечитает удалённое состояние.

После POST адаптер принимает как подтверждение HTTP 201 вместе с идентификатором поста допустимого формата. Только затем записывает результат и снова сохраняет состояние в удалённой ветке.

При следующем запуске подтверждённая доставка пропускается. Если подтверждения нет, но намерение уже существует, адаптер останавливается для ручного разбора. Новый запуск задания не получает разрешение повторить внешний запрос только потому, что предыдущее задание завершилось ошибкой.

Намерение не превращает два действия в транзакцию

Между записью в Git и созданием поста нет общей транзакции. Сохранить намерение и отправить POST атомарно нельзя. После введения маркера остаются разные состояния:

Момент сбояЧто знает следующий запускРешение
Намерение сохранено, процесс остановился до POSTЕсть намерение, подтверждения нетРучной разбор
Пост мог быть создан, ответ потерялсяЕсть намерение, подтверждения нетРучной разбор
Ответ получен, подтверждение не сохранилось в GitВ удалённом состоянии есть намерение без подтвержденияВосстановление подтверждения
Подтверждение сохраненоДоставка уже выполненаПропуск без POST

Цена решения видна в первой строке. Можно остановить доставку, которая вообще не дошла до внешнего сервиса. Намерение намеренно не позволяет отличить этот случай от потерянного ответа.

Поэтому это не гарантия exactly-once. Система предпочитает остановку потенциальному дублю и не обещает самостоятельно довести каждую отправку до успеха. Защита также зависит от сохранности состояния: ручное удаление маркера или откат журнала могут уничтожить основание для запрета повтора.

В текущем пилотном режиме правило ещё строже: даже явные отказы API, например 401 или 429, не запускают повтор автоматически. Это выбранная политика ограниченного запуска, а не утверждение, что любая ошибка HTTP имеет неизвестный результат.

Ключ доставки не равен хешу текста

Для языковых вариантов одной статьи нужны независимые состояния. В моём процессе ключ включает slug статьи, площадку и язык. Например:

example-note:linkedin-ru
example-note:linkedin-en

Подтверждение русской публикации не означает, что английская тоже отправлена. Неопределённый результат английской доставки не должен стирать русское подтверждение.

Это правило относится к новым подтверждениям linkedin-ru и linkedin-en. Для совместимости старое подтверждение с ключом linkedin блокирует обе языковые доставки: из него нельзя вывести, что русская публикация состоялась.

Хеш текста решает другую задачу: подтверждает, что отправляется именно подготовленный материал из плана. Он не является новым разрешением на публикацию. Исправление опечатки в уже отправленном тексте не должно создавать новую доставку.

Так разделяются идентичность действия и версия его содержимого. Иначе любой новый коммит мог бы снова разрешать уже выполненную операцию.

Повторный запуск должен читать свежее состояние

Даже правильная проверка маркера бесполезна, если два процесса одновременно прочитали старое состояние и оба решили отправлять.

В текущем workflow реальные отправки сериализованы одной группой concurrency. Адаптер дополнительно использует файловую блокировку внутри одного окружения. Она не защищает разные runner, поэтому сама по себе недостаточна.

Перед доставкой CI получает свежую ветку с состоянием. Push намерения выполняется без принудительной перезаписи истории. Конфликт останавливает отправку: проигравший процесс не должен продолжить POST на основании устаревшего решения.

Эти механизмы защищают согласованный протокол отправки. Они не делают внешний API частью Git-транзакции и не запрещают владельцу вручную создать такой же пост в обход адаптера.

Восстановление — отдельная операция

Если внешний сервис вернул идентификатор поста, но подтверждение не удалось сохранить в Git, локальный результат ещё может помочь. CI сохраняет подготовленный план, журналы состояния и результат выполнения как отдельные материалы для восстановления.

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

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

Срок хранения материалов для восстановления и срок действия запрета повтора тоже не одно и то же. Исчезновение CI-артефакта не удаляет намерение из Git и не делает повтор безопасным.

Тестировать нужно второй POST, которого не должно быть

В тестах адаптера внешняя HTTP-граница заменена управляемой подставной реализацией. Это позволяет воспроизводить сбои без реальных публикаций.

Проверки подтверждают порядок: к моменту первого POST намерение уже передано в механизм сохранения. Ошибка сохранения намерения оставляет число POST равным нулю. Таймаут или потеря соединения не приводят к подтверждению успеха, а повторный запуск не увеличивает число отправок.

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

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

Эти тесты проверяют решения адаптера на заданной границе. Они не доказывают поведение самой площадки при сетевой аварии и не превращают протокол в универсальную гарантию отсутствия дублей.

Когда автоматический повтор всё-таки уместен

Запрет повторов — не общее правило для любого API. Если сервис поддерживает договорённость об идемпотентности, клиент может отправлять повтор с тем же ключом, а сервис — распознавать исходное действие. Важно, чтобы ключ учитывался принимающей стороной и соблюдались ограничения её контракта. Такой подход подробно разобран в Amazon Builders’ Library.2

Локальный хеш запроса или маркер в Git не создаёт этой возможности у внешнего сервиса. Мой адаптер не опирается на серверный ключ идемпотентности. Его задача уже: не разрешать слепой повтор после собственного незавершённого действия.

Перед добавлением retry полезнее сначала ответить на вопрос: какой факт позволяет считать повтор безопасным? В этом автопубликаторе таймаут такого факта не даёт. Сохранённое намерение удерживает неопределённость между запусками, вместо того чтобы каждый раз принимать её за отсутствие действия.