Idempotency: Publishing Agents That Survive a Mid-Run Kill
A per-channel ledger makes cross-posting resumable. Why exit code 0 is not a receipt, and what to do when an API has no idempotency key.
A scheduled publishing agent is almost entirely I/O against services you do not own, on a clock you do not control. Ours fans out to four destinations per article: a search-engine index ping, a microblog, a federated social network, and a developer community site. That last one rate-limits hard enough that we pace requests 75 seconds apart and back off on 429s. A three-article run therefore spends over four minutes inside the fan-out, and most of those minutes are spent sleeping.
Four minutes is plenty of time to get killed. A CI job hits its wall-clock cap. A container gets evicted mid-sleep. Someone closes a laptop. The run dying is not the interesting part. The interesting part is what the next run does about it.
Retry is easy; knowing what already happened is not
The failure that costs you is not a crash — it is a partial fan-out that leaves no obvious trace. Three articles across four channels is twelve remote calls. The process dies on call seven. Article one is everywhere. Article two reached two of four channels. Article three exists nowhere but your git history.
Now pick a retry strategy. Re-run the whole job and article one gets posted a second time to every channel that has no server-side dedupe. Skip anything carrying a published flag on the article record and article two never receives its remaining two channels — not on the next run, not ever. Both strategies are wrong for the same reason: they track state at a granularity that does not match the work.
We shipped a worse version of this. For a long stretch, our publish command built and deployed the site but never invoked the syndication step at all. 57 articles went live and were never announced anywhere. Nothing threw. Exit code zero, every time. We found it by reading the script, not by reading logs, because there was nothing in the logs to read.
The rule that falls out of this: state belongs to the (item, channel) pair. Not to the run. Not to the item.
Design the ledger before you write the retry loop
Three properties do the real work, and none of them are the retry loop itself.
Write in two phases. Mark the pair in_flight before the remote call, resolve it to done or failed after. A kill that lands between the network write and the ledger write is not a hypothetical — it is the single most likely place to die, because that window contains the network. Without a two-phase record you cannot distinguish never sent from sent but unrecorded, and those two states demand opposite actions.
Store the identifier the remote system gave you. The post ID or URL that came back in the response is what lets you reconcile later without guessing. It also turns an ambiguous in_flight row into a question you can answer with a read.
Keep the ledger outside the run. Not process memory, not the job’s temp directory, not an in-memory queue that dies with the worker. A committed JSON file or a table. Ours lives in the repo, which means the diff shows exactly what shipped and when — the same reason we generate article metadata ahead of build time rather than during it.
With that in place, resume stops being a mode and becomes a filter:
// pending work is a query over the ledger, not a resume cursor
const pending = [];
for (const item of items) {
for (const channel of CHANNELS) {
const row = ledger.get(item.slug, channel.id);
if (!row || row.state === 'failed') pending.push({ item, channel });
else if (row.state === 'in_flight') pending.push({ item, channel, verify: true });
}
}
A resumed run and a fresh run now take the same code path. There is no recovery branch to maintain and no --resume flag anyone has to remember at 2am. That matters more than it looks, because you cannot reliably test a recovery branch: the kill can land anywhere, and the cases you write tests for are the ones you already thought of.
When the API gives you no idempotency, buy it with a read
Publishing APIs rarely ship the Idempotency-Key header that payment APIs standardized years ago. In practice you land in one of three tiers.
| What the channel offers | What resume does | What it costs |
|---|---|---|
| A real idempotency key | Replay the call with the same key | One extra header |
| A queryable natural key, usually the canonical URL | Search the channel for that URL before posting | One read per ambiguous pair |
| Nothing | Scan your own recent posts in a time window, or escalate to a human | Manual review, or accepted duplicate risk |
The canonical URL is the natural dedupe key for anything content-shaped, and most channels let you search your own posts for it. Pay that read only when a row is stuck at in_flight — on a clean run it never fires, so the cost sits at zero in the common case and one request in the case that actually needs it.
One more classification is worth making explicit before you write any of this: decide, per channel, whether a repeat is harmless. Index pings are naturally idempotent, so ping freely and treat them as at-least-once. Social posts are public and permanent, so prefer at-most-once and accept a missed announcement over a duplicate — a missing post can be sent by hand tomorrow, a double post cannot be un-seen. Applying one policy uniformly across both kinds is how the same link ends up in a feed three times.
OpenCode
Open-source terminal coding agent. Useful for the mechanical part of this refactor: splitting a linear publish script into per-channel stages, threading a ledger handle through each one, and generating the fault-injection harness that kills the run after N calls.
Open source, bring your own model API key
Affiliate link · We earn a commission at no cost to you.
The refactor is mostly mechanical once the ledger schema is settled, and it is exactly the kind of repetitive, well-specified edit worth handing to a coding agent while you keep the schema decision for yourself.
FAQ
Can I skip all this by putting the work in a queue with exactly-once delivery?
Where should the ledger live for a small setup?
How do I actually test the resume path?
None of this makes the agent more capable. It makes the agent’s failures cheap, which for anything running on a schedule is the property that determines whether you keep running it.
Tools used in this review
Some links above are affiliate links. We may earn a commission if you sign up. See our disclosure for details.
Related reading
2026-09-02
DeepSeek MLA: 70 GB of KV Cache at 1M Tokens
No DeepSeek-V4 config is public yet. The V3 one is, and its KV-cache math tells you what a million-token window actually costs in GPU memory.
2026-08-13
A Daily LLM Digest Agent Costs $166 and 34 Hours a Year
A year of one scheduled digest agent in production: token costs per stage, infrastructure line items, and the 41 runs that needed a human.
2026-08-13
When Agent Output Passes Zod and Still Breaks the Build
Four failure classes that survive a clean parse, and the three-layer validation pass we run instead.
2026-08-13
AI Agent Auth: API Keys vs Device Flow vs Scoped Tokens
Three credential models for non-human callers: static keys, the OAuth 2.0 device grant, and short-lived scoped tokens - and when each one fits.
2026-08-13
Error Messages as an Agent Interface
A field-by-field guide to API error bodies: stable codes, retryable flags, wait hints, fix examples, and the shapes that trap agents in retry loops.
Get the best tools, weekly
One email every Friday. No spam, unsubscribe anytime.