fix(gateway/discord): respect env proxy vars and prevent ECONNRESET on proxy tunnels

Gateway startup now bootstraps the global undici EnvHttpProxyAgent so
Node's fetch() honors https_proxy/HTTP_PROXY. The Discord gateway plugin
also falls back to env proxy vars for its WebSocket connection when no
explicit channels.discord.proxy is configured. All proxy ProxyAgent
instances disable keepAlive to avoid ECONNRESET from local proxies
(Clash, Surge, etc.) that close CONNECT tunnels after one request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
winter.loo 2026-03-15 23:42:05 +08:00
parent b31b681088
commit dfce4052dc
7 changed files with 28 additions and 7 deletions

View File

@ -6,6 +6,7 @@ import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { ProxyAgent, fetch as undiciFetch } from "undici";
import WebSocket from "ws";
import { resolveEnvHttpProxyUrl } from "../../../../src/infra/net/proxy-env.js";
const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot";
const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/";
@ -270,7 +271,7 @@ export function createDiscordGatewayPlugin(params: {
runtime: RuntimeEnv;
}): GatewayPlugin {
const intents = resolveDiscordGatewayIntents(params.discordConfig?.intents);
const proxy = params.discordConfig?.proxy?.trim();
const proxy = params.discordConfig?.proxy?.trim() || resolveEnvHttpProxyUrl("https");
const options = {
reconnect: { maxAttempts: 50 },
intents,
@ -287,7 +288,7 @@ export function createDiscordGatewayPlugin(params: {
try {
const wsAgent = new HttpsProxyAgent<string>(proxy);
const fetchAgent = new ProxyAgent(proxy);
const fetchAgent = new ProxyAgent({ uri: proxy, connect: { keepAlive: false } });
params.runtime.log?.("discord: gateway proxy enabled");

View File

@ -89,7 +89,8 @@ vi.mock("https-proxy-agent", () => ({
vi.mock("undici", () => ({
ProxyAgent: class {
proxyUrl: string;
constructor(proxyUrl: string) {
constructor(opts: string | { uri: string }) {
const proxyUrl = typeof opts === "string" ? opts : opts.uri;
this.proxyUrl = proxyUrl;
undiciProxyAgentSpy(proxyUrl);
restProxyAgentSpy(proxyUrl);
@ -98,6 +99,10 @@ vi.mock("undici", () => ({
fetch: undiciFetchMock,
}));
vi.mock("../../../../src/infra/net/proxy-env.js", () => ({
resolveEnvHttpProxyUrl: () => undefined,
}));
vi.mock("ws", () => ({
default: class MockWebSocket {
constructor(url: string, options?: { agent?: unknown }) {

View File

@ -9,7 +9,8 @@ const { undiciFetchMock, proxyAgentSpy } = vi.hoisted(() => ({
vi.mock("undici", () => {
class ProxyAgent {
proxyUrl: string;
constructor(proxyUrl: string) {
constructor(opts: string | { uri: string }) {
const proxyUrl = typeof opts === "string" ? opts : opts.uri;
if (proxyUrl === "bad-proxy") {
throw new Error("bad proxy");
}

View File

@ -12,7 +12,7 @@ export function resolveDiscordRestFetch(
return fetch;
}
try {
const agent = new ProxyAgent(proxy);
const agent = new ProxyAgent({ uri: proxy, connect: { keepAlive: false } });
const fetcher = ((input: RequestInfo | URL, init?: RequestInit) =>
undiciFetch(input as string | URL, {
...(init as Record<string, unknown>),

View File

@ -17,6 +17,10 @@ import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js";
import { setVerbose } from "../../globals.js";
import { GatewayLockError } from "../../infra/gateway-lock.js";
import {
ensureGlobalUndiciEnvProxyDispatcher,
ensureGlobalUndiciStreamTimeouts,
} from "../../infra/net/undici-global-dispatcher.js";
import { formatPortDiagnostics, inspectPortUsage } from "../../infra/ports.js";
import { cleanStaleGatewayProcessesSync } from "../../infra/restart-stale-pids.js";
import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logging/console.js";
@ -418,6 +422,11 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
}
: undefined;
// Proxy bootstrap must happen before timeout tuning so the timeouts wrap the
// active EnvHttpProxyAgent instead of being replaced by a bare proxy dispatcher.
ensureGlobalUndiciEnvProxyDispatcher();
ensureGlobalUndiciStreamTimeouts();
try {
await runGatewayLoop({
runtime: defaultRuntime,

View File

@ -111,6 +111,7 @@ describe("ensureGlobalUndiciStreamTimeouts", () => {
expect(next.options?.bodyTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS);
expect(next.options?.headersTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS);
expect(next.options?.connect).toEqual({
keepAlive: false,
autoSelectFamily: false,
autoSelectFamilyAttemptTimeout: 300,
});

View File

@ -93,7 +93,10 @@ export function ensureGlobalUndiciEnvProxyDispatcher(): void {
return;
}
try {
setGlobalDispatcher(new EnvHttpProxyAgent());
// Many local proxies (Clash, Surge, etc.) close HTTP CONNECT tunnels after
// the first request, causing ECONNRESET on reuse. Disable keep-alive so
// undici opens a fresh tunnel per request instead of pooling.
setGlobalDispatcher(new EnvHttpProxyAgent({ connect: { keepAlive: false } }));
lastAppliedProxyBootstrap = true;
} catch {
// Best-effort bootstrap only.
@ -120,10 +123,11 @@ export function ensureGlobalUndiciStreamTimeouts(opts?: { timeoutMs?: number }):
const connect = resolveConnectOptions(autoSelectFamily);
try {
if (kind === "env-proxy") {
const proxyConnect = { keepAlive: false, ...connect };
const proxyOptions = {
bodyTimeout: timeoutMs,
headersTimeout: timeoutMs,
...(connect ? { connect } : {}),
connect: proxyConnect,
} as ConstructorParameters<typeof EnvHttpProxyAgent>[0];
setGlobalDispatcher(new EnvHttpProxyAgent(proxyOptions));
} else {