mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
The proactive health-check refresh deactivation branch nulled the stored refresh_token on any unrecoverable error (e.g. invalid_grant). That was only meant for rotating one-time-use tokens (Codex/OpenAI); for non-rotating Google-family providers (gemini-cli/antigravity/gemini) it destroyed the user's only recovery artifact, leaving the connection permanently showing 'No valid refresh token available'. Gate the null on isRotatingProvider so non-rotating tokens are preserved. Closes #3679
This commit is contained in:
committed by
GitHub
parent
d8b6ae3688
commit
d1088f0dc6
@@ -8,6 +8,8 @@
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- fix(oauth): stop nulling the stored `refresh_token` of non-rotating providers when a proactive health-check refresh fails with `invalid_grant`. The destructive `refreshToken: null` write in `tokenHealthCheck` was only meant for rotating one-time-use tokens (Codex/OpenAI), but it also fired for Google-family providers (gemini-cli / antigravity / gemini) whose refresh tokens are non-rotating. Once nulled, the connection reported "No valid refresh token available" and could never recover even after re-activation. The token is now preserved (gated on `isRotatingProvider`) so it stays as the recovery artifact. ([#3679](https://github.com/diegosouzapw/OmniRoute/issues/3679) — thanks @3xa228148)
|
||||
|
||||
- fix(dashboard): self-host the Material Symbols icon font instead of loading it from the Google Fonts CDN. On networks where `fonts.googleapis.com` is unreachable (e.g. mainland China), the icon ligature font never loaded, so every icon rendered as its literal text name (`smart_toy`, `visibility`, …) and the layout broke — especially after importing many models. The font is now bundled locally via the `material-symbols` package (`@import "material-symbols/outlined.css"` in `globals.css`), removing the runtime CDN dependency. ([#3695](https://github.com/diegosouzapw/OmniRoute/issues/3695) — thanks @lqyiwwx)
|
||||
|
||||
- fix(antigravity): skip Google One AI credits retry on `full_quota_exhausted` verdict — antigravity executor now calls `decide429()` before attempting the credits retry so that a quota-exhausted account (24h cooldown) bypasses the extra upstream HTTP call instead of hanging for up to ~41s. Also persists the cooldown in the DB via `setConnectionRateLimitUntil` so post-restart routing skips exhausted connections without re-learning the hard way. Bonus: `antigravity429Engine` now recognises the real Antigravity "Individual quota reached. Contact your administrator to enable overages." error message as `quota_exhausted`. ([#3707](https://github.com/diegosouzapw/OmniRoute/issues/3707) — thanks @andrea-kingautomation)
|
||||
|
||||
@@ -543,13 +543,21 @@ export async function checkConnection(conn) {
|
||||
await updateProviderConnection(conn.id, {
|
||||
lastHealthCheckAt: now,
|
||||
testStatus: "expired",
|
||||
lastError: `Refresh token consumed (${result.error}). Please re-authenticate this account.`,
|
||||
lastError: isRotatingProvider
|
||||
? `Refresh token consumed (${result.error}). Please re-authenticate this account.`
|
||||
: `Refresh token rejected (${result.error}). Please re-authenticate this account.`,
|
||||
lastErrorAt: now,
|
||||
lastErrorType: result.error,
|
||||
lastErrorSource: "oauth",
|
||||
errorCode: result.error,
|
||||
isActive: false,
|
||||
refreshToken: null,
|
||||
// Only rotating-token providers (Codex/OpenAI/etc.) have single-use refresh
|
||||
// tokens that are genuinely consumed and worthless after a failed refresh, so
|
||||
// clearing them is safe. For non-rotating providers (Google: gemini-cli /
|
||||
// antigravity / gemini) the stored refresh_token is the user's only recovery
|
||||
// artifact — nulling it caused #3679 (the connection reports "No valid refresh
|
||||
// token available" and can never recover even after re-activation). Preserve it.
|
||||
...(isRotatingProvider ? { refreshToken: null } : {}),
|
||||
});
|
||||
logError(
|
||||
`${LOG_PREFIX} ✗ ${conn.provider}/${getConnectionLogLabel(conn)} — ` +
|
||||
|
||||
@@ -469,3 +469,60 @@ test("checkConnection skips providers listed in OMNIROUTE_HEALTHCHECK_SKIP_PROVI
|
||||
if (prevSkip === undefined) delete process.env.OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS;
|
||||
else process.env.OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS = prevSkip;
|
||||
});
|
||||
|
||||
// Regression for #3679: a non-rotating (Google-family) provider whose proactive
|
||||
// refresh fails with invalid_grant used to have its refresh_token NULLED, leaving
|
||||
// the connection unrecoverable ("No valid refresh token available"). The null was
|
||||
// only meant for rotating one-time-use tokens (Codex/OpenAI). Non-rotating providers
|
||||
// must keep the stored refresh_token as the recovery artifact.
|
||||
test("checkConnection preserves refresh_token for non-rotating providers on unrecoverable error (#3679)", async () => {
|
||||
await resetStorage();
|
||||
|
||||
const providerId = "custom-nonrotating-3679"; // NOT in ROTATING_REFRESH_PROVIDERS
|
||||
let refreshCount = 0;
|
||||
|
||||
await withHttpServer(
|
||||
(_req, res) => {
|
||||
refreshCount += 1;
|
||||
// Google returns invalid_grant → isUnrecoverableRefreshError() is true.
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "invalid_grant", error_description: "Bad Request" }));
|
||||
},
|
||||
async (tokenServer) => {
|
||||
await withPatchedProvider(
|
||||
providerId,
|
||||
{
|
||||
tokenUrl: `${tokenServer.url}/token`,
|
||||
clientId: "nonrotating-client-id",
|
||||
clientSecret: "nonrotating-client-secret",
|
||||
},
|
||||
async () => {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: providerId,
|
||||
authType: "oauth",
|
||||
name: "Non-rotating Account",
|
||||
email: "nonrotating@example.com",
|
||||
accessToken: "expired-access-token",
|
||||
refreshToken: "rt-preserve-3679",
|
||||
// Already expired → proactive refresh runs AND the still-valid guard fails,
|
||||
// so execution reaches the deactivation branch that used to null the token.
|
||||
expiresAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await tokenHealthCheck.checkConnection(connection);
|
||||
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
assert.equal(refreshCount, 1, "the expired token must trigger a refresh attempt");
|
||||
assert.equal(updated?.testStatus, "expired", "should reach the unrecoverable branch");
|
||||
// The fix: the refresh_token is PRESERVED (was nulled before the fix).
|
||||
assert.equal(
|
||||
updated?.refreshToken,
|
||||
"rt-preserve-3679",
|
||||
"non-rotating provider must keep its refresh_token for recovery"
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user