fix(search): name /v1/search 502 provider and cause (#10756)

Merged — validated together with a batch of related RaviTharuma PRs in one combined worktree (typecheck:core clean, complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution!
This commit is contained in:
Ravi Tharuma
2026-08-20 16:48:18 +02:00
committed by GitHub
parent 9eddafff60
commit 9d2240eab7
5 changed files with 107 additions and 12 deletions

View File

@@ -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))

View File

@@ -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);
}
}

View File

@@ -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<string, unknown>) : {};
const cause = rec.cause && typeof rec.cause === "object" ? (rec.cause as Record<string, unknown>) : {};
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}`,
};
}

View File

@@ -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);
}
}

View File

@@ -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;
}
});