From be8be34398e47f04475242f24e7e6b4b4e8097fc Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:30:50 -0700 Subject: [PATCH] fix: bound provider requests with a 60s timeout (scans can no longer hang) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stalled provider connection had no escape — every fetch() in the AI path lacked an AbortController, so one stuck request froze the *serialized* scan until the output tab's generic 90s "Analysis timed out" fired (output-tab.js:127, which is what users were hitting). - fetchWithTimeout() now wraps all 8 provider fetch sites with a 60s AbortController timeout (well under the 90s tab deadline). - New non-retryable `timeout` error kind: a stall falls straight through to the next provider in the attempt plan instead of re-hammering the stalled one, and surfaces an actionable "took too long — Retry / pick a faster provider" message (errors.js + an output-tab hint). Also fixes the time-flaky maintenance test that was reddening CI on main (pin the clock with vi.setSystemTime), so this branch lands green. 713 tests pass. --- background.js | 48 +++++++++++++++++++++++++++------------ errors.js | 5 ++++ output-tab.js | 1 + tests/errors.test.js | 6 +++++ tests/maintenance.test.js | 7 +++++- 5 files changed, 51 insertions(+), 16 deletions(-) diff --git a/background.js b/background.js index 163e3d1..cd2a322 100644 --- a/background.js +++ b/background.js @@ -1260,6 +1260,24 @@ function callCompat(provider, model, keys, prompt) { }); } +// Every provider request runs under a hard timeout. Without it a stalled connection +// hangs the (serialized) scan indefinitely — until the output tab's own 90s deadline +// fires with a generic "Analysis timed out". This aborts at the source, surfaces an +// actionable Retry, and lets the attempt plan fall through to the next provider. +const AI_FETCH_TIMEOUT_MS = 60_000; +async function fetchWithTimeout(url, opts = {}, label = 'Provider', ms = AI_FETCH_TIMEOUT_MS) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), ms); + try { + return await fetch(url, { ...opts, signal: ctrl.signal }); + } catch (e) { + if (e && e.name === 'AbortError') throw new Error(`${label} timed out after ${Math.round(ms / 1000)}s`); + throw e; + } finally { + clearTimeout(timer); + } +} + // OpenAI-compatible chat completion. `key` may be empty for keyless local servers (Ollama). // headerStyle 'azure' sends `api-key: ` (Azure OpenAI); otherwise `Authorization: Bearer`. async function callOpenAICompatible({ endpoint, key, model, prompt, label = 'Provider', maxTokens = 4096, headerStyle = 'bearer' }) { @@ -1268,11 +1286,11 @@ async function callOpenAICompatible({ endpoint, key, model, prompt, label = 'Pro if (headerStyle === 'azure') headers['api-key'] = key; else headers['Authorization'] = `Bearer ${key}`; } - const res = await fetch(endpoint, { + const res = await fetchWithTimeout(endpoint, { method: 'POST', headers, body: JSON.stringify(openaiBody(model, prompt, maxTokens)), - }); + }, label); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error?.message ?? err.message ?? `${label} API error ${res.status}`); @@ -1313,16 +1331,16 @@ async function mintAndStoreOpenAIKey() { // Bare OpenAI chat request returning the raw Response, so callers can branch on 401. function openaiChat(key, model, prompt) { - return fetch('https://api.openai.com/v1/chat/completions', { + return fetchWithTimeout('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify(openaiBody(model, prompt, 4096)), - }); + }, 'OpenAI'); } // Anthropic-compatible Messages API (x-api-key + anthropic-version). async function callAnthropicCompatible({ endpoint, key, model, prompt, label = 'Provider', maxTokens = 4096 }) { - const res = await fetch(endpoint, { + const res = await fetchWithTimeout(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1331,7 +1349,7 @@ async function callAnthropicCompatible({ endpoint, key, model, prompt, label = ' 'x-api-key': key, }, body: JSON.stringify(anthropicBody(model, prompt, maxTokens)), - }); + }, label); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error?.message ?? err.message ?? `${label} API error ${res.status}`); @@ -1407,7 +1425,7 @@ async function callAnthropic(model = 'claude-sonnet-4-6', prompt) { const { anthropicKey } = await chrome.storage.local.get('anthropicKey'); if (!anthropicKey) throw new Error('No Anthropic API key — add one in Settings'); - const res = await fetch('https://api.anthropic.com/v1/messages', { + const res = await fetchWithTimeout('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'anthropic-version': '2023-06-01', @@ -1420,7 +1438,7 @@ async function callAnthropic(model = 'claude-sonnet-4-6', prompt) { max_tokens: 4096, messages: [{ role: 'user', content: prompt }] }) - }); + }, 'Anthropic'); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error?.message ?? `Anthropic API error ${res.status}`); @@ -1433,14 +1451,14 @@ async function callAnthropic(model = 'claude-sonnet-4-6', prompt) { async function callGemini(key, model = 'gemini-2.5-flash', prompt) { const url = 'https://generativelanguage.googleapis.com/v1beta/models/' + encodeURIComponent(model) + ':generateContent?key=' + encodeURIComponent(key); - const res = await fetch(url, { + const res = await fetchWithTimeout(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { responseMimeType: 'application/json', maxOutputTokens: 4096 } }) - }); + }, 'Gemini'); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error?.message ?? `Gemini API error ${res.status}`); @@ -1466,11 +1484,11 @@ async function callNous(key, model = 'stepfun/step-3.7-flash', prompt) { }); for (let attempt = 0; ; attempt++) { - const res = await fetch('https://inference-api.nousresearch.com/v1/chat/completions', { + const res = await fetchWithTimeout('https://inference-api.nousresearch.com/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' }, body, - }); + }, 'Nous'); // Rate-limited / transient — back off and retry (honor Retry-After), up to 3 times. if ((res.status === 429 || res.status === 503) && attempt < 3) { @@ -1498,7 +1516,7 @@ async function callNous(key, model = 'stepfun/step-3.7-flash', prompt) { } async function callOpenRouter(key, model = 'x-ai/grok-4.3', prompt) { - const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { + const res = await fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1506,7 +1524,7 @@ async function callOpenRouter(key, model = 'x-ai/grok-4.3', prompt) { max_tokens: 4096, messages: [{ role: 'user', content: prompt }] }) - }); + }, 'OpenRouter'); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error?.message ?? `OpenRouter API error ${res.status}`); @@ -1546,7 +1564,7 @@ async function callXAI(model = 'grok-4.3', prompt) { reqHeaders['x-grok-model-override'] = model || 'grok-4.3'; reqHeaders['user-agent'] = 'xai-grok-cli'; } - const res = await fetch(endpoint, { method: 'POST', headers: reqHeaders, body }); + const res = await fetchWithTimeout(endpoint, { method: 'POST', headers: reqHeaders, body }, 'xAI'); if (res.ok) { const data = await res.json(); const text = data.choices?.[0]?.message?.content; diff --git a/errors.js b/errors.js index 2f447c2..bc16d2f 100644 --- a/errors.js +++ b/errors.js @@ -12,6 +12,9 @@ const KIND_META = { rate_limit: { retryable: true, fixable: false, priority: 2 }, server: { retryable: true, fixable: false, priority: 1 }, network: { retryable: true, fixable: false, priority: 1 }, + // A client-side request timeout: NOT retryable, so the attempt plan falls + // straight to the next provider rather than re-hammering a stalled one. + timeout: { retryable: false, fixable: false, priority: 1 }, unknown: { retryable: false, fixable: false, priority: 0 }, }; @@ -37,6 +40,7 @@ function humanize(kind, provider, fallback) { case 'rate_limit': return `${who} is rate-limited — wait a moment, or route this part to another provider.`; case 'not_found': return `${who} didn’t recognize that model — pick a valid model in Settings.`; case 'server': return `${who} is temporarily unavailable — retried and still failing. Try again shortly.`; + case 'timeout': return `${who} took too long to respond. Try again, or route this part to a faster provider in Settings.`; case 'network': return `Couldn’t reach ${provider || 'the provider'} — check your connection and retry.`; case 'bad_request': return fallback || `${who} rejected the request as malformed.`; default: return fallback || 'Something went wrong with the AI request.'; @@ -61,6 +65,7 @@ export function categorizeError(err, provider = '') { else if (status === 404 || /not found|unknown model|no such model|does not exist/i.test(low)) kind = 'not_found'; else if (status >= 500 || /server error|unavailable|bad gateway|gateway timeout|overloaded/i.test(low)) kind = 'server'; else if (status === 400 || /bad request|invalid request/i.test(low)) kind = 'bad_request'; + else if (/timed out after \d+\s*s\b/i.test(low)) kind = 'timeout'; else if (/network|failed to fetch|fetch failed|timeout|timed out|connection refused/i.test(low)) kind = 'network'; const meta = KIND_META[kind]; diff --git a/output-tab.js b/output-tab.js index b69754d..96a394a 100644 --- a/output-tab.js +++ b/output-tab.js @@ -288,6 +288,7 @@ async function init() { not_found: 'The model name is unrecognised. Open Settings and pick a valid model from the dropdown.', network: 'Can\'t reach the provider. Check your internet connection, then retry.', server: 'The provider is temporarily down. Retry in a few seconds.', + timeout: 'The provider took too long. Retry, or pick a faster model/provider in Settings.', }; const hint = HINTS[kind]; let hintEl = document.getElementById('error-hint'); diff --git a/tests/errors.test.js b/tests/errors.test.js index 471e40d..4fe7757 100644 --- a/tests/errors.test.js +++ b/tests/errors.test.js @@ -22,6 +22,12 @@ describe('categorizeError', () => { expect(categorizeError('Failed to fetch').kind).toBe('network'); expect(categorizeError('overloaded').retryable).toBe(true); }); + it('classifies a client request timeout as a non-retryable timeout (falls to next provider)', () => { + const r = categorizeError(new Error('Anthropic timed out after 60s'), 'Anthropic'); + expect(r.kind).toBe('timeout'); + expect(r.retryable).toBe(false); // don't re-hammer a stalled provider + expect(r.userMessage).toMatch(/took too long/i); + }); it('classifies an unknown model as a fixable not_found', () => { const r = categorizeError(new Error('model claude-x does not exist'), 'Anthropic'); expect(r.kind).toBe('not_found'); diff --git a/tests/maintenance.test.js b/tests/maintenance.test.js index c0d70b2..b3275d3 100644 --- a/tests/maintenance.test.js +++ b/tests/maintenance.test.js @@ -1,9 +1,14 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { bandFromSignals, daysSincePush, ciSignals, buildMaintenancePrompt, parseMaintenance, MAINT_BANDS, BUS_FACTORS } from '../maintenance.js'; const NOW = new Date('2026-06-13T00:00:00Z').getTime(); const daysAgo = (n) => new Date(NOW - n * 86400000).toISOString(); +// bandFromSignals/daysSincePush read the real Date.now(); pin it to NOW so the +// day-based assertions stay deterministic regardless of when the suite runs. +beforeAll(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-06-13T00:00:00Z')); }); +afterAll(() => { vi.useRealTimers(); }); + const signals = { pushedAt: daysAgo(45), archived: false,