diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index f52eb356e..3b1040c75 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -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. */ @@ -559,7 +578,7 @@ export class PlatformClient { payload: ManifestOnlyDeployPayload, ): AsyncGenerator { const url = `${this.baseUrl}/projects/${projectId}/deploy`; - yield* this.streamDeployRequest(url, { + yield* this.streamDeployRequest(url, projectId, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -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 { + private async *streamDeployRequest( + url: string, + projectId: string, + init: RequestInit, + ): AsyncGenerator { 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) { + 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 @@ -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(); @@ -1350,3 +1422,7 @@ export class PlatformClient { return res.json(); } } + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/void/test/unit/direct-r2-client.test.ts b/packages/void/test/unit/direct-r2-client.test.ts index 979f03799..6b82986aa 100644 --- a/packages/void/test/unit/direct-r2-client.test.ts +++ b/packages/void/test/unit/direct-r2-client.test.ts @@ -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 = []; + 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 = []; + 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 = []; + 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([