From 271b986bd364347039a378fa06e2c2e88379ca9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:37:58 +0000 Subject: [PATCH] Add fetch timeout functionality and unit test Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com> --- widget/esbuild.test.config.mjs | 2 +- widget/js/puzzle.js | 48 ++++++++++++++++++--- widget/test/widget.test.js | 78 ++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/widget/esbuild.test.config.mjs b/widget/esbuild.test.config.mjs index 7564ed34..b0e125c7 100644 --- a/widget/esbuild.test.config.mjs +++ b/widget/esbuild.test.config.mjs @@ -19,7 +19,7 @@ build({ outfile: './test/bundle.test.js', format: 'esm', loader: { '.css': 'text' }, - external: ['node:test', 'node:assert', 'happy-dom'], + external: ['node:test', 'node:assert', 'node:http', 'happy-dom'], plugins: [ CSSMinifyPlugin, inlineWorkerPlugin({ diff --git a/widget/js/puzzle.js b/widget/js/puzzle.js index e184de38..c24bc871 100644 --- a/widget/js/puzzle.js +++ b/widget/js/puzzle.js @@ -5,12 +5,17 @@ import { decode } from 'base64-arraybuffer'; const PUZZLE_BUFFER_LENGTH = 128; // RequestTimeout, Conflict, TooManyRequests const ACCEPTABLE_CLIENT_ERRORS = [408, 409, 429]; +const DEFAULT_TIMEOUT_MS = 5000; -export async function getPuzzle(endpoint, sitekey) { +export async function getPuzzle(endpoint, sitekey, options = {}) { + const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS; try { const response = await fetchWithBackoff(`${endpoint}?sitekey=${sitekey}`, { headers: [["x-pc-captcha-version", "1"]], mode: "cors" }, - 5 /*max attempts*/ + 5 /*max attempts*/, + 800 /*initialDelay*/, + 6000 /*maxDelay*/, + timeoutMs ); if (response.ok) { @@ -30,19 +35,44 @@ export async function getPuzzle(endpoint, sitekey) { throw Error('Internal error'); }; -function wait(delay) { - return new Promise((resolve) => setTimeout(resolve, delay)); +function wait(delay, signal) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(resolve, delay); + if (signal) { + signal.addEventListener('abort', () => { + clearTimeout(timeoutId); + reject(signal.reason || new Error('Aborted')); + }, { once: true }); + } + }); } -async function fetchWithBackoff(url, options, maxAttempts, initialDelay = 800, maxDelay = 6000) { +async function fetchWithBackoff(url, options, maxAttempts, initialDelay = 800, maxDelay = 6000, timeoutMs = DEFAULT_TIMEOUT_MS) { + const controller = new AbortController(); + const { signal } = controller; + let timeoutId = null; + for (let attempt = 0; attempt < maxAttempts; attempt++) { if (attempt > 0) { const delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay); - await wait(delay); + try { + await wait(delay, signal); + } catch (err) { + if (signal.aborted) { + throw new Error('Fetch timed out'); + } + throw err; + } } + if (timeoutId) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => controller.abort(new Error('Fetch timed out')), timeoutMs); + try { - const response = await fetch(url, options); + const response = await fetch(url, { ...options, signal }); + clearTimeout(timeoutId); if (response.ok) { return response; } else { @@ -57,6 +87,10 @@ async function fetchWithBackoff(url, options, maxAttempts, initialDelay = 800, m continue; } } catch (err) { + clearTimeout(timeoutId); + if (signal.aborted) { + throw new Error('Fetch timed out'); + } console.error('[privatecaptcha]', err); } } diff --git a/widget/test/widget.test.js b/widget/test/widget.test.js index eb37bade..93d53b7a 100644 --- a/widget/test/widget.test.js +++ b/widget/test/widget.test.js @@ -483,3 +483,81 @@ test('captcha.js getResponse returns widget solution', async (t) => { console.log('✓ getResponse test passed'); }); + +test('getPuzzle times out when server responds slowly', async (t) => { + const http = await import('node:http'); + const { getPuzzle } = await import('../js/puzzle.js'); + + // Create a slow server that takes 1 second to respond + const server = http.createServer((req, res) => { + setTimeout(() => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('slow response'); + }, 1000); + }); + + // Find a random available port + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + + // Save original fetch and use native Node.js fetch to avoid happy-dom's mixed content blocking + const originalFetch = globalThis.fetch; + const nativeFetch = (await import('node:http')).default; + + // Use native fetch that doesn't have mixed content restrictions + globalThis.fetch = async (url, options = {}) => { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const req = nativeFetch.request({ + hostname: parsedUrl.hostname, + port: parsedUrl.port, + path: parsedUrl.pathname + parsedUrl.search, + method: options.method || 'GET', + headers: options.headers ? Object.fromEntries(options.headers) : {} + }, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => { + resolve({ + ok: res.statusCode >= 200 && res.statusCode < 300, + status: res.statusCode, + text: async () => data, + json: async () => JSON.parse(data) + }); + }); + }); + + if (options.signal) { + options.signal.addEventListener('abort', () => { + req.destroy(); + reject(new Error('Aborted')); + }); + } + + req.on('error', reject); + req.end(); + }); + }; + + try { + const startTime = Date.now(); + await assert.rejects( + async () => { + await getPuzzle(`http://127.0.0.1:${port}/puzzle`, testSitekey, { timeout: 200 }); + }, + (err) => { + assert.ok(err.message.includes('timed out') || err.message.includes('Fetch timed out') || err.message.includes('Aborted'), + `Error message should indicate timeout, got: ${err.message}`); + return true; + }, + 'getPuzzle should reject with timeout error' + ); + const elapsed = Date.now() - startTime; + assert.ok(elapsed < 800, `Should timeout quickly (took ${elapsed}ms)`); + } finally { + globalThis.fetch = originalFetch; + server.close(); + } + + console.log('✓ getPuzzle timeout test passed'); +});