Lab 08 · Durable refund workflow
Build a refund workflow that pauses for a manager, survives a restart through a SQLite checkpoint, pays out exactly once, and can be re-decided with time travel.
What you will build
A refund workflow for Northwind written as a LangGraph graph. Small refunds are paid automatically. Larger ones pause with interrupt() until a manager answers. The pause is saved to a SQLite file, so a brand-new process can pick the request up and finish it. Finally you fork the saved history to replay the decision the other way.
What you will learn
- How
interrupt()pauses a graph andCommand(resume=...)continues it - Why a checkpointer is required for pausing, and how
SqliteSaverstores it on disk - Why a node re-runs from its first line on resume, and how to keep side effects idempotent
- How
thread_ididentifies one conversation or workflow run - How to inspect
get_state_historyand fork an earlier checkpoint (time travel)
Check your prerequisites
You need three things on your machine and three values from your instructor.
- Python 3.11, 3.12 or 3.13. Check with the command below. On Windows, install it from python.org and tick Add python.exe to PATH.
- A code editor. VS Code with the Python extension works well.
- A terminal. PowerShell on Windows, Terminal on macOS or Linux.
python --versionpython3 --versionThis lab uses no model, so it needs no API key, no .env and no network. Every node is plain Python. The only moving part is a SQLite file on your disk.
Create the project folder and virtual environment
Create a new folder for this lab, open it in a terminal, and create a virtual environment. A virtual environment is a private copy of Python for this project, so the libraries you install here never clash with other projects.
mkdir C:\labs\lab-08-durable-refunds
cd C:\labs\lab-08-durable-refunds
python -m venv .venv
.venv\Scripts\Activate.ps1mkdir -p ~/labs/lab-08-durable-refunds
cd ~/labs/lab-08-durable-refunds
python3 -m venv .venv
source .venv/bin/activateKeep lab folders on a short path such as C:\labs. Deep folders, for example inside OneDrive or Desktop, can exceed the Windows path limit. Some libraries then fail to load with The filename or extension is too long. OneDrive syncing can also lock files the labs write.
When activation works, your prompt starts with (.venv). Activate it again every time you open a new terminal for this lab. Open the folder in VS Code with code . if you like.
PowerShell says running scripts is disabled on this system? Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, and activate again.
This is what your folder will look like at the end of the lab:
Files marked as generated are created when you run the code. You do not create them yourself.
Install the libraries
Create a file named requirements.txt in your lab folder with the content below. The versions are pinned to the ones this lab was tested with.
langgraph==1.2.11
langgraph-checkpoint==4.2.0
langgraph-checkpoint-sqlite==3.1.1langgraph-checkpoint-sqlite provides SqliteSaver, which stores every checkpoint in a SQLite file. SQLite ships with Python, so there is no database server to install.
Then install everything into your virtual environment. It takes a minute or two.
python -m pip install --upgrade pip
pip install -r requirements.txtpython -m pip install --upgrade pip
pip install -r requirements.txtWrite the refund workflow
The workflow has four nodes:
validatechecks the amount. At or below 100 it is approved automatically.await_approvalpauses and waits for a manager.apply_refundpays out.notify_customeremails the result.
The flow is START → validate → (apply_refund | await_approval), then await_approval → (apply_refund | notify_customer), and apply_refund → notify_customer → END.
After every step LangGraph saves a checkpoint: a snapshot of the state plus what runs next. That is what makes pausing, resuming and time travel possible.
Create an empty file named refund_workflow.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports
interruptpauses the graph inside a node;Command(resume=...)continues it.SqliteSaveris a checkpointer that writes checkpoints to a SQLite file.Countercounts how often node bodies run, so you can see re-runs.
"""Lab 08: a refund workflow that pauses for a manager and survives a restart.
Plain-Python nodes on a SQLite checkpointer: no model, no API key.
Run: python refund_workflow.py
"""
import operator
from collections import Counter
from pathlib import Path
from typing import Annotated, Any, Literal
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import END, START, StateGraph
from langgraph.graph.state import CompiledStateGraph
from langgraph.types import Command, interrupt
from typing_extensions import TypedDictBlock 2Settings and pretend external systems
DB_PATH puts the checkpoint file next to the code. HOLDS and LEDGER stand in for real systems (a payments API and a ledger) that the workflow changes. NODE_CALLS records how many times each node body ran.
They are plain dictionaries so you can print them. In production they would be outside your process, which is exactly why writing to them twice would be a real bug.
DB_PATH = Path(__file__).resolve().parent / "refund_lab.db"
APPROVAL_THRESHOLD = 100.0
HOLDS: dict[str, str] = {} # stands in for the payments API
LEDGER: dict[str, str] = {} # stands in for the ledger
NODE_CALLS: Counter = Counter() # how often each node BODY ranBlock 3The workflow state
One refund request. decision moves from "" to auto, approved or rejected. audit has an operator.add reducer, so each node appends one line and you get a full audit trail at the end.
class RefundState(TypedDict):
"""One refund request moving through the workflow."""
request_id: str
customer: str
amount: float
decision: str # "" | "auto" | "approved" | "rejected"
receipt_id: str
audit: Annotated[list[str], operator.add]Block 4Node 1: validate
A pure function: it only reads state and returns an update. Pure nodes are safe to replay any number of times, which matters once resume and time travel start re-running steps.
def validate(state: RefundState) -> dict[str, Any]:
"""Pure, so replaying it costs nothing. Small refunds skip the manager."""
auto = state["amount"] <= APPROVAL_THRESHOLD
return {"decision": "auto" if auto else "",
"audit": [f"validated {state['request_id']} for {state['amount']:.2f}"]}Block 5Node 2: pause for a manager
interrupt(payload) saves a checkpoint and stops the graph. The payload is what the manager sees; keep it to JSON types (strings, numbers, dicts).
The key gotcha: when you resume, LangGraph runs this node again from its first line. This time interrupt() returns the manager's answer instead of pausing. So anything above interrupt() runs twice.
That is why the hold uses HOLDS.setdefault(request_id, ...): the second run finds the existing hold and does nothing. With HOLDS[...] = ... or a list append against a real payments API, you would place two holds.
Never wrap interrupt() in a broad try/except: it works by raising an exception, and catching it turns the pause into a silent decision.
def await_approval(state: RefundState) -> dict[str, Any]:
"""Pause for a manager. The hold is keyed because this body re-runs from line 1."""
NODE_CALLS["await_approval"] += 1
HOLDS.setdefault(state["request_id"], f"HOLD-{state['request_id']}")
answer = interrupt({"type": "refund_approval", "request_id": state["request_id"],
"customer": state["customer"], "amount": state["amount"]})
decision = "approved" if answer["approved"] else "rejected"
return {"decision": decision, "audit": [f"{answer['approver']} {decision} the refund"]}Block 6Nodes 3 and 4: pay and notify
apply_refund is idempotent: keyed by request_id, so running it twice still leaves one ledger entry and returns the same receipt. Resumes, retries and time travel can all re-run a node, so any node that moves money must be safe to repeat.
def apply_refund(state: RefundState) -> dict[str, Any]:
"""Move the money. Keyed by request id, so a resume or a replay pays out once."""
NODE_CALLS["apply_refund"] += 1
receipt = LEDGER.setdefault(state["request_id"], f"RCPT-{state['request_id']}")
return {"receipt_id": receipt, "audit": [f"paid {state['amount']:.2f} as {receipt}"]}
def notify_customer(state: RefundState) -> dict[str, Any]:
"""Tell the customer the outcome."""
return {"audit": [f"emailed {state['customer']}: {state['decision']}"]}Block 7Routing functions
Two conditional edges. The Literal[...] return types list the possible next nodes, which LangGraph uses to validate the graph and draw it.
def route_after_validation(state: RefundState) -> Literal["await_approval", "apply_refund"]:
"""Small refunds are paid straight away; the rest wait for a manager."""
return "apply_refund" if state["decision"] == "auto" else "await_approval"
def route_after_approval(state: RefundState) -> Literal["apply_refund", "notify_customer"]:
"""Approved refunds are paid; rejected ones only notify the customer."""
return "apply_refund" if state["decision"] == "approved" else "notify_customer"Block 8Wire the graph with a checkpointer
compile(checkpointer=...) is essential. Without a checkpointer interrupt() has nowhere to store the pause, and the graph cannot resume.
def build_graph(checkpointer: BaseCheckpointSaver | None = None) -> CompiledStateGraph:
"""Compile the workflow. Without a checkpointer, interrupt() cannot pause."""
builder = StateGraph(RefundState)
builder.add_node("validate", validate)
builder.add_node("await_approval", await_approval)
builder.add_node("apply_refund", apply_refund)
builder.add_node("notify_customer", notify_customer)
builder.add_edge(START, "validate")
builder.add_conditional_edges("validate", route_after_validation,
["await_approval", "apply_refund"])
builder.add_conditional_edges("await_approval", route_after_approval,
["apply_refund", "notify_customer"])
builder.add_edge("apply_refund", "notify_customer")
builder.add_edge("notify_customer", END)
return builder.compile(checkpointer=checkpointer)Block 9Process 1: start and pause
SqliteSaver.from_conn_string(path) is a context manager: it opens the file, creates the tables on first use, and closes the connection at the end of the with block.
The config carries a thread_id. Every checkpoint of this request is saved under it, and you use the same id to resume.
When the graph pauses, invoke returns the state with an __interrupt__ key that holds the payload. The code uses .get() so it prints a clear message instead of crashing if the amount was too small to need approval.
def first_process(request: RefundState, config: dict[str, Any]) -> None:
"""Start the refund and stop at the manager gate, then close the database."""
with SqliteSaver.from_conn_string(str(DB_PATH)) as saver:
graph = build_graph(saver)
paused = graph.invoke(request, config)
interrupts = paused.get("__interrupt__", [])
if not interrupts:
print("The workflow did not pause. Is the amount above APPROVAL_THRESHOLD?")
return
print("paused with:", interrupts[0].value)
print("hold placed before the pause:", HOLDS)Block 10Process 2: recover, resume and fork
This with block opens a new saver on the same file. It never saw the first run, yet get_state(config).next shows the thread waiting at await_approval: the pause was read from disk.
invoke(Command(resume={...}), config)continues the thread with the manager's answer.get_state_history(config)lists every checkpoint, newest first. The code picks the one taken just before the decision.update_state(before.config, None)creates a fork from that checkpoint. Resuming the fork with a rejection writes a new branch; the approved branch stays in history.
Do not simply re-invoke with the old checkpoint's config: the earlier answer is stored with it and would be replayed, so the decision would not change.
def second_process(config: dict[str, Any]) -> None:
"""Open the same database with a new saver, resume with approval, then fork a rejection."""
with SqliteSaver.from_conn_string(str(DB_PATH)) as saver:
graph = build_graph(saver)
print("recovered, next:", graph.get_state(config).next)
final = graph.invoke(Command(resume={"approved": True, "approver": "m.chen"}), config)
print("decision:", final["decision"], "| receipt:", final["receipt_id"])
print("await_approval ran", NODE_CALLS["await_approval"], "times | HOLDS:", HOLDS)
print("audit:", final["audit"])
# Time travel: fork the checkpoint taken just before the manager decided.
before = next(s for s in graph.get_state_history(config) if s.next == ("await_approval",))
fork_config = graph.update_state(before.config, None)
other = graph.invoke(Command(resume={"approved": False, "approver": "director"}), fork_config)
print("forked ->", other["decision"], "| ledger still:", LEDGER)
print("history now holds", len(list(graph.get_state_history(config))), "checkpoints")Block 11Run both processes
The demo deletes any old database, then runs the two with blocks one after the other. The two blocks stand in for two processes, for example a web server that is redeployed while the refund waits. They share nothing except the SQLite file (and the pretend external systems).
if __name__ == "__main__":
DB_PATH.unlink(missing_ok=True) # start from an empty checkpoint store
request: RefundState = {"request_id": "R-2", "customer": "grace@example.com", "amount": 640.0,
"decision": "", "receipt_id": "", "audit": []}
config = {"configurable": {"thread_id": "R-2"}}
first_process(request, config)
print("--- the process exits; the SQLite file keeps the paused thread ---")
second_process(config)Run the workflow
python refund_workflow.pypython3 refund_workflow.pyYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
paused with: {'type': 'refund_approval', 'request_id': 'R-2', 'customer': 'grace@example.com', 'amount': 640.0}
hold placed before the pause: {'R-2': 'HOLD-R-2'}
--- the process exits; the SQLite file keeps the paused thread ---
recovered, next: ('await_approval',)
decision: approved | receipt: RCPT-R-2
await_approval ran 2 times | HOLDS: {'R-2': 'HOLD-R-2'}
audit: ['validated R-2 for 640.00', 'm.chen approved the refund', 'paid 640.00 as RCPT-R-2', 'emailed grace@example.com: approved']
forked -> rejected | ledger still: {'R-2': 'RCPT-R-2'}
history now holds 9 checkpointsFollow the story in the output:
- paused with shows the interrupt payload. The hold was placed once.
- After the first
withblock closes, the second one reads the thread fromrefund_lab.dband seesnext: ('await_approval',). - After the approval,
await_approval ran 2 times(once to pause, once to resume), yetHOLDSstill has one entry. That is thesetdefaultat work. - The audit trail lists validate, approval, payment and email in order.
- The fork takes the other decision (
rejected), and the ledger still shows one payment: the fork changed the graph's state, not the money already moved.
That last point is the lesson of time travel: it rewrites state, not the outside world. The approval email already went out on the original branch.
Check your work
You are done when
- The output shows
recovered, next: ('await_approval',)from the second saver. await_approval ran 2 times, butHOLDShas exactly one entry.LEDGERhas exactly one entry,R-2: RCPT-R-2.- The fork prints
forked -> rejected. - A
refund_lab.dbfile exists next torefund_workflow.py.
Troubleshooting
ValueError: No checkpointer set, or the graph never pauses
Pass the saver to build_graph(saver) so compile(checkpointer=...) receives it.
KeyError: '__interrupt__'
The request did not pause. Refunds at or below APPROVAL_THRESHOLD (100) are approved automatically; use an amount above it.
KeyError: 'checkpoint_ns' when forking
Pass the full snapshot.config to update_state. A hand-built config with only thread_id and checkpoint_id is missing fields.
The fork prints approved again
You re-invoked the old checkpoint's config directly. Fork first with update_state(before.config, None) and resume on the config it returns.
ModuleNotFoundError: No module named 'langgraph.checkpoint.sqlite'
Install the requirements again inside the activated virtual environment: pip install -r requirements.txt.
'python' is not recognized, or the wrong Python runs
Activate the virtual environment first. On Windows you can also try py -3.11 instead of python when creating the venv.
Go further
- Change the amount to
100.0and confirm the refund is paid without a pause; then try100.01. - Open
refund_lab.dbin a SQLite browser between the twowithblocks to see the checkpoints on disk. - Add a
finance_gatenode for refunds over 1,000 that runs afterawait_approval. One interrupt per node keeps resume values unambiguous. - Make
apply_refundfail twice before succeeding and give it aRetryPolicy. Check thatLEDGERstill holds one entry. - Decide where a compensating correction email belongs after a fork turns an approval into a rejection.
Stuck?
Download the finished project and compare it with yours. It contains every file from this guide except .env and .venv, so you still add your own settings.