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
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 13:01:10 +02:00
1 parent ed04103eb6
commit bb3ee6b855
2 files changed
+51

No files matched your search

@@ -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<symbol, Record<string, number>>)[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);
});
});
+8
View File
@@ -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,