The Retry That Booked Marta's Flight Twice
Naive retry double-charges a non-idempotent agent; the fix is a checkpoint written before the side effect, a resume-branch-abandon policy decided in advance, and a replay stream you get for free.

Your agent's retry logic can be perfectly correct and still double-charge a customer. Here is why naive retry breaks a non-idempotent agent, and the small set of controls that fix it in both the Claude Agent SDK and LangChain Deep Agents.
Your retry logic is correct and it still charged the customer twice. The bug is not the retry; it is that nothing recorded what already happened.
In this article: You will learn why wrapping an agent loop in
try/exceptis not recovery, and what to build instead. We cover the idempotency guard that records intent before any side effect fires, durable checkpointing in both frameworks, the resume-branch-abandon policy you decide before an incident rather than during one, and the replay stream that turns your checkpoints into a debugger. By the end you will know exactly where deduplication lives and why the framework cannot do it for you.
Marta booked a flight to Barcelona at 10:42 a.m. She watched the spinner turn for thirty-eight minutes while the agent worked through her constraints. Then the container hosting the harness restarted on a routine cloud-scheduler preemption, and her partial progress was gone. The retry was automatic. It was also catastrophic: the book_flight call from the first run had already succeeded and charged her card before the crash. The retry charged her again. Two seats, two confirmation emails, one thirty-minute phone call with the airline's billing department to undo it.
Here is the uncomfortable part. The retry logic was correct by itself. Nothing in it was buggy. The missing piece was a record of what had already happened. This article builds that record, and the recovery policy that uses it, the same way twice: once in the Claude Agent SDK and once in LangChain Deep Agents.

Why "just retry" fails
The night-before-release instinct is to wrap the loop in try/except and re-run. That works for stateless functions. A multi-step agent is not a stateless function. Run search_flights, the model picks one and calls book_flight, the container crashes mid-write on the confirmation step. A naive retry re-runs search_flights, then calls book_flight again. The reservation system is stateful. It already received the first call. The second books a second seat. The problem is not the retry. The problem is that state has already drifted by the time the retry fires.
Pat Helland named this in 2012: idempotency is a non-negotiable prerequisite for retry in loosely coupled systems. A retry that re-executes side effects is not a recovery; it is a second execution of the same harm.
The problem also compounds. With five sequential tool calls at 95% success each, the run succeeds 77% of the time. At twenty steps, 36%. Every non-idempotent step is a multiplier on the blast radius of a careless retry. The field is littered with the evidence: an onboarding agent that sent the same email fourteen times in nine minutes after a retry loop fired on a network partition, and a CRM agent that looped for sixty-three hours and $4,200 retrying an upsert against 429 responses. The retry code was fine in both. Nobody recorded which steps had already succeeded.
The fix is one ordering rule:
Write the checkpoint before the side effect fires. Record the result after it lands. On retry, if the checkpoint already holds a result, skip the call.
The idempotency guard
Here is the load-bearing control, and it is framework-agnostic, because neither framework knows that book_flight is non-idempotent. The guard wraps any side-effecting call. It records intent before the call, the result after, and refuses to blindly re-fire a call that was in flight when the process died.
import json, hashlib
from pathlib import Path
class NeedsReconcile(Exception): ...
def _ckpt(session_id: str, step: str) -> Path:
return Path(f"checkpoints/{session_id}/{step}.json")
def idem_key(session_id: str, step: str, args: dict) -> str:
raw = f"{session_id}:{step}:{json.dumps(args, sort_keys=True)}"
return hashlib.sha256(raw.encode()).hexdigest()[:32] # stable intent fingerprint
def guarded(session_id: str, step: str, fn, args: dict):
"""Run a non-idempotent side effect at most once across retries."""
path = _ckpt(session_id, step)
if path.exists():
rec = json.loads(path.read_text())
if rec["status"] == "done": # ① already succeeded
return rec["result"] # skip; return recorded result
raise NeedsReconcile(step, rec["idem_key"]) # ② in-flight at crash: reconcile,
# do not blindly re-fire
key = idem_key(session_id, step, args)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"status": "in_flight", "idem_key": key})) # ③ BEFORE
result = fn(**args, idem_key=key) # ④ pass key to the downstream API
path.write_text(json.dumps({"status": "done", "idem_key": key,
"result": result})) # ⑤ AFTER it lands
return result
① A completed step returns its recorded result instead of running again; this is what spares Marta the second charge. ② A step that was in flight at crash time is not retried blindly, because the side effect may have landed before the process died; it goes to reconciliation. ③ and ⑤ are the ordering rule in code: intent first, result last. ④ passes an idempotency key downstream so APIs that support deduplication (the Stripe pattern) catch the duplicate too. Wrap book_flight in guarded and the double-booking cannot happen, in either framework.
Note: The full extracted listing at
code/harness_engineering/part-5-recovery/listings/01-idempotency-guard.py shows a runnable
version with a demonstration book_flight and crash/resume harness.
The three states the guard moves through, and what a crash does in each, are easier to see laid out:

Read as a single call across a crash, the same logic looks like this: the guard checks the store, and either returns a recorded result, fires once and records, or raises for reconciliation.

The guard handles the side effect. The next question is what makes resuming the rest of the run cheap, and that is where the two frameworks diverge.
Durable state under the guard
In Deep Agents (on LangGraph) durability is built in. A checkpointer writes a state snapshot at every super-step, so a crashed run resumes from its last good step instead of the beginning. Use InMemorySaver for tests, SqliteSaver for a single process, and PostgresSaver for anything that must survive a container restart, which is exactly Marta's case.
from langgraph.checkpoint.postgres import PostgresSaver
from deepagents import create_deep_agent
with PostgresSaver.from_conn_string("postgresql://localhost/agents") as cp:
cp.setup()
agent = create_deep_agent(
model="anthropic:claude-sonnet-4-6",
tools=[search_flights, book_flight], # book_flight is wrapped by `guarded`
checkpointer=cp, # ① per-step durable state in Postgres
)
config = {"configurable": {"thread_id": "marta-1042"}}
# The first run crashes after book_flight. On restart, re-invoke the SAME thread:
agent.invoke({"messages": [{"role": "user",
"content": "Continue Marta's booking."}]}, config) # ② resumes from last checkpoint
① The checkpoint survives the crash in the database. ② Re-invoking the same thread_id resumes from the last committed step; the steps that already succeeded are not re-run. A useful detail: when parallel steps run and one fails, the successful sibling is still checkpointed, so partial progress is preserved.
Note: The full extracted listing at
code/harness_engineering/part-5-recovery/listings/02-deepagents-postgres-resume.py shows
the tool stubs and guarded wrapping elided here.
The Claude Agent SDK persists the conversation, not arbitrary graph state, and exposes recovery through sessions. Capture the session_id, and resume continues that conversation. The SDK does not know book_flight is non-idempotent, so side-effect deduplication stays the guard's job. What the SDK adds on top is conversation resume and, separately, file rollback.
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
async def run(prompt: str) -> str | None:
session_id = None
async for m in query(prompt=prompt,
options=ClaudeAgentOptions(allowed_tools=["Read", "Write"])):
if isinstance(m, ResultMessage):
session_id = m.session_id # ① capture for later resume
return session_id
async def resume(session_id: str):
async for m in query(prompt="Continue where you left off.",
options=ClaudeAgentOptions(resume=session_id)): # ② same conversation
if isinstance(m, ResultMessage):
print("resumed:", m.subtype)
① Every ResultMessage carries the session_id. ② resume=session_id reconstructs the conversation so the agent continues with full context. For files the agent wrote, the SDK adds file checkpointing: it backs up files before Write/Edit/NotebookEdit and lets you call rewind_files() to restore a known-good state on disk. Note the boundary clearly: file rewind restores files, not the conversation, and the guard, not the SDK, prevents the second charge.
Note: The full extracted listing at
code/harness_engineering/part-5-recovery/listings/03-claude-sdk-session-resume.py shows a
runnable asyncio.run entry point around these two coroutines.
The recovery triad: resume, branch, abandon
A checkpoint gives you something to recover from. The policy decides how. There are three options, and the selection is mechanical, not a 2 a.m. judgment call.
- Resume continues from the last checkpoint. Use it for transient failures: timeouts, restarts, cleared rate limits. This is Marta's case:
book_flighthas a recorded result, so resume skips it and finishes the confirmation step. - Branch forks a new run from a prior checkpoint to try a different path without disturbing the original. Use it for debugging and alternative exploration.
- Abandon discards the in-flight state and starts clean. Use it for corruption, fatal logic errors, or budget exhaustion, where resuming would only re-enter the bad state.
Encode the choice so it is made before production, not invented during an incident.
from enum import Enum
class Recovery(Enum):
RESUME = "resume"; BRANCH = "branch"; ABANDON = "abandon"
def select_recovery(error_type: str, budget_exhausted: bool,
checkpoint_corrupt: bool, is_debugging: bool) -> Recovery:
if checkpoint_corrupt or budget_exhausted: # ① non-recoverable by definition
return Recovery.ABANDON
if is_debugging: # ② explore without disturbing the run
return Recovery.BRANCH
if error_type in {"timeout", "rate_limit", "network", "restart"}:
return Recovery.RESUME # ③ transient -> resume
return Recovery.ABANDON # ④ unknown/non-transient -> escalate
① Corruption and budget exhaustion cannot be resumed; the state that caused them is the problem. ② Debugging branches. ③ Transient failures resume. ④ Anything else escalates rather than retries, which is the rule that would have stopped the sixty-three-hour loop.
Note: The full extracted listing at code/harness_engineering/part-5-recovery/listings/04-select-recovery.py shows the same policy with a small driver that exercises each branch.
The whole decision is a short tree, and the point of writing it down is that an on-call engineer never has to guess:

Both frameworks implement all three. Branching is the one worth seeing paired, since it is how you explore a fix without losing the run that got you somewhere useful.
# Claude Agent SDK: fork leaves the original session untouched.
options = ClaudeAgentOptions(resume=session_id, fork_session=True) # new session id
# LangGraph (Deep Agents): update_state writes a diverging checkpoint from a prior config;
# delete_thread is the abandon path.
agent.update_state(config, {"messages": [alt_message]}) # branch from a checkpoint
Resume is the default path, branch is for exploration, abandon is for the unrecoverable. The triad is policy. Decide it now.
Replay: the checkpoint stream is also your debugger
One more payoff falls out for free. Because each checkpoint records the tool inputs and outputs, the model version, and the transcript hash, the same stream that powers recovery lets you replay an incident offline. You reconstruct exactly what the agent saw at each step instead of guessing from a log narrative, and you can confirm a fix against the real sequence before shipping it.
Recording the model version matters here. A replay can detect when the model shifted underneath a run, which is otherwise an invisible source of "it worked yesterday."

Do this today
- Wrap one non-idempotent tool in
guarded. Run it, kill the process right after the call, and re-run. Confirm the recorded result returns and no second side effect fires. - Force an in-flight crash. Kill the process between the intent write and the result write. Confirm the guard raises
NeedsReconcileinstead of blindly re-firing. - Drive
select_recoverythrough each branch. Feed it arate_limiterror, then a corrupt checkpoint, then a budget-exhausted flag. Confirm resume, abandon, abandon. - Pick your durable store deliberately. Use
InMemorySaverin tests, but move toPostgresSaver(Deep Agents) or capturedsession_idresume (Claude SDK) for anything that must survive a container restart. - Add the model version to every checkpoint record. It is one field now, and it is the only thing that will tell you a run broke because the model shifted under it.
The bug was never the retry
Marta's agent did not fail because the model was wrong. It dispatched exactly the right booking. It failed because nothing recorded that the booking had already happened before the retry fired. The guard records it. The checkpointer and session resume make continuing cheap. The triad decides resume versus branch versus abandon. And the same checkpoint stream replays the incident when you need to debug it.
The lesson generalizes past flights and charges. Any time an agent takes an action the outside world remembers, your retry is only as safe as your record of what already happened. Write the checkpoint before the side effect, decide the recovery policy before the incident, and the retry that booked Marta's flight twice books it exactly once.