[KongchangAI]
· 3 min read· 1,895 words

LangGraph Interrupt Mechanism Explained: Node Re-runs and Approval Payload Safety

LangGraph Interrupt Mechanism Explained: Node Re-runs and Approval Payload Safety

LangGraph's interrupt() re-runs the entire node on resume, potentially regenerating approval payloads that diverge from what executes.

The article clarifies LangGraph's often-misunderstood `interrupt()`: the first call pauses by raising an exception, and on resume the whole node re-runs, with `interrupt()` returning the stored resume value rather than continuing from the next line. This spawns four pitfalls: side effects before the interrupt re-execute; resume values match strictly by index, so interrupt order must be stable; a broad try/except swallows the pause and the graph never pauses; subgraph interrupts restart the parent node. The most dangerous is approval: if LLM generation sits before `interrupt()`, the draft is regenerated on resume, so what the approver saw may not be what is sent—and you pay twice. The article compares predecessor-node generation, executing from the resume value, the Functional API's `@task`, and downstream idempotency keys, recommending moving generation earlier, keeping the interrupt node side-effect-free, and always adding idempotency keys, plus test code to verify behavior locally.

LangGraph's interrupt() mechanism is often misunderstood as "resuming execution from where it paused," but the actual behavior is: on the first call, it raises an exception to pause; on resume, the entire node re-runs from the beginning, and interrupt() returns the stored resume value. This single rule explains all phenomena including duplicate side effects, index-based matching, try/except pitfalls, and subgraph restarts. For agent workflows requiring human approval, the most dangerous problem is: the payload the approver sees and the payload that actually executes may not be the same one.

reddit source: LangGraph interrupt() raises on the first pass and returns the stored resume value on the next, so t

Core Mechanism: Pausing Raises an Exception, Resuming Re-runs the Node

According to LangGraph's official documentation (the Interrupts page), when interrupt() is called, the runtime pauses execution by raising an exception. On resume, the node re-runs from the beginning, previously executed code executes again, and the value passed via Command(resume=...) becomes the return value of interrupt(). This means no code ever "continues running" from the line after interrupt()—the runtime re-executes the entire node, reaches the interrupt() call again, and then returns the stored resume value. This mechanism is the cornerstone for understanding all subsequent behaviors.

A typical approval node pattern looks like this:

def approve_and_send(state):
    draft = llm.invoke(state["request"])  # 1
    ok = interrupt({"draft": draft})      # 2
    if ok:
        send_email(draft)                 # 3
    return {"sent": bool(ok)}

Many examples expect execution to continue from line 3 after resuming, but the documentation explicitly states this is not the case. On resume, line 1 executes again, generating a new draft, then interrupt() returns the approval result, and line 3 sends this new draft.

LangGraph's persistence relies on a Checkpointer. Each thread_id corresponds to an execution trace, and the graph writes a snapshot of the entire state to a checkpoint after each step (super-step). The reason interrupt() can "pause" is that the runtime persists the current state along with the pending task information before raising the exception; on resume, it rebuilds the execution context from the checkpoint and reschedules from the node entry point. Common Checkpointer implementations include InMemorySaver (process-local only, suitable for testing) and persistence implementations based on SQLite/Postgres/Redis (suitable for production). Note: the checkpoint saves state, not Python stack frames or local variables—this is the fundamental reason for "resume means re-run" rather than "breakpoint continuation": the runtime has no mechanism to save execution position and can only reproduce the context by re-executing up to the interrupt() call.

Hidden Pitfall: Approval Payloads May Be Regenerated

In the pattern above, line 1 is an LLM call. The Checkpointers documentation states that resuming from a checkpoint re-executes "any LLM calls, API requests, or interrupts." These two rules combine to cause a situation where the approver approves the draft generated in the first round, but on resume a new draft is generated, and the final version sent is the new, unapproved one. The documentation does not explicitly warn about this approval mismatch, but it is a logical consequence of the two rules. Additionally, you pay for both generations.

If the generation process is deterministic or uses caching, the two results may be identical, but "may" is not "will." For approval workflows, this uncertainty is unacceptable.

Other Derived Rules: Side Effects, Index Matching, and Exception Handling

Duplicate side effects: The documentation warns that code before interrupt() re-executes when the node resumes, potentially overwriting initial updates or creating duplicate records. The official recommendation is to place idempotent operations before the interrupt, move side effects after the interrupt, or put them in a separate node.

Resume values match by position: LangGraph maintains a list of resume values for each task, strictly matched by index. Therefore the order of interrupt calls is critical—you cannot conditionally skip an interrupt(), nor loop over it with non-deterministic logic. Otherwise, stored answers may land on the wrong questions.

Broad except swallows the pause: The documentation explicitly prohibits wrapping interrupt() in a bare try/except, because the exception gets caught, the interrupt signal cannot propagate back to the graph, and the graph never pauses. Any node containing both interrupt( and except Exception is worth grepping for.

Subgraphs restart the parent node: When a node calls a subgraph that interrupts, the parent graph re-executes from the beginning of the node that calls the subgraph, and the subgraph also resumes from the beginning of its own node. Code in the parent node before the subgraph call also re-runs.

Command(resume=...) is a special control object in LangGraph used to resume interrupts. A normal invoke passes a new input state, whereas Command carries control instructions: the resume field's value is delivered to the corresponding interrupt() call as its return value, and the goto field can jump to a specified node. Multiple interrupt() calls within the same task are assigned indices in order of appearance, and the resume value list maps one-to-one by these indices—this is the source of "positional matching." If a single value rather than a list is passed on resume, it corresponds only to the first pending interrupt. Understanding the difference between Command and normal input is crucial: mixing them can cause state to be unexpectedly overwritten, or start a new round of execution when a resume was intended.

Solution Comparison: How to Safely Pass Approval Payloads

For the approval payload safety issue, there are several common approaches, each with its own use cases:

  • Put pre-generation work inside the node: Only suitable for read-only or idempotent operations (e.g., fetching data, upserting based on stable IDs). For any operation that creates, appends, sends, or charges, and for LLM outputs requiring human approval, this approach is wrong.
  • Generate in a predecessor node; the interrupt node only reads state: The draft is checkpointed before pausing, so what is approved is the stored version. The cost is an extra node and state field, and if the predecessor node fails midway, it re-runs on retry.
  • Execute based on the resume value after the interrupt: The human-edited payload is executed directly, as in the tool example that handles edited arguments. But if a subsequent interrupt in the same node pauses again, the code after the interrupt still re-runs.
  • Use the Functional API with @task: The entry point also executes from the beginning on resume, but completed tasks load their results from the checkpoint instead of recomputing. Putting side effects, API calls, and non-deterministic work into tasks eliminates duplicate execution of completed tasks. However, incomplete tasks may run again on resume, so downstream writes still need idempotency. @task is "replay from checkpoint," not "exactly once."
  • Idempotency keys in downstream systems: For money, email, and external writes, idempotency keys are always needed. They prevent duplicates but do not solve the problem of sending unapproved payloads.

My default recommendation is: in Graph API approval workflows, do not ship nodes where "the content being approved is computed above interrupt()." Move generation one node earlier, keep the interrupt node free of side effects, and execute based on the resume value or stored state rather than recomputed values. Then add idempotency keys regardless, because approval and exactly-once are two different problems.

The Functional API and @task is another programming paradigm LangGraph offers: building workflows with ordinary Python functions decorated with @entrypoint and @task, rather than an explicit StateGraph. The key difference is that @task return values are recorded in checkpoints, and although the entry point executes from the function's beginning on resume, calls to completed tasks return the checkpointed result directly and skip actual computation—this is the meaning of "replay from checkpoint" mentioned above. This contrasts with Graph API node re-runs: a node re-run genuinely re-executes its internal code, whereas @task short-circuits completed portions. But the shared boundary is that incomplete tasks or nodes still run once more on resume, so "exactly once" semantics must be backstopped by downstream idempotency mechanisms—@task itself does not provide them.

Verification Test: Confirm Behavior on Your Version

Since the Interrupts documentation is undated, it's best to verify on the actual LangGraph version you're running. Below is a conceptual test (adjust names for your version):

calls = {"gen": 0, "send": 0, "sent": None}

def node():
    calls["gen"] += 1
    draft = ...
    ok = interrupt({"draft": draft})
    if ok:
        calls["send"] += 1
        calls["sent"] = draft

graph = builder.compile(checkpointer=InMemorySaver())
cfg = {"configurable": {"thread_id": "t1"}}
first = graph.invoke({"request": "..."}, cfg)
shown = first["interrupt"]
graph.invoke(Command(resume=True), cfg)
assert calls["send"] == 1
assert calls["sent"] == payload_of(shown)
print(calls["gen"])  # expect 2 (simple mode)

If gen prints 2 and the second assertion fails under a non-deterministic generator, the problem from point 2 exists on your machine. After moving generation to a predecessor node, both assertions should pass.

Idempotency key is a general technique in distributed systems for ensuring "repeated submissions of the same operation take effect only once": the caller generates a unique identifier for each logical operation (e.g., a UUID or content hash), the downstream service records processed keys, and duplicate requests return the first result directly. In agent approval scenarios, it solves the duplicate execution problem—when a node re-run causes send_email to be called twice, the downstream deduplicates using the same key. But it does not solve the problem of the approval payload being regenerated: if the re-run produces a different draft, the idempotency key only causes the second send to be rejected or return the old result, masking the fact that "the approved content and the actual content differ." Hence the article emphasizes that approval consistency and exactly-once are two orthogonal problems, requiring the two separate mechanisms of "predecessor node storing the payload" and "idempotency keys."

Open Question: Where Do You Store the Approval Payload?

For developers running Graph API approval workflows, a practical question is: where do you store the payload that a human approved? In the state written by a predecessor node, in the resume value itself, or somewhere else? Has anyone stored a hash of the approved payload alongside the executed payload so that an audit can prove they match? Feel free to share your practices.

(This article is based on a Reddit discussion and LangGraph's official documentation)

One-line Summary

LangGraph's interrupt() re-runs the entire node on resume, which may cause the approval payload to be regenerated and diverge from what actually executes.

Paragraph Summary

The article clarifies the often-misunderstood mechanism of LangGraph's interrupt(): the first call pauses by raising an exception, and on resume the entire node re-runs from the beginning, with interrupt() returning the stored resume value rather than continuing from the next line. This rule gives rise to four categories of pitfalls: side effects before the interrupt re-execute; resume values strictly match by index, so interrupt order must be stable; a broad try/except swallows the pause exception and the graph never pauses; subgraph interrupts cause the parent node to restart from the beginning. The most dangerous case is approval scenarios: if LLM generation is placed before interrupt(), the draft is regenerated on resume, so what the approver sees may not be what is actually sent—and you pay for two generations. The article compares solutions including generation in a predecessor node, executing based on the resume value after the interrupt, the Functional API's @task, and downstream idempotency keys, recommending that generation be moved earlier, that the interrupt node remain side-effect-free, and that idempotency keys always be added, while providing test code for verifying behavior on your local version.

#LangGraph#interrupt mechanism#human-in-the-loop#approval workflow#idempotency key#Functional API
Share:

Related articles