fix(providers): DuckDuckGo VQD 429 misclassified as 503 (#6996) (#7185)

acquireVqdHeaders() discarded the upstream HTTP status of the
/duckchat/v1/status call and collapsed every non-2xx response to
{vqd4:null, vqdHash1:null}. execute() then always returned a
hardcoded 503 when the token could not be acquired, regardless of
whether DuckDuckGo actually returned 429 (rate limit), 403, or a
genuine 5xx.

This mattered beyond the confusing error message: per the resilience
contract only 408/500/502/503/504 should trip the whole-provider
circuit breaker, not 429. Mislabeling a real 429 as 503 caused the
entire ddgw/* catalog to get knocked offline for the breaker reset
window instead of a short cooldown.

Now acquireVqdHeaders()/acquireAuthHeaders() thread the real status
and Retry-After header through, and execute() surfaces a genuine 429
(with Retry-After) instead of the hardcoded 503; the 503 fallback is
kept for non-429 failures and network errors.

Regression test: tests/unit/duckduckgo-vqd-429-misclassification-6996.test.ts
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-14 21:21:49 -03:00
committed by GitHub
parent 8b38a21779
commit 17de0913de
3 changed files with 140 additions and 4 deletions

View File

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

View File

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

View File

@@ -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("<html></html>", { 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("<html></html>", { 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})`
);
});
});