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
127 changes: 127 additions & 0 deletions src/commands/deployments/deploy-poll.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}): 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');
});
});
51 changes: 45 additions & 6 deletions src/commands/deployments/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -277,14 +283,15 @@ async function startDirectDeployment(
await response.json();
}

async function pollDeployment(
export async function pollDeployment(
deploymentId: string,
spinner: ReturnType<typeof clack.spinner> | null | undefined,
syncBeforeRead: boolean,
): Promise<DeployProjectResult> {
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));
Expand All @@ -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(
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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}`);
}
}
Expand Down
Loading