← back to Marketing Command Center
copy: hard-timeout the Anthropic/Gemini fetches (no more infinite hang)
c683e1695020f8f2f4e6f6313ca9cb89c468164c · 2026-08-16 08:01:58 -0700 · Steve Abrams
Node's fetch has no default timeout, so a stalled upstream would hang
POST /generate and /score forever, spinning the user's button. Added a
shared fetchWithTimeout (AbortController, 45s, COPY_LLM_TIMEOUT_MS override)
and routed all three external LLM calls through it. Abort throws an
AbortError the existing callers already handle (500 for generate, heuristic
fallback for score). Mirrors the guards engine/llm.js already had.
Files touched
Diff
commit c683e1695020f8f2f4e6f6313ca9cb89c468164c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Aug 16 08:01:58 2026 -0700
copy: hard-timeout the Anthropic/Gemini fetches (no more infinite hang)
Node's fetch has no default timeout, so a stalled upstream would hang
POST /generate and /score forever, spinning the user's button. Added a
shared fetchWithTimeout (AbortController, 45s, COPY_LLM_TIMEOUT_MS override)
and routed all three external LLM calls through it. Abort throws an
AbortError the existing callers already handle (500 for generate, heuristic
fallback for score). Mirrors the guards engine/llm.js already had.
---
modules/copy/index.js | 22 +++++++++++++++++++---
1 file changed, 19 insertions(+), 3 deletions(-)
diff --git a/modules/copy/index.js b/modules/copy/index.js
index 2c07175..353a192 100644
--- a/modules/copy/index.js
+++ b/modules/copy/index.js
@@ -203,9 +203,25 @@ function userPromptFor(ctx, n) {
return lines.join('\n');
}
+// fetch() with a hard timeout. Node's fetch has NO default timeout, so a stalled
+// upstream (Anthropic/Gemini) would hang the POST /generate|/score request
+// forever, spinning the user's button. Aborts after LLM_TIMEOUT_MS and throws an
+// AbortError the existing callers already catch (→ 500 for generate, heuristic
+// fallback for score).
+const LLM_TIMEOUT_MS = Number(process.env.COPY_LLM_TIMEOUT_MS) || 45000;
+async function fetchWithTimeout(url, opts = {}, ms = LLM_TIMEOUT_MS) {
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), ms);
+ try {
+ return await fetch(url, { ...opts, signal: ctrl.signal });
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
async function llmGenerate(ctx, n) {
const key = process.env.ANTHROPIC_API_KEY;
- const resp = await fetch('https://api.anthropic.com/v1/messages', {
+ const resp = await fetchWithTimeout('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'content-type': 'application/json',
@@ -233,7 +249,7 @@ async function llmGenerate(ctx, n) {
async function geminiGenerate(ctx, n) {
const key = process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY;
const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash';
- const resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`, {
+ const resp = await fetchWithTimeout(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
@@ -615,7 +631,7 @@ async function geminiScore({ kind, text }) {
const user = `KIND: ${kind}\nCOPY:\n${String(text || '').slice(0, 4000)}`;
let resp;
try {
- resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`, {
+ resp = await fetchWithTimeout(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
← 6b14b13 auto-data-snapshot: 2026-08-16T03:49:55 (1 data files) — pub
·
back to Marketing Command Center
·
modules: hard-timeout all remaining unguarded external fetch ee1e924 →