import type { SearchRequest, SearchResponse, SearchTarget } from "./types.ts"; const MAX_ATTEMPTS = 4; const BASE_RETRY_DELAY_MS = 200; export interface SearchClientOptions { signal?: AbortSignal; timeoutMs: number; maxAttempts?: number; fetchImpl?: typeof fetch; sleep?: (milliseconds: number, signal?: AbortSignal) => Promise; } function abortError(message: string): Error { const error = new Error(message); error.name = "AbortError"; return error; } async function defaultSleep(milliseconds: number, signal?: AbortSignal): Promise { if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : abortError("Web search cancelled"); await new Promise((resolve, reject) => { const timer = setTimeout(resolve, milliseconds); signal?.addEventListener( "abort", () => { clearTimeout(timer); reject(signal.reason instanceof Error ? signal.reason : abortError("Web search cancelled")); }, { once: true }, ); }); } function errorMessageFromBody(body: string): string { if (!body.trim()) return "empty response body"; try { const parsed = JSON.parse(body) as Record; const error = parsed.error; if (typeof error === "string") return error; if (typeof error === "object" && error !== null) { const message = (error as Record).message; if (typeof message === "string") return message; } if (typeof parsed.message === "string") return parsed.message; } catch { // Fall back to a bounded text preview. } return body.replace(/\s+/g, " ").slice(0, 500); } function parseSearchResponse(body: string): SearchResponse { let parsed: unknown; try { parsed = JSON.parse(body); } catch (error) { throw new Error(`Raw web search returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`); } if (typeof parsed !== "object" || parsed === null || typeof (parsed as { output?: unknown }).output !== "string") { throw new Error("Raw web search response is missing its text output"); } const value = parsed as { output: string; encrypted_output?: unknown }; return { output: value.output, encrypted_output: typeof value.encrypted_output === "string" ? value.encrypted_output : null, }; } function combinedSignal(signal: AbortSignal | undefined, timeoutMs: number): { signal: AbortSignal; dispose: () => void } { const timeout = new AbortController(); const timer = setTimeout(() => timeout.abort(abortError(`Web search timed out after ${timeoutMs}ms`)), timeoutMs); const signals = signal ? [signal, timeout.signal] : [timeout.signal]; return { signal: AbortSignal.any(signals), dispose: () => clearTimeout(timer), }; } export async function executeSearchRequest( target: SearchTarget, request: SearchRequest, options: SearchClientOptions, ): Promise { const fetchImpl = options.fetchImpl ?? fetch; const sleep = options.sleep ?? defaultSleep; const maxAttempts = options.maxAttempts ?? MAX_ATTEMPTS; if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > MAX_ATTEMPTS) { throw new Error(`maxAttempts must be an integer from 1 to ${MAX_ATTEMPTS}`); } const operation = combinedSignal(options.signal, options.timeoutMs); try { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { const response = await fetchImpl(target.endpoint, { method: "POST", headers: target.headers, body: JSON.stringify(request), signal: operation.signal, }); const body = await response.text(); if (response.ok) return parseSearchResponse(body); if (response.status >= 500 && attempt < maxAttempts) { await sleep(BASE_RETRY_DELAY_MS * 2 ** (attempt - 1), operation.signal); continue; } throw new Error(`Raw web search failed with HTTP ${response.status}: ${errorMessageFromBody(body)}`); } catch (error) { if (operation.signal.aborted) { throw operation.signal.reason instanceof Error ? operation.signal.reason : abortError("Web search cancelled"); } if (error instanceof Error && /^Raw web search failed with HTTP/.test(error.message)) throw error; if (attempt >= maxAttempts) { throw new Error(`Raw web search request failed after ${maxAttempts} attempt(s): ${error instanceof Error ? error.message : String(error)}`); } await sleep(BASE_RETRY_DELAY_MS * 2 ** (attempt - 1), operation.signal); } } throw new Error("Raw web search request exhausted retries"); } finally { operation.dispose(); } }