From 2565c33b564a0dcd5e2a60e23d26645d385a1101 Mon Sep 17 00:00:00 2001 From: xfodev Date: Sun, 19 Jul 2026 04:25:02 -0700 Subject: [PATCH] fix(miner): bound the AMS export POST with a request timeout (#7237) --- packages/loopover-miner/lib/orb-export.d.ts | 3 +++ packages/loopover-miner/lib/orb-export.js | 14 ++++++++++++- test/unit/miner-orb-export.test.ts | 22 +++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/loopover-miner/lib/orb-export.d.ts b/packages/loopover-miner/lib/orb-export.d.ts index c14b601959..7107e6c42a 100644 --- a/packages/loopover-miner/lib/orb-export.d.ts +++ b/packages/loopover-miner/lib/orb-export.d.ts @@ -53,6 +53,8 @@ export function latestClosedAt(batch: OrbExportRow[]): string | null; export const DEFAULT_AMS_COLLECTOR_URL: string; +export const DEFAULT_ORB_EXPORT_TIMEOUT_MS: number; + export function resolveAmsCollectorUrl(env?: Record): string; export function sendAmsExportBatch(options: { @@ -61,6 +63,7 @@ export function sendAmsExportBatch(options: { collectorUrl?: string; collectorToken?: string | undefined; fetchFn?: typeof fetch; + timeoutMs?: number; }): Promise; export type ParsedOrbExportArgs = { json: boolean; enable: boolean; send: boolean; dryRun: boolean } | { error: string }; diff --git a/packages/loopover-miner/lib/orb-export.js b/packages/loopover-miner/lib/orb-export.js index b81f561f60..6d5ff6c1e7 100644 --- a/packages/loopover-miner/lib/orb-export.js +++ b/packages/loopover-miner/lib/orb-export.js @@ -177,7 +177,18 @@ export function resolveAmsCollectorUrl(env = process.env) { * response, `{ sent: 0, error }` otherwise — a network failure or non-2xx never throws, matching this module's * fail-open posture (a telemetry hiccup must never break the miner's real work). */ -export async function sendAmsExportBatch({ batch, secret, collectorUrl = resolveAmsCollectorUrl(), collectorToken, fetchFn = fetch }) { +// Bound a single AMS-collector POST so a hung/black-holed collector can't stall the export indefinitely (#7237). +// 10s matches this package's other default request timeouts (live-issue-snapshot.js / opportunity-fanout.js). +export const DEFAULT_ORB_EXPORT_TIMEOUT_MS = 10_000; + +export async function sendAmsExportBatch({ + batch, + secret, + collectorUrl = resolveAmsCollectorUrl(), + collectorToken, + fetchFn = fetch, + timeoutMs = DEFAULT_ORB_EXPORT_TIMEOUT_MS, +}) { if (!Array.isArray(batch) || batch.length === 0) return { sent: 0 }; const instanceId = amsInstanceId(secret); const body = JSON.stringify({ instanceId, events: batch }); @@ -192,6 +203,7 @@ export async function sendAmsExportBatch({ batch, secret, collectorUrl = resolve ...(collectorToken ? { authorization: `Bearer ${collectorToken}` } : {}), }, body, + signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) return { sent: 0, error: `http_${res.status}` }; } catch (error) { diff --git a/test/unit/miner-orb-export.test.ts b/test/unit/miner-orb-export.test.ts index a6d4d39431..5f9d839841 100644 --- a/test/unit/miner-orb-export.test.ts +++ b/test/unit/miner-orb-export.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ORB_EXPORT_ENABLED_BY_DEFAULT, DEFAULT_AMS_COLLECTOR_URL, + DEFAULT_ORB_EXPORT_TIMEOUT_MS, amsInstanceId, buildAnonymizedOrbBatch, collectOrbExportBatch, @@ -231,4 +232,25 @@ describe("sendAmsExportBatch (#5681)", () => { expect(result.sent).toBe(0); expect(result.error).toBeTruthy(); }); + + it("bounds the request with an AbortSignal.timeout (default 10s), honoring an explicit timeoutMs (#7237)", async () => { + expect(DEFAULT_ORB_EXPORT_TIMEOUT_MS).toBe(10_000); + + const fetchFn = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + await sendAmsExportBatch({ batch, secret: "s".repeat(64), fetchFn }); // no timeoutMs -> default applies + const [, init] = fetchFn.mock.calls[0] as [string, RequestInit]; + expect(init.signal).toBeInstanceOf(AbortSignal); + + fetchFn.mockClear(); + await sendAmsExportBatch({ batch, secret: "s".repeat(64), fetchFn, timeoutMs: 250 }); // explicit override + const [, override] = fetchFn.mock.calls[0] as [string, RequestInit]; + expect(override.signal).toBeInstanceOf(AbortSignal); + }); + + it("catches an aborted (timed-out) request via the existing catch, without throwing (#7237)", async () => { + const fetchFn = vi.fn().mockRejectedValue(new DOMException("The operation was aborted.", "TimeoutError")); + const result = await sendAmsExportBatch({ batch, secret: "s".repeat(64), fetchFn }); + expect(result.sent).toBe(0); + expect(result.error).toContain("aborted"); + }); });