diff --git a/changelog.d/fixes/10735-search-provider-named-errors.md b/changelog.d/fixes/10735-search-provider-named-errors.md new file mode 100644 index 0000000000..0e82f36aa8 --- /dev/null +++ b/changelog.d/fixes/10735-search-provider-named-errors.md @@ -0,0 +1 @@ +- **fix(search):** name `/v1/search` 502s with provider id and sanitized Node cause code, without hostnames ([#10735](https://github.com/diegosouzapw/OmniRoute/issues/10735)) diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index 42974dc8fa..ca6221538a 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -31,6 +31,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { z } from "zod"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { resolveSearchProxy, executeProviderFetch } from "./search/searchProxy.ts"; +import { formatSearchProviderFailure } from "./search/providerFailure.ts"; export interface SearchResult { title: string; @@ -1177,11 +1178,7 @@ async function tryZaiMCPProvider( /* non-critical — logging must not block search response */ }); - return { - success: false, - status: isTimeout ? 504 : 502, - error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err.message)}`, - }; + return formatSearchProviderFailure(config.id, err, isTimeout); } } diff --git a/open-sse/handlers/search/providerFailure.ts b/open-sse/handlers/search/providerFailure.ts new file mode 100644 index 0000000000..e021c2fa80 --- /dev/null +++ b/open-sse/handlers/search/providerFailure.ts @@ -0,0 +1,26 @@ +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +export interface SearchProviderFailure { + success: false; + status: number; + error: string; +} + +/** Named 502/504 for /v1/search — provider id + sanitized cause, no hostnames/URLs. */ +export function formatSearchProviderFailure( + providerId: string, + err: unknown, + isTimeout: boolean +): SearchProviderFailure { + const rec = err && typeof err === "object" ? (err as Record) : {}; + const cause = rec.cause && typeof rec.cause === "object" ? (rec.cause as Record) : {}; + const code = + typeof cause.code === "string" && /^[A-Z][A-Z0-9_]{1,39}$/.test(cause.code) ? cause.code : ""; + const msg = + sanitizeErrorMessage(typeof rec.message === "string" ? rec.message : "fetch failed") || "fetch failed"; + return { + success: false, + status: isTimeout ? 504 : 502, + error: `Search provider ${providerId} ${isTimeout ? "timeout" : "error"}: ${code ? `${msg} (cause: ${code})` : msg}`, + }; +} diff --git a/open-sse/handlers/search/searchProxy.ts b/open-sse/handlers/search/searchProxy.ts index f134b4a3bd..75faea4db7 100644 --- a/open-sse/handlers/search/searchProxy.ts +++ b/open-sse/handlers/search/searchProxy.ts @@ -10,6 +10,7 @@ import { saveCallLog } from "@/lib/usageDb"; import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { formatSearchProviderFailure } from "./providerFailure.ts"; import type { SearchProviderConfig } from "../../config/searchRegistry.ts"; import type { SearchResult } from "../search.ts"; @@ -231,15 +232,12 @@ export async function executeProviderFetch(p: ExecuteProviderFetchParams): Promi clearTimeout(timer); const error = err instanceof Error ? err : new Error(String(err)); const isTimeout = error.name === "AbortError"; + const safeMsg = sanitizeErrorMessage(error.message) || "fetch failed"; if (log) { - log.error("SEARCH", `${config.id} ${isTimeout ? "timeout" : "fetch error"}: ${error.message}`); + log.error("SEARCH", `${config.id} ${isTimeout ? "timeout" : "fetch error"}: ${safeMsg}`); } - logCall({ status: isTimeout ? 504 : 502, duration: Date.now() - startTime, error: error.message }); + logCall({ status: isTimeout ? 504 : 502, duration: Date.now() - startTime, error: safeMsg }); await emitEvent(isTimeout ? "timeout" : "error"); - return { - success: false, - status: isTimeout ? 504 : 502, - error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(error.message)}`, - }; + return formatSearchProviderFailure(config.id, error, isTimeout); } } diff --git a/tests/unit/search-provider-named-errors.test.ts b/tests/unit/search-provider-named-errors.test.ts new file mode 100644 index 0000000000..53fb5af624 --- /dev/null +++ b/tests/unit/search-provider-named-errors.test.ts @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-search-named-errors-")); + +const { handleSearch } = await import("../../open-sse/handlers/search.ts"); +const { formatSearchProviderFailure } = await import( + "../../open-sse/handlers/search/providerFailure.ts" +); + +test("formatSearchProviderFailure names the provider and sanitized Node cause", () => { + const err = new TypeError("fetch failed"); + (err as Error & { cause?: { code: string; address: string } }).cause = { + code: "ENETUNREACH", + address: "203.0.113.10", + }; + + const result = formatSearchProviderFailure("serper-search", err, false); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.equal( + result.error, + "Search provider serper-search error: fetch failed (cause: ENETUNREACH)" + ); + assert.equal(result.error.includes("203.0.113"), false); +}); + +test("formatSearchProviderFailure omits non-Node cause codes", () => { + const err = new TypeError("fetch failed"); + (err as Error & { cause?: { code: string } }).cause = { code: "not-a-node-code" }; + + const result = formatSearchProviderFailure("brave-search", err, false); + + assert.equal(result.status, 502); + assert.equal(result.error, "Search provider brave-search error: fetch failed"); +}); + +test("handleSearch names the provider and sanitized cause on fetch failed 502", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + const err = new TypeError("fetch failed"); + (err as Error & { cause?: { code: string; address: string } }).cause = { + code: "ENETUNREACH", + address: "203.0.113.10", + }; + throw err; + }; + + try { + const result = await handleSearch({ + query: "named provider 502", + provider: "serper-search", + maxResults: 5, + searchType: "web", + credentials: { apiKey: "test-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.equal( + result.error, + "Search provider serper-search error: fetch failed (cause: ENETUNREACH)" + ); + assert.equal(String(result.error).includes("203.0.113"), false); + } finally { + globalThis.fetch = originalFetch; + } +});