Skip to content

Commit 1aa5687

Browse files
authored
Merge pull request #25 from gitcommit90/fix/http-transport-response-locks
Fix upstream transport fallback and connection timing.
2 parents 1428e3e + 996c6ca commit 1aa5687

9 files changed

Lines changed: 172 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car
99

1010
## [Unreleased]
1111

12+
## [0.5.12] - 2026-08-12
13+
14+
### Fixed
15+
16+
- Proxy-generated HTTP 502/503/504 connection-reset responses now use the same retry, no-lock, and route-fallback behavior as thrown socket failures.
17+
- Outbound connections now allow five seconds per resolved address instead of Node's 250-millisecond default, preventing healthy but moderately latent TCP handshakes from being misreported as `ETIMEDOUT` across every ChatGPT address.
18+
1219
## [0.5.11] - 2026-08-12
1320

1421
### Fixed

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@gitcommit90/rerouted",
33
"productName": "ReRouted",
4-
"version": "0.5.11",
4+
"version": "0.5.12",
55
"description": "A local AI router for connected accounts, models, named routes, and automatic fallback.",
66
"author": "gitcommit90",
77
"license": "MIT",

‎src/cli/index.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33

44
const fs = require("node:fs");
55
const path = require("node:path");
6+
const { configureNetworkDefaults } = require("../lib/network");
67
const { createHeadlessRuntime, createProcessLock, defaultUserData } = require("../lib/headless-runtime");
78
const { createPrompts } = require("./prompts");
89
const { runFirstSetup } = require("./setup");
910
const packageJson = require("../../package.json");
1011

12+
configureNetworkDefaults();
13+
1114
const HELP = `ReRouted ${packageJson.version}
1215
1316
Usage:

‎src/lib/network.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"use strict";
2+
3+
const net = require("node:net");
4+
5+
// Node's 250 ms default is too short for otherwise healthy TCP paths and can
6+
// exhaust every resolved address before TLS has a chance to start. Keep
7+
// dual-stack address fallback, but give each connection attempt a realistic
8+
// window.
9+
const ADDRESS_ATTEMPT_TIMEOUT_MS = 5_000;
10+
11+
function configureNetworkDefaults(network = net) {
12+
if (typeof network.setDefaultAutoSelectFamily === "function") {
13+
network.setDefaultAutoSelectFamily(true);
14+
}
15+
if (typeof network.setDefaultAutoSelectFamilyAttemptTimeout === "function") {
16+
network.setDefaultAutoSelectFamilyAttemptTimeout(ADDRESS_ATTEMPT_TIMEOUT_MS);
17+
}
18+
return {
19+
autoSelectFamily:
20+
typeof network.getDefaultAutoSelectFamily === "function"
21+
? network.getDefaultAutoSelectFamily()
22+
: null,
23+
attemptTimeoutMs:
24+
typeof network.getDefaultAutoSelectFamilyAttemptTimeout === "function"
25+
? network.getDefaultAutoSelectFamilyAttemptTimeout()
26+
: null,
27+
};
28+
}
29+
30+
module.exports = { ADDRESS_ATTEMPT_TIMEOUT_MS, configureNetworkDefaults };

‎src/lib/router.js‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,14 @@ function classifyFailure(status, errorText) {
274274
text
275275
);
276276
if (request) return { eligible: false, kind: "request", defaultCooldownMs: 0 };
277+
const transportResponse =
278+
[502, 503, 504].includes(Number(status)) &&
279+
/upstream connect error|disconnect\/reset before headers|reset reason:\s*(?:connection|remote)|connection (?:termination|reset|refused)|socket hang up|tls (?:handshake|connection)|(?:dns|name) resolution|no healthy upstream|upstream (?:request )?timeout|gateway timeout/.test(
280+
text
281+
);
282+
if (transportResponse) {
283+
return { eligible: false, kind: "transport", defaultCooldownMs: 0 };
284+
}
277285
const capability =
278286
(status === 400 || status === 404 || status === 422) &&
279287
(/(?:unsupported|invalid|unknown)[ _-]?model|model[ _-]?(?:not[ _-]?found|unsupported|unavailable)/.test(
@@ -751,15 +759,17 @@ function isAbortError(err) {
751759
function transportErrorMessage(error) {
752760
const parts = [];
753761
const seen = new Set();
754-
let current = error;
755-
for (let depth = 0; current && depth < 4; depth += 1) {
756-
if (seen.has(current)) break;
762+
const pending = [error];
763+
for (let inspected = 0; pending.length && inspected < 12; inspected += 1) {
764+
const current = pending.shift();
765+
if (!current || seen.has(current)) continue;
757766
seen.add(current);
758767
const code = typeof current.code === "string" ? current.code.trim() : "";
759768
const message = String(current.message || current).trim();
760769
const detail = code && !message.includes(code) ? `${code}: ${message}` : message;
761770
if (detail && !parts.includes(detail)) parts.push(detail);
762-
current = current.cause;
771+
if (current.cause) pending.push(current.cause);
772+
if (Array.isArray(current.errors)) pending.push(...current.errors);
763773
}
764774
return parts.join("; ") || "Upstream connection failed";
765775
}
@@ -1014,6 +1024,7 @@ function createRouter({
10141024
failureKind: classification.kind,
10151025
defaultCooldownMs: classification.defaultCooldownMs,
10161026
resetAt: inspected.failure.resetAt,
1027+
transportError: classification.kind === "transport",
10171028
};
10181029
}
10191030
if (inspected.openAiJson) {
@@ -1076,6 +1087,7 @@ function createRouter({
10761087
failureKind: classification.kind,
10771088
defaultCooldownMs: classification.defaultCooldownMs,
10781089
resetAt: parseResetHint(res, text),
1090+
transportError: classification.kind === "transport",
10791091
};
10801092
}
10811093

@@ -1186,6 +1198,7 @@ function createRouter({
11861198
failureKind: classification.kind,
11871199
defaultCooldownMs: classification.defaultCooldownMs,
11881200
resetAt: parseResetHint(res, JSON.stringify(raw)),
1201+
transportError: classification.kind === "transport",
11891202
};
11901203
}
11911204
let openAiJson = raw;

‎src/main.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
const path = require("node:path");
44
const fs = require("node:fs");
5+
const { configureNetworkDefaults } = require("./lib/network");
56
const {
67
app,
78
BrowserWindow,
@@ -16,6 +17,8 @@ const {
1617
powerMonitor,
1718
} = require("electron");
1819

20+
configureNetworkDefaults();
21+
1922
const { createStore } = require("./lib/store");
2023
const { createRouter } = require("./lib/router");
2124
const { createGateway } = require("./lib/gateway");

‎tests/network.test.js‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"use strict";
2+
3+
const { describe, it } = require("node:test");
4+
const assert = require("node:assert/strict");
5+
6+
const {
7+
ADDRESS_ATTEMPT_TIMEOUT_MS,
8+
configureNetworkDefaults,
9+
} = require("../src/lib/network");
10+
11+
describe("network defaults", () => {
12+
it("keeps address-family fallback and replaces Node's 250 ms connection window", () => {
13+
const state = { autoSelectFamily: false, attemptTimeoutMs: 250 };
14+
const network = {
15+
setDefaultAutoSelectFamily(value) {
16+
state.autoSelectFamily = value;
17+
},
18+
getDefaultAutoSelectFamily() {
19+
return state.autoSelectFamily;
20+
},
21+
setDefaultAutoSelectFamilyAttemptTimeout(value) {
22+
state.attemptTimeoutMs = value;
23+
},
24+
getDefaultAutoSelectFamilyAttemptTimeout() {
25+
return state.attemptTimeoutMs;
26+
},
27+
};
28+
29+
assert.deepEqual(configureNetworkDefaults(network), {
30+
autoSelectFamily: true,
31+
attemptTimeoutMs: ADDRESS_ATTEMPT_TIMEOUT_MS,
32+
});
33+
assert.equal(ADDRESS_ATTEMPT_TIMEOUT_MS, 5_000);
34+
});
35+
});

‎tests/router-fallback.test.js‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,80 @@ describe("Claude Code canonical named routes", () => {
320320
});
321321

322322
describe("same-provider OAuth account fallback", () => {
323+
it("logs the individual socket causes hidden inside a Node AggregateError", async () => {
324+
const store = createStore(tmpConfig());
325+
store.seed({ providers: [chatgptAccount("prov_a", "token-a", 100)] });
326+
const logger = captureLogger();
327+
const router = createRouter({
328+
store,
329+
logger,
330+
transportRetryDelayMs: 0,
331+
transportRetryAttempts: 0,
332+
fetchImpl: async () => {
333+
const timeout = Object.assign(new Error("connect timed out 172.64.155.209:443"), {
334+
code: "ETIMEDOUT",
335+
});
336+
const unreachable = Object.assign(new Error("connect unreachable 2606:4700::1:443"), {
337+
code: "ENETUNREACH",
338+
});
339+
throw new TypeError("fetch failed", {
340+
cause: new AggregateError([timeout, unreachable], "connection attempts failed"),
341+
});
342+
},
343+
});
344+
345+
const result = await router.chatCompletions({
346+
body: {
347+
model: "chatgpt/gpt-5.4",
348+
messages: [{ role: "user", content: "hello" }],
349+
stream: false,
350+
},
351+
});
352+
353+
assert.equal(result.ok, false);
354+
const failure = logger.entries.find((entry) => entry.meta?.event === "account_failure");
355+
assert.match(failure.meta.transportCause, /ETIMEDOUT.*172\.64\.155\.209/);
356+
assert.match(failure.meta.transportCause, /ENETUNREACH.*2606:4700/);
357+
assert.deepEqual(store.load().providers[0].modelLocks, {});
358+
});
359+
360+
it("retries an HTTP proxy connection reset without locking the account", async () => {
361+
const store = createStore(tmpConfig());
362+
store.seed({ providers: [chatgptAccount("prov_a", "token-a", 100)] });
363+
const logger = captureLogger();
364+
let calls = 0;
365+
const router = createRouter({
366+
store,
367+
logger,
368+
transportRetryDelayMs: 0,
369+
fetchImpl: async () => {
370+
calls += 1;
371+
if (calls < 3) {
372+
return new Response(
373+
"upstream connect error or disconnect/reset before headers. reset reason: connection termination",
374+
{ status: 503 }
375+
);
376+
}
377+
return responsesSuccessResponse("recovered");
378+
},
379+
});
380+
381+
const result = await router.chatCompletions({
382+
body: {
383+
model: "chatgpt/gpt-5.4",
384+
messages: [{ role: "user", content: "hello" }],
385+
stream: false,
386+
},
387+
});
388+
389+
assert.equal(result.ok, true, JSON.stringify(result.error));
390+
assert.equal(calls, 3);
391+
assert.deepEqual(store.load().providers[0].modelLocks, {});
392+
const retries = logger.entries.filter((entry) => entry.meta?.event === "transport_retry_scheduled");
393+
assert.equal(retries.length, 2);
394+
assert.match(retries[0].meta.error, /disconnect\/reset before headers/);
395+
});
396+
323397
it("retries transport failures without locking the account", async () => {
324398
const store = createStore(tmpConfig());
325399
store.seed({ providers: [chatgptAccount("prov_a", "token-a", 100)] });

0 commit comments

Comments
 (0)