


























1+// Voyage batch tests cover bounded status/error response reads.
2+import { describe, expect, it } from "vitest";
3+import type { VoyageEmbeddingClient } from "./embedding-provider.js";
4+import { testing } from "./embedding-batch.js";
5+6+const { fetchVoyageBatchStatus, readVoyageBatchError, VOYAGE_BATCH_RESPONSE_MAX_BYTES } = testing;
7+8+function buildClient(): VoyageEmbeddingClient {
9+return {
10+baseUrl: "https://api.voyageai.test/v1",
11+headers: { authorization: "Bearer test" },
12+model: "voyage-3",
13+};
14+}
15+16+/**
17+ * Build deps whose withRemoteHttpResponse drives the real onResponse against a
18+ * caller-provided Response, so the bounded readers run exactly as in production.
19+ */
20+function buildDeps(response: Response): Parameters<typeof fetchVoyageBatchStatus>[0]["deps"] {
21+return {
22+now: () => 0,
23+sleep: async () => {},
24+postJsonWithRetry: (async () => {
25+throw new Error("postJsonWithRetry should not be called in these tests");
26+}) as never,
27+uploadBatchJsonlFile: (async () => {
28+throw new Error("uploadBatchJsonlFile should not be called in these tests");
29+}) as never,
30+withRemoteHttpResponse: (async (params: { onResponse: (res: Response) => Promise<unknown> }) =>
31+await params.onResponse(response)) as never,
32+};
33+}
34+35+/**
36+ * A streaming JSON-ish body that proves an oversized response stops being read
37+ * before the whole advertised payload is buffered into memory. getReadCount
38+ * reports how many chunks were pulled; cancel() flips wasCanceled.
39+ */
40+function streamingResponse(params: { chunkCount: number; chunkSize: number; status?: number }): {
41+response: Response;
42+getReadCount: () => number;
43+wasCanceled: () => boolean;
44+} {
45+let reads = 0;
46+let canceled = false;
47+const encoder = new TextEncoder();
48+const stream = new ReadableStream<Uint8Array>({
49+pull(controller) {
50+if (reads >= params.chunkCount) {
51+controller.close();
52+return;
53+}
54+reads += 1;
55+controller.enqueue(encoder.encode("a".repeat(params.chunkSize)));
56+},
57+cancel() {
58+canceled = true;
59+},
60+});
61+return {
62+response: new Response(stream, {
63+status: params.status ?? 200,
64+headers: { "content-type": "application/json" },
65+}),
66+getReadCount: () => reads,
67+wasCanceled: () => canceled,
68+};
69+}
70+71+describe("voyage batch bounded reads", () => {
72+it("uses a 16 MiB cap for batch status/error responses", () => {
73+expect(VOYAGE_BATCH_RESPONSE_MAX_BYTES).toBe(16 * 1024 * 1024);
74+});
75+76+it("parses a well-formed batch status response under the byte cap", async () => {
77+const response = new Response(JSON.stringify({ id: "batch_1", status: "completed" }), {
78+status: 200,
79+headers: { "content-type": "application/json" },
80+});
81+82+const status = await fetchVoyageBatchStatus({
83+client: buildClient(),
84+batchId: "batch_1",
85+deps: buildDeps(response),
86+});
87+88+expect(status).toEqual({ id: "batch_1", status: "completed" });
89+});
90+91+it("caps an oversized batch status stream instead of buffering the whole body", async () => {
92+const streamed = streamingResponse({ chunkCount: 64, chunkSize: 1024 });
93+94+await expect(
95+fetchVoyageBatchStatus({
96+client: buildClient(),
97+batchId: "batch_1",
98+deps: buildDeps(streamed.response),
99+maxResponseBytes: 4096,
100+}),
101+).rejects.toThrow(/voyage-batch-status: JSON response exceeds 4096 bytes/);
102+103+// Stream was cancelled mid-flight: fewer chunks read than the full payload.
104+expect(streamed.getReadCount()).toBeLessThan(64);
105+expect(streamed.wasCanceled()).toBe(true);
106+});
107+108+it("preserves the full NDJSON parse chain for an under-cap error file", async () => {
109+// Multi-line NDJSON with a blank line proves the bounded read does not
110+// disturb the original trim/split("\n")/JSON.parse/extractBatchErrorMessage
111+// pipeline: the first useful error message is still extracted byte-for-byte
112+// identically to the pre-change `await res.text()` path.
113+const body = [
114+JSON.stringify({ custom_id: "req-0", response: { status_code: 200 } }),
115+"",
116+JSON.stringify({ custom_id: "req-1", error: { message: "voyage upstream rejected" } }),
117+JSON.stringify({ custom_id: "req-2", error: { message: "second error ignored" } }),
118+"",
119+].join("\n");
120+const response = new Response(body, {
121+status: 200,
122+headers: { "content-type": "application/x-ndjson" },
123+});
124+125+const message = await readVoyageBatchError({
126+client: buildClient(),
127+errorFileId: "file_1",
128+deps: buildDeps(response),
129+});
130+131+// extractBatchErrorMessage returns the first line carrying a message, so the
132+// success line is skipped and the second error is not surfaced.
133+expect(message).toBe("voyage upstream rejected");
134+});
135+136+it("returns undefined for an empty error file via the original empty-body branch", async () => {
137+// Whitespace-only body must still hit the `!text.trim()` short-circuit after
138+// decoding the bounded buffer, returning undefined exactly as before.
139+const response = new Response(" \n", {
140+status: 200,
141+headers: { "content-type": "application/x-ndjson" },
142+});
143+144+const message = await readVoyageBatchError({
145+client: buildClient(),
146+errorFileId: "file_1",
147+deps: buildDeps(response),
148+});
149+150+expect(message).toBeUndefined();
151+});
152+153+it("fail-softs an oversized error file into formatUnavailableBatchError by design", async () => {
154+const streamed = streamingResponse({ chunkCount: 64, chunkSize: 1024 });
155+156+// Intended behavior: an over-cap error file must NOT throw out of
157+// readVoyageBatchError. An unbounded error body would otherwise OOM the
158+// worker, so the bounded overflow error is caught and degraded into a
159+// diagnostic string via formatUnavailableBatchError. We accept the lost
160+// detail; the overflow message names the cap so the truncation is visible.
161+const readError = async () =>
162+await readVoyageBatchError({
163+client: buildClient(),
164+errorFileId: "file_1",
165+deps: buildDeps(streamed.response),
166+maxResponseBytes: 4096,
167+});
168+169+await expect(readError()).resolves.toMatch(
170+/error file unavailable: voyage batch error file content exceeds 4096 bytes/,
171+);
172+173+// The bounded reader still cancels the stream mid-flight rather than
174+// buffering the whole advertised payload before failing soft.
175+expect(streamed.getReadCount()).toBeLessThan(64);
176+expect(streamed.wasCanceled()).toBe(true);
177+});
178+179+it("caps an oversized non-OK (error) diagnostic body instead of buffering it whole", async () => {
180+// Regression for the non-OK gap: `assertVoyageResponseOk` previously read the
181+// 4xx/5xx diagnostic body with an unbounded `await res.text()`. A hostile
182+// endpoint can return a 500 with a never-ending body, so that read must be
183+// bounded too. Drive a streaming 500 through the real status path and assert
184+// the bounded overflow error fires and the stream is cancelled mid-flight.
185+const streamed = streamingResponse({ chunkCount: 64, chunkSize: 1024, status: 500 });
186+187+await expect(
188+fetchVoyageBatchStatus({
189+client: buildClient(),
190+batchId: "batch_1",
191+deps: buildDeps(streamed.response),
192+maxResponseBytes: 4096,
193+}),
194+).rejects.toThrow(/voyage batch status failed: 500 \(error body exceeds 4096 bytes\)/);
195+196+// Stream was cancelled mid-flight rather than draining the whole body.
197+expect(streamed.getReadCount()).toBeLessThan(64);
198+expect(streamed.wasCanceled()).toBe(true);
199+});
200+201+it("preserves the diagnostic shape for a small non-OK (error) body", async () => {
202+// Under-cap non-OK body must still surface the original
203+// `${context}: ${status} ${text}` diagnostic byte-for-byte.
204+const response = new Response("voyage upstream is down", {
205+status: 503,
206+headers: { "content-type": "text/plain" },
207+});
208+209+await expect(
210+fetchVoyageBatchStatus({
211+client: buildClient(),
212+batchId: "batch_1",
213+deps: buildDeps(response),
214+}),
215+).rejects.toThrow(/voyage batch status failed: 503 voyage upstream is down/);
216+});
217+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。