Skip to main content

One Ordered, Fail-Closed Gate Around Every LangGraph Side Effect

· 4 min read
Vadim Nicolai
Senior Software Engineer

The checkpointer restores state. It does not restore the world.

LangGraph in production, a 10-part series. ← Part 4 · Part 6 →

A resumed node re-runs from its first line, so side effects must be idempotent by your design, not the framework's (LangGraph interrupts). In a warehouse that gap prints two labels for one parcel and ships it twice.

Route every action through one gate: checks ordered widest scope first, each returning a short reason or None, with the only writing check last.

class ProcessDied(BaseException):           # a kill: the gate's handler must not swallow it
pass

def key(p): # the parcel, not the station: one label each
return (p["run_key"], p["kind"], p["parcel"])

maybe_sent = "maybe sent: left for a person to reconcile"

def reserve(p, w):
status = w.reservations.get(key(p))
if status == "done":
return "duplicate of an earlier attempt"
if status == "reserved": # may or may not have printed; a person decides
return maybe_sent
w.reservations[key(p)] = "reserved" # Postgres: INSERT ... ON CONFLICT DO NOTHING RETURNING
return None

# widest scope first; the only check that writes goes last
checks = (check_live, check_process, check_graph, check_printer, reserve)

def attempt(p, w):
for check in checks:
try:
answer = check(p, w)
except Exception as exc: # a check that cannot decide is a refusal
answer = f"{check.__name__} could not decide ({type(exc).__name__})"
if answer is not None:
return answer if answer == maybe_sent else f"refused: {answer}"
if w.dies_mid_print:
raise ProcessDied()
w.printed.append(p)
w.reservations[key(p)] = "done" # Postgres: UPDATE ... SET status = 'done'
return "sent"

An excerpt: World, the four check_* functions and the graph wiring are elided. Each check returns a refusal string or None.

sent
refused: duplicate of an earlier attempt
refused: check_printer could not decide (ZeroDivisionError)
maybe sent: left for a person to reconcile
labels printed after the death: 0

A restored checkpoint tells you where the graph was, never what the world did​

The checkpointer records channel values, which node runs next, and pending writes (LangGraph checkpointers). Nothing in that record says the printer accepted a job.

Reserving first is the instinct, and here it is backwards​

Reserve first and a later refusal leaves a tombstone blocking the next legitimate attempt. The sequence is a tuple: live first, then process-wide send permitted, then action granted, then device reachable. The write comes last.

Here the lookup divides by zero and the gate answers refused: check_printer could not decide (ZeroDivisionError). The test raises each check in turn and asserts nothing printed.

The kill signal is not the gate's to interpret, which is why the listing subclasses BaseException (Python exceptions).

A row stuck at reserved means maybe sent, and at-most-once wins for physical actions​

A printer has no dedup of its own. The reservation is a unique key with a status column. ON CONFLICT "specifies an alternative action to raising a unique violation", so insert reserved with DO NOTHING RETURNING, and update to done only after the print succeeds (PostgreSQL INSERT).

A row still at reserved is genuinely unknowable: the gate returns "maybe sent" and a person reconciles it. For a physical action at-most-once beats at-least-once — a judgment, not a theorem.

The listing runs that path: the process dies after reserving, LangGraph resumes the thread, and the outcome is maybe sent: left for a person to reconcile, with labels printed after the death: 0.

The unique key is not demonstrated — the example's store is a dict.

The single chokepoint is a real cost, and the ordering survives without it​

A central gate serialises every effect and pushes ambiguity onto the reconciliation queue. But the objection defeats the chokepoint, not the ordering. Every alternative still asks the cheap questions first, treats an unreachable lookup as a no, and writes its reservation last.

A durable-execution engine pays when the whole workflow needs retry policy, not one print (LangGraph durable execution). Target-side idempotency keys solve the payment case and never the printer.

What to do on Monday​

  • Physical effect, no dedup at the target: one gate, write last.
  • Effect must outlive the graph process: outbox with a relay.
  • The workflow itself needs retries and backoff: a durable-execution engine.
  • Either way, keep refusal reasons distinct.

A gate that cannot say why it said no is half a gate.