diff --git a/packages/deploy/src/integrations-list.test.ts b/packages/deploy/src/integrations-list.test.ts index 79210ac1..56b59bc5 100644 --- a/packages/deploy/src/integrations-list.test.ts +++ b/packages/deploy/src/integrations-list.test.ts @@ -140,6 +140,143 @@ test('listIntegrations throws loud endpoint errors while authenticated', async ( ); }); +test('listIntegrations bounds a status endpoint that never settles', async () => { + const startedAt = Date.now(); + const operation = listIntegrations({ + workspaceId: 'ws-1', + token: 'tok', + requestTimeoutMs: 20, + client: { + async fetch(pathname) { + if (pathname === '/api/v1/integrations/catalog') { + return json({ providers: [{ id: 'daytona' }] }); + } + if (pathname === '/api/v1/me/integrations') { + return json({ integrations: [] }); + } + if (pathname === '/api/v1/workspaces/ws-1/integrations') { + return json({ integrations: [] }); + } + if (pathname.endsWith('/status?scope=deployer_user')) { + return json({ provider: 'daytona', status: 'connected' }); + } + return new Promise(() => {}); + } + } + }); + let guardTimer: ReturnType | undefined; + const guard = new Promise((_, reject) => { + guardTimer = setTimeout( + () => reject(new Error('test guard: integration request never settled')), + 500 + ); + }); + + try { + await assert.rejects( + Promise.race([operation, guard]), + (err) => { + assert.ok(err instanceof IntegrationsListError); + assert.equal(err.status, 408); + assert.equal( + err.endpoint, + '/api/v1/workspaces/ws-1/integrations/daytona/status?scope=workspace' + ); + assert.match(err.message, /timed out after 20ms/); + return true; + } + ); + } finally { + if (guardTimer) clearTimeout(guardTimer); + } + assert.ok(Date.now() - startedAt < 500); +}); + +test('listIntegrations preserves its typed timeout when an abort-aware client rejects', async () => { + let guardTimer: ReturnType | undefined; + const operation = listIntegrations({ + workspaceId: 'ws-1', + token: 'tok', + requestTimeoutMs: 20, + client: { + async fetch(_pathname, init = {}) { + return new Promise((_, reject) => { + init.signal?.addEventListener( + 'abort', + () => reject(new Error('client abort rejection')), + { once: true } + ); + }); + } + } + }); + const guard = new Promise((_, reject) => { + guardTimer = setTimeout( + () => reject(new Error('test guard: abort-aware request never settled')), + 500 + ); + }); + + try { + await assert.rejects( + Promise.race([operation, guard]), + (err) => { + assert.ok(err instanceof IntegrationsListError); + assert.equal(err.status, 408); + assert.equal(err.endpoint, '/api/v1/integrations/catalog'); + assert.match(err.message, /timed out after 20ms/); + return true; + } + ); + } finally { + if (guardTimer) clearTimeout(guardTimer); + } +}); + +test('listIntegrations bounds response body consumption and preserves the typed timeout', async () => { + let guardTimer: ReturnType | undefined; + const operation = listIntegrations({ + workspaceId: 'ws-1', + token: 'tok', + requestTimeoutMs: 20, + client: { + async fetch(pathname) { + if (pathname === '/api/v1/integrations/catalog') { + return json({ providers: [{ id: 'daytona' }] }); + } + if (pathname === '/api/v1/me/integrations') { + return new Response(new ReadableStream({ start() {} }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + return json({ integrations: [] }); + } + } + }); + const guard = new Promise((_, reject) => { + guardTimer = setTimeout( + () => reject(new Error('test guard: response body never settled')), + 500 + ); + }); + + try { + await assert.rejects( + Promise.race([operation, guard]), + (err) => { + assert.ok(err instanceof IntegrationsListError); + assert.equal(err.status, 408); + assert.equal(err.endpoint, '/api/v1/me/integrations'); + assert.match(err.message, /timed out after 20ms/); + return true; + } + ); + } finally { + if (guardTimer) clearTimeout(guardTimer); + } +}); + test('listIntegrations accepts adapter slug as provider filter and suggests it on unknown providers', async () => { const base = { activeWorkspace: null, diff --git a/packages/deploy/src/integrations-list.ts b/packages/deploy/src/integrations-list.ts index b675fab4..5dc97f90 100644 --- a/packages/deploy/src/integrations-list.ts +++ b/packages/deploy/src/integrations-list.ts @@ -52,6 +52,8 @@ export interface ListIntegrationsOptions { resolveWorkspaceToken?: typeof resolveWorkspaceToken; provider?: string; includeTriggers?: boolean; + /** Maximum time for any single Cloud catalog/status request. */ + requestTimeoutMs?: number; } export class IntegrationsListError extends Error { @@ -386,26 +388,54 @@ async function requestJson( pathname: string, init: RequestInit = {} ): Promise { - const response = options.client - ? await options.client.fetch(pathname, init) - : await (options.fetch ?? fetch)(`${cloudUrl}${pathname}`, { - ...init, - headers: { - accept: 'application/json', - 'content-type': 'application/json', - ...(auth.token ? { authorization: `Bearer ${auth.token}` } : {}), - ...(init.headers ?? {}) - } - }); - if (!response.ok) { - const body = await response.text().catch(() => ''); - const excerpt = body.length > 400 ? `${body.slice(0, 400)}...` : body; - throw new IntegrationsListError( - `integration catalog/status request failed: ${response.status} ${pathname}${excerpt ? ` ${excerpt}` : ''}`, - { status: response.status, endpoint: pathname, body: excerpt } - ); + const timeoutMs = options.requestTimeoutMs ?? 10_000; + const controller = new AbortController(); + const upstreamSignal = init.signal; + const abortFromUpstream = () => controller.abort(upstreamSignal?.reason); + if (upstreamSignal?.aborted) abortFromUpstream(); + else upstreamSignal?.addEventListener('abort', abortFromUpstream, { once: true }); + + let timeout: ReturnType | undefined; + const timeoutError = new IntegrationsListError( + `integration catalog/status request timed out after ${timeoutMs}ms: ${pathname}`, + { status: 408, endpoint: pathname, body: '' } + ); + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(timeoutError); + controller.abort(timeoutError); + }, timeoutMs); + }); + + try { + const request = (async (): Promise => { + const response = options.client + ? await options.client.fetch(pathname, { ...init, signal: controller.signal }) + : await (options.fetch ?? fetch)(`${cloudUrl}${pathname}`, { + ...init, + signal: controller.signal, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + ...(auth.token ? { authorization: `Bearer ${auth.token}` } : {}), + ...(init.headers ?? {}) + } + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + const excerpt = body.length > 400 ? `${body.slice(0, 400)}...` : body; + throw new IntegrationsListError( + `integration catalog/status request failed: ${response.status} ${pathname}${excerpt ? ` ${excerpt}` : ''}`, + { status: response.status, endpoint: pathname, body: excerpt } + ); + } + return await response.json(); + })(); + return await Promise.race([request, timeoutPromise]); + } finally { + if (timeout) clearTimeout(timeout); + upstreamSignal?.removeEventListener('abort', abortFromUpstream); } - return await response.json(); } function adapterSlugForCloudProvider(provider: string): string {