fix(quota): raise Node's 250ms connect-attempt cap for provider fetches (#3404)

Node's happy-eyeballs default (autoSelectFamilyAttemptTimeout=250ms)
aborts every connect attempt to provider endpoints whose TCP handshake
exceeds 250ms, so quota fetches from Node processes fail with
"fetch failed" while the Bun-based opencode CLI path succeeds (#3399).
Raise the per-attempt cap to 5s via a shared applyConnectAttemptTimeout()
helper called from the two Node entrypoints that host the quota module:
the Electron main process and the openchamber CLI. Family autoselection
stays enabled (::1 and IPv6→IPv4 fallback preserved); runtimes without
the setter are a no-op.
This commit is contained in:
ouyangjian28
2026-09-07 20:25:24 +03:00
committed by GitHub
parent fc61b10109
commit c779b765d9
4 changed files with 72 additions and 0 deletions
+7
View File
@@ -72,6 +72,13 @@ import {
printJson,
logStatus,
} from './cli-output.js';
import { applyConnectAttemptTimeout } from '../server/lib/network-defaults.js';
// The CLI process performs provider fetches (quota/usage, update notes) under
// Node/undici, whose happy-eyeballs default aborts each connect attempt after
// 250ms — distant provider endpoints routinely need longer handshakes, surfacing
// as "fetch failed" (#3399). No-op on runtimes without the setter.
applyConnectAttemptTimeout();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -0,0 +1,23 @@
import net from 'node:net';
// Node caps each happy-eyeballs connect attempt at 250ms by default
// (autoSelectFamilyAttemptTimeout). TCP handshakes to provider endpoints that are
// geographically distant routinely take 300-1500ms, so fetch() from a Node process
// aborts every attempt (ETIMEDOUT) and surfaces "fetch failed" even though the host
// is reachable — e.g. the z.ai quota endpoint from an IPv4-only egress (#3399).
//
// Every Node process entrypoint that performs provider fetches raises the
// per-attempt cap. Family autoselection itself stays enabled, so the IPv6→IPv4
// fallback and ::1/localhost servers keep working; disabling it instead breaks
// local MCP servers. Runtimes without the setter (Bun's fetch path, older Node)
// are a no-op.
export const CONNECT_ATTEMPT_TIMEOUT_MS = 5_000;
export const applyConnectAttemptTimeout = (netModule = net) => {
try {
netModule.setDefaultAutoSelectFamilyAttemptTimeout(CONNECT_ATTEMPT_TIMEOUT_MS);
return true;
} catch {
return false;
}
};
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
import net from 'node:net';
import { applyConnectAttemptTimeout, CONNECT_ATTEMPT_TIMEOUT_MS } from './network-defaults.js';
describe('applyConnectAttemptTimeout', () => {
it('raises the per-attempt connect timeout on runtimes that expose the setter', () => {
const previous = net.getDefaultAutoSelectFamilyAttemptTimeout();
try {
expect(applyConnectAttemptTimeout()).toBe(true);
expect(net.getDefaultAutoSelectFamilyAttemptTimeout()).toBe(CONNECT_ATTEMPT_TIMEOUT_MS);
} finally {
net.setDefaultAutoSelectFamilyAttemptTimeout(previous);
}
});
it('is a no-op on runtimes without the setter', () => {
expect(applyConnectAttemptTimeout({})).toBe(false);
});
it('survives a throwing setter', () => {
const setDefaultAutoSelectFamilyAttemptTimeout = vi.fn(() => {
throw new Error('not supported');
});
expect(applyConnectAttemptTimeout({ setDefaultAutoSelectFamilyAttemptTimeout })).toBe(false);
});
it('leaves family autoselection itself untouched', () => {
const setDefaultAutoSelectFamily = vi.fn();
const setDefaultAutoSelectFamilyAttemptTimeout = vi.fn();
applyConnectAttemptTimeout({ setDefaultAutoSelectFamily, setDefaultAutoSelectFamilyAttemptTimeout });
expect(setDefaultAutoSelectFamilyAttemptTimeout).toHaveBeenCalledTimes(1);
expect(setDefaultAutoSelectFamilyAttemptTimeout).toHaveBeenCalledWith(CONNECT_ATTEMPT_TIMEOUT_MS);
expect(setDefaultAutoSelectFamily).not.toHaveBeenCalled();
});
});