← 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
A lib/fetch-timeout.jsM modules/follow-counts/index.jsM modules/performance/index.jsM modules/playbook/index.jsM modules/profiles/index.jsM modules/segments/index.js
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 →