diff --git a/changelog.d/fixes/6996-duckduckgo-vqd-429-misclassified.md b/changelog.d/fixes/6996-duckduckgo-vqd-429-misclassified.md new file mode 100644 index 0000000000..36b15a9cbd --- /dev/null +++ b/changelog.d/fixes/6996-duckduckgo-vqd-429-misclassified.md @@ -0,0 +1 @@ +- fix(providers): DuckDuckGo AI Chat executor propagates the real upstream status (429 rate limit with `Retry-After`) instead of misclassifying VQD-token acquisition failures as a hardcoded 503 (#6996) diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index e9c6053548..bf653932c7 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -64,11 +64,18 @@ function shouldUseBrowserBacked(): boolean { interface DuckDuckGoVqdHeaders { vqd4: string | null; vqdHash1: string | null; + // #6996: the real upstream HTTP status of the VQD-acquisition attempt (null when + // no request was made / a network error was thrown). Lets execute() distinguish a + // retryable 429 rate-limit from a genuine 5xx instead of collapsing both to 503. + status: number | null; + retryAfter: string | null; } interface DuckDuckGoAuthHeaders { vqd4: string | null; vqdHash1: string | null; + status: number | null; + retryAfter: string | null; } interface DuckDuckGoModelCapabilities { @@ -369,10 +376,13 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { const isStreaming = stream !== false; const upstreamHeaders = upstreamExtraHeaders || {}; - const errorResponse = (status: number, message: string): Response => + const errorResponse = (status: number, message: string, retryAfter?: string | null): Response => new Response(JSON.stringify({ error: { message } }), { status, - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(retryAfter ? { "Retry-After": retryAfter } : {}), + }, }); if (messages.length === 0) { @@ -468,6 +478,19 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { const vqdHeaders = await this.acquireAuthHeaders(mergedSignal); if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) { clearTimeout(timeout); + // #6996: surface the real upstream status instead of a hardcoded 503 so a + // 429 rate-limit gets a connection-cooldown, not a whole-provider circuit + // breaker trip (see CLAUDE.md "Provider Circuit Breaker" — only + // 408/500/502/503/504 should trip it, not 429). Any other non-2xx status + // (403 anti-bot challenge, genuine 5xx, or a thrown network error where + // status is null) keeps the existing 503 fallback. + if (vqdHeaders.status === 429) { + return errorResponse( + 429, + "Failed to acquire VQD token: upstream rate limited", + vqdHeaders.retryAfter + ); + } return errorResponse(503, "Failed to acquire VQD token"); } @@ -555,16 +578,25 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { }); this.rememberResponseCookies(resp); - if (!resp.ok) return { vqd4: null, vqdHash1: null }; + if (!resp.ok) { + return { + vqd4: null, + vqdHash1: null, + status: resp.status, + retryAfter: resp.headers.get("Retry-After"), + }; + } return { vqd4: resp.headers.get("x-vqd-4"), vqdHash1: resp.headers.get("x-vqd-hash-1"), + status: resp.status, + retryAfter: null, }; } catch (error) { if (error instanceof DOMException && error.name === "AbortError") { throw error; } - return { vqd4: null, vqdHash1: null }; + return { vqd4: null, vqdHash1: null, status: null, retryAfter: null }; } } @@ -576,6 +608,8 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { return { vqd4: null, vqdHash1: await solveDuckDuckGoChallenge(challenge, FAKE_HEADERS["User-Agent"]), + status: null, + retryAfter: null, }; } catch (error) { void error; @@ -588,6 +622,8 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { return { vqd4: headers.vqd4, vqdHash1: await solveDuckDuckGoChallenge(headers.vqdHash1, FAKE_HEADERS["User-Agent"]), + status: headers.status, + retryAfter: headers.retryAfter, }; } catch (error) { void error; diff --git a/tests/unit/duckduckgo-vqd-429-misclassification-6996.test.ts b/tests/unit/duckduckgo-vqd-429-misclassification-6996.test.ts new file mode 100644 index 0000000000..8b00ac5dc3 --- /dev/null +++ b/tests/unit/duckduckgo-vqd-429-misclassification-6996.test.ts @@ -0,0 +1,99 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-6996-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { DuckDuckGoWebExecutor, STATUS_URL } = await import( + "../../open-sse/executors/duckduckgo-web.ts" +); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const executeInputBase = { + model: "gpt-4o-mini", + body: { + model: "gpt-4o-mini", + messages: [{ role: "user", content: "hi" }], + stream: false, + }, + stream: false, + credentials: {}, +}; + +describe("#6996 DuckDuckGo VQD 429 misclassification", () => { + let originalFetch: typeof fetch; + + before(() => { + originalFetch = globalThis.fetch; + }); + + after(() => { + globalThis.fetch = originalFetch; + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + }); + + it("propagates upstream 429 instead of masking it as a generic 503", async () => { + // Set the mock AFTER the module import so it wins over + // open-sse/utils/proxyFetch.ts's own module-load-time + // `globalThis.fetch = patchedFetch` side effect. + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as URL | Request).toString(); + if (url === STATUS_URL) { + return new Response("", { + status: 429, + headers: { "Retry-After": "30" }, + }); + } + if (url.includes("/duckchat/v1/chat")) { + throw new Error("unexpected chat POST reached without a VQD token"); + } + return new Response("", { status: 200 }); + }) as typeof fetch; + + const executor = new DuckDuckGoWebExecutor(); + const response = await executor.execute(executeInputBase); + + const httpResponse = + response instanceof Response + ? response + : (response as { response: Response }).response; + const bodyText = await httpResponse.text(); + + assert.equal( + httpResponse.status, + 429, + `expected the executor to surface DuckDuckGo's real 429 rate-limit status, got ${httpResponse.status} (body: ${bodyText})` + ); + }); + + it("still returns 503 fallback for a genuine 5xx status on the VQD endpoint", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as URL | Request).toString(); + if (url === STATUS_URL) { + return new Response("", { status: 500 }); + } + if (url.includes("/duckchat/v1/chat")) { + throw new Error("unexpected chat POST reached without a VQD token"); + } + return new Response("", { status: 200 }); + }) as typeof fetch; + + const executor = new DuckDuckGoWebExecutor(); + const response = await executor.execute(executeInputBase); + + const httpResponse = + response instanceof Response + ? response + : (response as { response: Response }).response; + const bodyText = await httpResponse.text(); + + assert.equal( + httpResponse.status, + 503, + `expected the executor to keep the 503 fallback for a genuine upstream 5xx, got ${httpResponse.status} (body: ${bodyText})` + ); + }); +});