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
26 changes: 26 additions & 0 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,23 @@ export function isExpiredTokenError(error: unknown): boolean {
return false;
}

/**
* True when `fetch` gave up waiting for response headers (undici's
* `HeadersTimeoutError`, 300 seconds by default). The request reached the
* server, which is still working on it.
*/
export function isHeadersTimeoutError(error: unknown): boolean {
let current = error;
for (let depth = 0; depth < 6 && current !== null && typeof current === 'object'; depth++) {
const { code, name } = current as { code?: unknown; name?: unknown };
if (code === 'UND_ERR_HEADERS_TIMEOUT' || name === 'HeadersTimeoutError') {
return true;
}
current = (current as { cause?: unknown }).cause;
}
return false;
}

function isPreflightRejection(error: unknown): boolean {
return (
error instanceof PlatformApiError &&
Expand Down Expand Up @@ -577,6 +594,10 @@ export type ManifestOnlyDeployPayload = DeployUploadPayload;
* - 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 headers timeout is retried too. Every attempt sends the same
* idempotency key, so once an earlier request has created its deployment,
* a receipt-aware platform answers the retry with that deployment's
* receipt and the CLI follows it to the real result.
* - 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
Expand All @@ -590,6 +611,11 @@ export type ManifestOnlyDeployPayload = DeployUploadPayload;
*/
const DEPLOY_POST_MAX_RETRIES = 3;
const DEPLOY_POST_BACKOFF_MS = [1000, 5000, 10000];
/**
* Every transport failure is retried, so a `TypeError` thrown by the deploy
* POST always comes after this many attempts.
*/
export const DEPLOY_POST_ATTEMPTS: number = DEPLOY_POST_MAX_RETRIES + 1;
const DEPLOY_OBSERVATION_TIMEOUT_MS = 30_000;
const DEPLOY_OBSERVATION_DEADLINE_MS = 30 * 60_000;
const DEPLOY_OBSERVATION_BACKOFF_MS = [1000, 2000, 5000, 10_000, 30_000];
Expand Down
60 changes: 54 additions & 6 deletions packages/void/src/cli/deploy-errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { isCliOutdatedError, parsePlatformErrorBody, PlatformApiError } from './client.ts';
import {
DEPLOY_POST_ATTEMPTS,
isCliOutdatedError,
isHeadersTimeoutError,
parsePlatformErrorBody,
PlatformApiError,
} from './client.ts';
import { platformErrorMessage } from './platform-errors.ts';

const BODY_SNIPPET_MAX = 200;
Expand Down Expand Up @@ -27,6 +33,31 @@ function appendTrailingLines(
return appendLogLine(appendDeploymentLine(message, deploymentId), logPath);
}

/**
* Describe the deepest `cause` of an error in one short line, e.g.
* `getaddrinfo ENOTFOUND api.example.com` under `fetch failed`.
*/
function describeRootCause(err: Error): string | null {
let root: unknown = err;
for (let depth = 0; depth < 6; depth++) {
const cause = (root as { cause?: unknown }).cause;
if (cause === null || typeof cause !== 'object') {
break;
}
root = cause;
}
if (root === err) {
return null;
}
const { code, message } = root as { code?: unknown; message?: unknown };
const text = typeof message === 'string' ? message.trim() : '';
const codeText = typeof code === 'string' ? code : '';
if (!text) {
return codeText || null;
}
return codeText && !text.includes(codeText) ? `${codeText}: ${text}` : text;
}

/**
* Format a server-emitted `event: 'error'` payload with an optional deployment id.
*/
Expand Down Expand Up @@ -56,14 +87,18 @@ export function formatConnectionLostMessage(
* Convert an exception thrown during the deploy NDJSON stream into a
* user-facing `Error` with a readable message.
*
* Three cases:
* Four cases:
* 1. `PlatformApiError` with 409 + a JSON body carrying `{error, deployment}`
* — surface the server-supplied error and prefer the body's deployment id.
* 2. Any other `PlatformApiError` — retain the client's actionable message and
* structured error fields; opaque provider failures use a trimmed body snippet.
* 3. Anything else — preserve the original message under a `Connection lost
* during deploy: <original>` prefix so the caller can still tell network,
* parse, and transport errors apart.
* 3. A headers timeout on the last deploy POST attempt — the platform sent
* no response in time. Explain that the deploy may still finish and how
* to check and retry.
* 4. Anything else — preserve the original message, plus its root cause when
* there is one, under a `Connection lost during deploy: <original>`
* prefix so the caller can still tell network, parse, and transport
* errors apart.
*/
export function formatStreamDeployError(
err: unknown,
Expand Down Expand Up @@ -98,10 +133,23 @@ export function formatStreamDeployError(
return err;
}

if (isHeadersTimeoutError(err)) {
const lines = [
`deploy: The Void platform did not respond while preparing the deploy. Tried ${DEPLOY_POST_ATTEMPTS} times; the last try waited 5 minutes.`,
'It may still finish. Check `void project status`, then run `void deploy` again to retry.',
];
if (logPath) {
lines.push('If this keeps happening, share the detailed log below.');
}
return new Error(appendTrailingLines(lines.join('\n'), deploymentId, logPath));
}

const original = err instanceof Error ? err.message : String(err);
const rootCause = err instanceof Error ? describeRootCause(err) : null;
const detail = rootCause && rootCause !== original ? ` (${rootCause})` : '';
return new Error(
appendTrailingLines(
`deploy: Connection lost during deploy: '${original}'.`,
`deploy: Connection lost during deploy: '${original}'${detail}.`,
deploymentId,
logPath,
),
Expand Down
89 changes: 87 additions & 2 deletions packages/void/test/unit/direct-r2-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test';
import { PlatformClient, type ManifestOnlyDeployPayload } from '../../src/cli/client.ts';
import {
DEPLOY_POST_ATTEMPTS,
PlatformClient,
type ManifestOnlyDeployPayload,
} from '../../src/cli/client.ts';
import type { DeployManifest } from '../../src/cli/types.ts';
import { withPlatformDiscovery } from '../helpers/platform-discovery.ts';

Expand Down Expand Up @@ -296,7 +300,9 @@ describe('PlatformClient.finalizeDeploy', () => {
posts++;
if (posts === 1) {
return Promise.reject(
new TypeError('fetch failed', { cause: new Error('read ECONNRESET') }),
new TypeError('fetch failed', {
cause: Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }),
}),
);
}
return Promise.resolve(
Expand Down Expand Up @@ -450,6 +456,85 @@ describe('PlatformClient.finalizeDeploy', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});

function headersTimeoutError() {
const cause = Object.assign(new Error('Headers Timeout Error'), {
name: 'HeadersTimeoutError',
code: 'UND_ERR_HEADERS_TIMEOUT',
});
return { cause, error: new TypeError('fetch failed', { cause }) };
}

const idempotencyKeys = () =>
deployPostCalls().map(
([, init]) => (init.headers as Record<string, string>)['x-void-deploy-idempotency-key'],
);

it('retries a headers timeout with the same idempotency key, then rethrows it', async () => {
const { cause, error } = headersTimeoutError();
fetchMock.mockRejectedValue(error);

vi.useFakeTimers();
const cliLog = makeCliLog();
const client = new PlatformClient(token, { apiUrl, cliLog });
try {
const assertion = expect(
client.finalizeDeploy(projectId, makePayload()).next(),
).rejects.toMatchObject({ message: 'fetch failed', cause });
await vi.runAllTimersAsync();
await assertion;

expect(deployPostCalls()).toHaveLength(4);
expect(DEPLOY_POST_ATTEMPTS).toBe(4);
expect(new Set(idempotencyKeys()).size).toBe(1);
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({ attempts: 4 }),
);
} finally {
vi.useRealTimers();
}
});

it('follows the existing deployment receipt returned to a retry after a headers timeout', async () => {
const done = {
event: 'done',
deployment: 'dpl_existing',
url: 'https://x.void.app',
urls: [],
assets: 1,
workers: 1,
seq: 3,
};
fetchMock
.mockRejectedValueOnce(headersTimeoutError().error)
.mockResolvedValueOnce(
ndjsonStreamResponse([
{ event: 'start', deploymentId: 'dpl_existing', seq: 1 },
{ event: 'worker_upload', seq: 2 },
done,
]),
);

vi.useFakeTimers();
try {
const deploy = collectDeploy();
await vi.runAllTimersAsync();
expect(await deploy).toEqual([
{ event: 'start', deploymentId: 'dpl_existing' },
{ event: 'worker_upload', seq: 2 },
done,
]);
expect(deployPostCalls()).toHaveLength(2);
const [first, second] = idempotencyKeys();
expect(second).toBe(first);
} finally {
vi.useRealTimers();
}
});

it('does not retry HTTP error responses — the server answered', async () => {
fetchMock.mockResolvedValue(new Response('busted', { status: 503 }));
const client = makeClient();
Expand Down
65 changes: 65 additions & 0 deletions packages/void/test/unit/stream-deploy-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,71 @@ describe('formatStreamDeployError', () => {
expect(formatted.message).toContain('fetch failed');
});

it('explains a headers timeout after the last retry in plain words and keeps the log line', () => {
const err = new TypeError('fetch failed', {
cause: Object.assign(new Error('Headers Timeout Error'), {
name: 'HeadersTimeoutError',
code: 'UND_ERR_HEADERS_TIMEOUT',
}),
});

const formatted = formatStreamDeployError(err, null, '/tmp/deploy.jsonl');

expect(formatted.message).toBe(
[
'deploy: The Void platform did not respond while preparing the deploy. Tried 4 times; the last try waited 5 minutes.',
'It may still finish. Check `void project status`, then run `void deploy` again to retry.',
'If this keeps happening, share the detailed log below.',
'Detailed log: /tmp/deploy.jsonl',
].join('\n'),
);
});

it('omits the log hint for a headers timeout when no log was written', () => {
const err = new TypeError('fetch failed', {
cause: Object.assign(new Error('Headers Timeout Error'), {
code: 'UND_ERR_HEADERS_TIMEOUT',
}),
});

const formatted = formatStreamDeployError(err, null, null);

expect(formatted.message).not.toContain('log');
expect(formatted.message).toContain('void project status');
});

it('adds the root cause of a fetch failure', () => {
const err = new TypeError('fetch failed', {
cause: Object.assign(new Error('getaddrinfo ENOTFOUND api.example.com'), {
code: 'ENOTFOUND',
}),
});

const formatted = formatStreamDeployError(err, null, '/tmp/deploy.jsonl');

expect(formatted.message).toBe(
"deploy: Connection lost during deploy: 'fetch failed' (getaddrinfo ENOTFOUND api.example.com).\n" +
'Detailed log: /tmp/deploy.jsonl',
);
});

it('uses the cause code when the root cause has no message', () => {
const refused = Object.assign(new AggregateError([], ''), { code: 'ECONNREFUSED' });
const closed = Object.assign(new Error('other side closed'), {
name: 'SocketError',
code: 'UND_ERR_SOCKET',
});

expect(
formatStreamDeployError(new TypeError('fetch failed', { cause: refused }), null).message,
).toBe("deploy: Connection lost during deploy: 'fetch failed' (ECONNREFUSED).");
expect(
formatStreamDeployError(new TypeError('fetch failed', { cause: closed }), null).message,
).toBe(
"deploy: Connection lost during deploy: 'fetch failed' (UND_ERR_SOCKET: other side closed).",
);
});

it('includes the deployment id for generic mid-stream errors when known', () => {
const err = new Error('reader canceled');

Expand Down
40 changes: 35 additions & 5 deletions platform/packages/api/src/deployment-execution.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { and, eq, getTableColumns, lte, ne, or, sql } from 'drizzle-orm';
import { paginatedR2Delete } from './assets.ts';
import { R2_CONCURRENCY } from './concurrency.ts';
import { createDb, type Db, type DbOrTx } from './db.ts';
import type { DeploymentInput, DeploymentOperation } from './deployment-operation-types.ts';
import type { ApiEnv } from './env.ts';
Expand Down Expand Up @@ -109,14 +110,43 @@ export async function prepareDeploymentInput(
};
delete input.body.workerFiles;
try {
// Hash one module at a time so at most one extra copy of module bytes is
// alive beside the decoded Blobs; a near-cap request already sits close
// to the isolate memory limit.
const modules: DeploymentInput['modules'] = [];
for (let index = 0; index < files.length; index++) {
const { name, content } = files[index]!;
const bytes = await content.arrayBuffer();
const digest = await sha256Hex(bytes);
const key = `${prefix}${index}`;
await r2.put(key, bytes);
input.modules.push({ name, key, digest, type: content.type });
const digest = await sha256Hex(await content.arrayBuffer());
modules.push({ name, key: `${prefix}${index}`, digest, type: content.type });
}
// The request has not answered yet, so upload the Blobs themselves in
// parallel. Workers let at most six R2 calls wait for a response at once
// (R2_CONCURRENCY); a larger pool would only queue inside the runtime.
// After the first failure no new put starts, and every started put
// settles before cleanup so none lands under the prefix after it was
// deleted.
let next = 0;
let failed = false;
const workers = await Promise.allSettled(
Array.from({ length: Math.min(R2_CONCURRENCY, files.length) }, async () => {
while (!failed && next < files.length) {
const index = next++;
try {
await r2.put(modules[index]!.key, files[index]!.content);
} catch (error) {
failed = true;
throw error;
}
}
}),
);
const rejected = workers.find(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
if (rejected) {
throw rejected.reason;
}
input.modules = modules;
const identity = JSON.stringify({
body: input.body,
modules: input.modules.map(({ name, digest, type }) => ({ name, digest, type })),
Expand Down
Loading
Loading