Skip to content

Commit 1428e3e

Browse files
authored
Merge pull request #24 from gitcommit90/fix/transport-failure-account-lock
Fix transport failure account locks
2 parents 78c300a + 067ab29 commit 1428e3e

5 files changed

Lines changed: 250 additions & 26 deletions

File tree

‎CHANGELOG.md‎

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

1010
## [Unreleased]
1111

12+
## [0.5.11] - 2026-08-12
13+
14+
### Fixed
15+
16+
- Connection-level failures now retry twice at ten-second intervals, preserve their underlying network cause in logs, advance named-route fallback after exhaustion, and never create misleading OAuth account locks.
17+
1218
## [0.5.10] - 2026-07-29
1319

1420
### Fixed
@@ -108,7 +114,8 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car
108114

109115
See [GitHub Releases](https://github.com/gitcommit90/rerouted/releases) for artifact digests and notes prior to the Keep a Changelog narrative. Notable themes in late 0.4.x included signed/notarized distribution, in-app updates, named routes, OAuth account pools, OpenAI chat completions and Responses routing, and launch hardening.
110116

111-
[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.10...HEAD
117+
[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.11...HEAD
118+
[0.5.11]: https://github.com/gitcommit90/rerouted/compare/v0.5.10...v0.5.11
112119
[0.5.10]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.10
113120
[0.5.9]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.9
114121
[0.5.8]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.8

‎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.10",
4+
"version": "0.5.11",
55
"description": "A local AI router for connected accounts, models, named routes, and automatic fallback.",
66
"author": "gitcommit90",
77
"license": "MIT",

‎src/lib/router.js‎

Lines changed: 149 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ const COOLDOWN_MS = {
2424
auth: 2 * 60_000,
2525
transient: 30_000,
2626
};
27+
const TRANSPORT_RETRY_DELAY_MS = 10_000;
28+
const TRANSPORT_RETRY_ATTEMPTS = 2;
2729
const PREOUTPUT_INSPECTION_BYTES = 64 * 1024;
2830
const CLAUDE_CODE_CANONICAL_ROUTES = new Map([
2931
["claude-fable-5", "fable"],
@@ -746,6 +748,66 @@ function isAbortError(err) {
746748
return /aborted|timeout|TimeoutError/i.test(msg);
747749
}
748750

751+
function transportErrorMessage(error) {
752+
const parts = [];
753+
const seen = new Set();
754+
let current = error;
755+
for (let depth = 0; current && depth < 4; depth += 1) {
756+
if (seen.has(current)) break;
757+
seen.add(current);
758+
const code = typeof current.code === "string" ? current.code.trim() : "";
759+
const message = String(current.message || current).trim();
760+
const detail = code && !message.includes(code) ? `${code}: ${message}` : message;
761+
if (detail && !parts.includes(detail)) parts.push(detail);
762+
current = current.cause;
763+
}
764+
return parts.join("; ") || "Upstream connection failed";
765+
}
766+
767+
function isTransportError(error) {
768+
const codes = new Set([
769+
"EAI_AGAIN",
770+
"ECONNABORTED",
771+
"ECONNREFUSED",
772+
"ECONNRESET",
773+
"EHOSTUNREACH",
774+
"ENETDOWN",
775+
"ENETUNREACH",
776+
"ENOTFOUND",
777+
"EPIPE",
778+
"ETIMEDOUT",
779+
"UND_ERR_CONNECT_TIMEOUT",
780+
"UND_ERR_HEADERS_TIMEOUT",
781+
"UND_ERR_SOCKET",
782+
]);
783+
const seen = new Set();
784+
let current = error;
785+
for (let depth = 0; current && depth < 4; depth += 1) {
786+
if (seen.has(current)) break;
787+
seen.add(current);
788+
if (codes.has(current.code)) return true;
789+
if (/\bfetch failed\b/i.test(String(current.message || ""))) return true;
790+
current = current.cause;
791+
}
792+
return false;
793+
}
794+
795+
function waitForTransportRetry(ms, signal) {
796+
if (signal?.aborted) return Promise.resolve(false);
797+
if (!ms || ms <= 0) return Promise.resolve(true);
798+
return new Promise((resolve) => {
799+
let timer;
800+
const finish = (ready) => {
801+
if (timer) clearTimeout(timer);
802+
signal?.removeEventListener("abort", onAbort);
803+
resolve(ready);
804+
};
805+
const onAbort = () => finish(false);
806+
timer = setTimeout(() => finish(true), ms);
807+
signal?.addEventListener("abort", onAbort, { once: true });
808+
});
809+
}
810+
749811
function memberSignal(outer, timeoutMs) {
750812
const ms = timeoutMs ?? REQUEST_TIMEOUT_MS;
751813
const ctrl = new AbortController();
@@ -790,7 +852,16 @@ function memberSignal(outer, timeoutMs) {
790852
/**
791853
* @param {{ store, fetchImpl?, requestLog?, timeoutMs?, usage?, logger? }} opts
792854
*/
793-
function createRouter({ store, fetchImpl = fetch, requestLog, timeoutMs, usage, logger = appLogger } = {}) {
855+
function createRouter({
856+
store,
857+
fetchImpl = fetch,
858+
requestLog,
859+
timeoutMs,
860+
usage,
861+
logger = appLogger,
862+
transportRetryDelayMs = TRANSPORT_RETRY_DELAY_MS,
863+
transportRetryAttempts = TRANSPORT_RETRY_ATTEMPTS,
864+
} = {}) {
794865
const rrState = new Map();
795866
const log = requestLog || createRequestLog();
796867
let totalRequests = 0;
@@ -1158,17 +1229,29 @@ function createRouter({ store, fetchImpl = fetch, requestLog, timeoutMs, usage,
11581229
return {
11591230
ok: false,
11601231
status: 408,
1161-
error: e.message || "Upstream request timeout",
1162-
cooldownEligible: true,
1163-
failureKind: "transient",
1164-
defaultCooldownMs: COOLDOWN_MS.transient,
1232+
error: transportErrorMessage(e) || "Upstream request timeout",
1233+
cooldownEligible: false,
1234+
failureKind: "transport",
1235+
defaultCooldownMs: 0,
1236+
transportError: false,
11651237
};
11661238
}
1167-
const error = e.message || String(e);
1239+
const error = transportErrorMessage(e);
11681240
const classification = classifyFailure(502, error);
1241+
if (classification.kind !== "request" && isTransportError(e)) {
1242+
return {
1243+
ok: false,
1244+
status: 502,
1245+
error,
1246+
cooldownEligible: false,
1247+
failureKind: "transport",
1248+
defaultCooldownMs: 0,
1249+
transportError: true,
1250+
};
1251+
}
11691252
return {
11701253
ok: false,
1171-
status: classification.kind === "request" ? 400 : 502,
1254+
status: 400,
11721255
error,
11731256
cooldownEligible: classification.eligible,
11741257
failureKind: classification.kind,
@@ -1290,19 +1373,55 @@ function createRouter({ store, fetchImpl = fetch, requestLog, timeoutMs, usage,
12901373
}
12911374
}
12921375
let result;
1293-
try {
1294-
result = await tryMember(attemptMember, body, stream, signal);
1295-
} catch (e) {
1296-
const status = isAbortError(e) ? 408 : 500;
1297-
const classification = classifyFailure(status, e.message);
1298-
result = {
1299-
ok: false,
1300-
status,
1301-
error: e.message || String(e),
1302-
cooldownEligible: classification.eligible,
1303-
failureKind: classification.kind,
1304-
defaultCooldownMs: classification.defaultCooldownMs,
1305-
};
1376+
let transportRetry = 0;
1377+
while (true) {
1378+
try {
1379+
result = await tryMember(attemptMember, body, stream, signal);
1380+
} catch (e) {
1381+
const status = isAbortError(e) ? 408 : 500;
1382+
const error = transportErrorMessage(e);
1383+
const classification = classifyFailure(status, error);
1384+
const transportError = isTransportError(e);
1385+
result = {
1386+
ok: false,
1387+
status,
1388+
error,
1389+
cooldownEligible: transportError ? false : classification.eligible,
1390+
failureKind: transportError ? "transport" : classification.kind,
1391+
defaultCooldownMs: transportError ? 0 : classification.defaultCooldownMs,
1392+
transportError,
1393+
};
1394+
}
1395+
if (!result.transportError || result.canceled || transportRetry >= transportRetryAttempts) {
1396+
break;
1397+
}
1398+
transportRetry += 1;
1399+
logger.warn("router transport retry scheduled", {
1400+
event: "transport_retry_scheduled",
1401+
routeModel: modelId,
1402+
upstreamModel: member.upstreamModel,
1403+
providerId: provider.id,
1404+
providerType: canonicalProviderType(provider.type),
1405+
providerName: provider.name,
1406+
accountAlias: provider.accountAlias || null,
1407+
status: result.status,
1408+
error: result.error,
1409+
retryAttempt: transportRetry,
1410+
maxRetries: transportRetryAttempts,
1411+
retryDelayMs: transportRetryDelayMs,
1412+
});
1413+
if (!(await waitForTransportRetry(transportRetryDelayMs, signal))) {
1414+
result = {
1415+
ok: false,
1416+
status: 499,
1417+
error: "Client disconnected",
1418+
cooldownEligible: false,
1419+
canceled: true,
1420+
failureKind: "canceled",
1421+
defaultCooldownMs: 0,
1422+
};
1423+
break;
1424+
}
13061425
}
13071426

13081427
if (result.ok) {
@@ -1359,15 +1478,22 @@ function createRouter({ store, fetchImpl = fetch, requestLog, timeoutMs, usage,
13591478
};
13601479
}
13611480

1362-
const savedLock = persistModelLock(provider, member.upstreamModel, result);
1481+
const transportCause = result.transportError ? result.error : null;
1482+
const publicError = result.transportError
1483+
? `Upstream connection failed after ${transportRetryAttempts} retries`
1484+
: result.error;
1485+
const savedLock = persistModelLock(provider, member.upstreamModel, {
1486+
...result,
1487+
error: publicError,
1488+
});
13631489
const failed = {
13641490
providerId: provider.id,
13651491
providerType: canonicalProviderType(provider.type),
13661492
providerName: provider.name,
13671493
accountAlias: provider.accountAlias || null,
13681494
model: member.upstreamModel,
13691495
status: result.status,
1370-
error: result.error,
1496+
error: publicError,
13711497
failureKind: result.failureKind,
13721498
retryable: true,
13731499
lockedUntil: savedLock?.until || null,
@@ -1378,6 +1504,7 @@ function createRouter({ store, fetchImpl = fetch, requestLog, timeoutMs, usage,
13781504
event: oauthAttempt ? "account_failure" : "route_member_failure",
13791505
routeModel: modelId,
13801506
...failed,
1507+
...(transportCause ? { transportCause } : {}),
13811508
});
13821509
fallbackFrom = failed;
13831510
}

‎tests/router-fallback.test.js‎

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

322322
describe("same-provider OAuth account fallback", () => {
323+
it("retries transport failures without locking the account", async () => {
324+
const store = createStore(tmpConfig());
325+
store.seed({ providers: [chatgptAccount("prov_a", "token-a", 100)] });
326+
const logger = captureLogger();
327+
let calls = 0;
328+
const router = createRouter({
329+
store,
330+
logger,
331+
transportRetryDelayMs: 0,
332+
fetchImpl: async () => {
333+
calls += 1;
334+
if (calls < 3) {
335+
const cause = new Error("getaddrinfo EAI_AGAIN chatgpt.com");
336+
cause.code = "EAI_AGAIN";
337+
throw new TypeError("fetch failed", { cause });
338+
}
339+
return responsesSuccessResponse("recovered");
340+
},
341+
});
342+
343+
const result = await router.chatCompletions({
344+
body: {
345+
model: "chatgpt/gpt-5.4",
346+
messages: [{ role: "user", content: "hello" }],
347+
stream: false,
348+
},
349+
});
350+
351+
assert.equal(result.ok, true, JSON.stringify(result.error));
352+
assert.equal(calls, 3);
353+
assert.deepEqual(store.load().providers[0].modelLocks, {});
354+
const retries = logger.entries.filter((entry) => entry.meta?.event === "transport_retry_scheduled");
355+
assert.equal(retries.length, 2);
356+
assert.match(retries[0].meta.error, /EAI_AGAIN/);
357+
});
358+
359+
it("advances a route after transport retries are exhausted without locking the account", async () => {
360+
const store = createStore(tmpConfig());
361+
store.seed({
362+
providers: [
363+
chatgptAccount("prov_chatgpt", "chatgpt-token", 100),
364+
claudeAccount("prov_claude", "claude-token", 200),
365+
],
366+
combos: [
367+
{
368+
id: "transport-fallback",
369+
name: "transport-fallback",
370+
strategy: "fallback",
371+
members: [
372+
{ providerId: "prov_chatgpt", model: "gpt-5.4" },
373+
{ providerId: "prov_claude", model: "claude-fable-5" },
374+
],
375+
},
376+
],
377+
});
378+
const calls = [];
379+
const logger = captureLogger();
380+
const router = createRouter({
381+
store,
382+
logger,
383+
transportRetryDelayMs: 0,
384+
fetchImpl: async (_url, options) => {
385+
const token = authToken(options);
386+
calls.push(token);
387+
if (token === "chatgpt-token") {
388+
const cause = new Error("socket disconnected before TLS handshake");
389+
cause.code = "ECONNRESET";
390+
throw new TypeError("fetch failed", { cause });
391+
}
392+
return claudeSuccessResponse("fallback worked");
393+
},
394+
});
395+
396+
const result = await router.chatCompletions({
397+
body: {
398+
model: "transport-fallback",
399+
messages: [{ role: "user", content: "hello" }],
400+
stream: false,
401+
},
402+
});
403+
404+
assert.equal(result.ok, true, JSON.stringify(result.error));
405+
assert.equal(result.openAiJson.choices[0].message.content, "fallback worked");
406+
assert.deepEqual(calls, ["chatgpt-token", "chatgpt-token", "chatgpt-token", "claude-token"]);
407+
assert.deepEqual(store.load().providers.find((provider) => provider.id === "prov_chatgpt").modelLocks, {});
408+
const failure = logger.entries.find((entry) => entry.meta?.event === "account_failure");
409+
assert.equal(failure.meta.error, "Upstream connection failed after 2 retries");
410+
assert.match(failure.meta.transportCause, /ECONNRESET/);
411+
});
412+
323413
it("does not turn context failures into cooldowns or a terminal 429", async () => {
324414
const store = createStore(tmpConfig());
325415
const quotaLock = {

0 commit comments

Comments
 (0)