Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ 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.
- 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

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions src/lib/network.js
Original file line number Diff line number Diff line change
@@ -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 };
21 changes: 17 additions & 4 deletions src/lib/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -751,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";
}
Expand Down Expand Up @@ -1014,6 +1024,7 @@ function createRouter({
failureKind: classification.kind,
defaultCooldownMs: classification.defaultCooldownMs,
resetAt: inspected.failure.resetAt,
transportError: classification.kind === "transport",
};
}
if (inspected.openAiJson) {
Expand Down Expand Up @@ -1076,6 +1087,7 @@ function createRouter({
failureKind: classification.kind,
defaultCooldownMs: classification.defaultCooldownMs,
resetAt: parseResetHint(res, text),
transportError: classification.kind === "transport",
};
}

Expand Down Expand Up @@ -1186,6 +1198,7 @@ function createRouter({
failureKind: classification.kind,
defaultCooldownMs: classification.defaultCooldownMs,
resetAt: parseResetHint(res, JSON.stringify(raw)),
transportError: classification.kind === "transport",
};
}
let openAiJson = raw;
Expand Down
3 changes: 3 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const path = require("node:path");
const fs = require("node:fs");
const { configureNetworkDefaults } = require("./lib/network");
const {
app,
BrowserWindow,
Expand All @@ -16,6 +17,8 @@ const {
powerMonitor,
} = require("electron");

configureNetworkDefaults();

const { createStore } = require("./lib/store");
const { createRouter } = require("./lib/router");
const { createGateway } = require("./lib/gateway");
Expand Down
35 changes: 35 additions & 0 deletions tests/network.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
74 changes: 74 additions & 0 deletions tests/router-fallback.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,80 @@ 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)] });
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)] });
Expand Down
Loading