diff --git a/packages/cli/src/controllers/build.ts b/packages/cli/src/controllers/build.ts index 6648891..411d496 100644 --- a/packages/cli/src/controllers/build.ts +++ b/packages/cli/src/controllers/build.ts @@ -31,10 +31,39 @@ export interface BuildLogsOptions { cursor?: string; } +/** + * Backoff between retry attempts. The length also bounds the retry budget: with + * two delays the stream is opened at most three times. Injectable so tests can + * exercise the retry paths without real timers. + */ +const RETRY_BACKOFF_MS: readonly number[] = [500, 1500]; + +const TRANSIENT_OPEN_STATUSES = new Set([408, 429, 500, 502, 503, 504]); + +type TerminalRecord = Extract; + +type BuildLogsClient = NonNullable< + Awaited> +>; + +interface BuildLogsDeps { + backoffMs?: readonly number[]; +} + +type ReadOutcome = + | { kind: "done" } + | { kind: "auth" } + | { kind: "not-found" } + | { kind: "fatal"; status: number } + | { kind: "retryable-open"; status: number } + | { kind: "retryable-network" } + | { kind: "retryable-terminal"; record: TerminalRecord }; + export async function runBuildLogs( context: CommandContext, buildId: string, options: BuildLogsOptions = {}, + deps: BuildLogsDeps = {}, ): Promise { const client = await requireComputeAuth( context.runtime.env, @@ -50,34 +79,199 @@ export async function runBuildLogs( ); } - const { data, response } = await client.GET("/v1/builds/{buildId}/logs", { - params: { - path: { buildId }, - query: { - ...(options.follow ? { follow: "true" as const } : {}), - ...(options.cursor ? { cursor: options.cursor } : {}), + const backoffMs = deps.backoffMs ?? RETRY_BACKOFF_MS; + const signal = context.runtime.signal; + // Resume from the last cursor we saw so a reconnect doesn't reprint logs. + let cursor = options.cursor; + + for (let attempt = 0; ; attempt++) { + // biome-ignore lint/performance/noAwaitInLoops: retries are sequential — each attempt resumes from the prior cursor. + const result = await readBuildLogs( + context, + client, + buildId, + options, + cursor, + ); + cursor = result.cursor; + const { outcome } = result; + + if (outcome.kind === "done") { + return; + } + + const immediate = immediateStatus(outcome); + if (immediate !== null) { + throw buildLogsRequestError(buildId, immediate); + } + + if (attempt >= backoffMs.length) { + surfaceExhaustedRetry(context, buildId, outcome); + return; + } + + if (!(await backoffBeforeRetry(backoffMs[attempt], signal))) { + return; + } + } +} + +/** + * HTTP status to surface immediately (no retry), or null when the outcome is a + * retryable failure the loop should back off and re-attempt. + */ +function immediateStatus(outcome: ReadOutcome): number | null { + switch (outcome.kind) { + case "auth": + return 401; + case "not-found": + return 404; + case "fatal": + return outcome.status; + default: + return null; + } +} + +function surfaceExhaustedRetry( + context: CommandContext, + buildId: string, + outcome: ReadOutcome, +): void { + if (outcome.kind === "retryable-terminal") { + writeBuildLogRecord(context, outcome.record); + process.exitCode = 1; + return; + } + throw buildLogsRequestError( + buildId, + outcome.kind === "retryable-open" ? outcome.status : 0, + ); +} + +/** Waits the backoff, returning false if the wait was canceled by an abort. */ +async function backoffBeforeRetry( + ms: number, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + return false; + } + try { + await sleep(ms, signal); + return true; + } catch { + return false; + } +} + +async function readBuildLogs( + context: CommandContext, + client: BuildLogsClient, + buildId: string, + options: BuildLogsOptions, + cursor: string | undefined, +): Promise<{ outcome: ReadOutcome; cursor: string | undefined }> { + const opened = await openBuildLogStream( + context, + client, + buildId, + options, + cursor, + ); + if (opened.kind !== "ok") { + return { outcome: opened, cursor }; + } + return consumeBuildLogStream(context, opened.body, cursor); +} + +type OpenOutcome = + | { kind: "ok"; body: ReadableStream } + | Exclude; + +async function openBuildLogStream( + context: CommandContext, + client: BuildLogsClient, + buildId: string, + options: BuildLogsOptions, + cursor: string | undefined, +): Promise { + try { + const { data, response } = await client.GET("/v1/builds/{buildId}/logs", { + params: { + path: { buildId }, + query: { + ...(options.follow ? { follow: "true" as const } : {}), + ...(cursor ? { cursor } : {}), + }, }, - }, - parseAs: "stream", - signal: context.runtime.signal, - }); + parseAs: "stream", + signal: context.runtime.signal, + }); + const body = data as ReadableStream | null | undefined; + if (response.ok && body) { + return { kind: "ok", body }; + } + return openFailureOutcome(response.status); + } catch (error) { + if (isAbortError(error) || context.runtime.signal.aborted) { + throw error; + } + return { kind: "retryable-network" }; + } +} - const body = data as ReadableStream | null | undefined; - if (!response.ok || !body) { - throw buildLogsRequestError(buildId, response.status); +function openFailureOutcome( + status: number, +): Exclude { + if (status === 401) { + return { kind: "auth" }; } + if (status === 404) { + return { kind: "not-found" }; + } + if (TRANSIENT_OPEN_STATUSES.has(status)) { + return { kind: "retryable-open", status }; + } + return { kind: "fatal", status }; +} - let sawError = false; - await forEachNdjsonRecord(body, (record) => { - if (record.type === "terminal" && record.kind === "error") { - sawError = true; +async function consumeBuildLogStream( + context: CommandContext, + body: ReadableStream, + cursor: string | undefined, +): Promise<{ outcome: ReadOutcome; cursor: string | undefined }> { + let latestCursor = cursor; + let retryableTerminal: TerminalRecord | null = null; + try { + await forEachNdjsonRecord(body, (record) => { + if (record.cursor) { + latestCursor = record.cursor; + } + if ( + record.type === "terminal" && + record.kind === "error" && + record.retryable + ) { + retryableTerminal = record; + return; + } + writeBuildLogRecord(context, record); + }); + } catch (error) { + if (isAbortError(error) || context.runtime.signal.aborted) { + throw error; } - writeBuildLogRecord(context, record); - }); + return { outcome: { kind: "retryable-network" }, cursor: latestCursor }; + } - if (sawError) { - process.exitCode = 1; + if (retryableTerminal) { + return { + outcome: { kind: "retryable-terminal", record: retryableTerminal }, + cursor: latestCursor, + }; } + return { outcome: { kind: "done" }, cursor: latestCursor }; } function writeBuildLogRecord( @@ -149,7 +343,29 @@ async function forEachNdjsonRecord( } } +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + +function sleep(milliseconds: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timeout); + reject(signal.reason); + }; + const timeout = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + function buildLogsRequestError(buildId: string, status: number): CliError { + if (status === 401) { + return authRequiredError(["prisma-cli auth login"]); + } if (status === 404) { return new CliError({ code: "BUILD_NOT_FOUND", @@ -164,7 +380,10 @@ function buildLogsRequestError(buildId: string, status: number): CliError { code: "BUILD_LOGS_FAILED", domain: "app", summary: `Failed to read logs for build ${buildId}`, - why: `The Management API returned HTTP ${status}.`, + why: + status > 0 + ? `The Management API returned HTTP ${status}.` + : "The log stream could not be read after several attempts.", fix: "Retry the command, or rerun with --trace for more detail.", exitCode: 1, }); diff --git a/packages/cli/tests/build-logs-controller.test.ts b/packages/cli/tests/build-logs-controller.test.ts new file mode 100644 index 0000000..6d9b5a9 --- /dev/null +++ b/packages/cli/tests/build-logs-controller.test.ts @@ -0,0 +1,212 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { BuildLogsOptions } from "../src/controllers/build"; + +const BUILD_ID = "bld_123"; + +interface GetResult { + data: ReadableStream | null; + response: { ok: boolean; status: number }; +} + +function ndjsonStream(lines: unknown[]): ReadableStream { + const encoder = new TextEncoder(); + const payload = lines.map((line) => `${JSON.stringify(line)}\n`).join(""); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(payload)); + controller.close(); + }, + }); +} + +function streamResult(lines: unknown[]): GetResult { + return { data: ndjsonStream(lines), response: { ok: true, status: 200 } }; +} + +function errorResult(status: number): GetResult { + return { data: null, response: { ok: false, status } }; +} + +function logLine(text: string, cursor: string) { + return { + type: "log" as const, + text, + level: "info" as const, + source: "stdout" as const, + cursor, + }; +} + +function retryableTerminal(cursor: string | null) { + return { + type: "terminal" as const, + kind: "error" as const, + code: "upstream_error", + message: "Failed to read build logs.", + retryable: true, + cursor, + }; +} + +async function runWithClient( + get: ReturnType, + options: BuildLogsOptions = {}, +) { + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth: vi.fn().mockResolvedValue({ token: "t", GET: get }), + })); + + const { createTestCommandContext } = await import("./helpers"); + const { runBuildLogs } = await import("../src/controllers/build"); + const { context, stdout, stderr } = await createTestCommandContext({ + isTTY: false, + env: { PRISMA_CLI_MOCK_FIXTURE_PATH: undefined }, + }); + + // backoffMs of zeros keeps the retry budget at 3 attempts without real waits. + const run = runBuildLogs(context, BUILD_ID, options, { backoffMs: [0, 0] }); + return { run, stdout, stderr }; +} + +function queryOf(call: unknown[]): Record { + const [, init] = call as [ + string, + { params: { query: Record } }, + ]; + return init.params.query; +} + +afterEach(() => { + process.exitCode = undefined; + vi.doUnmock("../src/lib/auth/guard"); + vi.resetModules(); + vi.restoreAllMocks(); +}); + +describe("build logs controller", () => { + it("maps a 401 to the shared auth-required error", async () => { + const get = vi.fn().mockResolvedValue(errorResult(401)); + const { run } = await runWithClient(get); + + await expect(run).rejects.toMatchObject({ + code: "AUTH_REQUIRED", + domain: "auth", + }); + expect(get).toHaveBeenCalledTimes(1); + }); + + it("retries a retryable terminal error and resumes from the cursor", async () => { + const get = vi + .fn() + .mockResolvedValueOnce( + streamResult([logLine("first", "c1"), retryableTerminal("c1")]), + ) + .mockResolvedValueOnce( + streamResult([ + logLine("second", "c2"), + { + type: "terminal", + kind: "end", + code: "end", + retryable: false, + cursor: "c2", + message: "", + }, + ]), + ); + const { run, stdout } = await runWithClient(get); + + await run; + + expect(process.exitCode).toBeUndefined(); + expect(stdout.buffer).toContain("first"); + expect(stdout.buffer).toContain("second"); + expect(get).toHaveBeenCalledTimes(2); + expect(queryOf(get.mock.calls[0])).not.toHaveProperty("cursor"); + expect(queryOf(get.mock.calls[1])).toMatchObject({ cursor: "c1" }); + }); + + it("exits non-zero and surfaces the message once when every attempt fails", async () => { + const get = vi + .fn() + .mockImplementation(async () => streamResult([retryableTerminal("c1")])); + const { run, stderr } = await runWithClient(get); + + await run; + + expect(process.exitCode).toBe(1); + expect(get).toHaveBeenCalledTimes(3); + const occurrences = + stderr.buffer.split("Failed to read build logs.").length - 1; + expect(occurrences).toBe(1); + }); + + it("reconnects a dropped --follow stream from the cursor", async () => { + const get = vi + .fn() + .mockResolvedValueOnce( + streamResult([logLine("a", "c1"), retryableTerminal("c1")]), + ) + .mockResolvedValueOnce(streamResult([logLine("b", "c2")])); + const { run, stdout } = await runWithClient(get, { follow: true }); + + await run; + + expect(process.exitCode).toBeUndefined(); + expect(get).toHaveBeenCalledTimes(2); + expect(queryOf(get.mock.calls[0])).toMatchObject({ follow: "true" }); + expect(queryOf(get.mock.calls[1])).toMatchObject({ + follow: "true", + cursor: "c1", + }); + expect(stdout.buffer).toContain("a"); + expect(stdout.buffer).toContain("b"); + }); + + it("retries a transient open status and then succeeds", async () => { + const get = vi + .fn() + .mockResolvedValueOnce(errorResult(503)) + .mockResolvedValueOnce(streamResult([logLine("ok", "c1")])); + const { run, stdout } = await runWithClient(get); + + await run; + + expect(process.exitCode).toBeUndefined(); + expect(get).toHaveBeenCalledTimes(2); + expect(stdout.buffer).toContain("ok"); + }); + + it("does not retry a 404 and surfaces BUILD_NOT_FOUND", async () => { + const get = vi.fn().mockResolvedValue(errorResult(404)); + const { run } = await runWithClient(get); + + await expect(run).rejects.toMatchObject({ code: "BUILD_NOT_FOUND" }); + expect(get).toHaveBeenCalledTimes(1); + }); + + it("does not retry a non-retryable terminal end", async () => { + const get = vi.fn().mockResolvedValue( + streamResult([ + logLine("only", "c1"), + { + type: "terminal", + kind: "end", + code: "no_logs", + retryable: false, + cursor: "c1", + message: "No logs were produced.", + }, + ]), + ); + const { run, stdout, stderr } = await runWithClient(get); + + await run; + + expect(process.exitCode).toBeUndefined(); + expect(get).toHaveBeenCalledTimes(1); + expect(stdout.buffer).toContain("only"); + expect(stderr.buffer).toContain("No logs were produced."); + }); +});