From e6647d627c1c08d24771fccda8736d57b62f98d5 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich <294273268+gitcommit90@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:39:27 +0000 Subject: [PATCH 1/2] Fix proxy transport responses locking accounts --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- src/lib/router.js | 11 +++++++++++ tests/router-fallback.test.js | 37 +++++++++++++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f64e6..d8cbff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car ## [Unreleased] +## [0.5.12] - 2026-08-12 + +### Fixed + +- Proxy-generated HTTP 502/503/504 connection-reset responses now use the same retry, no-lock, and route-fallback behavior as thrown socket failures. + ## [0.5.11] - 2026-08-12 ### Fixed diff --git a/package-lock.json b/package-lock.json index db42db2..310c6f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gitcommit90/rerouted", - "version": "0.5.11", + "version": "0.5.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gitcommit90/rerouted", - "version": "0.5.11", + "version": "0.5.12", "license": "MIT", "bin": { "rerouted": "src/cli/index.js" diff --git a/package.json b/package.json index 9e548d9..f87137a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@gitcommit90/rerouted", "productName": "ReRouted", - "version": "0.5.11", + "version": "0.5.12", "description": "A local AI router for connected accounts, models, named routes, and automatic fallback.", "author": "gitcommit90", "license": "MIT", diff --git a/src/lib/router.js b/src/lib/router.js index 53175ca..bfb0d6a 100644 --- a/src/lib/router.js +++ b/src/lib/router.js @@ -274,6 +274,14 @@ function classifyFailure(status, errorText) { text ); if (request) return { eligible: false, kind: "request", defaultCooldownMs: 0 }; + const transportResponse = + [502, 503, 504].includes(Number(status)) && + /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( + text + ); + if (transportResponse) { + return { eligible: false, kind: "transport", defaultCooldownMs: 0 }; + } const capability = (status === 400 || status === 404 || status === 422) && (/(?:unsupported|invalid|unknown)[ _-]?model|model[ _-]?(?:not[ _-]?found|unsupported|unavailable)/.test( @@ -1014,6 +1022,7 @@ function createRouter({ failureKind: classification.kind, defaultCooldownMs: classification.defaultCooldownMs, resetAt: inspected.failure.resetAt, + transportError: classification.kind === "transport", }; } if (inspected.openAiJson) { @@ -1076,6 +1085,7 @@ function createRouter({ failureKind: classification.kind, defaultCooldownMs: classification.defaultCooldownMs, resetAt: parseResetHint(res, text), + transportError: classification.kind === "transport", }; } @@ -1186,6 +1196,7 @@ function createRouter({ failureKind: classification.kind, defaultCooldownMs: classification.defaultCooldownMs, resetAt: parseResetHint(res, JSON.stringify(raw)), + transportError: classification.kind === "transport", }; } let openAiJson = raw; diff --git a/tests/router-fallback.test.js b/tests/router-fallback.test.js index d960021..b298392 100644 --- a/tests/router-fallback.test.js +++ b/tests/router-fallback.test.js @@ -320,6 +320,43 @@ describe("Claude Code canonical named routes", () => { }); describe("same-provider OAuth account fallback", () => { + it("retries an HTTP proxy connection reset without locking the account", async () => { + const store = createStore(tmpConfig()); + store.seed({ providers: [chatgptAccount("prov_a", "token-a", 100)] }); + const logger = captureLogger(); + let calls = 0; + const router = createRouter({ + store, + logger, + transportRetryDelayMs: 0, + fetchImpl: async () => { + calls += 1; + if (calls < 3) { + return new Response( + "upstream connect error or disconnect/reset before headers. reset reason: connection termination", + { status: 503 } + ); + } + return responsesSuccessResponse("recovered"); + }, + }); + + const result = await router.chatCompletions({ + body: { + model: "chatgpt/gpt-5.4", + messages: [{ role: "user", content: "hello" }], + stream: false, + }, + }); + + assert.equal(result.ok, true, JSON.stringify(result.error)); + assert.equal(calls, 3); + assert.deepEqual(store.load().providers[0].modelLocks, {}); + const retries = logger.entries.filter((entry) => entry.meta?.event === "transport_retry_scheduled"); + assert.equal(retries.length, 2); + assert.match(retries[0].meta.error, /disconnect\/reset before headers/); + }); + it("retries transport failures without locking the account", async () => { const store = createStore(tmpConfig()); store.seed({ providers: [chatgptAccount("prov_a", "token-a", 100)] }); From 996c6caa50a9563d5f09fbf11872ac87f041e532 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich <294273268+gitcommit90@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:30:07 +0000 Subject: [PATCH 2/2] Allow viable upstream connection attempts --- CHANGELOG.md | 1 + src/cli/index.js | 3 +++ src/lib/network.js | 30 ++++++++++++++++++++++++++++ src/lib/router.js | 10 ++++++---- src/main.js | 3 +++ tests/network.test.js | 35 +++++++++++++++++++++++++++++++++ tests/router-fallback.test.js | 37 +++++++++++++++++++++++++++++++++++ 7 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 src/lib/network.js create mode 100644 tests/network.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d8cbff2..ec2cabb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car ### Fixed - Proxy-generated HTTP 502/503/504 connection-reset responses now use the same retry, no-lock, and route-fallback behavior as thrown socket failures. +- 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. ## [0.5.11] - 2026-08-12 diff --git a/src/cli/index.js b/src/cli/index.js index c10896c..f1f0e03 100755 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -3,11 +3,14 @@ const fs = require("node:fs"); const path = require("node:path"); +const { configureNetworkDefaults } = require("../lib/network"); const { createHeadlessRuntime, createProcessLock, defaultUserData } = require("../lib/headless-runtime"); const { createPrompts } = require("./prompts"); const { runFirstSetup } = require("./setup"); const packageJson = require("../../package.json"); +configureNetworkDefaults(); + const HELP = `ReRouted ${packageJson.version} Usage: diff --git a/src/lib/network.js b/src/lib/network.js new file mode 100644 index 0000000..62b2a9a --- /dev/null +++ b/src/lib/network.js @@ -0,0 +1,30 @@ +"use strict"; + +const net = require("node:net"); + +// Node's 250 ms default is too short for otherwise healthy TCP paths and can +// exhaust every resolved address before TLS has a chance to start. Keep +// dual-stack address fallback, but give each connection attempt a realistic +// window. +const ADDRESS_ATTEMPT_TIMEOUT_MS = 5_000; + +function configureNetworkDefaults(network = net) { + if (typeof network.setDefaultAutoSelectFamily === "function") { + network.setDefaultAutoSelectFamily(true); + } + if (typeof network.setDefaultAutoSelectFamilyAttemptTimeout === "function") { + network.setDefaultAutoSelectFamilyAttemptTimeout(ADDRESS_ATTEMPT_TIMEOUT_MS); + } + return { + autoSelectFamily: + typeof network.getDefaultAutoSelectFamily === "function" + ? network.getDefaultAutoSelectFamily() + : null, + attemptTimeoutMs: + typeof network.getDefaultAutoSelectFamilyAttemptTimeout === "function" + ? network.getDefaultAutoSelectFamilyAttemptTimeout() + : null, + }; +} + +module.exports = { ADDRESS_ATTEMPT_TIMEOUT_MS, configureNetworkDefaults }; diff --git a/src/lib/router.js b/src/lib/router.js index bfb0d6a..e2a9d1e 100644 --- a/src/lib/router.js +++ b/src/lib/router.js @@ -759,15 +759,17 @@ function isAbortError(err) { function transportErrorMessage(error) { const parts = []; const seen = new Set(); - let current = error; - for (let depth = 0; current && depth < 4; depth += 1) { - if (seen.has(current)) break; + const pending = [error]; + for (let inspected = 0; pending.length && inspected < 12; inspected += 1) { + const current = pending.shift(); + if (!current || seen.has(current)) continue; seen.add(current); const code = typeof current.code === "string" ? current.code.trim() : ""; const message = String(current.message || current).trim(); const detail = code && !message.includes(code) ? `${code}: ${message}` : message; if (detail && !parts.includes(detail)) parts.push(detail); - current = current.cause; + if (current.cause) pending.push(current.cause); + if (Array.isArray(current.errors)) pending.push(...current.errors); } return parts.join("; ") || "Upstream connection failed"; } diff --git a/src/main.js b/src/main.js index faf6f68..208341d 100644 --- a/src/main.js +++ b/src/main.js @@ -2,6 +2,7 @@ const path = require("node:path"); const fs = require("node:fs"); +const { configureNetworkDefaults } = require("./lib/network"); const { app, BrowserWindow, @@ -16,6 +17,8 @@ const { powerMonitor, } = require("electron"); +configureNetworkDefaults(); + const { createStore } = require("./lib/store"); const { createRouter } = require("./lib/router"); const { createGateway } = require("./lib/gateway"); diff --git a/tests/network.test.js b/tests/network.test.js new file mode 100644 index 0000000..973166e --- /dev/null +++ b/tests/network.test.js @@ -0,0 +1,35 @@ +"use strict"; + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); + +const { + ADDRESS_ATTEMPT_TIMEOUT_MS, + configureNetworkDefaults, +} = require("../src/lib/network"); + +describe("network defaults", () => { + it("keeps address-family fallback and replaces Node's 250 ms connection window", () => { + const state = { autoSelectFamily: false, attemptTimeoutMs: 250 }; + const network = { + setDefaultAutoSelectFamily(value) { + state.autoSelectFamily = value; + }, + getDefaultAutoSelectFamily() { + return state.autoSelectFamily; + }, + setDefaultAutoSelectFamilyAttemptTimeout(value) { + state.attemptTimeoutMs = value; + }, + getDefaultAutoSelectFamilyAttemptTimeout() { + return state.attemptTimeoutMs; + }, + }; + + assert.deepEqual(configureNetworkDefaults(network), { + autoSelectFamily: true, + attemptTimeoutMs: ADDRESS_ATTEMPT_TIMEOUT_MS, + }); + assert.equal(ADDRESS_ATTEMPT_TIMEOUT_MS, 5_000); + }); +}); diff --git a/tests/router-fallback.test.js b/tests/router-fallback.test.js index b298392..1079382 100644 --- a/tests/router-fallback.test.js +++ b/tests/router-fallback.test.js @@ -320,6 +320,43 @@ describe("Claude Code canonical named routes", () => { }); describe("same-provider OAuth account fallback", () => { + it("logs the individual socket causes hidden inside a Node AggregateError", async () => { + const store = createStore(tmpConfig()); + store.seed({ providers: [chatgptAccount("prov_a", "token-a", 100)] }); + const logger = captureLogger(); + const router = createRouter({ + store, + logger, + transportRetryDelayMs: 0, + transportRetryAttempts: 0, + fetchImpl: async () => { + const timeout = Object.assign(new Error("connect timed out 172.64.155.209:443"), { + code: "ETIMEDOUT", + }); + const unreachable = Object.assign(new Error("connect unreachable 2606:4700::1:443"), { + code: "ENETUNREACH", + }); + throw new TypeError("fetch failed", { + cause: new AggregateError([timeout, unreachable], "connection attempts failed"), + }); + }, + }); + + const result = await router.chatCompletions({ + body: { + model: "chatgpt/gpt-5.4", + messages: [{ role: "user", content: "hello" }], + stream: false, + }, + }); + + assert.equal(result.ok, false); + const failure = logger.entries.find((entry) => entry.meta?.event === "account_failure"); + assert.match(failure.meta.transportCause, /ETIMEDOUT.*172\.64\.155\.209/); + assert.match(failure.meta.transportCause, /ENETUNREACH.*2606:4700/); + assert.deepEqual(store.load().providers[0].modelLocks, {}); + }); + it("retries an HTTP proxy connection reset without locking the account", async () => { const store = createStore(tmpConfig()); store.seed({ providers: [chatgptAccount("prov_a", "token-a", 100)] });