From 55f6b9808befbfdfa8007dd16ddb3f1e1282c879 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:10:47 +0700 Subject: [PATCH] fix(executors): DuckDuckGo ERR_BN_LIMIT without blind retry + proxy pool support (#12110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige ERR_BN_LIMIT do DuckDuckGo sem retry cego, com suporte a pool de proxy e teste próprio (199 linhas). Validado no worktree combinado. Obrigado! --- .../duckduckgo-err-bn-limit-and-proxy-pool.md | 1 + .../registry/duckduckgo-web/index.ts | 9 + open-sse/executors/duckduckgo-web.ts | 11 + tests/unit/duckduckgo-bn-limit-418.test.ts | 199 ++++++++++++++++++ 4 files changed, 220 insertions(+) create mode 100644 changelog.d/fixes/duckduckgo-err-bn-limit-and-proxy-pool.md create mode 100644 tests/unit/duckduckgo-bn-limit-418.test.ts diff --git a/changelog.d/fixes/duckduckgo-err-bn-limit-and-proxy-pool.md b/changelog.d/fixes/duckduckgo-err-bn-limit-and-proxy-pool.md new file mode 100644 index 0000000000..ec42e37340 --- /dev/null +++ b/changelog.d/fixes/duckduckgo-err-bn-limit-and-proxy-pool.md @@ -0,0 +1 @@ +- **fix(executors):** handle DuckDuckGo ERR_BN_LIMIT (418) without retrying — when the upstream returns `418 ERR_BN_LIMIT` (rate-limit/ban), the executor now returns the error immediately instead of burning another VQD acquisition that would only count against the IP limit. The retry logic for `418 ERR_CHALLENGE` (unsolved challenge) remains unchanged. ([#11598](https://github.com/diegosouzapw/OmniRoute/pull/11598)) diff --git a/open-sse/config/providers/registry/duckduckgo-web/index.ts b/open-sse/config/providers/registry/duckduckgo-web/index.ts index 2a722cf57a..01d5b2087a 100644 --- a/open-sse/config/providers/registry/duckduckgo-web/index.ts +++ b/open-sse/config/providers/registry/duckduckgo-web/index.ts @@ -8,6 +8,15 @@ export const duckduckgo_webProvider: RegistryEntry = { baseUrl: "https://duck.ai/duckchat/v1/chat", authType: "none", authHeader: "none", + poolConfig: { + minSessions: 2, + maxSessions: 5, + cooldownBase: 1000, + cooldownMax: 10000, + cooldownJitter: 500, + requestTimeout: 30000, + requestJitter: 50, + }, // #8000: current Duck.ai free lineup — wire ids per duckchat/v1/models (2026-08-26): // gpt-5.4-nano was retired upstream and gpt-5.6-luna joined the free tier. models: [ diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index 63d9afafd0..10ba401ea1 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -629,6 +629,17 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { let chatResponse = await sendChat(vqdHeaders); if (chatResponse.status === 418) { + // Check if this is ERR_BN_LIMIT (rate limit/ban) — cannot be solved by retrying with fresh VQD + const bodyText = await chatResponse.clone().text(); + const parsedError = parseDuckDuckGoError(bodyText); + const errorType = parsedError ? String(parsedError.type) : ""; + if (errorType === "ERR_BN_LIMIT") { + // ERR_BN_LIMIT means the IP/session is banned/rate-limited — retrying won't help + // Return the error immediately without burning another VQD acquisition + clearTimeout(timeout); + return await this.processResponse(chatResponse, isStreaming, hasTools, requestedTools); + } + // ERR_CHALLENGE: the challenge was unsolved or expired — try once with fresh VQD this.pendingVqdHash1 = null; const freshVqd = await this.acquireAuthHeaders(mergedSignal); if (freshVqd.vqd4 || freshVqd.vqdHash1) { diff --git a/tests/unit/duckduckgo-bn-limit-418.test.ts b/tests/unit/duckduckgo-bn-limit-418.test.ts new file mode 100644 index 0000000000..f95f3a1403 --- /dev/null +++ b/tests/unit/duckduckgo-bn-limit-418.test.ts @@ -0,0 +1,199 @@ +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-bn-limit-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { DuckDuckGoWebExecutor, STATUS_URL, MODELS_URL } = + await import("../../open-sse/executors/duckduckgo-web.ts"); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + +// Load real challenge from fixtures +const FIXTURES = path.join( + path.dirname(new URL(import.meta.url).pathname), + "../fixtures/duckduckgo/challenge-variants.json" +); +const VARIANTS = JSON.parse(fs.readFileSync(FIXTURES, "utf8")); +const REAL_CHALLENGE_B64 = VARIANTS["variant-0.js"].challengeBase64; + +const executeInputBase = { + model: "gpt-4o-mini", + body: { + model: "gpt-4o-mini", + messages: [{ role: "user", content: "hi" }], + stream: false, + }, + stream: false, + credentials: {}, +}; + +// Valid model catalog response +const MODEL_CATALOG_RESPONSE = { + models: [ + { id: "gpt-5.4-mini", accessTier: ["free"] }, + { id: "claude-haiku-4-5", accessTier: ["free"] }, + { id: "mistral-small-2603", accessTier: ["free"] }, + ], +}; + +describe("DuckDuckGo ERR_BN_LIMIT (418) — no retry on rate-limit ban", () => { + let originalFetch: typeof fetch; + let fetchCallLog: string[]; + + before(() => { + originalFetch = globalThis.fetch; + }); + + after(() => { + globalThis.fetch = originalFetch; + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }); + + it("does NOT retry with fresh VQD on 418 ERR_BN_LIMIT — returns error immediately", async () => { + fetchCallLog = []; + + // Mock: STATUS_URL returns real challenge, CHAT_URL returns 418 ERR_BN_LIMIT + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as URL | Request).toString(); + fetchCallLog.push(url); + console.log(`[MOCK FETCH] ${url}`); + + if (url === MODELS_URL) { + console.log(`[MOCK] Returning model catalog for ${url}`); + return new Response(JSON.stringify(MODEL_CATALOG_RESPONSE), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + if (url === STATUS_URL) { + // Return a REAL challenge from fixtures (base64-encoded JavaScript) + console.log(`[MOCK] Returning REAL challenge for ${url}`); + return new Response("", { + status: 200, + headers: { "x-vqd-hash-1": REAL_CHALLENGE_B64 }, + }); + } + + if (url.includes("/duckchat/v1/chat")) { + // Return 418 with ERR_BN_LIMIT error body + console.log(`[MOCK] Returning 418 ERR_BN_LIMIT for ${url}`); + return new Response(JSON.stringify({ type: "ERR_BN_LIMIT", overrideCode: "f46c" }), { + status: 418, + headers: { "Content-Type": "application/json" }, + }); + } + + // Warmup requests (homepage, country, auth token, search page) + console.log(`[MOCK] Returning HTML for warmup ${url}`); + 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(); + const body = JSON.parse(bodyText); + + // Verify status is 418 (not masked to 503/502) + assert.equal( + httpResponse.status, + 418, + `expected 418 status for ERR_BN_LIMIT, got ${httpResponse.status} (body: ${bodyText})` + ); + + // Verify error message contains ERR_BN_LIMIT + assert.ok( + body.error?.message?.includes("ERR_BN_LIMIT"), + `error message should contain ERR_BN_LIMIT: ${bodyText}` + ); + + // CRITICAL: Should only call STATUS_URL ONCE (no retry with fresh VQD) + const statusCalls = fetchCallLog.filter((u) => u === STATUS_URL); + assert.equal( + statusCalls.length, + 1, + `expected exactly 1 call to STATUS_URL (no retry on ERR_BN_LIMIT), got ${statusCalls.length} calls: ${JSON.stringify(fetchCallLog)}` + ); + }); + + it("still retries once with fresh VQD on 418 ERR_CHALLENGE", async () => { + fetchCallLog = []; + let statusCallCount = 0; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as URL | Request).toString(); + fetchCallLog.push(url); + console.log(`[MOCK FETCH] ${url}`); + + if (url === MODELS_URL) { + console.log(`[MOCK] Returning model catalog for ${url}`); + return new Response(JSON.stringify(MODEL_CATALOG_RESPONSE), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + if (url === STATUS_URL) { + statusCallCount++; + // Return a REAL challenge from fixtures (base64-encoded JavaScript) + console.log(`[MOCK] Returning REAL challenge #${statusCallCount} for ${url}`); + return new Response("", { + status: 200, + headers: { "x-vqd-hash-1": REAL_CHALLENGE_B64 }, + }); + } + + if (url.includes("/duckchat/v1/chat")) { + // First chat call: return 418 ERR_CHALLENGE + // Second chat call (retry): return success + const isRetry = fetchCallLog.filter((u) => u.includes("/duckchat/v1/chat")).length > 1; + if (!isRetry) { + console.log(`[MOCK] Returning 418 ERR_CHALLENGE for ${url}`); + return new Response(JSON.stringify({ type: "ERR_CHALLENGE", overrideCode: "abc123" }), { + status: 418, + headers: { "Content-Type": "application/json" }, + }); + } + // Retry succeeds + console.log(`[MOCK] Returning success for retry ${url}`); + return new Response('data: {"message":"done"}\n\n', { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + + // Warmup requests + console.log(`[MOCK] Returning HTML for warmup ${url}`); + 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(); + + // Should eventually succeed (200) after retry + assert.equal( + httpResponse.status, + 200, + `expected 200 after ERR_CHALLENGE retry, got ${httpResponse.status} (body: ${bodyText})` + ); + + // Should call STATUS_URL TWICE (initial + retry) + const statusCalls = fetchCallLog.filter((u) => u === STATUS_URL); + assert.equal( + statusCalls.length, + 2, + `expected 2 calls to STATUS_URL for ERR_CHALLENGE retry, got ${statusCalls.length}: ${JSON.stringify(fetchCallLog)}` + ); + }); +});