From fc83d8541f0d26f28d4f2ee72f307de227cdb606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 27 May 2026 00:05:12 +0200 Subject: [PATCH] fix(ai): bound every provider fetch with a 60 s AbortSignal timeout The OpenAI, Anthropic, and local OpenAI-compatible clients all used a bare `fetch()` with no `signal`. A tar-pit upstream (a misconfigured local Ollama on a stalled GPU, a flaky proxy in front of a hosted provider, or a deliberately malicious user-configured LOCAL endpoint) could accept the POST and then hang indefinitely, pinning the request handler until the connection died on the socket level. A 60 s ceiling lands well outside any legitimate completion budget (production timings live in `getEvent()?.addExternalCall` traces) while capping the worst case. The Codex client uses an SSE stream with its own loop and stays as-is for now; a follow-up can thread the same ceiling through the streaming read once the contract there is settled. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/ai/anthropic-client.ts | 2 ++ src/lib/ai/local-client.ts | 4 ++++ src/lib/ai/openai-client.ts | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index 5b637ebde..5a110f1e9 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -49,6 +49,8 @@ export class AnthropicClient implements AIProvider { system: params.systemPrompt, messages: [{ role: "user", content: wrapForJson(params.userPrompt) }], }), + // 60 s ceiling — see openai-client.ts for the rationale. + signal: AbortSignal.timeout(60_000), }); if (!res.ok) { diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index 33eeafaa4..d967a4162 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -50,6 +50,10 @@ export class LocalOpenAICompatibleClient implements AIProvider { temperature: params.temperature ?? 0.3, max_tokens: params.maxTokens ?? 1000, }), + // 60 s ceiling — a misbehaving local endpoint (Ollama on a stalled + // GPU, a tar-pit proxy) shouldn't be able to pin a worker + // indefinitely. Real local completions land well inside this. + signal: AbortSignal.timeout(60_000), }); if (!res.ok) { diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index 5c0882813..f4b17d1e3 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -46,6 +46,10 @@ export class OpenAIClient implements AIProvider { max_tokens: params.maxTokens ?? 1000, response_format: { type: "json_object" }, }), + // Cap any single completion at 60 s so a tar-pit upstream cannot + // pin a worker indefinitely. Real completions land well inside this + // budget — see `getEvent()?.addExternalCall` timings in production. + signal: AbortSignal.timeout(60_000), }); if (!res.ok) {