fix(exa): bound search error bodies

This commit is contained in:
Vincent Koc
2026-06-19 18:52:09 +02:00
parent 6037d1a85c
commit 1e53ee4fd5
2 changed files with 47 additions and 2 deletions
@@ -1,5 +1,6 @@
// Exa provider module implements model/runtime integration.
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
@@ -28,6 +29,7 @@ const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const;
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
const EXA_MAX_SEARCH_COUNT = 100;
const EXA_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type ExaConfig = {
apiKey?: string;
@@ -76,6 +78,10 @@ async function readExaSearchResults(response: Response): Promise<ExaSearchResult
}
}
async function readExaErrorDetail(response: Response): Promise<string> {
return await readResponseTextLimited(response, EXA_ERROR_BODY_LIMIT_BYTES);
}
function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined {
const trimmed = normalizeOptionalLowercaseString(value);
if (!trimmed) {
@@ -407,7 +413,7 @@ async function runExaSearch(params: {
},
async (res) => {
if (!res.ok) {
const detail = await res.text();
const detail = await readExaErrorDetail(res);
throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`);
}
return readExaSearchResults(res);
@@ -607,6 +613,7 @@ export const testing = {
resolveExaSearchCount,
resolveExaSearchEndpoint,
resolveFreshnessStartDate,
readExaErrorDetail,
readExaSearchResults,
} as const;
export { testing as __testing };
@@ -1,9 +1,31 @@
// Exa tests cover exa web search provider plugin behavior.
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { testing } from "../test-api.js";
import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js";
import { createExaWebSearchProvider } from "./exa-web-search-provider.js";
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("exa web search provider", () => {
it("exposes the expected metadata and selection wiring", () => {
const provider = createExaWebSearchProvider();
@@ -242,4 +264,20 @@ describe("exa web search provider", () => {
"Exa API returned malformed JSON",
);
});
it("bounds Exa API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"exa upstream unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const detail = await testing.readExaErrorDetail(tracked.response);
expect(detail).toContain("exa upstream unavailable");
expect(detail).not.toContain("tail");
expect(await testing.readExaErrorDetail(new Response("short"))).toBe("short");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});