diff --git a/src/cli.ts b/src/cli.ts index 9845f98..26a9021 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -69,6 +69,7 @@ import { runUndeploy } from "./commands/undeploy.js"; import { runSessionsBilling } from "./commands/sessions/billing.js"; import { runSessionsList } from "./commands/sessions/list.js"; import { configureLogging } from "./lib/command-log.js"; +import { formatCliError } from "./lib/api.js"; import { DEFAULT_API_BASE } from "./lib/config.js"; const require = createRequire(import.meta.url); @@ -464,7 +465,9 @@ async function main(): Promise { usage .command("show") - .description("Show usage credits (project default, or org rollup with --org)") + .description( + "Show usage credits (project default, or org rollup with --org)", + ) .option("--project ", "Project UUID (default: .voicethere/config.json)") .option("--org", "Organization rollup instead of a single project") .option("--period ", "24h, 7d, 30d, or utc_month") @@ -571,7 +574,10 @@ async function main(): Promise { ) .option("--project ", "Project UUID") .option("--limit ", "Max rows when listing project logs", "20") - .option("--session ", "Filter to one orchestrator session id (one conversation)") + .option( + "--session ", + "Filter to one orchestrator session id (one conversation)", + ) .option("--q ", "Search log messages") .option("--level ", "Filter by level (debug|info|warn|error)") .option( @@ -595,17 +601,9 @@ async function main(): Promise { sessionId: options.session, q: options.q, level: options.level as - | "debug" - | "info" - | "warn" - | "error" - | undefined, + "debug" | "info" | "warn" | "error" | undefined, severity: options.severity as - | "debug" - | "info" - | "warn" - | "error" - | undefined, + "debug" | "info" | "warn" | "error" | undefined, json: options.json, }); }, @@ -708,9 +706,18 @@ async function main(): Promise { .description("Export conversation transcripts to a downloadable JSON file") .option("--project ", "Project UUID") .option("--session ", "Export one session by orchestrator session id") - .option("--q ", "Export sessions matching transcript text or session id") - .option("--all", "Export all conversations (optionally within a time window)") - .option("--period ", "24h, 7d, 30d, or utc_month (filter/all modes)") + .option( + "--q ", + "Export sessions matching transcript text or session id", + ) + .option( + "--all", + "Export all conversations (optionally within a time window)", + ) + .option( + "--period ", + "24h, 7d, 30d, or utc_month (filter/all modes)", + ) .option("--from ", "Custom range start (ISO-8601)") .option("--to ", "Custom range end (ISO-8601)") .option("--wait", "Poll until the export job completes or fails") @@ -1038,7 +1045,6 @@ async function main(): Promise { } main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); + console.error(formatCliError(error)); process.exitCode = 1; }); diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index 054396e..ed40b38 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -3,6 +3,8 @@ import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as httpRetry from "../lib/http-retry.js"; + import { ApiError } from "../lib/api.js"; import { evaluateExistingCredentials, @@ -198,6 +200,7 @@ describe("runLogin", () => { }), "utf8", ); + vi.spyOn(httpRetry, "withHttpRetries").mockImplementation((fn) => fn()); vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ error: { message: "down" } }), { status: 503, diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 4bd2ef5..ec0b23c 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ApiError, VoicethereApi } from "./api.js"; +import { ApiError, formatCliError, VoicethereApi } from "./api.js"; import { DEFAULT_API_BASE, getCredentialsPath, @@ -133,12 +133,39 @@ describe("slugifyName", () => { }); }); +describe("formatCliError", () => { + it("formats ApiError with error_id and request_id", () => { + const error = new ApiError(400, "bad input", { + error: { + message: "bad input", + request_id: "req-abc", + error_id: "err-xyz", + }, + }); + + expect(formatCliError(error)).toBe( + "Error: bad input\nerror_id: err-xyz\nrequest_id: req-abc", + ); + }); + + it("omits missing ApiError metadata lines", () => { + const error = new ApiError(500, "oops"); + expect(formatCliError(error)).toBe("Error: oops"); + }); + + it("formats generic errors on one line", () => { + expect(formatCliError(new Error("boom"))).toBe("Error: boom"); + expect(formatCliError("plain")).toBe("Error: plain"); + }); +}); + describe("VoicethereApi", () => { const apiKey = "vth_dev_test"; const apiBase = "https://app.voicethere.dev/api/v1"; afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); }); it("lists projects with bearer auth", async () => { @@ -179,9 +206,11 @@ describe("VoicethereApi", () => { }); it("sends x-voicethere-org-id for personal user keys", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ projects: [] }), { status: 200 }), - ); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + new Response(JSON.stringify({ projects: [] }), { status: 200 }), + ); const api = new VoicethereApi("vthu_personal", apiBase, { orgId: "org-header", @@ -230,6 +259,7 @@ describe("VoicethereApi", () => { code: "NWRTC_UNAUTHORIZED", message: "Invalid API key", request_id: "req-1", + error_id: "err-uuid-1", }, }), { status: 401 }, @@ -243,9 +273,61 @@ describe("VoicethereApi", () => { code: "NWRTC_UNAUTHORIZED", message: "Invalid API key", requestId: "req-1", + errorId: "err-uuid-1", } satisfies Partial); }); + it("retries on gateway status then throws ApiError", async () => { + vi.useFakeTimers(); + const body = JSON.stringify({ + error: { + code: "GATEWAY", + message: "upstream unavailable", + request_id: "req-gw", + error_id: "err-gw", + }, + }); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockImplementation(() => + Promise.resolve(new Response(body, { status: 503 })), + ); + + const api = new VoicethereApi(apiKey, apiBase); + const assertion = expect(api.listProjects()).rejects.toMatchObject({ + name: "ApiError", + status: 503, + message: "upstream unavailable", + requestId: "req-gw", + errorId: "err-gw", + }); + + await vi.runAllTimersAsync(); + await assertion; + + expect(fetchMock).toHaveBeenCalledTimes(8); + vi.useRealTimers(); + }); + + it("retries on network error then succeeds", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValueOnce( + new Response(JSON.stringify({ projects: [] }), { status: 200 }), + ); + + const api = new VoicethereApi(apiKey, apiBase); + const promise = api.listProjects(); + await vi.runAllTimersAsync(); + const projects = await promise; + + expect(projects).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + it("uploads a build with optional message field", async () => { const bundleDir = join( tmpdir(), diff --git a/src/lib/api.ts b/src/lib/api.ts index 231748c..3e625a1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3,6 +3,11 @@ import { basename } from "node:path"; import { USER_ORG_ID_HEADER, isUserApiKeyToken } from "./auth-headers.js"; import { logApiBase, logVerbose } from "./command-log.js"; +import { + isRetryableHttpStatus, + RetryableHttpStatusError, + withHttpRetries, +} from "./http-retry.js"; import { formatTosNotAcceptedMessage, isTosNotAcceptedError, @@ -13,6 +18,7 @@ export interface ApiErrorBody { code?: string; message?: string; request_id?: string; + error_id?: string; }; } @@ -20,6 +26,7 @@ export class ApiError extends Error { readonly status: number; readonly code?: string; readonly requestId?: string; + readonly errorId?: string; constructor(status: number, message: string, body?: ApiErrorBody) { super(message); @@ -27,7 +34,54 @@ export class ApiError extends Error { this.status = status; this.code = body?.error?.code; this.requestId = body?.error?.request_id; + this.errorId = body?.error?.error_id; + } +} + +export function formatCliError(error: unknown): string { + if (error instanceof ApiError) { + const lines = [`Error: ${error.message}`]; + if (error.errorId) { + lines.push(`error_id: ${error.errorId}`); + } + if (error.requestId) { + lines.push(`request_id: ${error.requestId}`); + } + return lines.join("\n"); + } + if (error instanceof Error) { + return `Error: ${error.message}`; + } + return `Error: ${String(error)}`; +} + +function formatHttpRetryLog(error: unknown): string { + if (error instanceof RetryableHttpStatusError) { + return `HTTP ${error.status}`; + } + if (error instanceof Error) { + return error.message; } + return String(error); +} + +export function throwApiErrorFromResponse( + method: string, + pathname: string, + status: number, + text: string, +): void { + const payload = text.length > 0 ? (JSON.parse(text) as ApiErrorBody) : null; + const errorBody = + payload && typeof payload === "object" && "error" in payload + ? payload + : undefined; + const message = isTosNotAcceptedError(errorBody) + ? formatTosNotAcceptedMessage(errorBody, errorBody?.error?.message ?? "") + : (errorBody?.error?.message ?? + `Request failed: ${method} ${pathname} (${status})`); + logVerbose(`error: ${errorBody?.error?.code ?? "unknown"} — ${message}`); + throw new ApiError(status, message, errorBody); } export interface Project { @@ -519,10 +573,7 @@ export type CreateConversationExportBody = }; export type ConversationExportJobStatus = - | "queued" - | "active" - | "completed" - | "failed"; + "queued" | "active" | "completed" | "failed"; export interface ConversationExportJobProgress { conversations_total: number; @@ -1105,32 +1156,51 @@ export class VoicethereApi { logVerbose("request body: multipart/form-data (bundle upload)"); } - const started = performance.now(); - const response = await fetch(url, { method, headers, body }); - logVerbose( - `response: ${response.status} (${Math.round(performance.now() - started)}ms)`, - ); - const text = await response.text(); - const payload = - text.length > 0 ? (JSON.parse(text) as T | ApiErrorBody) : null; - - if (!response.ok) { - const errorBody = - payload && typeof payload === "object" && "error" in payload - ? (payload as ApiErrorBody) - : undefined; - const message = isTosNotAcceptedError(errorBody) - ? formatTosNotAcceptedMessage( - errorBody, - errorBody?.error?.message ?? "", - ) - : (errorBody?.error?.message ?? - `Request failed: ${method} ${url.pathname} (${response.status})`); - logVerbose(`error: ${errorBody?.error?.code ?? "unknown"} — ${message}`); - throw new ApiError(response.status, message, errorBody); - } - - return (payload ?? ({} as T)) as T; + return withHttpRetries( + async () => { + const started = performance.now(); + const response = await fetch(url, { method, headers, body }); + logVerbose( + `response: ${response.status} (${Math.round(performance.now() - started)}ms)`, + ); + const text = await response.text(); + + if (!response.ok && isRetryableHttpStatus(response.status)) { + throw new RetryableHttpStatusError(response.status, text); + } + + const payload = + text.length > 0 ? (JSON.parse(text) as T | ApiErrorBody) : null; + + if (!response.ok) { + throwApiErrorFromResponse( + method, + url.pathname, + response.status, + text, + ); + } + + return (payload ?? ({} as T)) as T; + }, + { + onRetry: ({ attempt, maxAttempts, delayMs, error }) => { + logVerbose( + `retrying after ${delayMs}ms (attempt ${attempt}/${maxAttempts}): ${formatHttpRetryLog(error)}`, + ); + }, + }, + ).catch((error: unknown) => { + if (error instanceof RetryableHttpStatusError) { + throwApiErrorFromResponse( + method, + url.pathname, + error.status, + error.bodyText, + ); + } + throw error; + }); } } diff --git a/src/lib/http-retry.test.ts b/src/lib/http-retry.test.ts new file mode 100644 index 0000000..c66e96c --- /dev/null +++ b/src/lib/http-retry.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from "vitest"; +import { + API_HTTP_RETRY_DELAYS_MS, + isRetryableHttpStatus, + isRetryableNetworkError, + RetryableHttpStatusError, + withHttpRetries, +} from "./http-retry.js"; + +describe("isRetryableHttpStatus", () => { + it("retries gateway statuses only", () => { + expect(isRetryableHttpStatus(502)).toBe(true); + expect(isRetryableHttpStatus(503)).toBe(true); + expect(isRetryableHttpStatus(504)).toBe(true); + expect(isRetryableHttpStatus(500)).toBe(false); + expect(isRetryableHttpStatus(401)).toBe(false); + expect(isRetryableHttpStatus(429)).toBe(false); + }); +}); + +describe("isRetryableNetworkError", () => { + it("detects TypeError and fetch failed", () => { + expect(isRetryableNetworkError(new TypeError("Failed to fetch"))).toBe( + true, + ); + expect(isRetryableNetworkError(new Error("fetch failed"))).toBe(true); + }); + + it("detects errno codes on error.cause", () => { + const err = new Error("fetch failed", { + cause: Object.assign(new Error("reset"), { code: "ECONNRESET" }), + }); + expect(isRetryableNetworkError(err)).toBe(true); + + const timeout = new Error("fetch failed", { + cause: { code: "ETIMEDOUT" }, + }); + expect(isRetryableNetworkError(timeout)).toBe(true); + }); + + it("detects undici UND_ERR_* on error.cause", () => { + const err = new Error("fetch failed", { + cause: { name: "UND_ERR_CONNECT_TIMEOUT" }, + }); + expect(isRetryableNetworkError(err)).toBe(true); + }); + + it("rejects non-retryable errors", () => { + expect(isRetryableNetworkError(new Error("bad request"))).toBe(false); + expect(isRetryableNetworkError("oops")).toBe(false); + }); +}); + +describe("withHttpRetries", () => { + it("uses the exact delay schedule", () => { + expect(API_HTTP_RETRY_DELAYS_MS).toEqual([ + 500, 1000, 2500, 5000, 15000, 30000, 60000, + ]); + }); + + it("succeeds after transient network failures", async () => { + const sleep = vi.fn(async () => {}); + const fn = vi + .fn() + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValue("ok"); + + const result = await withHttpRetries(fn, { sleep }); + + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(2); + expect(sleep.mock.calls.map((c) => c[0])).toEqual([500, 1000]); + }); + + it("gives up after the final attempt without an extra wait", async () => { + const sleep = vi.fn(async () => {}); + const networkError = new TypeError("fetch failed"); + const fn = vi.fn().mockRejectedValue(networkError); + + await expect(withHttpRetries(fn, { sleep })).rejects.toBe(networkError); + expect(fn).toHaveBeenCalledTimes(8); + expect(sleep).toHaveBeenCalledTimes(7); + expect(sleep.mock.calls.map((c) => c[0])).toEqual([ + ...API_HTTP_RETRY_DELAYS_MS, + ]); + }); + + it("does not retry non-retryable errors", async () => { + const sleep = vi.fn(async () => {}); + const appError = new Error("validation failed"); + const fn = vi.fn().mockRejectedValue(appError); + + await expect(withHttpRetries(fn, { sleep })).rejects.toBe(appError); + expect(fn).toHaveBeenCalledOnce(); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("retries RetryableHttpStatusError for gateway statuses", async () => { + const sleep = vi.fn(async () => {}); + const fn = vi + .fn() + .mockRejectedValueOnce(new RetryableHttpStatusError(503, "down")) + .mockResolvedValue("ok"); + + const result = await withHttpRetries(fn, { sleep }); + + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + expect(sleep.mock.calls[0]?.[0]).toBe(500); + }); + + it("invokes onRetry with attempt metadata", async () => { + const sleep = vi.fn(async () => {}); + const onRetry = vi.fn(); + const err = new RetryableHttpStatusError(502, ""); + + await withHttpRetries( + vi.fn().mockRejectedValueOnce(err).mockResolvedValue("ok"), + { sleep, onRetry }, + ); + + expect(onRetry).toHaveBeenCalledOnce(); + expect(onRetry.mock.calls[0][0]).toEqual({ + attempt: 1, + maxAttempts: 8, + delayMs: 500, + error: err, + }); + }); +}); diff --git a/src/lib/http-retry.ts b/src/lib/http-retry.ts new file mode 100644 index 0000000..eb4ceb9 --- /dev/null +++ b/src/lib/http-retry.ts @@ -0,0 +1,124 @@ +/** Fixed backoff between HTTP transport/gateway retries (7 waits + 1 initial attempt). */ +export const API_HTTP_RETRY_DELAYS_MS = [ + 500, 1000, 2500, 5000, 15000, 30000, 60000, +] as const; + +const RETRYABLE_ERRNO_CODES = new Set([ + "ECONNRESET", + "ETIMEDOUT", + "ECONNREFUSED", + "ENOTFOUND", +]); + +function errnoCode(error: unknown): string | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +function errorName(error: unknown): string | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + const name = (error as { name?: unknown }).name; + return typeof name === "string" ? name : undefined; +} + +export function isRetryableHttpStatus(status: number): boolean { + return status === 502 || status === 503 || status === 504; +} + +export function isRetryableNetworkError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false; + } + + if (err.name === "TypeError") { + return true; + } + + const message = err.message.toLowerCase(); + if (message.includes("fetch failed")) { + return true; + } + + const cause = err.cause; + const causeCode = errnoCode(cause); + if (causeCode && RETRYABLE_ERRNO_CODES.has(causeCode)) { + return true; + } + + const causeName = errorName(cause); + if (causeName?.startsWith("UND_ERR")) { + return true; + } + + return false; +} + +/** Thrown internally when a gateway status should be retried before surfacing ApiError. */ +export class RetryableHttpStatusError extends Error { + readonly status: number; + readonly bodyText: string; + + constructor(status: number, bodyText: string) { + super(`HTTP ${status}`); + this.name = "RetryableHttpStatusError"; + this.status = status; + this.bodyText = bodyText; + } +} + +function isRetryableHttpError(err: unknown): boolean { + return ( + err instanceof RetryableHttpStatusError && isRetryableHttpStatus(err.status) + ); +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function withHttpRetries( + fn: () => Promise, + options?: { + sleep?: (ms: number) => Promise; + onRetry?: (info: { + attempt: number; + maxAttempts: number; + delayMs: number; + error: unknown; + }) => void; + }, +): Promise { + const delays = API_HTTP_RETRY_DELAYS_MS; + const maxAttempts = delays.length + 1; + const sleep = options?.sleep ?? defaultSleep; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await fn(); + } catch (error: unknown) { + const retryable = + isRetryableNetworkError(error) || isRetryableHttpError(error); + if (!retryable || attempt >= maxAttempts - 1) { + throw error; + } + + const delayMs = delays[attempt] ?? delays.at(-1)!; + options?.onRetry?.({ + attempt: attempt + 1, + maxAttempts, + delayMs, + error, + }); + await sleep(delayMs); + } + } + + throw new Error( + "withHttpRetries: exhausted attempts without return or throw", + ); +} diff --git a/src/lib/user-api.test.ts b/src/lib/user-api.test.ts index a4815ba..076c762 100644 --- a/src/lib/user-api.test.ts +++ b/src/lib/user-api.test.ts @@ -16,6 +16,7 @@ describe("UserApi", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); }); it("sends Bearer user key and org header", async () => { @@ -51,4 +52,41 @@ describe("UserApi", () => { expect(init.headers.Cookie).toBe("session=legacy"); expect(init.headers.Authorization).toBeUndefined(); }); + + it("retries gateway failures before surfacing ApiError", async () => { + vi.useFakeTimers(); + const body = JSON.stringify({ + error: { + message: "gateway timeout", + request_id: "req-u", + error_id: "err-u", + }, + }); + fetchMock.mockImplementation(() => + Promise.resolve({ + ok: false, + status: 504, + text: async () => body, + }), + ); + + const api = new UserApi("https://app.voicethere.dev/api/v1", { + kind: "user_api_key", + token: "vthu_secret", + }); + + const assertion = expect(api.listOrgs()).rejects.toMatchObject({ + name: "ApiError", + status: 504, + message: "gateway timeout", + requestId: "req-u", + errorId: "err-u", + }); + + await vi.runAllTimersAsync(); + await assertion; + + expect(fetchMock).toHaveBeenCalledTimes(8); + vi.useRealTimers(); + }); }); diff --git a/src/lib/user-api.ts b/src/lib/user-api.ts index 74f616a..05ff5b9 100644 --- a/src/lib/user-api.ts +++ b/src/lib/user-api.ts @@ -1,10 +1,11 @@ -import { ApiError, type ApiErrorBody } from "./api.js"; +import { type ApiErrorBody, throwApiErrorFromResponse } from "./api.js"; +import { + isRetryableHttpStatus, + RetryableHttpStatusError, + withHttpRetries, +} from "./http-retry.js"; import { USER_ORG_ID_HEADER } from "./auth-headers.js"; import { logApiBase, logVerbose } from "./command-log.js"; -import { - formatTosNotAcceptedMessage, - isTosNotAcceptedError, -} from "./tos-gate.js"; import type { UserCommandAuth } from "./user-session.js"; export { USER_ORG_ID_HEADER }; @@ -161,32 +162,57 @@ export class UserApi { logVerbose(`request body: ${JSON.stringify(options.json)}`); } - const started = performance.now(); - const response = await fetch(url, { method, headers, body }); - logVerbose( - `response: ${response.status} (${Math.round(performance.now() - started)}ms)`, - ); - const text = await response.text(); - const payload = - text.length > 0 ? (JSON.parse(text) as T | ApiErrorBody) : null; - - if (!response.ok) { - const errorBody = - payload && typeof payload === "object" && "error" in payload - ? (payload as ApiErrorBody) - : undefined; - const message = isTosNotAcceptedError(errorBody) - ? formatTosNotAcceptedMessage( - errorBody, - errorBody?.error?.message ?? "", - ) - : (errorBody?.error?.message ?? - `Request failed: ${method} ${url.pathname} (${response.status})`); - logVerbose(`error: ${errorBody?.error?.code ?? "unknown"} — ${message}`); - throw new ApiError(response.status, message, errorBody); - } - - return (payload ?? ({} as T)) as T; + return withHttpRetries( + async () => { + const started = performance.now(); + const response = await fetch(url, { method, headers, body }); + logVerbose( + `response: ${response.status} (${Math.round(performance.now() - started)}ms)`, + ); + const text = await response.text(); + + if (!response.ok && isRetryableHttpStatus(response.status)) { + throw new RetryableHttpStatusError(response.status, text); + } + + const payload = + text.length > 0 ? (JSON.parse(text) as T | ApiErrorBody) : null; + + if (!response.ok) { + throwApiErrorFromResponse( + method, + url.pathname, + response.status, + text, + ); + } + + return (payload ?? ({} as T)) as T; + }, + { + onRetry: ({ attempt, maxAttempts, delayMs, error }) => { + const detail = + error instanceof RetryableHttpStatusError + ? `HTTP ${error.status}` + : error instanceof Error + ? error.message + : String(error); + logVerbose( + `retrying after ${delayMs}ms (attempt ${attempt}/${maxAttempts}): ${detail}`, + ); + }, + }, + ).catch((error: unknown) => { + if (error instanceof RetryableHttpStatusError) { + throwApiErrorFromResponse( + method, + url.pathname, + error.status, + error.bodyText, + ); + } + throw error; + }); } }