Skip to content

Commit 2621873

Browse files
committed
fix: resolve Telegram outbound fetch failure on Node 22 (#25676)
Node 22's built-in globalThis.fetch uses undici's internal Agent whose connect options are frozen at construction time. Calling net.setDefaultAutoSelectFamily(true) after the agent is already created has no effect on it, causing outbound Telegram API calls to fail with `TypeError: fetch failed` on networks with broken IPv6 routing. Fix: replace the global undici dispatcher with a new Agent that has autoSelectFamily: true and autoSelectFamilyAttemptTimeout: 300 set explicitly in its connect options. This ensures all subsequent globalThis.fetch calls use IPv4 fallback via Happy Eyeballs. Inbound polling was unaffected because grammyjs/runner re-creates connections on each poll cycle, while outbound send calls hit the stale internal dispatcher.
1 parent 9ef0fc2 commit 2621873

File tree

2 files changed

+59
-0
lines changed

2 files changed

+59
-0
lines changed

src/telegram/fetch.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import { resetTelegramFetchStateForTests, resolveTelegramFetch } from "./fetch.j
44

55
const setDefaultAutoSelectFamily = vi.hoisted(() => vi.fn());
66
const setDefaultResultOrder = vi.hoisted(() => vi.fn());
7+
const setGlobalDispatcher = vi.hoisted(() => vi.fn());
8+
const AgentCtor = vi.hoisted(() =>
9+
vi.fn(function MockAgent(this: { options: unknown }, options: unknown) {
10+
this.options = options;
11+
}),
12+
);
713

814
vi.mock("node:net", async () => {
915
const actual = await vi.importActual<typeof import("node:net")>("node:net");
@@ -21,12 +27,19 @@ vi.mock("node:dns", async () => {
2127
};
2228
});
2329

30+
vi.mock("undici", () => ({
31+
Agent: AgentCtor,
32+
setGlobalDispatcher,
33+
}));
34+
2435
const originalFetch = globalThis.fetch;
2536

2637
afterEach(() => {
2738
resetTelegramFetchStateForTests();
2839
setDefaultAutoSelectFamily.mockReset();
2940
setDefaultResultOrder.mockReset();
41+
setGlobalDispatcher.mockReset();
42+
AgentCtor.mockClear();
3043
vi.unstubAllEnvs();
3144
vi.clearAllMocks();
3245
if (originalFetch) {
@@ -133,4 +146,23 @@ describe("resolveTelegramFetch", () => {
133146

134147
expect(setDefaultResultOrder).toHaveBeenCalledTimes(2);
135148
});
149+
150+
it("replaces global undici dispatcher with autoSelectFamily-enabled agent", async () => {
151+
globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch;
152+
resolveTelegramFetch();
153+
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);
154+
expect(AgentCtor).toHaveBeenCalledWith({
155+
connect: {
156+
autoSelectFamily: true,
157+
autoSelectFamilyAttemptTimeout: 300,
158+
},
159+
});
160+
});
161+
162+
it("sets global dispatcher only once across multiple calls", async () => {
163+
globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch;
164+
resolveTelegramFetch();
165+
resolveTelegramFetch();
166+
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);
167+
});
136168
});

src/telegram/fetch.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as dns from "node:dns";
22
import * as net from "node:net";
3+
import { Agent, setGlobalDispatcher } from "undici";
34
import type { TelegramNetworkConfig } from "../config/types.telegram.js";
45
import { resolveFetch } from "../infra/fetch.js";
56
import { createSubsystemLogger } from "../logging/subsystem.js";
@@ -10,6 +11,7 @@ import {
1011

1112
let appliedAutoSelectFamily: boolean | null = null;
1213
let appliedDnsResultOrder: string | null = null;
14+
let appliedGlobalDispatcher = false;
1315
const log = createSubsystemLogger("telegram/network");
1416

1517
// Node 22 workaround: enable autoSelectFamily to allow IPv4 fallback on broken IPv6 networks.
@@ -29,6 +31,30 @@ function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void
2931
// ignore if unsupported by the runtime
3032
}
3133
}
34+
35+
// Node 22's built-in globalThis.fetch uses undici's internal Agent whose
36+
// connect options are frozen at construction time. Calling
37+
// net.setDefaultAutoSelectFamily() after that agent is created has no
38+
// effect on it. Replace the global dispatcher with one that carries the
39+
// correct autoSelectFamily setting so every subsequent globalThis.fetch
40+
// call inherits it.
41+
// See: https://github.com/openclaw/openclaw/issues/25676
42+
if (autoSelectDecision.value !== null && !appliedGlobalDispatcher) {
43+
try {
44+
setGlobalDispatcher(
45+
new Agent({
46+
connect: {
47+
autoSelectFamily: autoSelectDecision.value,
48+
autoSelectFamilyAttemptTimeout: 300,
49+
},
50+
}),
51+
);
52+
appliedGlobalDispatcher = true;
53+
log.info("global undici dispatcher updated for autoSelectFamily");
54+
} catch {
55+
// ignore if setGlobalDispatcher is unavailable
56+
}
57+
}
3258
}
3359

3460
// Apply DNS result order workaround for IPv4/IPv6 issues.
@@ -68,4 +94,5 @@ export function resolveTelegramFetch(
6894
export function resetTelegramFetchStateForTests(): void {
6995
appliedAutoSelectFamily = null;
7096
appliedDnsResultOrder = null;
97+
appliedGlobalDispatcher = false;
7198
}

0 commit comments

Comments
 (0)