From 70f5c26a71ec001ce9c249509fa4be1cf9bb50df Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 19:37:09 +0100 Subject: [PATCH] refactor: move provider HTTP errors out of tts --- src/agents/provider-http-errors.test.ts | 37 +++++ src/agents/provider-http-errors.ts | 152 ++++++++++++++++++ src/plugin-sdk/provider-http.ts | 2 +- src/plugin-sdk/speech-core.ts | 2 +- src/plugin-sdk/speech.ts | 2 +- .../speech-core.ts | 2 +- 6 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 src/agents/provider-http-errors.test.ts create mode 100644 src/agents/provider-http-errors.ts diff --git a/src/agents/provider-http-errors.test.ts b/src/agents/provider-http-errors.test.ts new file mode 100644 index 00000000000..422d4d4e7ad --- /dev/null +++ b/src/agents/provider-http-errors.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + assertOkOrThrowProviderError, + extractProviderErrorDetail, + extractProviderRequestId, +} from "./provider-http-errors.js"; + +describe("provider error utils", () => { + it("formats nested provider error details with request ids", async () => { + const response = new Response( + JSON.stringify({ + detail: { + message: "Quota exceeded", + status: "quota_exceeded", + }, + }), + { + status: 429, + headers: { "x-request-id": "req_123" }, + }, + ); + + await expect(assertOkOrThrowProviderError(response, "Provider API error")).rejects.toThrow( + "Provider API error (429): Quota exceeded [code=quota_exceeded] [request_id=req_123]", + ); + }); + + it("reads string error fields and fallback request id headers", async () => { + const response = new Response(JSON.stringify({ error: "Invalid API key" }), { + status: 401, + headers: { "request-id": "fallback_req" }, + }); + + expect(await extractProviderErrorDetail(response)).toBe("Invalid API key"); + expect(extractProviderRequestId(response)).toBe("fallback_req"); + }); +}); diff --git a/src/agents/provider-http-errors.ts b/src/agents/provider-http-errors.ts new file mode 100644 index 00000000000..5d99dc453e7 --- /dev/null +++ b/src/agents/provider-http-errors.ts @@ -0,0 +1,152 @@ +export { asFiniteNumber } from "../shared/number-coercion.js"; +import { normalizeOptionalString as trimToUndefined } from "../shared/string-coerce.js"; +export { normalizeOptionalString as trimToUndefined } from "../shared/string-coerce.js"; + +export function asBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +export function asObject(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export function truncateErrorDetail(detail: string, limit = 220): string { + return detail.length <= limit ? detail : `${detail.slice(0, limit - 1)}…`; +} + +export async function readResponseTextLimited( + response: Response, + limitBytes = 16 * 1024, +): Promise { + if (limitBytes <= 0) { + return ""; + } + const reader = response.body?.getReader(); + if (!reader) { + return ""; + } + + const decoder = new TextDecoder(); + let total = 0; + let text = ""; + let reachedLimit = false; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + if (!value || value.byteLength === 0) { + continue; + } + const remaining = limitBytes - total; + if (remaining <= 0) { + reachedLimit = true; + break; + } + const chunk = value.byteLength > remaining ? value.subarray(0, remaining) : value; + total += chunk.byteLength; + text += decoder.decode(chunk, { stream: true }); + if (total >= limitBytes) { + reachedLimit = true; + break; + } + } + text += decoder.decode(); + } finally { + if (reachedLimit) { + await reader.cancel().catch(() => {}); + } + } + + return text; +} + +export function formatProviderErrorPayload(payload: unknown): string | undefined { + const root = asObject(payload); + const detailObject = asObject(root?.detail); + const subject = asObject(root?.error) ?? detailObject ?? root; + if (!subject) { + return undefined; + } + const message = + trimToUndefined(subject.message) ?? + trimToUndefined(subject.detail) ?? + trimToUndefined(root?.message) ?? + trimToUndefined(root?.error) ?? + trimToUndefined(root?.detail); + const type = trimToUndefined(subject.type); + const code = trimToUndefined(subject.code) ?? trimToUndefined(subject.status); + const metadata = [type ? `type=${type}` : undefined, code ? `code=${code}` : undefined] + .filter((value): value is string => Boolean(value)) + .join(", "); + if (message && metadata) { + return `${truncateErrorDetail(message)} [${metadata}]`; + } + if (message) { + return truncateErrorDetail(message); + } + if (metadata) { + return `[${metadata}]`; + } + return undefined; +} + +export async function extractProviderErrorDetail(response: Response): Promise { + const rawBody = trimToUndefined(await readResponseTextLimited(response)); + if (!rawBody) { + return undefined; + } + try { + return formatProviderErrorPayload(JSON.parse(rawBody)) ?? truncateErrorDetail(rawBody); + } catch { + return truncateErrorDetail(rawBody); + } +} + +export function extractProviderRequestId(response: Response): string | undefined { + return ( + trimToUndefined(response.headers.get("x-request-id")) ?? + trimToUndefined(response.headers.get("request-id")) + ); +} + +export function formatProviderHttpErrorMessage(params: { + label: string; + status: number; + detail?: string; + requestId?: string; +}): string { + const { label, status, detail, requestId } = params; + return ( + `${label} (${status})` + + (detail ? `: ${detail}` : "") + + (requestId ? ` [request_id=${requestId}]` : "") + ); +} + +export async function createProviderHttpError(response: Response, label: string): Promise { + const detail = await extractProviderErrorDetail(response); + const requestId = extractProviderRequestId(response); + return new Error( + formatProviderHttpErrorMessage({ + label, + status: response.status, + detail, + requestId, + }), + ); +} + +export async function assertOkOrThrowProviderError( + response: Response, + label: string, +): Promise { + if (response.ok) { + return; + } + throw await createProviderHttpError(response, label); +} diff --git a/src/plugin-sdk/provider-http.ts b/src/plugin-sdk/provider-http.ts index a6453228183..b897ce0f20c 100644 --- a/src/plugin-sdk/provider-http.ts +++ b/src/plugin-sdk/provider-http.ts @@ -10,7 +10,7 @@ export { formatProviderHttpErrorMessage, readResponseTextLimited, truncateErrorDetail, -} from "../tts/provider-error-utils.js"; +} from "../agents/provider-http-errors.js"; export { assertOkOrThrowHttpError, buildAudioTranscriptionFormData, diff --git a/src/plugin-sdk/speech-core.ts b/src/plugin-sdk/speech-core.ts index 3291c4b12b3..1da45f6aa25 100644 --- a/src/plugin-sdk/speech-core.ts +++ b/src/plugin-sdk/speech-core.ts @@ -49,4 +49,4 @@ export { readResponseTextLimited, trimToUndefined, truncateErrorDetail, -} from "../tts/provider-error-utils.js"; +} from "../agents/provider-http-errors.js"; diff --git a/src/plugin-sdk/speech.ts b/src/plugin-sdk/speech.ts index 548254a0860..504607d0120 100644 --- a/src/plugin-sdk/speech.ts +++ b/src/plugin-sdk/speech.ts @@ -44,7 +44,7 @@ export { readResponseTextLimited, trimToUndefined, truncateErrorDetail, -} from "../tts/provider-error-utils.js"; +} from "../agents/provider-http-errors.js"; export { normalizeApplyTextNormalization, normalizeLanguageCode, diff --git a/src/plugins/capability-runtime-vitest-shims/speech-core.ts b/src/plugins/capability-runtime-vitest-shims/speech-core.ts index d70b73a9668..411668956f4 100644 --- a/src/plugins/capability-runtime-vitest-shims/speech-core.ts +++ b/src/plugins/capability-runtime-vitest-shims/speech-core.ts @@ -31,7 +31,7 @@ export { readResponseTextLimited, trimToUndefined, truncateErrorDetail, -} from "../../tts/provider-error-utils.js"; +} from "../../agents/provider-http-errors.js"; export async function summarizeText(): Promise { throw new Error("summarizeText is unavailable in the Vitest capability contract shim");