pickuma.
AI & Dev Tools

Idempotency Is the Whole Game: Making a Publishing Agent Survive a Mid-Run Kill

A publish run that dies halfway leaves some channels posted and some not. Here is the per-channel ledger design that makes cross-posting resumable, why exit code 0 is not a receipt, and what to do when an API offers no idempotency key.

7 min read

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 offersWhat resume doesWhat it costs
A real idempotency keyReplay the call with the same keyOne extra header
A queryable natural key, usually the canonical URLSearch the channel for that URL before postingOne read per ambiguous pair
NothingScan your own recent posts in a time window, or escalate to a humanManual 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

Try OpenCode

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?
No. Exactly-once at the broker still gives you at-least-once at the effect site, because the acknowledgement and the remote side effect cannot commit together across a network boundary you do not own. The ledger is where you convert at-least-once delivery into at-most-once publishing. A queue is a fine transport underneath it, not a replacement for it.
Where should the ledger live for a small setup?
A committed JSON file keyed by item slug is enough while you have a single writer and the row count stays in the low thousands. It is diffable, reviewable, and survives a machine dying. Move to a table when two runs can overlap, since concurrent writers to a file will silently clobber each other and you will not notice until a duplicate shows up publicly.
How do I actually test the resume path?
Add an env var that aborts the process after N remote calls, then run the fan-out against a staging or dry-run config with N swept across the whole range. Assert on the ledger diff between the killed run and the follow-up run: every pair terminal, no pair attempted twice. Randomized kill points find the ordering bugs that hand-picked ones miss.

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

See all AI & Dev Tools articles →

Get the best tools, weekly

One email every Friday. No spam, unsubscribe anytime.