diff --git a/.changeset/connectors-fetch-transport-errors.md b/.changeset/connectors-fetch-transport-errors.md new file mode 100644 index 0000000..76774b6 --- /dev/null +++ b/.changeset/connectors-fetch-transport-errors.md @@ -0,0 +1,8 @@ +--- +'@gemstack/connector-github': patch +'@gemstack/connector-google-drive': patch +--- + +Wrap transport failures in the connector's typed error class. + +The clients only wrapped non-2xx responses in `GitHubError`/`GoogleDriveError`; a `fetch()` rejection (DNS failure, timeout, offline) escaped as a raw `TypeError`. Each `fetch` call site now rethrows transport failures as the connector's error type with `status: 0`, so all failures surface through one typed class. diff --git a/packages/connector-github/src/client.ts b/packages/connector-github/src/client.ts index 6607ff7..7e3d0ba 100644 --- a/packages/connector-github/src/client.ts +++ b/packages/connector-github/src/client.ts @@ -32,7 +32,7 @@ export async function gh( 'no GitHub token available — set GITHUB_TOKEN or provide one via the mount `credentials` option', ) } - const res = await fetch(`${API}${path}`, { + const res = await ghFetch(`${API}${path}`, { method, headers: { Authorization: `Bearer ${token}`, @@ -42,7 +42,7 @@ export async function gh( ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - }) + }, `${method} ${path}`) if (!res.ok) { const detail = await res.text().catch(() => '') throw new GitHubError(res.status, `${method} ${path} -> ${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`) @@ -50,3 +50,12 @@ export async function gh( if (res.status === 204) return undefined as T return (await res.json()) as T } + +/** `fetch`, but a transport failure (DNS, timeout, offline) is rethrown as a `GitHubError` (`status: 0`) instead of a raw `TypeError`. */ +async function ghFetch(url: string, init: RequestInit, label: string): Promise { + try { + return await fetch(url, init) + } catch (cause) { + throw new GitHubError(0, `${label} -> network error: ${cause instanceof Error ? cause.message : String(cause)}`) + } +} diff --git a/packages/connector-github/src/index.test.ts b/packages/connector-github/src/index.test.ts index 315b45c..0a0ecc9 100644 --- a/packages/connector-github/src/index.test.ts +++ b/packages/connector-github/src/index.test.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' import { McpTestClient } from '@gemstack/mcp/testing' import type { McpToolResult } from '@gemstack/mcp' import { mountConnectors } from '@gemstack/connectors' -import github from './index.js' +import github, { GitHubError } from './index.js' const realFetch = globalThis.fetch @@ -119,3 +119,13 @@ test('a non-2xx response surfaces status and detail', async () => { /404/, ) }) + +test('a transport failure surfaces as a GitHubError with status 0', async () => { + globalThis.fetch = (async () => { + throw new TypeError('fetch failed') + }) as unknown as typeof fetch + await assert.rejects( + () => client().callTool('github_get-repo', { owner: 'o', repo: 'r' }), + (err: unknown) => err instanceof GitHubError && err.status === 0 && /network error/.test(err.message), + ) +}) diff --git a/packages/connector-google-drive/src/client.ts b/packages/connector-google-drive/src/client.ts index bce5e39..1605ce3 100644 --- a/packages/connector-google-drive/src/client.ts +++ b/packages/connector-google-drive/src/client.ts @@ -36,7 +36,7 @@ export async function gd( body?: unknown, ): Promise { const token = bearer(ctx) - const res = await fetch(`${API}${path}`, { + const res = await gdFetch(`${API}${path}`, { method, headers: { Authorization: `Bearer ${token}`, @@ -44,7 +44,7 @@ export async function gd( ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - }) + }, `${method} ${path}`) if (!res.ok) { const detail = await res.text().catch(() => '') throw new GoogleDriveError( @@ -59,10 +59,19 @@ export async function gd( /** GET a file's raw bytes as text (for `alt=media` downloads and Docs exports). */ export async function gdText(ctx: ConnectorContext, path: string): Promise { const token = bearer(ctx) - const res = await fetch(`${API}${path}`, { method: 'GET', headers: { Authorization: `Bearer ${token}` } }) + const res = await gdFetch(`${API}${path}`, { method: 'GET', headers: { Authorization: `Bearer ${token}` } }, `GET ${path}`) if (!res.ok) { const detail = await res.text().catch(() => '') throw new GoogleDriveError(res.status, `GET ${path} -> ${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`) } return await res.text() } + +/** `fetch`, but a transport failure (DNS, timeout, offline) is rethrown as a `GoogleDriveError` (`status: 0`) instead of a raw `TypeError`. */ +async function gdFetch(url: string, init: RequestInit, label: string): Promise { + try { + return await fetch(url, init) + } catch (cause) { + throw new GoogleDriveError(0, `${label} -> network error: ${cause instanceof Error ? cause.message : String(cause)}`) + } +} diff --git a/packages/connector-google-drive/src/index.test.ts b/packages/connector-google-drive/src/index.test.ts index be86005..f9e1459 100644 --- a/packages/connector-google-drive/src/index.test.ts +++ b/packages/connector-google-drive/src/index.test.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' import { McpTestClient } from '@gemstack/mcp/testing' import type { McpToolResult } from '@gemstack/mcp' import { mountConnectors } from '@gemstack/connectors' -import drive from './index.js' +import drive, { GoogleDriveError } from './index.js' const realFetch = globalThis.fetch @@ -185,3 +185,13 @@ test('a non-2xx response surfaces status and detail', async () => { mockFetch(() => ({ status: 404, body: 'File not found' })) await assert.rejects(() => client().callTool('google-drive_get-file', { fileId: 'nope' }), /404/) }) + +test('a transport failure surfaces as a GoogleDriveError with status 0', async () => { + globalThis.fetch = (async () => { + throw new TypeError('fetch failed') + }) as unknown as typeof fetch + await assert.rejects( + () => client().callTool('google-drive_get-file', { fileId: 'x' }), + (err: unknown) => err instanceof GoogleDriveError && err.status === 0 && /network error/.test(err.message), + ) +})