Skip to content
Open
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
25 changes: 23 additions & 2 deletions packages/clawdhub/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ describe('apiRequest', () => {
}

expect(caught).toBeInstanceOf(Error)
expect((caught as Error).message).toBe('Timeout')
expect((caught as Error).message).toMatch(/timed out/)
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(clearTimeoutMock.mock.calls.length).toBeGreaterThanOrEqual(3)
vi.unstubAllGlobals()
Expand Down Expand Up @@ -343,9 +343,30 @@ describe('fetchText', () => {
}

expect(caught).toBeInstanceOf(Error)
expect((caught as Error).message).toBe('Timeout')
expect((caught as Error).message).toMatch(/timed out/)
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(clearTimeoutMock.mock.calls.length).toBeGreaterThanOrEqual(3)
vi.unstubAllGlobals()
})
})

describe('fetchWithTimeout — non-Error normalization', () => {
it('wraps DOMException-like non-Error throws into proper Error instances', async () => {
const fetchMock = vi.fn(async () => {
// Simulate a runtime that throws a non-Error object on abort
throw { message: 'The operation was aborted', name: 'AbortError' }
})
vi.stubGlobal('fetch', fetchMock)

let caught: unknown
try {
await apiRequest('https://example.com', { method: 'GET', path: '/x' })
} catch (error) {
caught = error
}

expect(caught).toBeInstanceOf(Error)
expect((caught as Error).message).toContain('The operation was aborted')
vi.unstubAllGlobals()
})
})
21 changes: 17 additions & 4 deletions packages/clawdhub/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import type { ArkValidator } from './schema/index.js'
import { ApiRoutes, parseArk } from './schema/index.js'

const REQUEST_TIMEOUT_MS = 15_000
const UPLOAD_TIMEOUT_MS = 120_000
const REQUEST_TIMEOUT_SECONDS = Math.ceil(REQUEST_TIMEOUT_MS / 1000)
const UPLOAD_TIMEOUT_SECONDS = Math.ceil(UPLOAD_TIMEOUT_MS / 1000)
const RETRY_COUNT = 2
const RETRY_BACKOFF_BASE_MS = 300
const RETRY_BACKOFF_MAX_MS = 5_000
Expand Down Expand Up @@ -147,7 +149,7 @@ export async function apiRequestForm<T>(
method: args.method,
headers,
body: args.form,
})
}, UPLOAD_TIMEOUT_MS)
if (!response.ok) {
throwHttpStatusError(response.status, await readResponseTextSafe(response), response.headers)
}
Expand Down Expand Up @@ -205,11 +207,22 @@ export async function downloadZip(
)
}

async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<Response> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS)
const timeoutSeconds = Math.ceil(timeoutMs / 1000)
const timeout = setTimeout(
() => controller.abort(new Error(`Request timed out after ${timeoutSeconds}s`)),
timeoutMs,
)
try {
return await fetch(url, { ...init, signal: controller.signal })
} catch (error) {
if (error instanceof Error) throw error
// Normalize non-Error throws (e.g. DOMException from AbortController) into proper Errors
const message = typeof error === 'object' && error !== null && 'message' in error
? String((error as { message: unknown }).message)
: String(error)
throw new Error(message, { cause: error })
} finally {
clearTimeout(timeout)
}
Expand Down Expand Up @@ -425,7 +438,7 @@ async function fetchJsonFormViaCurl(url: string, args: FormRequestArgs) {
'--show-error',
'--location',
'--max-time',
String(REQUEST_TIMEOUT_SECONDS),
String(UPLOAD_TIMEOUT_SECONDS),
'--write-out',
CURL_WRITE_OUT_FORMAT,
'-X',
Expand Down