fix(oauth): preserve non-rotating providers' refresh_token on unrecoverable refresh (#3679) (#3766)

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:
Diego Rodrigues de Sa e Souza
2026-06-13 08:32:51 -03:00
committed by GitHub
parent d8b6ae3688
commit d1088f0dc6
3 changed files with 69 additions and 2 deletions

View File

@@ -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)

View File

@@ -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)}` +

View File

@@ -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"
);
}
);
}
);
});