agent_signal
← All templates
LangGraph · 1.2.15 · Python ✓ Tested

Tested on LangGraph (langgraph 1.2.15, pinned in requirements.txt)

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.

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.
Pro template. Below is a preview: the setup, the naive version and the real run output. The fixed, runnable code is in the Signal Pro pack.

Code preview

idempotent_approval_preview.pysrc
"""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.txtsrc
langgraph==1.2.15
langgraph-checkpoint==4.3.0
run_output.txtsrc
== 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)
LICENSEsrc
MIT License

Copyright (c) 2026 Agent Signal

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Upgrade 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.

Free starter pack

The approval-flow starter pack

Tested human-in-the-loop approval starters for LangGraph, OpenAI Agents SDK, Vercel AI SDK and Google ADK, plus the weekly Agent Signal newsletter. Free.

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.