From bb3ee6b855d85588a0b83403e8d87989dc8f3b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 13:01:10 +0200 Subject: [PATCH] fix(safe-fetch): leave the deadline to the caller's signal The pinned dispatchers kept undici's own 300 s header and body timers. A local model with the response timeout set above five minutes answers a non-streamed completion only once it is done, so its headers could take longer than 300 s and undici dropped a call the caller still allowed; the failure read as a bare network error. Every safeFetch call carries its own AbortSignal.timeout, so that signal is now the one deadline. Refs #1267 --- .../safe-fetch-dispatcher-timeouts.test.ts | 43 +++++++++++++++++++ src/lib/safe-fetch-dispatcher.ts | 8 ++++ 2 files changed, 51 insertions(+) create mode 100644 src/lib/__tests__/safe-fetch-dispatcher-timeouts.test.ts diff --git a/src/lib/__tests__/safe-fetch-dispatcher-timeouts.test.ts b/src/lib/__tests__/safe-fetch-dispatcher-timeouts.test.ts new file mode 100644 index 000000000..91b2779ca --- /dev/null +++ b/src/lib/__tests__/safe-fetch-dispatcher-timeouts.test.ts @@ -0,0 +1,43 @@ +/** + * #1267 — the pinned dispatchers leave the deadline to the caller's signal. + * + * undici's Agent arms its own 300 s header timer and 300 s body timer by + * default. A local model with the response timeout at 600 s answers a + * non-streamed completion only once it is done, so its headers can arrive + * after 300 s: undici then dropped a call the caller still allowed, and the + * failure read as a bare network error. Waiting five minutes in a unit test + * proves nothing faster, so this reads the options the Agent was built with. + */ +import { afterEach, describe, expect, it } from "vitest"; +import type { Agent } from "undici"; +import { + getPinnedOperatorApprovedDispatcher, + getPinnedPublicDispatcher, + _resetPinnedDispatcherForTests, +} from "../safe-fetch-dispatcher"; + +function agentOptions(agent: Agent): { + headersTimeout?: number; + bodyTimeout?: number; +} { + const key = Object.getOwnPropertySymbols(agent).find( + (s) => s.description === "options", + ); + expect(key).toBeDefined(); + return (agent as unknown as Record>)[key!]; +} + +afterEach(() => { + _resetPinnedDispatcherForTests(); +}); + +describe("pinned dispatcher timers", () => { + it.each([ + ["public", getPinnedPublicDispatcher], + ["operator-approved", getPinnedOperatorApprovedDispatcher], + ])("the %s dispatcher arms no header or body timer of its own", (_, get) => { + const options = agentOptions(get()); + expect(options.headersTimeout).toBe(0); + expect(options.bodyTimeout).toBe(0); + }); +}); diff --git a/src/lib/safe-fetch-dispatcher.ts b/src/lib/safe-fetch-dispatcher.ts index 544278ce0..5a5f3803a 100644 --- a/src/lib/safe-fetch-dispatcher.ts +++ b/src/lib/safe-fetch-dispatcher.ts @@ -147,6 +147,14 @@ let cachedOperatorApproved: Agent | null = null; function createPinnedDispatcher(policy: AddressPolicy): Agent { return new Agent({ + // #1267 — undici's own 300 s header and body timers would cut a call the + // caller allowed longer: a local model with a 600 s response timeout + // answers a non-streamed completion only when it is done, so its headers + // can take longer than 300 s, and the failure surfaced as a bare + // "network" error. Every `safeFetch` call carries its own + // `AbortSignal.timeout`, so that signal is the one deadline. + headersTimeout: 0, + bodyTimeout: 0, connect: { lookup: policy === "public" ? pinnedPublicLookup : pinnedOperatorApprovedLookup,