ldrouter 1.17.4 → 1.17.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/) and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.17.5] - 2026-09-19
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **An account whose session was revoked kept being selected instead of leaving the Codex pool.** Auth0 answers `refresh_token_invalidated` ("Your session has ended. Please log in again.") for an account whose login was revoked; the account cannot serve anything again until it is re-imported. The credential layer surfaced that as a bare `oauth_refresh_failed`, which the router classified `unknown` — and `unknown` is not a routing decision, so `shouldFallback` answered false, the retry never advanced to a sibling account, and the request answered `502`. Live production data confirmed the shape: two accounts holding invalidated refresh tokens sat at the head of the pool, so every request landed on one of them, and 226 of 241 failed requests carried `attempts_count=1` while healthy accounts sat idle behind them. Credential failure is now its own failure class, checked before the error-type switch, and it always advances to the next candidate — with or without a combo plan, since a direct `codex1/...` model has no fallback triggers to consult.
|
|
12
|
+
- **The account with the dead credential is now disabled, not merely degraded.** Marking down *and* disabling (`enabled=0`) is what actually stops the churn, mirroring quota exhaustion: every selection path — `expandCodexAccountCandidates`, `getCodexAccountForProvider` — filters on `enabled`. Deliberately kept out of `isUpstreamHealthFailure`, because one re-imported account must not open the provider circuit breaker for its healthy siblings.
|
|
13
|
+
- **`codexCredentialError` discarded the raw error code.** Its wrapping message is deliberately generic, so without the cause the routing layer could not distinguish a dead account from any other `authentication_error`. The raw error is now carried as `cause`.
|
|
14
|
+
|
|
7
15
|
## [1.17.4] - 2026-09-18
|
|
8
16
|
|
|
9
17
|
### Fixed
|
|
@@ -296,6 +296,7 @@ export class GatewayRunner {
|
|
|
296
296
|
catch (e) {
|
|
297
297
|
const err = e instanceof GatewayError ? e : new GatewayError('upstream_error', e.message, { cause: e });
|
|
298
298
|
const quotaFailure = isQuotaFailure(err);
|
|
299
|
+
const credentialFailure = isCredentialFailure(err);
|
|
299
300
|
debugUpstream(ctx.requestId, 'ATTEMPT ERROR', [
|
|
300
301
|
`attempt=${i + 1}`,
|
|
301
302
|
`provider=${provider.name}`,
|
|
@@ -329,6 +330,11 @@ export class GatewayRunner {
|
|
|
329
330
|
// dead account on every request for hours (verified live) and answered "usage limited".
|
|
330
331
|
if (quotaFailure)
|
|
331
332
|
markQuotaExhausted(candidate, err.message);
|
|
333
|
+
// A dead refresh token also leaves the pool outright: the account cannot serve anything
|
|
334
|
+
// again until it is re-imported, so a retry may only be spent on a sibling account. Without
|
|
335
|
+
// this the retry landed on the same dead account and the request answered 502.
|
|
336
|
+
if (credentialFailure)
|
|
337
|
+
markCredentialsDead(candidate, err.message);
|
|
332
338
|
if (isUpstreamHealthFailure(err)) {
|
|
333
339
|
recordFailure(provider.id, provider.cbFailureThreshold, provider.cbCooldownSeconds);
|
|
334
340
|
if (candidate.codexAccountId)
|
|
@@ -1032,6 +1038,39 @@ export function markQuotaExhausted(candidate, message) {
|
|
|
1032
1038
|
if (candidate.qoderAccountId)
|
|
1033
1039
|
setQoderAccountHealth(candidate.qoderAccountId, 'down', reason, false);
|
|
1034
1040
|
}
|
|
1041
|
+
/**
|
|
1042
|
+
* A credential failure is a durable, per-account verdict — the same class as quota exhaustion.
|
|
1043
|
+
*
|
|
1044
|
+
* `withCodexCredentials` throws these as bare codes when the account's refresh token is dead
|
|
1045
|
+
* (Auth0 answers `refresh_token_invalidated`, "Your session has ended. Please log in again.").
|
|
1046
|
+
* The account cannot serve anything again until it is re-imported, so retrying it is pure waste
|
|
1047
|
+
* and leaving it in the pool answers 502 to every client.
|
|
1048
|
+
*
|
|
1049
|
+
* Live evidence (production, 2026-09-19): two accounts holding invalidated refresh tokens sat at
|
|
1050
|
+
* the head of the pool, so every request landed on one of them — `oauth_refresh_failed`,
|
|
1051
|
+
* classified `unknown`, `attempts_count=1`, 502 straight to the client (226 of 241 failed
|
|
1052
|
+
* requests had exactly one attempt while healthy accounts sat idle behind them).
|
|
1053
|
+
*
|
|
1054
|
+
* Deliberately NOT part of `isUpstreamHealthFailure`: one re-imported account must not open the
|
|
1055
|
+
* provider circuit breaker for its healthy siblings. The verdict is applied per account by
|
|
1056
|
+
* `markCredentialsDead` instead.
|
|
1057
|
+
*
|
|
1058
|
+
* Both spellings are checked because the code travels either raw (from `withCodexCredentials`
|
|
1059
|
+
* inside the gateway) or wrapped by `codexCredentialError`, which now keeps it as `cause`.
|
|
1060
|
+
*/
|
|
1061
|
+
export function isCredentialFailure(err) {
|
|
1062
|
+
const codes = /^(oauth_refresh_failed|invalid_refresh_response|credential_unavailable|account_not_found)$/;
|
|
1063
|
+
const cause = err.cause;
|
|
1064
|
+
return codes.test(err.message) || (typeof cause?.message === 'string' && codes.test(cause.message));
|
|
1065
|
+
}
|
|
1066
|
+
/** Takes an account with a dead refresh token out of the pool. `enabled=0` is what stops the churn. */
|
|
1067
|
+
export function markCredentialsDead(candidate, message) {
|
|
1068
|
+
const reason = redactString(message);
|
|
1069
|
+
if (candidate.codexAccountId)
|
|
1070
|
+
setCodexAccountHealth(candidate.codexAccountId, 'down', reason, false);
|
|
1071
|
+
if (candidate.qoderAccountId)
|
|
1072
|
+
setQoderAccountHealth(candidate.qoderAccountId, 'down', reason, false);
|
|
1073
|
+
}
|
|
1035
1074
|
/**
|
|
1036
1075
|
* Whether a failed attempt should advance to the next candidate.
|
|
1037
1076
|
*
|
|
@@ -1043,11 +1082,17 @@ export function markQuotaExhausted(candidate, message) {
|
|
|
1043
1082
|
* plan means no fallback.
|
|
1044
1083
|
*/
|
|
1045
1084
|
export function shouldRetryAttempt(comboPlan, err) {
|
|
1085
|
+
if (isCredentialFailure(err))
|
|
1086
|
+
return true;
|
|
1046
1087
|
if (isQuotaFailure(err))
|
|
1047
1088
|
return true;
|
|
1048
1089
|
return comboPlan ? shouldFallback(comboPlan, { type: classifyFailure(err), status: err.status }) : false;
|
|
1049
1090
|
}
|
|
1050
1091
|
export function classifyFailure(err) {
|
|
1092
|
+
// Checked before the type switch for the same reason as quota: a dead account is not a routing
|
|
1093
|
+
// decision the combo's triggers were meant to gate, and `unknown` is not a routing decision at all.
|
|
1094
|
+
if (isCredentialFailure(err))
|
|
1095
|
+
return 'credential';
|
|
1051
1096
|
// Checked first, before the type switch: a quota refusal arrives as `upstream_rate_limit` (a
|
|
1052
1097
|
// 429) or `upstream_error` (Codex rewraps it as a 502 with a 429 cause; Qoder uses code 112) and
|
|
1053
1098
|
// must not fall through to `http_status`/`unknown`, neither of which is a routing decision.
|
|
@@ -11,7 +11,9 @@ export function codexCredentialError(error) {
|
|
|
11
11
|
if (code === 'account_not_found')
|
|
12
12
|
return new GatewayError('invalid_request_error', 'Codex account not found', { status: 404 });
|
|
13
13
|
if (code === 'oauth_refresh_failed' || code === 'invalid_refresh_response' || code === 'credential_unavailable') {
|
|
14
|
-
|
|
14
|
+
// `cause` carries the raw credential code: the wrapping message is deliberately generic, so
|
|
15
|
+
// without it the routing layer cannot tell a dead account from any other authentication_error.
|
|
16
|
+
return new GatewayError('authentication_error', 'Codex credentials could not be refreshed — re-import the account', { status: 401, cause: error });
|
|
15
17
|
}
|
|
16
18
|
return error;
|
|
17
19
|
}
|