Skip to content

Commit 1077077

Browse files
committed
fix(client): handle Graphiti read timeouts gracefully
1 parent 7528cdb commit 1077077

2 files changed

Lines changed: 313 additions & 0 deletions

File tree

src/services/client.test.ts

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,4 +265,289 @@ describe("client", () => {
265265
}
266266
});
267267
});
268+
269+
describe("searchFacts error handling", () => {
270+
it("should warn and return empty array on MCP timeout code", async () => {
271+
const client = new GraphitiClient("http://test:8000/mcp");
272+
const callTool = (client as unknown as {
273+
callTool: (
274+
name: string,
275+
args: Record<string, unknown>,
276+
) => Promise<unknown>;
277+
}).callTool;
278+
const originalWarn = console.warn;
279+
const originalError = console.error;
280+
const warnings: unknown[][] = [];
281+
const errors: unknown[][] = [];
282+
283+
(client as unknown as {
284+
callTool: (
285+
name: string,
286+
args: Record<string, unknown>,
287+
) => Promise<unknown>;
288+
}).callTool = () =>
289+
Promise.reject({
290+
code: -32001,
291+
message: "Request timed out",
292+
stack: "noisy stack",
293+
});
294+
console.warn = (...args: unknown[]) => warnings.push(args);
295+
console.error = (...args: unknown[]) => errors.push(args);
296+
297+
try {
298+
const result = await client.searchFacts({ query: "test" });
299+
assertEquals(result, []);
300+
assertEquals(warnings, [[
301+
"[graphiti]",
302+
"searchFacts request timed out; returning no facts",
303+
]]);
304+
assertEquals(errors, []);
305+
} finally {
306+
(client as unknown as {
307+
callTool: typeof callTool;
308+
}).callTool = callTool;
309+
console.warn = originalWarn;
310+
console.error = originalError;
311+
}
312+
});
313+
314+
it("should warn and return empty array on timeout message", async () => {
315+
const client = new GraphitiClient("http://test:8000/mcp");
316+
const callTool = (client as unknown as {
317+
callTool: (
318+
name: string,
319+
args: Record<string, unknown>,
320+
) => Promise<unknown>;
321+
}).callTool;
322+
const originalWarn = console.warn;
323+
const originalError = console.error;
324+
const warnings: unknown[][] = [];
325+
const errors: unknown[][] = [];
326+
327+
(client as unknown as {
328+
callTool: (
329+
name: string,
330+
args: Record<string, unknown>,
331+
) => Promise<unknown>;
332+
}).callTool = () =>
333+
Promise.reject(new Error("Request timed out after 30s"));
334+
console.warn = (...args: unknown[]) => warnings.push(args);
335+
console.error = (...args: unknown[]) => errors.push(args);
336+
337+
try {
338+
const result = await client.searchFacts({ query: "test" });
339+
assertEquals(result, []);
340+
assertEquals(warnings, [[
341+
"[graphiti]",
342+
"searchFacts request timed out; returning no facts",
343+
]]);
344+
assertEquals(errors, []);
345+
} finally {
346+
(client as unknown as {
347+
callTool: typeof callTool;
348+
}).callTool = callTool;
349+
console.warn = originalWarn;
350+
console.error = originalError;
351+
}
352+
});
353+
354+
it("should preserve error logging for non-timeout errors", async () => {
355+
const client = new GraphitiClient("http://test:8000/mcp");
356+
const callTool = (client as unknown as {
357+
callTool: (
358+
name: string,
359+
args: Record<string, unknown>,
360+
) => Promise<unknown>;
361+
}).callTool;
362+
const originalWarn = console.warn;
363+
const originalError = console.error;
364+
const warnings: unknown[][] = [];
365+
const errors: unknown[][] = [];
366+
const err = new Error("Boom");
367+
368+
(client as unknown as {
369+
callTool: (
370+
name: string,
371+
args: Record<string, unknown>,
372+
) => Promise<unknown>;
373+
}).callTool = () => Promise.reject(err);
374+
console.warn = (...args: unknown[]) => warnings.push(args);
375+
console.error = (...args: unknown[]) => errors.push(args);
376+
377+
try {
378+
const result = await client.searchFacts({ query: "test" });
379+
assertEquals(result, []);
380+
assertEquals(warnings, []);
381+
assertEquals(errors, [["[graphiti]", "searchFacts error:", err]]);
382+
} finally {
383+
(client as unknown as {
384+
callTool: typeof callTool;
385+
}).callTool = callTool;
386+
console.warn = originalWarn;
387+
console.error = originalError;
388+
}
389+
});
390+
});
391+
392+
describe("searchNodes error handling", () => {
393+
it("should warn and return empty array on MCP timeout code", async () => {
394+
const client = new GraphitiClient("http://test:8000/mcp");
395+
const callTool = (client as unknown as {
396+
callTool: (
397+
name: string,
398+
args: Record<string, unknown>,
399+
) => Promise<unknown>;
400+
}).callTool;
401+
const originalWarn = console.warn;
402+
const originalError = console.error;
403+
const warnings: unknown[][] = [];
404+
const errors: unknown[][] = [];
405+
406+
(client as unknown as {
407+
callTool: (
408+
name: string,
409+
args: Record<string, unknown>,
410+
) => Promise<unknown>;
411+
}).callTool = () =>
412+
Promise.reject({
413+
code: -32001,
414+
message: "Request timed out",
415+
stack: "noisy stack",
416+
});
417+
console.warn = (...args: unknown[]) => warnings.push(args);
418+
console.error = (...args: unknown[]) => errors.push(args);
419+
420+
try {
421+
const result = await client.searchNodes({ query: "test" });
422+
assertEquals(result, []);
423+
assertEquals(warnings, [[
424+
"[graphiti]",
425+
"searchNodes request timed out; returning no nodes",
426+
]]);
427+
assertEquals(errors, []);
428+
} finally {
429+
(client as unknown as {
430+
callTool: typeof callTool;
431+
}).callTool = callTool;
432+
console.warn = originalWarn;
433+
console.error = originalError;
434+
}
435+
});
436+
437+
it("should preserve error logging for non-timeout errors", async () => {
438+
const client = new GraphitiClient("http://test:8000/mcp");
439+
const callTool = (client as unknown as {
440+
callTool: (
441+
name: string,
442+
args: Record<string, unknown>,
443+
) => Promise<unknown>;
444+
}).callTool;
445+
const originalWarn = console.warn;
446+
const originalError = console.error;
447+
const warnings: unknown[][] = [];
448+
const errors: unknown[][] = [];
449+
const err = new Error("Boom");
450+
451+
(client as unknown as {
452+
callTool: (
453+
name: string,
454+
args: Record<string, unknown>,
455+
) => Promise<unknown>;
456+
}).callTool = () => Promise.reject(err);
457+
console.warn = (...args: unknown[]) => warnings.push(args);
458+
console.error = (...args: unknown[]) => errors.push(args);
459+
460+
try {
461+
const result = await client.searchNodes({ query: "test" });
462+
assertEquals(result, []);
463+
assertEquals(warnings, []);
464+
assertEquals(errors, [["[graphiti]", "searchNodes error:", err]]);
465+
} finally {
466+
(client as unknown as {
467+
callTool: typeof callTool;
468+
}).callTool = callTool;
469+
console.warn = originalWarn;
470+
console.error = originalError;
471+
}
472+
});
473+
});
474+
475+
describe("getEpisodes error handling", () => {
476+
it("should warn and return empty array on timeout message", async () => {
477+
const client = new GraphitiClient("http://test:8000/mcp");
478+
const callTool = (client as unknown as {
479+
callTool: (
480+
name: string,
481+
args: Record<string, unknown>,
482+
) => Promise<unknown>;
483+
}).callTool;
484+
const originalWarn = console.warn;
485+
const originalError = console.error;
486+
const warnings: unknown[][] = [];
487+
const errors: unknown[][] = [];
488+
489+
(client as unknown as {
490+
callTool: (
491+
name: string,
492+
args: Record<string, unknown>,
493+
) => Promise<unknown>;
494+
}).callTool = () =>
495+
Promise.reject(new Error("Request timed out after 30s"));
496+
console.warn = (...args: unknown[]) => warnings.push(args);
497+
console.error = (...args: unknown[]) => errors.push(args);
498+
499+
try {
500+
const result = await client.getEpisodes({ groupId: "test" });
501+
assertEquals(result, []);
502+
assertEquals(warnings, [[
503+
"[graphiti]",
504+
"getEpisodes request timed out; returning no episodes",
505+
]]);
506+
assertEquals(errors, []);
507+
} finally {
508+
(client as unknown as {
509+
callTool: typeof callTool;
510+
}).callTool = callTool;
511+
console.warn = originalWarn;
512+
console.error = originalError;
513+
}
514+
});
515+
516+
it("should preserve error logging for non-timeout errors", async () => {
517+
const client = new GraphitiClient("http://test:8000/mcp");
518+
const callTool = (client as unknown as {
519+
callTool: (
520+
name: string,
521+
args: Record<string, unknown>,
522+
) => Promise<unknown>;
523+
}).callTool;
524+
const originalWarn = console.warn;
525+
const originalError = console.error;
526+
const warnings: unknown[][] = [];
527+
const errors: unknown[][] = [];
528+
const err = new Error("Boom");
529+
530+
(client as unknown as {
531+
callTool: (
532+
name: string,
533+
args: Record<string, unknown>,
534+
) => Promise<unknown>;
535+
}).callTool = () => Promise.reject(err);
536+
console.warn = (...args: unknown[]) => warnings.push(args);
537+
console.error = (...args: unknown[]) => errors.push(args);
538+
539+
try {
540+
const result = await client.getEpisodes({ groupId: "test" });
541+
assertEquals(result, []);
542+
assertEquals(warnings, []);
543+
assertEquals(errors, [["[graphiti]", "getEpisodes error:", err]]);
544+
} finally {
545+
(client as unknown as {
546+
callTool: typeof callTool;
547+
}).callTool = callTool;
548+
console.warn = originalWarn;
549+
console.error = originalError;
550+
}
551+
});
552+
});
268553
});

src/services/client.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,22 @@ export class GraphitiClient {
115115
);
116116
}
117117

118+
private isRequestTimeout(err: unknown): boolean {
119+
if (typeof err === "string") {
120+
return /request timed out/i.test(err);
121+
}
122+
123+
if (!err || typeof err !== "object") return false;
124+
125+
const { code, message } = err as {
126+
code?: unknown;
127+
message?: unknown;
128+
};
129+
130+
return code === -32001 ||
131+
(typeof message === "string" && /request timed out/i.test(message));
132+
}
133+
118134
private async reconnect(): Promise<void> {
119135
this.connected = false;
120136
try {
@@ -211,6 +227,10 @@ export class GraphitiClient {
211227
});
212228
return this.parseWrappedArray<GraphitiFact>(result, "facts") ?? [];
213229
} catch (err) {
230+
if (this.isRequestTimeout(err)) {
231+
logger.warn("searchFacts request timed out; returning no facts");
232+
return [];
233+
}
214234
logger.error("searchFacts error:", err);
215235
return [];
216236
}
@@ -232,6 +252,10 @@ export class GraphitiClient {
232252
});
233253
return this.parseWrappedArray<GraphitiNode>(result, "nodes") ?? [];
234254
} catch (err) {
255+
if (this.isRequestTimeout(err)) {
256+
logger.warn("searchNodes request timed out; returning no nodes");
257+
return [];
258+
}
235259
logger.error("searchNodes error:", err);
236260
return [];
237261
}
@@ -253,6 +277,10 @@ export class GraphitiClient {
253277
[];
254278
return raw.map(normalizeEpisode);
255279
} catch (err) {
280+
if (this.isRequestTimeout(err)) {
281+
logger.warn("getEpisodes request timed out; returning no episodes");
282+
return [];
283+
}
256284
logger.error("getEpisodes error:", err);
257285
return [];
258286
}

0 commit comments

Comments
 (0)