Skip to content

Commit 70f5c26

Browse files
committed
refactor: move provider HTTP errors out of tts
1 parent bc0f54b commit 70f5c26

6 files changed

Lines changed: 193 additions & 4 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
assertOkOrThrowProviderError,
4+
extractProviderErrorDetail,
5+
extractProviderRequestId,
6+
} from "./provider-http-errors.js";
7+
8+
describe("provider error utils", () => {
9+
it("formats nested provider error details with request ids", async () => {
10+
const response = new Response(
11+
JSON.stringify({
12+
detail: {
13+
message: "Quota exceeded",
14+
status: "quota_exceeded",
15+
},
16+
}),
17+
{
18+
status: 429,
19+
headers: { "x-request-id": "req_123" },
20+
},
21+
);
22+
23+
await expect(assertOkOrThrowProviderError(response, "Provider API error")).rejects.toThrow(
24+
"Provider API error (429): Quota exceeded [code=quota_exceeded] [request_id=req_123]",
25+
);
26+
});
27+
28+
it("reads string error fields and fallback request id headers", async () => {
29+
const response = new Response(JSON.stringify({ error: "Invalid API key" }), {
30+
status: 401,
31+
headers: { "request-id": "fallback_req" },
32+
});
33+
34+
expect(await extractProviderErrorDetail(response)).toBe("Invalid API key");
35+
expect(extractProviderRequestId(response)).toBe("fallback_req");
36+
});
37+
});

src/agents/provider-http-errors.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
export { asFiniteNumber } from "../shared/number-coercion.js";
2+
import { normalizeOptionalString as trimToUndefined } from "../shared/string-coerce.js";
3+
export { normalizeOptionalString as trimToUndefined } from "../shared/string-coerce.js";
4+
5+
export function asBoolean(value: unknown): boolean | undefined {
6+
return typeof value === "boolean" ? value : undefined;
7+
}
8+
9+
export function asObject(value: unknown): Record<string, unknown> | undefined {
10+
return typeof value === "object" && value !== null && !Array.isArray(value)
11+
? (value as Record<string, unknown>)
12+
: undefined;
13+
}
14+
15+
export function truncateErrorDetail(detail: string, limit = 220): string {
16+
return detail.length <= limit ? detail : `${detail.slice(0, limit - 1)}…`;
17+
}
18+
19+
export async function readResponseTextLimited(
20+
response: Response,
21+
limitBytes = 16 * 1024,
22+
): Promise<string> {
23+
if (limitBytes <= 0) {
24+
return "";
25+
}
26+
const reader = response.body?.getReader();
27+
if (!reader) {
28+
return "";
29+
}
30+
31+
const decoder = new TextDecoder();
32+
let total = 0;
33+
let text = "";
34+
let reachedLimit = false;
35+
36+
try {
37+
while (true) {
38+
const { value, done } = await reader.read();
39+
if (done) {
40+
break;
41+
}
42+
if (!value || value.byteLength === 0) {
43+
continue;
44+
}
45+
const remaining = limitBytes - total;
46+
if (remaining <= 0) {
47+
reachedLimit = true;
48+
break;
49+
}
50+
const chunk = value.byteLength > remaining ? value.subarray(0, remaining) : value;
51+
total += chunk.byteLength;
52+
text += decoder.decode(chunk, { stream: true });
53+
if (total >= limitBytes) {
54+
reachedLimit = true;
55+
break;
56+
}
57+
}
58+
text += decoder.decode();
59+
} finally {
60+
if (reachedLimit) {
61+
await reader.cancel().catch(() => {});
62+
}
63+
}
64+
65+
return text;
66+
}
67+
68+
export function formatProviderErrorPayload(payload: unknown): string | undefined {
69+
const root = asObject(payload);
70+
const detailObject = asObject(root?.detail);
71+
const subject = asObject(root?.error) ?? detailObject ?? root;
72+
if (!subject) {
73+
return undefined;
74+
}
75+
const message =
76+
trimToUndefined(subject.message) ??
77+
trimToUndefined(subject.detail) ??
78+
trimToUndefined(root?.message) ??
79+
trimToUndefined(root?.error) ??
80+
trimToUndefined(root?.detail);
81+
const type = trimToUndefined(subject.type);
82+
const code = trimToUndefined(subject.code) ?? trimToUndefined(subject.status);
83+
const metadata = [type ? `type=${type}` : undefined, code ? `code=${code}` : undefined]
84+
.filter((value): value is string => Boolean(value))
85+
.join(", ");
86+
if (message && metadata) {
87+
return `${truncateErrorDetail(message)} [${metadata}]`;
88+
}
89+
if (message) {
90+
return truncateErrorDetail(message);
91+
}
92+
if (metadata) {
93+
return `[${metadata}]`;
94+
}
95+
return undefined;
96+
}
97+
98+
export async function extractProviderErrorDetail(response: Response): Promise<string | undefined> {
99+
const rawBody = trimToUndefined(await readResponseTextLimited(response));
100+
if (!rawBody) {
101+
return undefined;
102+
}
103+
try {
104+
return formatProviderErrorPayload(JSON.parse(rawBody)) ?? truncateErrorDetail(rawBody);
105+
} catch {
106+
return truncateErrorDetail(rawBody);
107+
}
108+
}
109+
110+
export function extractProviderRequestId(response: Response): string | undefined {
111+
return (
112+
trimToUndefined(response.headers.get("x-request-id")) ??
113+
trimToUndefined(response.headers.get("request-id"))
114+
);
115+
}
116+
117+
export function formatProviderHttpErrorMessage(params: {
118+
label: string;
119+
status: number;
120+
detail?: string;
121+
requestId?: string;
122+
}): string {
123+
const { label, status, detail, requestId } = params;
124+
return (
125+
`${label} (${status})` +
126+
(detail ? `: ${detail}` : "") +
127+
(requestId ? ` [request_id=${requestId}]` : "")
128+
);
129+
}
130+
131+
export async function createProviderHttpError(response: Response, label: string): Promise<Error> {
132+
const detail = await extractProviderErrorDetail(response);
133+
const requestId = extractProviderRequestId(response);
134+
return new Error(
135+
formatProviderHttpErrorMessage({
136+
label,
137+
status: response.status,
138+
detail,
139+
requestId,
140+
}),
141+
);
142+
}
143+
144+
export async function assertOkOrThrowProviderError(
145+
response: Response,
146+
label: string,
147+
): Promise<void> {
148+
if (response.ok) {
149+
return;
150+
}
151+
throw await createProviderHttpError(response, label);
152+
}

src/plugin-sdk/provider-http.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export {
1010
formatProviderHttpErrorMessage,
1111
readResponseTextLimited,
1212
truncateErrorDetail,
13-
} from "../tts/provider-error-utils.js";
13+
} from "../agents/provider-http-errors.js";
1414
export {
1515
assertOkOrThrowHttpError,
1616
buildAudioTranscriptionFormData,

src/plugin-sdk/speech-core.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,4 @@ export {
4949
readResponseTextLimited,
5050
trimToUndefined,
5151
truncateErrorDetail,
52-
} from "../tts/provider-error-utils.js";
52+
} from "../agents/provider-http-errors.js";

src/plugin-sdk/speech.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export {
4444
readResponseTextLimited,
4545
trimToUndefined,
4646
truncateErrorDetail,
47-
} from "../tts/provider-error-utils.js";
47+
} from "../agents/provider-http-errors.js";
4848
export {
4949
normalizeApplyTextNormalization,
5050
normalizeLanguageCode,

src/plugins/capability-runtime-vitest-shims/speech-core.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export {
3131
readResponseTextLimited,
3232
trimToUndefined,
3333
truncateErrorDetail,
34-
} from "../../tts/provider-error-utils.js";
34+
} from "../../agents/provider-http-errors.js";
3535

3636
export async function summarizeText(): Promise<never> {
3737
throw new Error("summarizeText is unavailable in the Vitest capability contract shim");

0 commit comments

Comments
 (0)