[object Object]

← back to Marketing Command Center

modules: hard-timeout all remaining unguarded external fetches

ee1e9247db2fd56a7fe22fe49ad21c48d95d0b78 · 2026-08-16 09:34:49 -0700 · Steve Abrams

Node's global fetch has no default timeout, so a stalled upstream would hang
the awaiting request forever. Added lib/fetch-timeout.js (shared AbortController
wrapper, 45s, MCC_FETCH_TIMEOUT_MS override) and routed the last 5 unguarded
external calls through it:
  playbook      → Gemini generateContent
  performance   → GA4 Data API runReport
  follow-counts → Norma :9810 monitor
  profiles      → Constant Contact v3
  segments      → Constant Contact v3
Abort throws an error each caller's existing try/catch already handles (error
surface / mock fallback). Completes the fetch-timeout sweep started in engine
+ copy. (copy keeps its earlier local helper; identical behavior.)

Files touched

Diff

commit ee1e9247db2fd56a7fe22fe49ad21c48d95d0b78
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Aug 16 09:34:49 2026 -0700

    modules: hard-timeout all remaining unguarded external fetches
    
    Node's global fetch has no default timeout, so a stalled upstream would hang
    the awaiting request forever. Added lib/fetch-timeout.js (shared AbortController
    wrapper, 45s, MCC_FETCH_TIMEOUT_MS override) and routed the last 5 unguarded
    external calls through it:
      playbook      → Gemini generateContent
      performance   → GA4 Data API runReport
      follow-counts → Norma :9810 monitor
      profiles      → Constant Contact v3
      segments      → Constant Contact v3
    Abort throws an error each caller's existing try/catch already handles (error
    surface / mock fallback). Completes the fetch-timeout sweep started in engine
    + copy. (copy keeps its earlier local helper; identical behavior.)
---
 lib/fetch-timeout.js           | 22 ++++++++++++++++++++++
 modules/follow-counts/index.js |  3 ++-
 modules/performance/index.js   |  3 ++-
 modules/playbook/index.js      |  3 ++-
 modules/profiles/index.js      |  3 ++-
 modules/segments/index.js      |  3 ++-
 6 files changed, 32 insertions(+), 5 deletions(-)

diff --git a/lib/fetch-timeout.js b/lib/fetch-timeout.js
new file mode 100644
index 0000000..b09188c
--- /dev/null
+++ b/lib/fetch-timeout.js
@@ -0,0 +1,22 @@
+// Shared fetch() with a hard timeout.
+//
+// Node's global fetch has NO default timeout, so a stalled upstream (an LLM API,
+// GA4, Constant Contact, Norma, etc.) will hang the awaiting request FOREVER —
+// which spins a panel button or blocks an endpoint indefinitely. This wraps fetch
+// in an AbortController that fires after `ms`, throwing an AbortError the caller's
+// existing try/catch already handles (surface an error / fall back to mock).
+//
+// Default 45s; override per-call, or globally via MCC_FETCH_TIMEOUT_MS.
+const DEFAULT_MS = Number(process.env.MCC_FETCH_TIMEOUT_MS) || 45000;
+
+async function fetchWithTimeout(url, opts = {}, ms = DEFAULT_MS) {
+  const ctrl = new AbortController();
+  const timer = setTimeout(() => ctrl.abort(), ms);
+  try {
+    return await fetch(url, { ...opts, signal: ctrl.signal });
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+module.exports = { fetchWithTimeout, DEFAULT_MS };
diff --git a/modules/follow-counts/index.js b/modules/follow-counts/index.js
index fe37270..90574dd 100644
--- a/modules/follow-counts/index.js
+++ b/modules/follow-counts/index.js
@@ -16,6 +16,7 @@
 // NO scraping, NO unofficial endpoints — official Graph API counts only.
 const fs = require('fs');
 const path = require('path');
+const { fetchWithTimeout } = require('../../lib/fetch-timeout.js');
 
 const DATA = path.join(__dirname, '..', '..', 'data');
 const ACCOUNTS = path.join(DATA, 'follow-counts-accounts.json');
@@ -60,7 +61,7 @@ const todayKey = (d = new Date()) => d.toISOString().slice(0, 10); // YYYY-MM-DD
 async function fetchCountsFromNorma() {
   const url = `${NORMA_BASE}/api/skill/monitor`;
   try {
-    const r = await fetch(url, {
+    const r = await fetchWithTimeout(url, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json', Authorization: normaAuth },
       body: JSON.stringify({ period: 'day', push_to_pulse: false }),
diff --git a/modules/performance/index.js b/modules/performance/index.js
index 4bf1837..9ee4261 100644
--- a/modules/performance/index.js
+++ b/modules/performance/index.js
@@ -11,6 +11,7 @@
 
 const GA4_API = 'https://analyticsdata.googleapis.com/v1beta';
 const RANGES = { '7d': 7, '30d': 30, '90d': 90 };
+const { fetchWithTimeout } = require('../../lib/fetch-timeout.js');
 
 const ga4Live = () =>
   Boolean(process.env.GA4_PROPERTY_ID && process.env.GA4_ACCESS_TOKEN);
@@ -127,7 +128,7 @@ function mockTopPages(days) {
 
 // ── GA4 Data API (runReport) ────────────────────────────────────────────────────
 async function ga4RunReport(body) {
-  const r = await fetch(
+  const r = await fetchWithTimeout(
     `${GA4_API}/properties/${process.env.GA4_PROPERTY_ID}:runReport`,
     {
       method: 'POST',
diff --git a/modules/playbook/index.js b/modules/playbook/index.js
index d49fd4f..4a24fc1 100644
--- a/modules/playbook/index.js
+++ b/modules/playbook/index.js
@@ -23,6 +23,7 @@ const path = require('path');
 const crypto = require('crypto');
 
 const brand = require('../../lib/brand.js');
+const { fetchWithTimeout } = require('../../lib/fetch-timeout.js');
 const segmentPerf = require('../segment-perf');
 
 const DATA_DIR = path.join(__dirname, '..', '..', 'data');
@@ -371,7 +372,7 @@ async function geminiGenerate(signals) {
     contents: [{ role: 'user', parts: [{ text: geminiPrompt(signals) }] }],
     generationConfig: { temperature: 0.55, maxOutputTokens: 1400, responseMimeType: 'application/json' },
   };
-  const r = await fetch(url, {
+  const r = await fetchWithTimeout(url, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify(body),
diff --git a/modules/profiles/index.js b/modules/profiles/index.js
index fb143de..7658457 100644
--- a/modules/profiles/index.js
+++ b/modules/profiles/index.js
@@ -15,6 +15,7 @@
 // systems — pure read.
 const fs = require('fs');
 const path = require('path');
+const { fetchWithTimeout } = require('../../lib/fetch-timeout.js');
 
 const DATA_DIR = path.join(__dirname, '..', '..', 'data');
 const SEG_FILE = path.join(DATA_DIR, 'segments.json');
@@ -232,7 +233,7 @@ async function ctct(pathname) {
     Accept: 'application/json',
   };
   if (process.env.CTCT_API_KEY) headers['x-api-key'] = process.env.CTCT_API_KEY;
-  const r = await fetch(`${CTCT_API}${pathname}`, { headers });
+  const r = await fetchWithTimeout(`${CTCT_API}${pathname}`, { headers });
   const text = await r.text();
   let json; try { json = text ? JSON.parse(text) : {}; } catch { json = {}; }
   if (!r.ok) { const e = new Error(`CC GET ${pathname} → ${r.status}`); e.status = r.status; e.detail = json; throw e; }
diff --git a/modules/segments/index.js b/modules/segments/index.js
index 6ab00a6..5bb747e 100644
--- a/modules/segments/index.js
+++ b/modules/segments/index.js
@@ -12,6 +12,7 @@
 // is purely audience definition + preview (read-only against contacts).
 const fs = require('fs');
 const path = require('path');
+const { fetchWithTimeout } = require('../../lib/fetch-timeout.js');
 
 const DATA_DIR = path.join(__dirname, '..', '..', 'data');
 const SEG_FILE = path.join(DATA_DIR, 'segments.json');
@@ -140,7 +141,7 @@ async function ctct(pathname) {
     Accept: 'application/json',
   };
   if (process.env.CTCT_API_KEY) headers['x-api-key'] = process.env.CTCT_API_KEY;
-  const r = await fetch(`${CTCT_API}${pathname}`, { headers });
+  const r = await fetchWithTimeout(`${CTCT_API}${pathname}`, { headers });
   const text = await r.text();
   let json; try { json = text ? JSON.parse(text) : {}; } catch { json = {}; }
   if (!r.ok) { const e = new Error(`CC GET ${pathname} → ${r.status}`); e.status = r.status; e.detail = json; throw e; }

← c683e16 copy: hard-timeout the Anthropic/Gemini fetches (no more inf  ·  back to Marketing Command Center  ·  auto-data-snapshot: 2026-08-16T09:54:36 (1 data files) — pub f6af2dd →