diff --git a/apps/api/src/api/routes/downstream-token.integration.test.ts b/apps/api/src/api/routes/downstream-token.integration.test.ts index 06d427bd3f..a3306d1b8a 100644 --- a/apps/api/src/api/routes/downstream-token.integration.test.ts +++ b/apps/api/src/api/routes/downstream-token.integration.test.ts @@ -9,7 +9,13 @@ import { seedCommonTestPgFixtures, } from "../../database/test-db-pg"; import type { StudioDatabase } from "../../database"; -import { createDownstreamTokenRoutes } from "./downstream-token"; +const mockClearRefreshBackoff = mock((_connectionId?: string) => {}); +mock.module("../../oauth/token-refresh", () => ({ + canRefresh: () => false, + clearRefreshBackoff: mockClearRefreshBackoff, +})); + +const { createDownstreamTokenRoutes } = await import("./downstream-token"); describe("Downstream Token Routes", () => { let database: StudioDatabase; @@ -104,6 +110,20 @@ describe("Downstream Token Routes", () => { expect(body.expiresAt).toBeTruthy(); }); + it("clears the token-refresh backoff entry on save", async () => { + // Regression: a stale backoff from the previous dead token would falsely bounce a refresh right after reconnect. + mockClearRefreshBackoff.mockClear(); + + const res = await app.request("/connections/conn_1/oauth-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accessToken: "at", refreshToken: "rt" }), + }); + + expect(res.status).toBe(200); + expect(mockClearRefreshBackoff).toHaveBeenCalledWith("conn_1"); + }); + it("stores expiresIn: 0 as already-expired, not never-expiring", async () => { const res = await app.request("/connections/conn_1/oauth-token", { method: "POST", diff --git a/apps/api/src/api/routes/downstream-token.ts b/apps/api/src/api/routes/downstream-token.ts index aa60d6986e..94ef581bbe 100644 --- a/apps/api/src/api/routes/downstream-token.ts +++ b/apps/api/src/api/routes/downstream-token.ts @@ -7,7 +7,7 @@ import { Hono } from "hono"; import type { StudioContext } from "../../core/studio-context"; -import { canRefresh } from "../../oauth/token-refresh"; +import { canRefresh, clearRefreshBackoff } from "../../oauth/token-refresh"; import { resolveOriginTokenEndpoint } from "../../oauth/resolve-token-endpoint"; import { DownstreamTokenStorage, @@ -138,6 +138,9 @@ export const createDownstreamTokenRoutes = () => { const token = await tokenStorage.upsert(tokenData); + // A freshly saved token has never failed - drop any backoff armed by the previous dead token. + clearRefreshBackoff(connectionId); + return c.json({ success: true, expiresAt: token.expiresAt,