Retry в промпте агента может опубликовать статью дважды: кейс MCP и DEV.to

Если вы ведёте соло-пайплайн «агент пишет и выкладывает за меня» через MCP и scheduled task, инструкция «повторить при ошибке» в task prompt опаснее, чем кажется: таймаут после успешного POST на сервере неотличим от «запрос не дошёл», и второй вызов создаёт дубликат. Разбор enjoy_kumawat показывает, как retry для 429 и для URLError — разные классы риска, и как закрыть дыру проверкой уже опубликованного title — без переписывания промпта агенту.
Retry зашит в task prompt, а не в код скрипта
Типичный agentic workflow: scheduled task дважды в сутки генерирует материал и запускает публикацию. В шаге 4 инструкций задачи явно прописано: при ответе API 429 подождать 35 секунд и повторить попытку. Автор следовал этому правилу десятки раз — retry живёт в промпте агента, а не в publish_devto.py.
Скрипт до фикса при любой HTTPError или URLError завершался через sys.exit. Таймаут urlopen — 30 секунд. Агент между отдельными invocations задачи не видит, создал ли сервер статью до обрыва соединения.
Промпт говорит «retry» — а код молча выходит. Caller решает повторить. Именно здесь ломается идемпотентность.
Два входа в один API: MCP-tool и publish-скрипт
Один репозиторий — small MCP server — обслуживает два независимых пути к POST https://Dev.to/api/articles:
publish_devto.py— то, что реально вызывает scheduled task («go live»).create_articleвserver.py— для MCP-клиентов вроде Claude Desktop.
Один класс бага в двух местах: и скрипт, и MCP-tool до фикса делали «слепой» POST без проверки, опубликовано ли уже. Retry после timeout или dropped connection у MCP-клиента попадает в ту же ловушку, что и повторный запуск задачи по инструкции из промпта.
Почему Dev.to не страхует от слепого retry
API Dev.to не поддерживает idempotency-key: нет заголовка Idempotency-Key, нет client-supplied request ID для «уже обработанного» write. POST /api/articles либо создаёт статью, либо нет — режима «выполни только один раз» нет.
Для 429 повтор логичен: запрос отклонён до обработки, на сервере ничего не появилось. URLError при таймауте шире — DNS, обрыв, timeout. Caller не знает, успел ли первый POST пройти server-side.
Дилемма простая: не retry — рискуете потерять публикацию; retry — рискуете второй копией с тем же title и body, если первый POST уже прошёл.
Автор намеренно не гонял таймаут против production API — «risking an actual duplicate live article». Вместо этого воспроизвёл сценарий offline.
Offline repro: один publish, две записи
Fake urlopen на POST записывает статью в server_articles, затем бросает URLError("timed out"):
- Первый вызов unfixed-скрипта: exit с timeout, в
server_articlesуже одна запись. - Второй вызов (имитация retry из task instructions): снова exit, в списке две записи с идентичным title — «one intended publish, two live articles».
# Упрощённая логика repro из поста
# POST → статья на «сервере» → URLError("timed out")
# Retry → второй POST с тем же title/body → дубликат
Фикс: live-список как общий source of truth
Перед POST при published=True скрипт вызывает already_published: GET https://Dev.to/api/articles/me/published?per_page=30, сравнение title. Совпадение — вывод ALREADY PUBLISHED (skipped duplicate), возврат URL без второго POST.
Лимит per_page=30 намеренный: покрывает окно «retry вскоре после timeout», не претендует на reuse старого title месяцами спустя.
Та же проверка в create_article в server.py: при совпадении title — already_published: True без POST. Stub-тест с title уже в списке подтверждает, что POST не вызывается.
После фикса при повторном запуске repro server_articles остаётся с одной записью.
Автор сознательно не добавлял client-side idempotency key, локальный кэш «уже пытался» и встроенный retry/backoff в скрипт. Аргумент: retry происходит между отдельными invocations scheduled task без гарантированного shared state; проверка live-списка на Dev.to — общий якорь для всех попыток, включая MCP и агента.
Инструкция «retry on 429» в task prompt не менялась — расширили безопасность для failure modes, которые caller может перепутать с «безопасным» retry.
Практика для соло-workflow с агентами и MCP
Мотивация автора — пост в MCP-теге про idempotent agent writes; сначала казалось «не про меня» (нет distributed queue), затем проверил свой single write path с explicit retry в промпте.
Вывод для тех, кто собирает похожий пайплайн:
- Разделяйте классы ошибок в голове агента и в guardrails кода. 429 и timeout — не одно и то же; промпт не заменяет проверку «уже опубликовано».
- Один API — один класс защиты на всех entry points. MCP-tool и cron-скрипт должны сходиться на одном source of truth — здесь published list по title.
- Не полагайтесь на idempotency API, если платформа её не даёт: стабильный ключ (title + список опубликованных) лучше, чем надежда на «retry безопасен».
Если в вашем цикле агент сам себе задаёт retry в task prompt — заложите такую проверку до POST, а не после N-го «успешного» таймаута.
Источники
- My Publish Script Has a Retry Instruction in Its Own Task Prompt. It Had No Guard Against That Retry Creating a Duplicate. — enjoy_kumawat, Dev.to, 2026-08-03