Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
110 changes: 110 additions & 0 deletions src/commands/create.recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { CLIError, isTransientApiError } from '../lib/errors.js';

vi.mock('../lib/api/platform.js', () => ({
listOrganizations: vi.fn(),
createProject: vi.fn(),
getProject: vi.fn(),
getProjectApiKey: vi.fn(),
NETWORK_ERROR_CODE: 'NETWORK_ERROR',
}));

import {
createProjectOrReportAmbiguousResult,
isAmbiguousProjectCreateFailure,
waitForProjectActive,
} from './create.js';

const createdProject = {
id: 'project-id',
organization_id: 'org-id',
name: 'demo',
appkey: 'demo-appkey',
region: 'eu-central',
status: 'creating',
instance_type: 'shared',
service_version: null,
customized_domain: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};

describe('create project recovery', () => {
beforeEach(async () => {
vi.clearAllMocks();
const platform = await import('../lib/api/platform.js');
(platform.createProject as Mock).mockResolvedValue(createdProject);
(platform.getProject as Mock).mockResolvedValue({ ...createdProject, status: 'active' });
});

it('reports an unknown result after a gateway failure without adopting a project', async () => {
const platform = await import('../lib/api/platform.js');
(platform.createProject as Mock).mockRejectedValueOnce(
new CLIError('Request failed: 502', 1, undefined, 502),
);

await expect(createProjectOrReportAmbiguousResult('org-id', 'demo', 'eu-central', undefined))
.rejects.toMatchObject({
code: 'PROJECT_CREATE_RESULT_UNKNOWN',
statusCode: 502,
message: expect.stringContaining('insforge list --json'),
});
});

it('never reconciles an ordinary API 500', async () => {
const platform = await import('../lib/api/platform.js');
const failure = new CLIError('Internal server error', 1, undefined, 500);
(platform.createProject as Mock).mockRejectedValueOnce(failure);

await expect(createProjectOrReportAmbiguousResult('org-id', 'demo', 'eu-central', undefined))
.rejects.toBe(failure);
});

it('recognizes only gateway and transport failures as ambiguous', () => {
expect(isAmbiguousProjectCreateFailure(new CLIError('network', 1, 'NETWORK_ERROR'))).toBe(true);
expect(isAmbiguousProjectCreateFailure(new CLIError('gateway', 1, undefined, 503))).toBe(true);
expect(isAmbiguousProjectCreateFailure(new CLIError('invalid request', 1, undefined, 400))).toBe(false);
expect(isAmbiguousProjectCreateFailure(new CLIError('server error', 1, undefined, 500))).toBe(false);
});

it('continues polling through three transient activation-read failures when the project recovers', async () => {
const platform = await import('../lib/api/platform.js');
(platform.getProject as Mock)
.mockRejectedValueOnce(new CLIError('Request failed: 502', 1, undefined, 502))
.mockRejectedValueOnce(new CLIError('Request failed: 502', 1, undefined, 502))
.mockRejectedValueOnce(new CLIError('Request failed: 502', 1, undefined, 502))
.mockResolvedValueOnce({ ...createdProject, status: 'active' });
vi.useFakeTimers();
try {
const pending = waitForProjectActive('project-id');
await vi.runAllTimersAsync();
await expect(pending).resolves.toBeUndefined();
} finally {
vi.useRealTimers();
}
expect(platform.getProject).toHaveBeenCalledTimes(4);
});

it('preserves the last actionable error when the activation deadline expires', async () => {
const platform = await import('../lib/api/platform.js');
(platform.getProject as Mock).mockRejectedValue(
new CLIError('Request failed: 502', 1, undefined, 502),
);
vi.useFakeTimers();
try {
const pending = waitForProjectActive('project-id', undefined, 10_000)
.catch(err => err as CLIError);
await vi.runAllTimersAsync();
const timeout = await pending;
expect(timeout).toMatchObject({
code: 'PROJECT_ACTIVATION_TIMEOUT',
message: expect.stringContaining('Last control-plane error: Request failed: 502'),
});
expect(timeout.statusCode).toBeUndefined();
expect(isTransientApiError(timeout)).toBe(false);
} finally {
vi.useRealTimers();
}
expect(platform.getProject).toHaveBeenCalledTimes(4);
});
});
78 changes: 72 additions & 6 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import {
createProject,
getProject,
getProjectApiKey,
NETWORK_ERROR_CODE,
} from '../lib/api/platform.js';
import { getAnonKey, runRawSql } from '../lib/api/oss.js';
import { applyAuthProvider, VALID_AUTH_PROVIDERS, type AuthProvider } from '../auth-providers/apply.js';
import { getGlobalConfig, saveGlobalConfig, saveProjectConfig, getFrontendUrl, buildOssHost } from '../lib/config.js';
import { requireAuth } from '../lib/credentials.js';
import { handleError, getRootOpts, CLIError } from '../lib/errors.js';
import { handleError, getRootOpts, CLIError, isTransientApiError } from '../lib/errors.js';
import { outputJson } from '../lib/output.js';
import { readEnvFile } from '../lib/env.js';
import { installSkills, reportCliUsage } from '../lib/skills.js';
Expand All @@ -37,16 +38,81 @@ const SAFE_MARKETPLACE_SLUG = /^[a-z0-9][a-z0-9-]{0,99}$/;

export type Framework = 'react' | 'nextjs';

async function waitForProjectActive(projectId: string, apiUrl?: string, timeoutMs = 120_000): Promise<void> {
const PROJECT_POLL_INTERVAL_MS = 3_000;
const PROJECT_POLL_TIMEOUT_MS = 120_000;
const PROXY_STATUSES = new Set([502, 503, 504]);

/**
* Wait for project provisioning without mistaking a transient control-plane
* read failure for a failed creation.
*/
export async function waitForProjectActive(
projectId: string,
apiUrl?: string,
timeoutMs = PROJECT_POLL_TIMEOUT_MS,
): Promise<void> {
const start = Date.now();
let lastTransientError: CLIError | undefined;
while (Date.now() - start < timeoutMs) {
const project = await getProject(projectId, apiUrl);
if (project.status === 'active') return;
await new Promise((r) => setTimeout(r, 3000));
try {
const project = await getProject(projectId, apiUrl);
// A successful control-plane read means a previous transient error is
// no longer useful when explaining a later provisioning timeout.
lastTransientError = undefined;
if (project.status === 'active') return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} catch (err) {
if (!isTransientApiError(err)) throw err;
// Keep polling through the configured deadline: a temporary control
// plane outage must not turn into an early create failure. If it never
// recovers, preserve the last classified API error at the deadline.
lastTransientError = err as CLIError;
}
await new Promise((r) => setTimeout(r, PROJECT_POLL_INTERVAL_MS));
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}
if (lastTransientError) {
throw new CLIError(
`Project activation timed out. Last control-plane error: ${lastTransientError.message}`,
1,
'PROJECT_ACTIVATION_TIMEOUT',
);
}
throw new CLIError('Project creation timed out. Check the dashboard for status.');
}

/** A 502/503/504 or a lost transport response says nothing reliable about the POST outcome. */
export function isAmbiguousProjectCreateFailure(err: unknown): boolean {
if (!(err instanceof CLIError)) return false;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return err.code === NETWORK_ERROR_CODE ||
(err.statusCode !== undefined && PROXY_STATUSES.has(err.statusCode));
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/**
* A gateway or transport failure can arrive after the platform accepted the
* POST. The create-project API exposes no request correlation or idempotency
* key, so a later project-list result cannot prove ownership. Never adopt a
* name/time match: that could attach the caller to a collaborator's project.
*/
export async function createProjectOrReportAmbiguousResult(
orgId: string,
name: string,
region: string | undefined,
apiUrl: string | undefined,
): Promise<Awaited<ReturnType<typeof createProject>>> {
try {
return await createProject(orgId, name, region, apiUrl);
} catch (err) {
if (!isAmbiguousProjectCreateFailure(err)) throw err;
const apiError = err as CLIError;
throw new CLIError(
'Project creation may have succeeded, but the platform did not return a result. ' +
'Run `insforge list --json` before retrying to avoid creating a duplicate project.',
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
apiError.exitCode,
'PROJECT_CREATE_RESULT_UNKNOWN',
apiError.statusCode,
);
}
}

const INSFORGE_BANNER = [
'██╗███╗ ██╗███████╗███████╗ ██████╗ ██████╗ ██████╗ ███████╗',
'██║████╗ ██║██╔════╝██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝',
Expand Down Expand Up @@ -348,7 +414,7 @@ export function registerCreateCommand(program: Command): void {
try {
s?.start('Creating project...');

const project = await createProject(orgId, projectName, opts.region, apiUrl);
const project = await createProjectOrReportAmbiguousResult(orgId, projectName, opts.region, apiUrl);

s?.message('Waiting for project to become active...');
await waitForProjectActive(project.id, apiUrl);
Expand Down
Loading