diff --git a/changelog.d/maintenance/kimi-health-check-jitter-determinism.md b/changelog.d/maintenance/kimi-health-check-jitter-determinism.md new file mode 100644 index 0000000000..5b57eaa422 --- /dev/null +++ b/changelog.d/maintenance/kimi-health-check-jitter-determinism.md @@ -0,0 +1 @@ +- **test(kimi):** the Kimi background health sweep no longer draws its refresh window inside the assertion. `checkKimiWebConnectionIfNeeded` spreads the refresh over `[60, 240)` seconds before expiry so a fleet of connections does not stampede the token endpoint, and the test used a token expiring in 90 seconds and asserted that a refresh happened — which is true only when the draw lands at 90 or above, i.e. 150 of the 180 possible values. Measured: the test fails 1 run in 6 (16.7% by construction; 4 of 20 local runs), and it is what the Node 26 nightly hit and reported as a Node-compat break (#11361). The spread is now `defaultKimiRefreshJitterSec()` and the window is injectable as `jitterSecFn`, so the test decides it instead of rolling for it; production behaviour is unchanged. Cases were added for a token outside the window and for the default spread's range. diff --git a/src/lib/tokenHealthCheckKimi.ts b/src/lib/tokenHealthCheckKimi.ts index 79916a1423..621abc6260 100644 --- a/src/lib/tokenHealthCheckKimi.ts +++ b/src/lib/tokenHealthCheckKimi.ts @@ -2,6 +2,21 @@ import { isKimiTokenExpiringSoon } from "@omniroute/open-sse/utils/kimiJwt.ts"; import { exchangeKimiRefreshToken } from "@/lib/kimi/tokenRefresh"; import { updateProviderConnection } from "@/lib/db/providers"; +/** + * Refresh window, spread over [60, 240) seconds before expiry so a fleet of + * connections does not stampede the token endpoint at the same instant. + * + * Kept as a named export rather than inline: it is the only nondeterminism in this + * path, and a caller that needs a decision it can predict — a test — has to be able + * to replace it. `tests/unit/token-health-check-kimi.test.ts` used a token expiring + * in 90 s and asserted a refresh, which is a coin the draw loses 1 in 6 times + * (a refresh needs `jitter >= 90`, i.e. 150 of the 180 possible values). It failed + * that way on the Node 26 nightly and was triaged as a Node-compat break. + */ +export function defaultKimiRefreshJitterSec(): number { + return 60 + Math.floor(Math.random() * 180); +} + export async function checkKimiWebConnectionIfNeeded(params: { conn: any; now: string; @@ -12,6 +27,12 @@ export async function checkKimiWebConnectionIfNeeded(params: { logPrefix: string; exchangeFn?: typeof exchangeKimiRefreshToken; persistFn?: typeof updateProviderConnection; + /** + * Seconds before expiry at which a refresh is triggered. Defaults to the random + * spread below; injectable so a caller — a test above all — can decide the window + * instead of drawing it. + */ + jitterSecFn?: () => number; }): Promise { const { conn, log, logWarn, getConnectionLogLabel, logPrefix } = params; const provider = String(conn?.provider || "").toLowerCase(); @@ -21,20 +42,23 @@ export async function checkKimiWebConnectionIfNeeded(params: { if (!refreshToken) return true; // Handled, but cannot refresh without refresh_token const token = conn.apiKey || conn.accessToken; - // Calculate jitter: random value between 60 and 240 seconds (1 to 4 min before expiry) - const jitterSec = 60 + Math.floor(Math.random() * 180); + const jitterSec = (params.jitterSecFn ?? defaultKimiRefreshJitterSec)(); const expiringSoon = isKimiTokenExpiringSoon(token, jitterSec); if (!expiringSoon) return true; - log(`${logPrefix} Kimi Web connection ${getConnectionLogLabel(conn)} token expiring soon; refreshing in background...`); + log( + `${logPrefix} Kimi Web connection ${getConnectionLogLabel(conn)} token expiring soon; refreshing in background...` + ); const exchange = params.exchangeFn || exchangeKimiRefreshToken; const persist = params.persistFn || updateProviderConnection; const res = await exchange(refreshToken); if (res.success && res.accessToken) { - log(`${logPrefix} Kimi Web connection ${getConnectionLogLabel(conn)} token refreshed successfully.`); + log( + `${logPrefix} Kimi Web connection ${getConnectionLogLabel(conn)} token refreshed successfully.` + ); await persist(conn.id, { apiKey: res.accessToken, accessToken: res.accessToken, diff --git a/tests/unit/token-health-check-kimi.test.ts b/tests/unit/token-health-check-kimi.test.ts index 87b8169528..aaf6950d07 100644 --- a/tests/unit/token-health-check-kimi.test.ts +++ b/tests/unit/token-health-check-kimi.test.ts @@ -1,6 +1,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { checkKimiWebConnectionIfNeeded } from "../../src/lib/tokenHealthCheckKimi.ts"; +import { + checkKimiWebConnectionIfNeeded, + defaultKimiRefreshJitterSec, +} from "../../src/lib/tokenHealthCheckKimi.ts"; describe("Kimi Background Health Sweep", () => { it("skips non-kimi-web connections", async () => { @@ -16,9 +19,12 @@ describe("Kimi Background Health Sweep", () => { assert.equal(handled, false); }); - it("triggers refresh when Kimi token is within jittered expiration window", async () => { + it("triggers refresh when the token is inside the refresh window", async () => { const nowSec = Math.floor(Date.now() / 1000); - // Token expiring in 90 seconds (within 60-240s window) + // Token expiring in 90 seconds. The window is decided by the caller here, not + // drawn: with the default spread of [60, 240) a 90 s token is refreshed only + // when the draw lands >= 90, which is 150 of 180 values — so this assertion + // used to fail 1 run in 6, and did so on the Node 26 nightly (#11361). const token = "eyJhbGciOiJIUzUxMiJ9." + Buffer.from(JSON.stringify({ exp: nowSec + 90, iat: nowSec })).toString("base64url") + @@ -38,6 +44,7 @@ describe("Kimi Background Health Sweep", () => { logError: () => {}, getConnectionLogLabel: () => "kimi-web-1", logPrefix: "[Test]", + jitterSecFn: () => 120, exchangeFn: async () => { calledRefresh = true; return { @@ -53,4 +60,51 @@ describe("Kimi Background Health Sweep", () => { assert.equal(handled, true); assert.equal(calledRefresh, true); }); + + it("leaves a token outside the window alone", async () => { + const nowSec = Math.floor(Date.now() / 1000); + const token = + "eyJhbGciOiJIUzUxMiJ9." + + Buffer.from(JSON.stringify({ exp: nowSec + 900, iat: nowSec })).toString("base64url") + + ".sig"; + + let calledRefresh = false; + const handled = await checkKimiWebConnectionIfNeeded({ + conn: { + id: "kimi-conn-2", + provider: "kimi-web", + apiKey: token, + refreshToken: "refresh_123", + }, + now: new Date().toISOString(), + log: () => {}, + logWarn: () => {}, + logError: () => {}, + getConnectionLogLabel: () => "kimi-web-2", + logPrefix: "[Test]", + jitterSecFn: () => 240, + exchangeFn: async () => { + calledRefresh = true; + return { + success: true, + accessToken: "new_token", + refreshToken: "new_refresh", + expiresAtSec: nowSec + 900, + }; + }, + persistFn: async () => {}, + }); + + // Handled (it is a kimi-web connection) but not refreshed. + assert.equal(handled, true); + assert.equal(calledRefresh, false); + }); + + it("the default spread stays inside [60, 240)", () => { + for (let i = 0; i < 2_000; i++) { + const jitter = defaultKimiRefreshJitterSec(); + assert.ok(Number.isInteger(jitter), `jitter must be whole seconds, got ${jitter}`); + assert.ok(jitter >= 60 && jitter < 240, `jitter out of range: ${jitter}`); + } + }); });