From 0f3060729bd73eb965490a656b3c1a977560cdae Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:59:22 -0300 Subject: [PATCH] fix(api): fall back to existing access token for any OAuth provider on refresh failure (#4786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. --- src/lib/usage/providerLimits.ts | 7 +- ...stoken-fallback-on-refresh-failure.test.ts | 85 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/unit/provider-limits-accesstoken-fallback-on-refresh-failure.test.ts diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 40898f7727..7bf6371f87 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -327,7 +327,12 @@ export async function refreshAndUpdateCredentials( | null; if (!refreshResult) { - if (connection.provider === "github" && connection.accessToken) { + // Refresh failed but we still have an accessToken — fall back to the + // existing token for ANY OAuth provider (graceful degradation) instead of + // hard-failing. Previously this was qualified to `provider === "github"`, + // which left every other provider stuck on a transient refresh failure even + // when a usable access token was still on hand. + if (connection.accessToken) { return { connection, refreshed: false }; } throw withStatus( diff --git a/tests/unit/provider-limits-accesstoken-fallback-on-refresh-failure.test.ts b/tests/unit/provider-limits-accesstoken-fallback-on-refresh-failure.test.ts new file mode 100644 index 0000000000..df8fd531c8 --- /dev/null +++ b/tests/unit/provider-limits-accesstoken-fallback-on-refresh-failure.test.ts @@ -0,0 +1,85 @@ +/** + * Graceful-degradation fallback when `refreshCredentials` returns null but the + * connection still holds a usable `accessToken`. + * + * Previously this fallback was qualified to `connection.provider === "github"`: + * every OTHER OAuth provider whose refresh momentarily failed was hard-failed + * with "Failed to refresh credentials. Please re-authorize the connection.", + * even though a still-valid access token was on hand. The fix drops the + * github-specific qualifier so the fallback applies to ANY provider that still + * has an accessToken. + * + * Before the fix this test fails: a non-github provider would throw the 401 + * re-authorize error. After the fix it returns `{ refreshed: false }`. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +process.env.DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-accesstoken-fallback-") +); + +const { getExecutor } = await import("../../open-sse/executors/index.ts"); +const { refreshAndUpdateCredentials } = await import("../../src/lib/usage/providerLimits.ts"); + +// `gemini` is a non-rotating (no rotation lock group), non-github OAuth provider, +// so `shouldAttemptRotatingRefresh` is true and the refresh path is reached. +function geminiConnection() { + return { + id: "gemini-fallback-1", + provider: "gemini", + accessToken: "still-valid-access-token", + refreshToken: "some-refresh-token", + tokenExpiresAt: new Date(Date.now() - 60_000).toISOString(), // expired → needsRefresh fires + providerSpecificData: {}, + }; +} + +test("falls back to the existing accessToken for a non-github provider when refreshCredentials returns null", async () => { + const exec = getExecutor("gemini"); + const origNeeds = exec.needsRefresh; + const origRefresh = exec.refreshCredentials; + exec.needsRefresh = () => true; // force the refresh attempt + exec.refreshCredentials = async () => null; // upstream refresh failed + try { + const result = await refreshAndUpdateCredentials(geminiConnection(), { + allowRotatingRefresh: true, + }); + assert.equal( + result.refreshed, + false, + "a non-github provider with an accessToken must fall back, not throw" + ); + assert.equal( + result.connection.accessToken, + "still-valid-access-token", + "the existing access token must be preserved" + ); + } finally { + exec.needsRefresh = origNeeds; + exec.refreshCredentials = origRefresh; + } +}); + +test("still throws when refresh fails AND there is no accessToken to fall back on", async () => { + const exec = getExecutor("gemini"); + const origNeeds = exec.needsRefresh; + const origRefresh = exec.refreshCredentials; + exec.needsRefresh = () => true; + exec.refreshCredentials = async () => null; + try { + const conn = geminiConnection(); + conn.accessToken = ""; // no usable token → must not silently degrade + await assert.rejects( + () => refreshAndUpdateCredentials(conn, { allowRotatingRefresh: true }), + /Failed to refresh credentials/, + "without an accessToken the refresh failure must surface as an error" + ); + } finally { + exec.needsRefresh = origNeeds; + exec.refreshCredentials = origRefresh; + } +});