fix(vydra): preserve errors for broken response bodies (#110478)

Co-authored-by: Pick-cat <huang.ting3@xydigit.com>
This commit is contained in:
Peter Steinberger
2026-07-18 07:59:17 +01:00
committed by GitHub
co-authored by Pick-cat
parent 3659642ac7
commit 1f160696dc
2 changed files with 54 additions and 8 deletions
+28
View File
@@ -142,6 +142,34 @@ describe("downloadVydraAsset", () => {
expect(result).toMatchObject({ name: "ProviderHttpError", status: 304, statusCode: 304 });
});
it("preserves HTTP metadata when the error body stream fails", async () => {
const result = await downloadVydraAsset({
url: "https://cdn.vydra.example/generated/test.png",
kind: "image",
timeoutMs: 250,
fetchFn: async () =>
new Response(
new ReadableStream({
start(controller) {
controller.error(new Error("broken error body"));
},
}),
{
status: 502,
headers: { "x-request-id": "req-vydra-broken-body" },
},
),
maxBytes: 1024 * 1024,
}).catch((error: unknown) => error);
expect(result).toMatchObject({
name: "ProviderHttpError",
status: 502,
statusCode: 502,
requestId: "req-vydra-broken-body",
});
});
it("does not bound a dripping body when only chunk idle timeout is used", async () => {
// Negative control: chunkTimeoutMs resets on every drip, so idle alone never fires.
server = http.createServer((_req, res) => {
+26 -8
View File
@@ -38,6 +38,8 @@ type VydraAuthStore = Parameters<typeof resolveApiKeyForProvider>[0]["store"];
type VydraMediaKind = "audio" | "image" | "video";
class VydraDownloadBodyTimeoutError extends Error {}
type VydraJobPayload = {
id?: string;
jobId?: string;
@@ -246,9 +248,13 @@ function resolveVydraDownloadBodyTimeout(params: {
chunkTimeoutMs: params.timeoutMs,
timeoutMs: Math.max(1, params.deadlineMs - Date.now()),
onIdleTimeout: ({ chunkTimeoutMs }: { chunkTimeoutMs: number }) =>
new Error(`Vydra ${params.kind} download stalled after ${chunkTimeoutMs}ms`),
new VydraDownloadBodyTimeoutError(
`Vydra ${params.kind} download stalled after ${chunkTimeoutMs}ms`,
),
onTimeout: () =>
new Error(`Vydra ${params.kind} download timed out after ${params.timeoutMs}ms`),
new VydraDownloadBodyTimeoutError(
`Vydra ${params.kind} download timed out after ${params.timeoutMs}ms`,
),
};
}
@@ -267,15 +273,27 @@ export async function downloadVydraAsset(params: {
if (!response.ok) {
// Shared assertOkOrThrowHttpError reads error detail without a wall-clock bound; a dripping
// non-2xx body would hang before the success-path reader runs.
const prefix = await readResponseTextPrefix(
response,
16 * 1024,
resolveVydraDownloadBodyTimeout({ kind: params.kind, timeoutMs, deadlineMs }),
);
let errorBody: string | null = null;
try {
errorBody =
(
await readResponseTextPrefix(
response,
16 * 1024,
resolveVydraDownloadBodyTimeout({ kind: params.kind, timeoutMs, deadlineMs }),
)
).text || null;
} catch (error) {
if (error instanceof VydraDownloadBodyTimeoutError) {
throw error;
}
// The shared formatter historically ignored unreadable error bodies. Preserve the known
// HTTP status and request id even when the provider closes a failed response mid-stream.
}
// Re-run the bounded body through the shared formatter so provider error metadata and
// sensitive-text redaction remain identical to the previous assertOkOrThrowHttpError path.
throw await createProviderHttpError(
new Response(prefix.text || null, {
new Response(errorBody, {
headers: response.headers,
status: response.status,
statusText: response.statusText,