Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
7 changes: 5 additions & 2 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { outputJson } from '../lib/output.js';
import { readEnvFile } from '../lib/env.js';
import { installSkills, reportCliUsage } from '../lib/skills.js';
import { captureEvent, trackCommand, shutdownAnalytics } from '../lib/analytics.js';
import { deployProject } from './deployments/deploy.js';
import { deployProject, POLL_TIMEOUT_MINUTES } from './deployments/deploy.js';
import type { ProjectConfig } from '../types.js';

const execAsync = promisify(exec);
Expand Down Expand Up @@ -493,7 +493,10 @@ export function registerCreateCommand(program: Command): void {
} else {
deploySpinner.stop('Deployment is still building');
clack.log.info(`Deployment ID: ${result.deploymentId}`);
clack.log.warn('Deployment did not finish within 2 minutes.');
clack.log.warn(`Deployment did not finish within ${POLL_TIMEOUT_MINUTES} 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}`);
}
} catch (err) {
Expand Down
168 changes: 168 additions & 0 deletions src/commands/deployments/deploy-poll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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 });
}

function httpError(message: string, statusCode: number, code?: string): CLIError {
return new CLIError(message, 1, code, statusCode);
}

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(httpError('OSS request failed: 502', 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(result.lastError).toBeNull();
expect(ossMock.ossFetch).toHaveBeenCalledTimes(3);
});

it('tolerates a 429 while polling — rate limits are transient too', async () => {
ossMock.ossFetch
.mockRejectedValueOnce(httpError('Too many requests', 429, 'RATE_LIMITED'))
.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.lastError).toBeNull();
});

it('tolerates a transient failure on the sync request when syncBeforeRead is set', async () => {
ossMock.ossFetch
// round 1: the sync POST itself 503s, so the status read never happens
.mockRejectedValueOnce(httpError('OSS request failed: 503', 503))
// round 2: sync succeeds, then the status read reports READY
.mockResolvedValueOnce(jsonResponse({}))
.mockResolvedValueOnce(deploymentResponse('READY', { url: 'https://app.vercel.app' }));

const promise = pollDeployment('dep_1', null, true);
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(ossMock.ossFetch).toHaveBeenCalledTimes(3);
});

it('still fails fast on 4xx status responses', async () => {
ossMock.ossFetch.mockRejectedValueOnce(httpError('Deployment not found.', 404, 'NOT_FOUND'));

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 fast on 501 — an unimplemented route will not start working mid-poll', async () => {
ossMock.ossFetch.mockRejectedValueOnce(httpError('Not implemented', 501));

const promise = pollDeployment('dep_1', null, false);
const assertion = expect(promise).rejects.toThrow('Not implemented');
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
await assertion;
expect(ossMock.ossFetch).toHaveBeenCalledTimes(1);
});

it('still fails fast on CLIErrors with no statusCode (auth, project-not-linked)', async () => {
ossMock.ossFetch.mockRejectedValueOnce(new CLIError('Not authenticated.', 2, 'AUTH_ERROR'));

const promise = pollDeployment('dep_1', null, false);
const assertion = expect(promise).rejects.toThrow('Not authenticated.');
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;
expect(ossMock.ossFetch).toHaveBeenCalledTimes(1);
});

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.
// The deployment never reaches READY, so the caller must be able to tell
// this apart from an ordinary slow build.
ossMock.ossFetch
.mockResolvedValueOnce(deploymentResponse('BUILDING'))
.mockRejectedValue(httpError('OSS request failed: 502', 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.liveUrl).toBeNull();
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 status endpoint');
});

it('leaves lastError null when the final read succeeded and the build was just slow', async () => {
// A transient 502 early on 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 — a single instance can only be .json()'d once.
ossMock.ossFetch
.mockRejectedValueOnce(httpError('OSS request failed: 502', 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');
});
});
66 changes: 59 additions & 7 deletions src/commands/deployments/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,19 @@ 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;
export const POLL_TIMEOUT_MINUTES = Math.round(POLL_TIMEOUT_MS / 60_000);
const DIRECT_UPLOAD_CONCURRENCY = 8;

// HTTP statuses worth retrying while a deployment is in flight. Gateway and
// proxy failures (502/503/504) are routinely transient, an app-layer 500 on a
// read-only status poll usually is too, and 408/429 are explicitly retryable —
// polling every 5s is exactly the shape that trips a rate limit. 501 is
// deliberately excluded: a route this backend does not implement will not
// start working mid-poll, so failing fast beats waiting out the whole window.
const TRANSIENT_POLL_STATUSES = new Set([408, 429, 500, 502, 503, 504]);

const EXCLUDE_PATTERNS = [
'node_modules',
'.git',
Expand Down Expand Up @@ -277,14 +286,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 +317,49 @@ async function pollDeployment(
);
}

// This read succeeded, so an earlier transient failure is no longer the
// reason we might time out — drop it.
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
// A CLIError is terminal unless its status says otherwise: the failures
// thrown just above (deployment ERROR/CANCELED) plus auth and
// project-not-linked carry no statusCode at all, and a 4xx or 501 will
// not start working mid-poll. Everything else is transient — gateway
// 5xx, 408/429, and network-level fetch errors, which are not CLIErrors
// — because the deployment keeps running server-side regardless. Keep
// polling, but remember why the read failed so a full-window outage is
// not reported as an ordinary "still building" timeout.
const isTransient =
!(err instanceof CLIError) ||
(err.statusCode !== undefined && TRANSIENT_POLL_STATUSES.has(err.statusCode));
if (!isTransient) {
throw err;
}

lastTransientError =
err instanceof CLIError
? err.message
: formatFetchError(err, 'the deployment status endpoint');

// Keep the elapsed counter moving; otherwise a run of failed reads looks
// like a hung command for the rest of the poll window.
const elapsed = Math.round((Date.now() - startTime) / 1000);
spinner?.message(`Building and deploying... (${elapsed}s, status check failed, retrying)`);
}
}

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 +452,13 @@ 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. Lets callers tell "slow build" apart from "we never managed to
* reach the status endpoint", which otherwise look identical.
*/
lastError: string | null;
}

/**
Expand Down Expand Up @@ -525,10 +573,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.');
clack.log.warn(`Deployment did not finish within ${POLL_TIMEOUT_MINUTES} 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