mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
8b38a21779
commit
17de0913de
@@ -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)
|
||||
@@ -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;
|
||||
|
||||
99
tests/unit/duckduckgo-vqd-429-misclassification-6996.test.ts
Normal file
99
tests/unit/duckduckgo-vqd-429-misclassification-6996.test.ts
Normal 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})`
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user