# LangGraph 1.2.15: Idempotent approval: no double charges on resume

> When a run resumes after interrupt(), LangGraph re-runs the paused node from the top. This template shows the double-charge bug and the fix: approval-only node, separate execute node, and an idempotency key derived from thread_id.

Status: tested 2026-10-15 · Python 3.13 (tested) · License: MIT · Tier: Pro
Pro template: this page has a preview only; full code is in the Signal Pro pack (https://tryagentsignal.com/pricing/).

## What it shows
- **NAIVE**: The charge happens before interrupt() in the same node: the card is charged while the run is paused and charged again on resume (2 charges).
- **FIXED**: (1) the approval node contains nothing but interrupt(), (2) the side effect is in a separate execute node that runs after approval, (3) execute passes an idempotency key derived from thread_id. Result: 0 charges while paused, 1 after approval.
- **Replay check**: Re-running execute from the checkpoint before it (get_state_history + invoke(None, config), simulating an at-least-once retry after a crash) still leaves exactly 1 charge.

## Mocked vs real
No LLM. FakePaymentAPI is an in-process stand-in for a payment API that removes duplicate requests by idempotency key (the way Stripe does). All LangGraph code is real: interrupt, InMemorySaver, Command resume, state history replay. Not tested: SQLite/Postgres checkpointers, a real payment API.

## Gotchas
- Splitting nodes alone is not enough: a step can still run again on retries or replays. The idempotency key is what guarantees one charge, and the receiving API has to honour it.
- Derive the key from something stable (thread_id plus a step name, or an order id). Never use a random UUID generated inside the node, because the node re-runs and gets a new one.
- Replaying from an earlier checkpoint re-runs the steps after it. That is useful for recovery, and exactly why the key matters.
- InMemorySaver only lives for the process. Use the SQLite or Postgres checkpointer in production.

## FAQ
### Why does my LangGraph side effect run twice after approval?
On resume, the interrupted node re-executes from the top, so anything before interrupt() in that node runs again.

### What is the fix?
Keep the approval node to just interrupt(), move the side effect into a separate node, and pass an idempotency key derived from stable data such as thread_id.

### What is in the free preview vs the Pro pack?
The page shows the setup and the NAIVE graph plus the real run output. The FIXED graph and the replay scenario are in the Signal Pro pack.

### Which versions was it tested on?
langgraph 1.2.15 and langgraph-checkpoint 4.3.0 on Python 3.13, Oct 15, 2026. MIT-licensed.

## Code preview (full code in the Pro pack)

### idempotent_approval_preview.py

```python
"""Side effects must not repeat when a node re-executes on resume after interrupt().
Shows: (1) NAIVE: side effect before interrupt() in the same node -> runs twice.
       (2) FIXED: split nodes + idempotency key (derived from thread_id) checked against a ledger,
           so even a retried/re-run execute node charges once. No LLM; all LangGraph code real.
The 'payment API' is a fake in-process ledger (stands in for Stripe etc.)."""
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command

class FakePaymentAPI:
    def __init__(self): self.charges = []; self.by_key = {}
    def charge(self, amount, idempotency_key=None):
        if idempotency_key and idempotency_key in self.by_key:   # server-side dedupe, like Stripe
            return self.by_key[idempotency_key]
        cid = f"ch_{len(self.charges)+1}"; self.charges.append((cid, amount))
        if idempotency_key: self.by_key[idempotency_key] = cid
        return cid

class S(TypedDict, total=False):
    amount: int
    charge_id: str
    approved: bool

def naive_graph(api):
    def node(s):
        api.charge(s["amount"])                       # BAD: before interrupt -> re-runs on resume
        ok = interrupt({"approve_charge": s["amount"]})
        return {"approved": ok}
    b = StateGraph(S); b.add_node("node", node); b.add_edge(START, "node"); b.add_edge("node", END)

# ... PREVIEW ENDS HERE: first 30 of 57 lines (setup + the NAIVE version).
# The FIXED version and the runnable scenarios are in the Signal Pro pack.

```

### requirements.txt

```
langgraph==1.2.15
langgraph-checkpoint==4.3.0

```

### run_output.txt

```
== NAIVE: after pause, charges=[('ch_1', 1999)]
   after resume, charges=[('ch_1', 1999), ('ch_2', 1999)] state={'amount': 1999, 'approved': True}
== FIXED: after pause, charges=[]
   after resume, charges=[('ch_1', 1999)] state={'amount': 1999, 'charge_id': 'ch_1', 'approved': True}
   after replaying execute from checkpoint, charges=[('ch_1', 1999)]  (still 1 -> idempotent)

```
