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
38 changes: 22 additions & 16 deletions cli/commands/deploy/command.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { DeploymentRoutingConvergence } from "../../shared/deployment/contr
import { FakeTime } from "#std/testing/time";
import { stripAnsi } from "../../ui/ansi.ts";
import { setVerboseMode } from "../../utils/index.ts";
import { RELEASE_ASSET_MANIFEST_SCHEMA_VERSION } from "veryfront/release-assets";

/**
* The real Deploy Execution module with test-bounded polling: these suites
Expand Down Expand Up @@ -209,20 +210,20 @@ function createDeployFetchHandler(options: {
state: "ready",
manifest_version: 1,
manifest: {
schemaVersion: 1,
schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION,
projectId: PROJECT_ID,
releaseId: RELEASE_ID,
releaseVersion: 41,
manifestVersion: 1,
builderVersion: "test",
sourceContentHash: options.sourceDigest,
sourceContentHash: options.sourceDigest.slice("sha256:".length),
createdAt: "2026-07-10T09:20:00.000Z",
assetBasePath: "/_vf/assets",
modules: {},
css: [],
routes: {},
dependencyMode: "source",
dependencies: {},
fallback: { mode: "jit", gaps: [] },
},
});
}
Expand Down Expand Up @@ -838,13 +839,13 @@ it("uses canonical production read-back in human and JSON modes", async () => {
state: "ready",
manifest_version: 1,
manifest: {
schemaVersion: 1,
schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION,
projectId: PROJECT_ID,
releaseId: RELEASE_ID,
releaseVersion: 41,
manifestVersion: 1,
builderVersion: "test",
sourceContentHash: sourceDigest,
sourceContentHash: sourceDigest.slice("sha256:".length),
createdAt: "2026-07-10T09:20:00.000Z",
assetBasePath: "/_vf/assets",
modules: {
Expand All @@ -858,10 +859,11 @@ it("uses canonical production read-back in human and JSON modes", async () => {
routes: {
"/dashboard": {
modules: ["pages/dashboard.tsx"],
css: [],
},
},
dependencyMode: "source",
dependencies: {},
fallback: { mode: "jit", gaps: [] },
},
});
}
Expand Down Expand Up @@ -1064,7 +1066,10 @@ it("uses canonical production read-back in human and JSON modes", async () => {
await time.tickAsync(0);
for (
let tick = 0;
releaseSourceReads < 20 && tick < 40;
// The deploy flow now does more pre-mutation verification before this
// poll starts. Keep the read budget fixed at 20, but allow enough fake
// clock ticks for the async chain to issue all reads under load.
releaseSourceReads < 20 && tick < 60;
tick++
) {
await time.tickAsync(500);
Expand Down Expand Up @@ -1208,13 +1213,13 @@ it("deploys production from a dirty worktree when the pushed digest matches the
state: "ready",
manifest_version: 1,
manifest: {
schemaVersion: 1,
schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION,
projectId: PROJECT_ID,
releaseId: RELEASE_ID,
releaseVersion: 41,
manifestVersion: 1,
builderVersion: "test",
sourceContentHash: sourceDigest,
sourceContentHash: sourceDigest.slice("sha256:".length),
createdAt: "2026-07-10T09:20:00.000Z",
assetBasePath: "/_vf/assets",
modules: {
Expand All @@ -1228,10 +1233,11 @@ it("deploys production from a dirty worktree when the pushed digest matches the
routes: {
"/dashboard": {
modules: ["pages/dashboard.tsx"],
css: [],
},
},
dependencyMode: "source",
dependencies: {},
fallback: { mode: "jit", gaps: [] },
},
});
}
Expand Down Expand Up @@ -1569,20 +1575,20 @@ it("uses an alternative slug when inferred first deploy project creation conflic
state: "ready",
manifest_version: 1,
manifest: {
schemaVersion: 1,
schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION,
projectId: PROJECT_ID,
releaseId: RELEASE_ID,
releaseVersion: 41,
manifestVersion: 1,
builderVersion: "test",
sourceContentHash: sourceDigest,
sourceContentHash: sourceDigest.slice("sha256:".length),
createdAt: "2026-07-10T09:20:00.000Z",
assetBasePath: "/_vf/assets",
modules: {},
css: [],
routes: {},
dependencyMode: "source",
dependencies: {},
fallback: { mode: "jit", gaps: [] },
},
});
}
Expand Down Expand Up @@ -1754,20 +1760,20 @@ it("collects configured app and pages routes when projectDir has a trailing slas
state: "ready",
manifest_version: 1,
manifest: {
schemaVersion: 1,
schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION,
projectId: PROJECT_ID,
releaseId: RELEASE_ID,
releaseVersion: 41,
manifestVersion: 1,
builderVersion: "test",
sourceContentHash: sourceDigest,
sourceContentHash: sourceDigest.slice("sha256:".length),
createdAt: "2026-07-10T09:20:00.000Z",
assetBasePath: "/_vf/assets",
modules: {},
css: [],
routes: {},
dependencyMode: "source",
dependencies: {},
fallback: { mode: "jit", gaps: [] },
},
}));
}
Expand Down
21 changes: 21 additions & 0 deletions cli/shared/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import {
createApiClient,
isRetryableApiReadError,
readConfigFile,
resolveConfig,
resolveConfigWithAuth,
Expand All @@ -19,6 +20,26 @@ import { join } from "veryfront/platform/path";
import { __resetEnvLoaderForTests, loadEnv } from "veryfront/utils/env-loader";
import { deleteToken, saveToken } from "../auth/token-store.ts";

describe("isRetryableApiReadError", () => {
it("retries gateway and connection failures but not authoritative client statuses", () => {
assertEquals(isRetryableApiReadError({ status: 503 }), true);
assertEquals(
isRetryableApiReadError(Object.assign(new Error("connection reset"), {
code: "ECONNRESET",
})),
true,
);
assertEquals(
isRetryableApiReadError(Object.assign(new Error("unauthorized"), {
cause: Object.assign(new Error("connection reset"), { code: "ECONNRESET" }),
status: 401,
})),
false,
);
assertEquals(isRetryableApiReadError(new DOMException("cancelled", "AbortError")), false);
});
});

function createMockEnv(overrides: Partial<EnvironmentConfig> = {}): EnvironmentConfig {
return {
apiUrl: overrides.apiUrl,
Expand Down
55 changes: 41 additions & 14 deletions cli/shared/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ function isTransientStatus(status: number): boolean {
return status === 502 || status === 503 || status === 504;
}

/**
* Classify failures from an idempotent API read conservatively.
*
* A structured HTTP status is authoritative: authentication, validation, and
* other client failures must not become retryable merely because an attached
* cause resembles a connection error.
*/
export function isRetryableApiReadError(error: unknown): boolean {
const status = typeof error === "object" && error !== null
? (error as { status?: unknown }).status
: undefined;
return typeof status === "number" ? isTransientStatus(status) : isRetryableConnectionError(error);
}

/** Sleep for `ms` milliseconds plus a random jitter up to 20% of `ms`. */
function sleepWithJitter(ms: number): Promise<void> {
const jitter = Math.floor(ms * 0.2 * Math.random());
Expand Down Expand Up @@ -343,8 +357,19 @@ function resolveConfigByMode(
return resolveConfigBase(projectDir, env ?? getEnvironmentConfig(), interactive);
}

export interface ApiReadOptions {
/** Abort the in-flight HTTP request when this signal fires. */
signal?: AbortSignal;
/** Use `none` when a higher-level polling loop owns retry timing. */
retryPolicy?: "default" | "none";
}

export interface ApiClient {
get<T>(path: string, params?: Record<string, string>): Promise<T>;
get<T>(
path: string,
params?: Record<string, string>,
options?: ApiReadOptions,
): Promise<T>;
post<T>(path: string, body?: unknown): Promise<T>;
put<T>(path: string, body?: unknown): Promise<T>;
patch<T>(path: string, body?: unknown): Promise<T>;
Expand Down Expand Up @@ -384,9 +409,11 @@ export function createApiClient(config: ResolvedConfig): ApiClient {
method: string,
url: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
const response = await fetch(url, {
method,
...(signal ? { signal } : {}),
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
Expand Down Expand Up @@ -429,6 +456,7 @@ export function createApiClient(config: ResolvedConfig): ApiClient {
path: string,
body?: unknown,
params?: Record<string, string>,
options: ApiReadOptions = {},
): Promise<T> {
const url = new URL(`${apiUrl}${path}`);

Expand All @@ -438,26 +466,21 @@ export function createApiClient(config: ResolvedConfig): ApiClient {

const urlStr = url.toString();
let lastError: unknown;
const maxAttempts = options.retryPolicy === "none" ? 1 : API_MAX_RETRIES;

for (let attempt = 0; attempt < API_MAX_RETRIES; attempt++) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await requestOnce<T>(method, urlStr, body);
return await requestOnce<T>(method, urlStr, body, options.signal);
} catch (error) {
lastError = error;

const status = (error as { status?: number }).status;
const isTransient = status !== undefined
? isTransientStatus(status)
: isRetryableConnectionError(error);
const isRefused = isConnectionRefusedError(error);

// Idempotent: retry on transient HTTP status or any retryable connection error.
// Idempotent: retry on transient HTTP status or status-less retryable connection errors.
// Non-idempotent: retry only on connection-refused (request never reached server).
const shouldRetry = isIdempotent(method)
? (isTransient || isRetryableConnectionError(error))
: isRefused;
const shouldRetry = isIdempotent(method) ? isRetryableApiReadError(error) : isRefused;

if (!shouldRetry || attempt >= API_MAX_RETRIES - 1) {
if (!shouldRetry || attempt >= maxAttempts - 1) {
throw error;
}

Expand All @@ -473,8 +496,12 @@ export function createApiClient(config: ResolvedConfig): ApiClient {
}

return {
get<T>(path: string, params?: Record<string, string>): Promise<T> {
return request<T>("GET", path, undefined, params);
get<T>(
path: string,
params?: Record<string, string>,
options?: ApiReadOptions,
): Promise<T> {
return request<T>("GET", path, undefined, params, options);
},
post<T>(path: string, body?: unknown): Promise<T> {
return request<T>("POST", path, body);
Expand Down
52 changes: 52 additions & 0 deletions cli/shared/deployment/control-plane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,58 @@ async function collectReleaseFiles(files: AsyncIterable<DeployReleaseFile>) {
}

describe("createHttpDeployControlPlane", () => {
it("treats only not-found release asset manifests as polling absence", async () => {
const notFound = { status: 404 };
const forbidden = { status: 403 };
let error: unknown = notFound;
const controlPlane = createHttpDeployControlPlane(
config,
mockClientReturning({
get: () => Promise.reject(error),
}),
);

assertEquals(
await controlPlane.getReleaseAssetManifest("my-project", "release-1"),
null,
);

error = forbidden;
await assertRejects(
() => controlPlane.getReleaseAssetManifest("my-project", "release-1"),
);
});

it("does not treat a successful null manifest response as polling absence", async () => {
const controlPlane = createHttpDeployControlPlane(
config,
mockClientReturning({
get: () => Promise.resolve(null),
}),
);

await assertRejects(
() => controlPlane.getReleaseAssetManifest("my-project", "release-1"),
Error,
"empty manifest response",
);
});

it("does not treat an empty successful manifest response as polling absence", async () => {
const controlPlane = createHttpDeployControlPlane(
config,
mockClientReturning({
get: () => Promise.resolve(undefined),
}),
);

await assertRejects(
() => controlPlane.getReleaseAssetManifest("my-project", "release-1"),
Error,
"empty manifest response",
);
});

it("normalizes legacy deployment references before returning them", async () => {
const controlPlane = createHttpDeployControlPlane(
config,
Expand Down
Loading