Skip to content

Commit c79efe4

Browse files
authored
fix(langgraph): fail fast when interrupt is called without checkpointer (#1343)
1 parent 5908932 commit c79efe4

File tree

6 files changed

+77
-121
lines changed

6 files changed

+77
-121
lines changed

libs/langgraph/src/interrupt.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { AsyncLocalStorageProviderSingleton } from "@langchain/core/singletons";
22
import { RunnableConfig } from "@langchain/core/runnables";
3-
import { type PendingWrite } from "@langchain/langgraph-checkpoint";
4-
import { GraphInterrupt } from "./errors.js";
3+
import {
4+
BaseCheckpointSaver,
5+
type PendingWrite,
6+
} from "@langchain/langgraph-checkpoint";
7+
import { GraphInterrupt, GraphValueError } from "./errors.js";
58
import {
69
CONFIG_KEY_CHECKPOINT_NS,
710
CONFIG_KEY_SCRATCHPAD,
811
CONFIG_KEY_SEND,
12+
CONFIG_KEY_CHECKPOINTER,
913
CHECKPOINT_NAMESPACE_SEPARATOR,
1014
RESUME,
1115
} from "./constants.js";
@@ -68,6 +72,9 @@ export function interrupt<I = unknown, R = any>(value: I): R {
6872
throw new Error("No configurable found in config");
6973
}
7074

75+
const checkpointer: BaseCheckpointSaver = conf[CONFIG_KEY_CHECKPOINTER];
76+
if (!checkpointer) throw new GraphValueError("No checkpointer set");
77+
7178
// Track interrupt index
7279
const scratchpad: PregelScratchpad = conf[CONFIG_KEY_SCRATCHPAD];
7380
scratchpad.interruptCounter += 1;

libs/langgraph/src/pregel/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -976,7 +976,7 @@ export class Pregel<
976976
const checkpointer: BaseCheckpointSaver =
977977
config.configurable?.[CONFIG_KEY_CHECKPOINTER] ?? this.checkpointer;
978978
if (!checkpointer) {
979-
throw new Error("No checkpointer set");
979+
throw new GraphValueError("No checkpointer set");
980980
}
981981

982982
const checkpointNamespace: string =

libs/langgraph/src/pregel/retry.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,17 @@ const DEFAULT_RETRY_ON_HANDLER = (error: any) => {
3030
) {
3131
return false;
3232
}
33+
34+
// Thrown when interrupt is called without a checkpointer
35+
if (error.name === "GraphValueError") {
36+
return false;
37+
}
38+
3339
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3440
if ((error as any)?.code === "ECONNABORTED") {
3541
return false;
3642
}
43+
3744
const status =
3845
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3946
(error as any)?.response?.status ?? (error as any)?.status;

libs/langgraph/src/tests/pregel.test.ts

Lines changed: 38 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3275,32 +3275,25 @@ graph TD;
32753275
retryPolicy: { logWarning: false },
32763276
})
32773277
.addEdge(START, "tool_two");
3278+
32783279
let toolTwo = toolTwoGraph.compile();
32793280

32803281
const tracer = new FakeTracer();
3281-
const result = await toolTwo.invoke(
3282-
{ my_key: "value", market: "DE" },
3283-
{ callbacks: [tracer] }
3284-
);
3285-
expect(result).toEqual({
3286-
my_key: "value",
3287-
market: "DE",
3288-
__interrupt__: [
3289-
{
3290-
interrupt_id: expect.any(String),
3291-
value: "Just because...",
3292-
resumable: true,
3293-
when: "during",
3294-
ns: [expect.stringMatching(/^tool_two:/)],
3295-
},
3296-
],
3297-
});
3282+
3283+
await expect(
3284+
toolTwo.invoke(
3285+
{ my_key: "value", market: "DE" },
3286+
{ callbacks: [tracer] }
3287+
)
3288+
).rejects.toThrow("No checkpointer set");
3289+
32983290
expect(toolTwoNodeCount).toBe(1); // interrupts aren't retried
32993291
expect(tracer.runs.length).toBe(1);
3292+
33003293
const run = tracer.runs[0];
33013294
expect(run.end_time).toBeDefined();
3302-
expect(run.error).toBeUndefined();
3303-
expect(run.outputs).toEqual({ market: "DE", my_key: "value" });
3295+
expect(run.error).toEqual(expect.stringMatching(/No checkpointer set/));
3296+
expect(run.outputs).toBeUndefined();
33043297

33053298
expect(await toolTwo.invoke({ my_key: "value", market: "US" })).toEqual({
33063299
my_key: "value all good",
@@ -3536,33 +3529,19 @@ graph TD;
35363529
let toolTwo = toolTwoGraph.compile();
35373530

35383531
const tracer = new FakeTracer();
3539-
expect(
3540-
await toolTwo.invoke(
3532+
await expect(
3533+
toolTwo.invoke(
35413534
{ my_key: "value", market: "DE" },
35423535
{ callbacks: [tracer] }
35433536
)
3544-
).toEqual({
3545-
my_key: "value one",
3546-
market: "DE",
3547-
__interrupt__: [
3548-
{
3549-
interrupt_id: expect.any(String),
3550-
value: "Just because...",
3551-
resumable: true,
3552-
ns: [expect.stringMatching(/^tool_two:/)],
3553-
when: "during",
3554-
},
3555-
],
3556-
});
3537+
).rejects.toThrow(/No checkpointer set/);
35573538

35583539
expect(toolTwoNodeCount).toBe(1); // interrupts aren't retried
3559-
expect(tracer.runs.length).toBe(1);
3540+
expect(tracer.runs.length).toBe(2);
35603541

3561-
const run = tracer.runs[0];
3562-
expect(run.end_time).toBeDefined();
3563-
expect(run.error).toBeUndefined();
3564-
// TODO: there seems to be a bug with tracing
3565-
// expect(run.outputs).toEqual({ market: "DE", my_key: "value one" });
3542+
const run = tracer.runs.at(-1);
3543+
expect(run?.end_time).toBeDefined();
3544+
expect(run?.error).toEqual(expect.stringMatching(/No checkpointer set/));
35663545

35673546
expect(await toolTwo.invoke({ my_key: "value", market: "US" })).toEqual({
35683547
my_key: "value all good one",
@@ -10950,6 +10929,25 @@ graph TD;
1095010929
expect(result.messages).toHaveLength(1);
1095110930
});
1095210931

10932+
it("should fail fast when interrupt is called without a checkpointer", async () => {
10933+
const graph = new StateGraph(MessagesAnnotation)
10934+
.addNode("one", () => {
10935+
interrupt("<INTERRUPTED>");
10936+
return {};
10937+
})
10938+
.addEdge(START, "one")
10939+
.compile();
10940+
10941+
const config = { configurable: { thread_id: "1" } };
10942+
const input = { messages: [{ type: "human" as const, content: "test" }] };
10943+
10944+
await expect(graph.invoke(input, config)).rejects.toThrow(
10945+
"No checkpointer set"
10946+
);
10947+
10948+
await expect(graph.getState(config)).rejects.toThrow("No checkpointer set");
10949+
});
10950+
1095310951
describe("should interrupt and resume with Command inside a subgraph and usable zod schema", async () => {
1095410952
it("with zod v3 schemas", async () => {
1095510953
const schema = z3.object({

libs/langgraph/src/tests/python_port/checkpoint.test.ts

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -807,26 +807,13 @@ describe("Checkpoint Tests (Python port)", () => {
807807
// Compile the graph without a checkpointer first
808808
const tool_two = tool_two_graph.compile();
809809

810-
// Test basic invoke functionality
811-
const result1 = await tool_two.invoke({
812-
my_key: "value",
813-
market: "DE",
814-
});
815-
816-
expect(result1).toEqual({
817-
my_key: "value one",
818-
market: "DE",
819-
__interrupt__: [
820-
{
821-
interrupt_id: expect.any(String),
822-
value: "Just because...",
823-
resumable: true,
824-
when: "during",
825-
ns: [expect.stringMatching(/^tool_two:/)],
826-
},
827-
],
828-
});
829-
810+
// Test basic invoke functionality, should fail b/c of lack of checkpointer
811+
await expect(
812+
tool_two.invoke({
813+
my_key: "value",
814+
market: "DE",
815+
})
816+
).rejects.toThrow(/No checkpointer set/);
830817
expect(tool_two_node_count).toBe(1);
831818

832819
// Test with a different market value

libs/langgraph/src/tests/python_port/interrupt.test.ts

Lines changed: 15 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -303,9 +303,7 @@ describe("Async Pregel Interrupt Tests (Python port)", () => {
303303

304304
// Define our state schema
305305
const StateAnnotation = Annotation.Root({
306-
my_key: Annotation<string>({
307-
reducer: (a, b) => (a || "") + b,
308-
}),
306+
my_key: Annotation<string>({ reducer: (a, b) => (a || "") + b }),
309307
market: Annotation<string>(),
310308
});
311309

@@ -333,41 +331,22 @@ describe("Async Pregel Interrupt Tests (Python port)", () => {
333331

334332
const tracer = new FakeTracer();
335333

336-
// Invoke with "DE" should complete normally but with an interrupt internally
337-
const result1 = await toolTwo.invoke(
338-
{ my_key: "value", market: "DE" },
339-
{ callbacks: [tracer] }
340-
);
341-
342-
expect(result1).toEqual({
343-
my_key: "value",
344-
market: "DE",
345-
__interrupt__: [
346-
{
347-
interrupt_id: expect.any(String),
348-
value: "Just because...",
349-
resumable: true,
350-
when: "during",
351-
ns: [expect.stringMatching(/^tool_two:.*$/)],
352-
},
353-
],
354-
});
334+
// Invoke with "DE" should fail b/c of lack of checkpointer
335+
await expect(
336+
toolTwo.invoke({ my_key: "value", market: "DE" }, { callbacks: [tracer] })
337+
).rejects.toThrow(/No checkpointer set/);
355338

356339
expect(toolTwoNodeCount).toBe(1);
357340
expect(tracer.runs.length).toBe(1);
358341

359342
const run = tracer.runs[0];
360343
expect(run.end_time).toBeDefined();
361-
expect(run.error).toBeUndefined();
362-
expect(run.outputs).toEqual({ market: "DE", my_key: "value" });
344+
expect(run.error).toBeDefined();
345+
expect(run.outputs).toBeUndefined();
363346

364347
// Invoke with "US" should not interrupt
365348
const result2 = await toolTwo.invoke({ my_key: "value", market: "US" });
366-
367-
expect(result2).toEqual({
368-
my_key: "value all good",
369-
market: "US",
370-
});
349+
expect(result2).toEqual({ my_key: "value all good", market: "US" });
371350

372351
// Now test with a checkpointer
373352
const checkpointer = new MemorySaver();
@@ -502,44 +481,22 @@ describe("Async Pregel Interrupt Tests (Python port)", () => {
502481

503482
const tracer = new FakeTracer();
504483

505-
// Invoke with "DE" should complete normally but with an interrupt internally
506-
const result1 = await toolTwo.invoke(
507-
{ my_key: "value", market: "DE" },
508-
{ callbacks: [tracer] }
509-
);
510-
511-
expect(result1).toEqual({
512-
my_key: "value",
513-
market: "DE",
514-
__interrupt__: [
515-
{
516-
interrupt_id: expect.any(String),
517-
value: "Just because...",
518-
resumable: true,
519-
when: "during",
520-
ns: [
521-
expect.stringMatching(/^tool_two:.*$/),
522-
expect.stringMatching(/^do:.*$/),
523-
],
524-
},
525-
],
526-
});
484+
// Invoke with "DE" should fail b/c of lack of checkpointer
485+
await expect(
486+
toolTwo.invoke({ my_key: "value", market: "DE" }, { callbacks: [tracer] })
487+
).rejects.toThrow(/No checkpointer set/);
527488

528489
expect(toolTwoNodeCount).toBe(1);
529490
expect(tracer.runs.length).toBe(1);
530491

531492
const run = tracer.runs[0];
532493
expect(run.end_time).toBeDefined();
533-
expect(run.error).toBeUndefined();
534-
expect(run.outputs).toEqual({ market: "DE", my_key: "value" });
494+
expect(run.error).toBeDefined();
495+
expect(run.outputs).toBeUndefined();
535496

536497
// Invoke with "US" should not interrupt
537498
const result2 = await toolTwo.invoke({ my_key: "value", market: "US" });
538-
539-
expect(result2).toEqual({
540-
my_key: "value all good",
541-
market: "US",
542-
});
499+
expect(result2).toEqual({ my_key: "value all good", market: "US" });
543500

544501
// Now test with a checkpointer
545502
const checkpointer = new MemorySaver();

0 commit comments

Comments
 (0)