diff --git a/src/commands/deployments/deploy-poll.test.ts b/src/commands/deployments/deploy-poll.test.ts new file mode 100644 index 00000000..ad5d8efd --- /dev/null +++ b/src/commands/deployments/deploy-poll.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CLIError } from '../../lib/errors.js'; + +const ossMock = vi.hoisted(() => ({ + ossFetch: vi.fn(), +})); +vi.mock('../../lib/api/oss.js', () => ossMock); + +import { pollDeployment, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from './deploy.js'; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function deploymentResponse(status: string, extra: Record = {}): Response { + return jsonResponse({ id: 'dep_1', status, url: null, metadata: null, ...extra }); +} + +beforeEach(() => { + vi.useFakeTimers(); + ossMock.ossFetch.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('pollDeployment', () => { + it('keeps polling through a transient gateway 502 and resolves once READY', async () => { + ossMock.ossFetch + .mockResolvedValueOnce(deploymentResponse('BUILDING')) + .mockRejectedValueOnce(new CLIError('OSS request failed: 502', 1, undefined, 502)) + .mockResolvedValueOnce(deploymentResponse('READY', { url: 'https://app.vercel.app' })); + + const promise = pollDeployment('dep_1', null, false); + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3); + + const result = await promise; + expect(result.isReady).toBe(true); + expect(result.liveUrl).toBe('https://app.vercel.app'); + expect(ossMock.ossFetch).toHaveBeenCalledTimes(3); + }); + + // A rate limit or request timeout mid-poll says nothing about the deployment, + // so these 4xx are retried alongside 5xx rather than aborting the command. + it.each([408, 429])('keeps polling through a transient %i and resolves once READY', async (status) => { + ossMock.ossFetch + .mockRejectedValueOnce(new CLIError(`OSS request failed: ${status}`, 1, undefined, status)) + .mockResolvedValueOnce(deploymentResponse('READY', { url: 'https://app.vercel.app' })); + + const promise = pollDeployment('dep_1', null, false); + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2); + + const result = await promise; + expect(result.isReady).toBe(true); + expect(result.liveUrl).toBe('https://app.vercel.app'); + expect(result.lastError).toBeNull(); + expect(ossMock.ossFetch).toHaveBeenCalledTimes(2); + }); + + it('still fails fast on 4xx status responses', async () => { + ossMock.ossFetch.mockRejectedValueOnce(new CLIError('Deployment not found.', 1, 'NOT_FOUND', 404)); + + const promise = pollDeployment('dep_1', null, false); + const assertion = expect(promise).rejects.toThrow('Deployment not found.'); + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + await assertion; + expect(ossMock.ossFetch).toHaveBeenCalledTimes(1); + }); + + it('still fails when the deployment itself reports ERROR', async () => { + ossMock.ossFetch.mockResolvedValueOnce(deploymentResponse('ERROR')); + + const promise = pollDeployment('dep_1', null, false); + const assertion = expect(promise).rejects.toThrow('Deployment failed with status: ERROR'); + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + await assertion; + }); + + it('reports the last read failure when 5xx persists for the whole window', async () => { + // One good read, then the gateway is down until the poll window closes. + // Without lastError this is indistinguishable from an ordinary slow build. + ossMock.ossFetch + .mockResolvedValueOnce(deploymentResponse('BUILDING')) + .mockRejectedValue(new CLIError('OSS request failed: 502', 1, undefined, 502)); + + const promise = pollDeployment('dep_1', null, false); + await vi.advanceTimersByTimeAsync(POLL_TIMEOUT_MS + POLL_INTERVAL_MS); + + const result = await promise; + expect(result.isReady).toBe(false); + expect(result.lastError).toBe('OSS request failed: 502'); + // Last known status is still surfaced for context. + expect(result.deployment?.status).toBe('BUILDING'); + }); + + it('reports a network-level failure that persists for the whole window', async () => { + ossMock.ossFetch.mockRejectedValue(new TypeError('fetch failed')); + + const promise = pollDeployment('dep_1', null, false); + await vi.advanceTimersByTimeAsync(POLL_TIMEOUT_MS + POLL_INTERVAL_MS); + + const result = await promise; + expect(result.isReady).toBe(false); + expect(result.lastError).toContain('the deployment API'); + }); + + it('leaves lastError null when the final read succeeded and the build was just slow', async () => { + // An early transient 502 must not be reported as the timeout reason once + // later reads succeed — otherwise a slow build looks like an outage. + // A fresh Response per call: one instance can only be .json()'d once. + ossMock.ossFetch + .mockRejectedValueOnce(new CLIError('OSS request failed: 502', 1, undefined, 502)) + .mockImplementation(() => Promise.resolve(deploymentResponse('BUILDING'))); + + const promise = pollDeployment('dep_1', null, false); + await vi.advanceTimersByTimeAsync(POLL_TIMEOUT_MS + POLL_INTERVAL_MS); + + const result = await promise; + expect(result.isReady).toBe(false); + expect(result.lastError).toBeNull(); + expect(result.deployment?.status).toBe('BUILDING'); + }); +}); diff --git a/src/commands/deployments/deploy.ts b/src/commands/deployments/deploy.ts index d9ab8386..4d0758e7 100644 --- a/src/commands/deployments/deploy.ts +++ b/src/commands/deployments/deploy.ts @@ -23,10 +23,16 @@ import type { import { trackDeploymentUsage } from './utils.js'; import { loadDeployIgnore, IGNORE_FILE_NAME, type DeployIgnore } from './ignore-file.js'; -const POLL_INTERVAL_MS = 5_000; -const POLL_TIMEOUT_MS = 300_000; +export const POLL_INTERVAL_MS = 5_000; +export const POLL_TIMEOUT_MS = 300_000; const DIRECT_UPLOAD_CONCURRENCY = 8; +// 4xx statuses that are retryable while a deployment is in flight. A rate limit +// or request timeout on the status endpoint says nothing about the deployment, +// which keeps running server-side — and polling every 5s is exactly the shape +// that trips a rate limit. Every other 4xx stays terminal. +const TRANSIENT_4XX_STATUSES = new Set([408, 429]); + const EXCLUDE_PATTERNS = [ 'node_modules', '.git', @@ -277,7 +283,7 @@ async function startDirectDeployment( await response.json(); } -async function pollDeployment( +export async function pollDeployment( deploymentId: string, spinner: ReturnType | null | undefined, syncBeforeRead: boolean, @@ -285,6 +291,7 @@ async function pollDeployment( spinner?.message('Building and deploying...'); const startTime = Date.now(); let deployment: DeploymentSchema | null = null; + let lastTransientError: string | null = null; while (Date.now() - startTime < POLL_TIMEOUT_MS) { await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); @@ -307,18 +314,40 @@ async function pollDeployment( ); } + // This read succeeded, so an earlier transient failure is no longer the + // reason we might time out. + lastTransientError = null; const elapsed = Math.round((Date.now() - startTime) / 1000); spinner?.message(`Building and deploying... (${elapsed}s, status: ${deployment.status})`); } catch (err) { - if (err instanceof CLIError) throw err; - // Ignore transient fetch errors during polling + // Deployment-failure errors (thrown above, no statusCode) and 4xx + // responses other than the retryable ones are terminal. Gateway 5xx + // responses on the status endpoint are transient — the deployment itself + // may still succeed — so keep polling, same as network-level fetch errors. + const isTerminal = + err instanceof CLIError && + (err.statusCode === undefined || + (err.statusCode < 500 && !TRANSIENT_4XX_STATUSES.has(err.statusCode))); + if (isTerminal) { + throw err; + } + // Transient: keep polling, but remember why the read failed so that an + // outage lasting the whole window is not reported as a plain timeout. + lastTransientError = + err instanceof CLIError ? err.message : formatFetchError(err, 'the deployment API'); } } const isReady = deployment?.status.toUpperCase() === 'READY'; const liveUrl = isReady ? (deployment?.url ?? null) : null; - return { deploymentId, deployment, isReady, liveUrl }; + return { + deploymentId, + deployment, + isReady, + liveUrl, + lastError: isReady ? null : lastTransientError, + }; } async function deployProjectDirect( @@ -411,6 +440,12 @@ export interface DeployProjectResult { deployment: DeploymentSchema | null; isReady: boolean; liveUrl: string | null; + /** + * When the poll window closed without READY: why the most recent status read + * failed, or null if it succeeded and the deployment was simply still + * building. Distinguishes "slow build" from "never reached the backend". + */ + lastError: string | null; } /** @@ -525,10 +560,14 @@ export function registerDeploymentsDeployCommand(deploymentsCmd: Command): void id: result.deploymentId, status: result.deployment?.status ?? 'building', timedOut: true, + ...(result.lastError ? { lastError: result.lastError } : {}), }); } else { clack.log.info(`Deployment ID: ${result.deploymentId}`); clack.log.warn('Deployment did not finish within 5 minutes.'); + if (result.lastError) { + clack.log.warn(`Could not read the deployment status: ${result.lastError}`); + } clack.log.info(`Check status with: npx @insforge/cli deployments status ${result.deploymentId}`); } }