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
92 changes: 84 additions & 8 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,25 @@ export type UploadUrlAssetRequest = DeployAssetUploadRequest;
*/
export type ManifestOnlyDeployPayload = DeployUploadPayload;

/**
* Retry contract for the deploy POST's transport phase.
*
* - Only `fetch` rejections (`TypeError` — no HTTP response) are retried.
* A Response of any status means the server answered; surface it
* verbatim rather than replay a request it may have accepted.
* - A rejected fetch does NOT prove the request never arrived — the
* connection may have dropped after delivery. Arbitration is left to
* the server's precheck, the only atomic (project-locked) verdict: if
* an earlier attempt or a concurrent deploy is still in flight, the
* replay is rejected with 409 and surfaced as a deploy conflict
* naming the deployment id. A client-side GET-then-decide lookup can
* never be authoritative (the row can transition between lookup and
* replay), so no dedupe query is made — the residual case is the lost
* attempt completing within seconds and being redeployed identically.
*/
const DEPLOY_POST_MAX_RETRIES = 3;
const DEPLOY_POST_BACKOFF_MS = [1000, 5000, 10000];

/**
* HTTP client for the Void platform API.
*/
Expand Down Expand Up @@ -559,7 +578,7 @@ export class PlatformClient {
payload: ManifestOnlyDeployPayload,
): AsyncGenerator<DeployEvent> {
const url = `${this.baseUrl}/projects/${projectId}/deploy`;
yield* this.streamDeployRequest(url, {
yield* this.streamDeployRequest(url, projectId, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand All @@ -573,16 +592,37 @@ export class PlatformClient {
* POST + NDJSON-stream-reader used by `finalizeDeploy()`. Captures
* `cf-ray` + `cf-cache-status` for postmortem correlation.
*/
private async *streamDeployRequest(url: string, init: RequestInit): AsyncGenerator<DeployEvent> {
private async *streamDeployRequest(
url: string,
projectId: string,
init: RequestInit,
): AsyncGenerator<DeployEvent> {
const cliLog = this.cliLog;
cliLog?.info('deploy_post', { url });

// `fetch` reports transport failures as TypeError ('fetch failed') —
// the only retryable case per the DEPLOY_POST_* contract above.
// Anything else (e.g. AbortError) is not transient and fails fast.
let res: Response;
try {
res = await fetch(url, init);
} catch (err) {
cliLog?.error('deploy_fetch_rejected', err, { url });
throw err;
let attempt = 0;
while (true) {
try {
res = await fetch(url, init);
break;
} catch (err) {
if (!(err instanceof TypeError) || attempt >= DEPLOY_POST_MAX_RETRIES) {
Comment thread
Brooooooklyn marked this conversation as resolved.
cliLog?.error('deploy_fetch_rejected', err, { url, attempts: attempt + 1 });
throw err;
}
cliLog?.warn('deploy_fetch_retry', {
url,
attempt: attempt + 1,
delayMs: DEPLOY_POST_BACKOFF_MS[attempt],
error: err instanceof Error ? err.message : String(err),
});
await sleep(DEPLOY_POST_BACKOFF_MS[attempt]);
attempt++;
}
}

// `cf-ray` and `cf-cache-status` are the correlation keys that platform
Expand All @@ -598,7 +638,39 @@ export class PlatformClient {
});

if (!res.ok) {
await throwPlatformApiError('Deploy failed', res);
const body = await res.text();
if (res.status === 409) {
// The precheck's authoritative verdict — a deployment is already
// in flight (possibly the earlier attempt of this same deploy,
// or a concurrent one). Surface it as a structured error event
// naming the deployment rather than a bare HTTP failure.
let parsed: { error?: string; deployment?: string } | null = null;
try {
const raw: unknown = JSON.parse(body);
if (raw !== null && typeof raw === 'object') {
parsed = raw as { error?: string; deployment?: string };
}
} catch {
// Non-JSON 409 — fall through to the generic error below.
}
if (parsed?.deployment) {
cliLog?.warn('deploy_conflict', {
url,
deploymentId: parsed.deployment,
error: parsed.error,
});
yield {
event: 'error',
step: 'finalize',
message:
`${parsed.error ?? 'deploy in progress'}: deployment ${parsed.deployment} — ` +
'it may be an earlier attempt of this deploy or a concurrent one; ' +
'inspect with `void project status` before redeploying',
};
return;
}
}
throw new PlatformApiError('Deploy failed', res.status, body);
}

const reader = res.body!.getReader();
Expand Down Expand Up @@ -1350,3 +1422,7 @@ export class PlatformClient {
return res.json();
}
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
186 changes: 186 additions & 0 deletions packages/void/test/unit/direct-r2-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,192 @@ describe('PlatformClient.finalizeDeploy', () => {
});
});

function makeCliLog() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
path: '/tmp/test.jsonl',
close: vi.fn(),
};
}

const deployPostCalls = () => fetchMock.mock.calls.filter(([u]) => String(u).endsWith('/deploy'));
const deploymentListCalls = () =>
fetchMock.mock.calls.filter(([u]) => String(u).includes('/deployments'));

it('retries a transport-level fetch rejection with backoff, then streams events', async () => {
vi.useFakeTimers();
let posts = 0;
fetchMock.mockImplementation(() => {
posts++;
if (posts === 1) {
return Promise.reject(
new TypeError('fetch failed', { cause: new Error('read ECONNRESET') }),
);
}
return Promise.resolve(
ndjsonStreamResponse([
{ event: 'done', deployment: 'd1', url: 'https://x', urls: [], assets: 0, workers: 0 },
]),
);
});

const cliLog = makeCliLog();
const client = new PlatformClient(token, { apiUrl, cliLog });
try {
const events: Array<unknown> = [];
const consume = (async () => {
for await (const event of client.finalizeDeploy(projectId, makePayload())) {
events.push(event);
}
})();
await vi.runAllTimersAsync();
await consume;

expect(deployPostCalls()).toHaveLength(2);
// No client-side dedupe GET — the server's precheck is authoritative.
expect(deploymentListCalls()).toHaveLength(0);
expect(events).toEqual([
{ event: 'done', deployment: 'd1', url: 'https://x', urls: [], assets: 0, workers: 0 },
]);
expect(cliLog.warn).toHaveBeenCalledWith(
'deploy_fetch_retry',
expect.objectContaining({ attempt: 1, delayMs: 1000 }),
);
expect(cliLog.error).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});

it('throws the original error after exhausting transport retries', async () => {
vi.useFakeTimers();
fetchMock.mockImplementation(() => Promise.reject(new TypeError('fetch failed')));

const cliLog = makeCliLog();
const client = new PlatformClient(token, { apiUrl, cliLog });
try {
const consume = (async () => {
for await (const _event of client.finalizeDeploy(projectId, makePayload())) {
// unreachable — every attempt rejects
}
})();
const assertion = expect(consume).rejects.toBeInstanceOf(TypeError);
await vi.runAllTimersAsync();
await assertion;

// 1 initial attempt + 3 retries — no dedupe GETs.
expect(deployPostCalls()).toHaveLength(4);
expect(deploymentListCalls()).toHaveLength(0);
const retries = cliLog.warn.mock.calls.filter(([msg]) => msg === 'deploy_fetch_retry');
expect(retries.map(([, fields]) => fields?.delayMs)).toEqual([1000, 5000, 10000]);
expect(cliLog.error).toHaveBeenCalledWith(
'deploy_fetch_rejected',
expect.any(TypeError),
expect.objectContaining({
url: `${apiUrl}/projects/${projectId}/deploy`,
attempts: 4,
}),
);
} finally {
vi.useRealTimers();
}
});

it('surfaces the server 409 as an error event when a deploy is already in progress', async () => {
vi.useFakeTimers();
// The replay hits the authoritative concurrency guard — possibly the
// earlier attempt of this same deploy, or a concurrent one.
let posts = 0;
fetchMock.mockImplementation(() => {
posts++;
if (posts === 1) {
return Promise.reject(new TypeError('fetch failed'));
}
return Promise.resolve(
jsonResponse({ error: 'deploy in progress', deployment: 'dpl_conflict' }, 409),
);
});

const cliLog = makeCliLog();
const client = new PlatformClient(token, { apiUrl, cliLog });
try {
const events: Array<unknown> = [];
const consume = (async () => {
for await (const event of client.finalizeDeploy(projectId, makePayload())) {
events.push(event);
}
})();
await vi.runAllTimersAsync();
await consume;

expect(deployPostCalls()).toHaveLength(2);
expect(events).toEqual([
{
event: 'error',
step: 'finalize',
message: expect.stringContaining('dpl_conflict'),
},
]);
expect(cliLog.warn).toHaveBeenCalledWith(
'deploy_conflict',
expect.objectContaining({ deploymentId: 'dpl_conflict' }),
);
} finally {
vi.useRealTimers();
}
});

it('surfaces a first-attempt 409 without retrying', async () => {
fetchMock.mockResolvedValue(
jsonResponse({ error: 'deploy in progress', deployment: 'dpl_busy' }, 409),
);

const client = makeClient();
const events: Array<unknown> = [];
for await (const event of client.finalizeDeploy(projectId, makePayload())) {
events.push(event);
}

expect(deployPostCalls()).toHaveLength(1);
expect(events[0]).toMatchObject({
event: 'error',
message: expect.stringContaining('dpl_busy'),
});
});

it('throws the platform error for a 409 without a deployment id', async () => {
fetchMock.mockResolvedValue(new Response('conflict', { status: 409 }));
const client = makeClient();
const gen = client.finalizeDeploy(projectId, makePayload());
await expect(gen.next()).rejects.toMatchObject({
name: 'PlatformApiError',
status: 409,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('does not retry non-transport rejections (e.g. AbortError)', async () => {
fetchMock.mockRejectedValueOnce(new DOMException('The operation was aborted.', 'AbortError'));
const client = makeClient();
const gen = client.finalizeDeploy(projectId, makePayload());
await expect(gen.next()).rejects.toBeInstanceOf(DOMException);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('does not retry HTTP error responses — the server answered', async () => {
fetchMock.mockResolvedValue(new Response('busted', { status: 503 }));
const client = makeClient();
const gen = client.finalizeDeploy(projectId, makePayload());
await expect(gen.next()).rejects.toMatchObject({
name: 'PlatformApiError',
status: 503,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('serializes workerFiles as base64 in the JSON body', async () => {
fetchMock.mockResolvedValueOnce(
ndjsonStreamResponse([
Expand Down
Loading