Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/connectors-fetch-transport-errors.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 11 additions & 2 deletions packages/connector-github/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export async function gh<T = unknown>(
'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}`,
Expand All @@ -42,11 +42,20 @@ export async function gh<T = unknown>(
...(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}` : ''}`)
}
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<Response> {
try {
return await fetch(url, init)
} catch (cause) {
throw new GitHubError(0, `${label} -> network error: ${cause instanceof Error ? cause.message : String(cause)}`)
}
}
12 changes: 11 additions & 1 deletion packages/connector-github/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
)
})
15 changes: 12 additions & 3 deletions packages/connector-google-drive/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ export async function gd<T = unknown>(
body?: unknown,
): Promise<T> {
const token = bearer(ctx)
const res = await fetch(`${API}${path}`, {
const res = await gdFetch(`${API}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(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(
Expand All @@ -59,10 +59,19 @@ export async function gd<T = unknown>(
/** GET a file's raw bytes as text (for `alt=media` downloads and Docs exports). */
export async function gdText(ctx: ConnectorContext, path: string): Promise<string> {
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<Response> {
try {
return await fetch(url, init)
} catch (cause) {
throw new GoogleDriveError(0, `${label} -> network error: ${cause instanceof Error ? cause.message : String(cause)}`)
}
}
12 changes: 11 additions & 1 deletion packages/connector-google-drive/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
)
})
Loading