diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 25ad48b..91b7373 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -27,6 +27,26 @@ import { SDK_VERSION } from "./version.js"; import { repairToolSchemas } from "./schema-repair.js"; import { EntityManager } from "./entity-manager.js"; +function isTransientNetworkError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const msg = err.message.toLowerCase(); + const cause = (err as Error & { cause?: unknown }).cause; + const causeMsg = + cause instanceof Error + ? cause.message.toLowerCase() + : typeof cause === "string" + ? cause.toLowerCase() + : ""; + const combined = `${msg} ${causeMsg}`; + return ( + combined.includes("fetch failed") || + combined.includes("econnreset") || + combined.includes("socket") || + combined.includes("other side closed") || + combined.includes("network") + ); +} + /** * Authenticated tool pipe for the Stitch MCP Server. * @@ -178,8 +198,22 @@ export class StitchToolClient implements StitchToolClientSpec { /** * Generic tool caller with type support and error parsing. + * Retries once on transient network errors after reconnecting. */ async callTool(name: string, args: Record): Promise { + try { + return await this.callToolOnce(name, args); + } catch (err) { + if (!isTransientNetworkError(err)) throw err; + await this.resetConnection(); + return await this.callToolOnce(name, args); + } + } + + private async callToolOnce( + name: string, + args: Record, + ): Promise { if (!this.isConnected) await this.connect(); const localTool = this.localVirtualTools.find((t) => t.name === name); @@ -196,6 +230,19 @@ export class StitchToolClient implements StitchToolClientSpec { return this.parseToolResponse(result, name); } + private async resetConnection(): Promise { + this.isConnected = false; + this.connectPromise = null; + if (this.transport) { + try { + await this.transport.close(); + } catch { + /* ignore close errors during reset */ + } + this.transport = null; + } + } + /** * Make a direct REST POST to the Stitch API. * diff --git a/packages/sdk/test/unit/client.test.ts b/packages/sdk/test/unit/client.test.ts index 102fd3a..6e99fbc 100644 --- a/packages/sdk/test/unit/client.test.ts +++ b/packages/sdk/test/unit/client.test.ts @@ -263,6 +263,50 @@ describe("StitchToolClient", () => { }); }); + describe("callTool transient network retry", () => { + it("reconnects and retries once on fetch failed", async () => { + const client = new StitchToolClient({ apiKey: "k" }); + let attempts = 0; + + client["client"].connect = vi.fn(async () => { + client["isConnected"] = true; + }); + + client["client"].callTool = vi.fn(async () => { + attempts++; + if (attempts === 1) { + const err = new TypeError("fetch failed"); + (err as any).cause = new Error("other side closed"); + throw err; + } + return { + isError: false, + content: [{ type: "text", text: '{"ok":true}' }], + }; + }); + + const result = await client.callTool("list_projects", {}); + expect(result).toEqual({ ok: true }); + expect(attempts).toBe(2); + }); + + it("does not retry on auth errors", async () => { + const client = new StitchToolClient({ apiKey: "bad" }); + client["isConnected"] = true; + + client["client"].callTool = vi.fn(async () => ({ + isError: true, + content: [{ type: "text", text: "401 Unauthorized" }], + })); + + const { StitchError } = await import("../../src/spec/errors.js"); + await expect(client.callTool("list_projects", {})).rejects.toThrow( + StitchError, + ); + expect(client["client"].callTool).toHaveBeenCalledTimes(1); + }); + }); + // ─── Slice 3: httpPost transport ──────────────────────────────── describe("httpPost", () => { // Test 10: sends X-Goog-Api-Key header