Skip to content

Commit 3c307df

Browse files
committed
feat(credentials): keep valid client-credential tokens through refresh outages
The per-instance client-credential mint cache now: - serves a cached token that is inside its five-minute early-refresh window but not expired when the refresh fails with an outage or throttling, as MSAL, Azure.Core and botocore do; a refusal of the credentials still fails - retries a quick transient mint failure once after about a second - replays failures by kind instead of a flat 30 s: a refusal 30 s, a throttled answer for its Retry-After (1 s to 5 min), an outage 5 s growing to 30 s while it continues The policy lives in lib/credentials/token-request-resilience.ts so the coordinated resolver can share it.
1 parent ee743a4 commit 3c307df

3 files changed

Lines changed: 362 additions & 30 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { interruptibleSleep } from '@sim/utils/helpers'
2+
import { backoffWithJitter } from '@sim/utils/retry'
3+
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
4+
5+
/**
6+
* How a failed token request is retried:
7+
* - `permanent`: the provider refused the credentials or the account (or the failure is not a
8+
* provider answer at all); asking again cannot help until the secret changes.
9+
* - `throttled`: a 429, or an outage with a `Retry-After`; the provider said when to come back.
10+
* - `transient`: any other outage (5xx, timeout, network failure, malformed token response).
11+
*/
12+
export type TokenRequestFailureKind = 'permanent' | 'throttled' | 'transient'
13+
14+
export interface TokenRequestFailure {
15+
kind: TokenRequestFailureKind
16+
/** Non-permanent failures in a row, this one included; 1 for a permanent failure. */
17+
consecutiveFailures: number
18+
/** How long the failure is replayed before the provider is asked again. */
19+
replayMs: number
20+
}
21+
22+
/**
23+
* A token with less than this left is treated as expired and never served, so clock skew and a
24+
* call already in flight cannot carry it past its expiry.
25+
*/
26+
export const TOKEN_EXPIRY_MARGIN_MS = 30_000
27+
28+
/**
29+
* How long a remembered failure outlives its replay, so the next failure continues the streak
30+
* and backs off longer. A failure after a quieter gap starts a new streak.
31+
*/
32+
export const TOKEN_FAILURE_STREAK_RETENTION_MS = 5 * 60_000
33+
34+
/** A refusal of the credentials is replayed this long, so a revoked secret does not hammer the provider. */
35+
const PERMANENT_FAILURE_REPLAY_MS = 30_000
36+
/**
37+
* A provider's `Retry-After` is honored for at most five minutes. Microsoft Entra ID, the most
38+
* explicit documented case, throttles client-credentials token requests with `Retry-After: 60`;
39+
* five minutes leaves room for per-minute provider windows while a proxy or a misbehaving provider
40+
* answering with hours cannot lock every workflow on an application out for that long.
41+
*/
42+
const RETRY_AFTER_BOUNDS = { baseMs: 1_000, maxMs: 5 * 60_000 } as const
43+
/**
44+
* An outage is retried after 5 s at first (Entra's resilience guidance: the first back-off retry at
45+
* least 5 s after the response), doubling while it continues, up to 30 s: the flat replay every
46+
* failure used to get, so no outage is replayed longer than before.
47+
*/
48+
const OUTAGE_BACKOFF = { baseMs: 5_000, maxMs: 30_000 } as const
49+
/** The pause before the one in-request retry: MSAL's documented one-second retry-once. */
50+
const IN_REQUEST_RETRY_DELAY = { baseMs: 1_000, maxMs: 1_000 } as const
51+
/**
52+
* A failed attempt is retried only when it failed this quickly. A timed-out attempt (10 s) is
53+
* never retried in-request, and a retried exchange holding a coordinated application lock stays
54+
* within the 15 s its waiters allow: 2 s, the 1.2 s pause, and one more 10 s attempt.
55+
*/
56+
const IN_REQUEST_RETRY_BUDGET_MS = 2_000
57+
58+
/** Classifies a failed token request; see {@link TokenRequestFailureKind}. */
59+
export function classifyTokenRequestFailure(error: unknown): TokenRequestFailureKind {
60+
if (!(error instanceof TokenServiceAccountValidationError)) return 'permanent'
61+
if (error.code !== 'provider_unavailable') return 'permanent'
62+
return error.status === 429 || error.retryAfterMs !== undefined ? 'throttled' : 'transient'
63+
}
64+
65+
/**
66+
* Decides how long a failed token request is replayed. A failure carrying `retryAfterMs` (the
67+
* provider's `Retry-After`, or what remains of a replay another layer already decided) is replayed
68+
* that long within 1 s to 5 min. Otherwise a permanent failure is replayed 30 s and an outage backs
69+
* off from 5 s to 30 s with jitter as it continues. `previousConsecutiveFailures` counts the
70+
* non-permanent failures immediately before this one (0 after a success or a permanent failure).
71+
*/
72+
export function assessTokenRequestFailure(
73+
error: unknown,
74+
previousConsecutiveFailures = 0
75+
): TokenRequestFailure {
76+
const kind = classifyTokenRequestFailure(error)
77+
const consecutiveFailures = kind === 'permanent' ? 1 : previousConsecutiveFailures + 1
78+
const retryAfterMs =
79+
error instanceof TokenServiceAccountValidationError ? error.retryAfterMs : undefined
80+
if (retryAfterMs !== undefined) {
81+
return {
82+
kind,
83+
consecutiveFailures,
84+
replayMs: backoffWithJitter(1, retryAfterMs, RETRY_AFTER_BOUNDS),
85+
}
86+
}
87+
if (kind === 'permanent') {
88+
return { kind, consecutiveFailures, replayMs: PERMANENT_FAILURE_REPLAY_MS }
89+
}
90+
return {
91+
kind,
92+
consecutiveFailures,
93+
replayMs: Math.min(
94+
backoffWithJitter(consecutiveFailures, null, OUTAGE_BACKOFF),
95+
OUTAGE_BACKOFF.maxMs
96+
),
97+
}
98+
}
99+
100+
/**
101+
* Runs a token request, retrying a transient failure once after about a second when the failed
102+
* attempt was quick (see {@link IN_REQUEST_RETRY_BUDGET_MS}). Throttled and permanent failures,
103+
* and an aborted `signal`, are never retried.
104+
*/
105+
export async function requestTokenWithRetry<T>(
106+
request: () => Promise<T>,
107+
signal?: AbortSignal
108+
): Promise<T> {
109+
const startedAt = Date.now()
110+
try {
111+
return await request()
112+
} catch (error) {
113+
signal?.throwIfAborted()
114+
if (
115+
classifyTokenRequestFailure(error) !== 'transient' ||
116+
Date.now() - startedAt > IN_REQUEST_RETRY_BUDGET_MS
117+
) {
118+
throw error
119+
}
120+
await interruptibleSleep(backoffWithJitter(1, null, IN_REQUEST_RETRY_DELAY), signal)
121+
signal?.throwIfAborted()
122+
return request()
123+
}
124+
}

‎apps/sim/lib/oauth/credential-service-cancellation.test.ts‎

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import { credential } from '@sim/db/schema'
22
import { createDeferred } from '@sim/testing/helpers/deferred'
33
import { queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock'
44
import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock'
5-
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
66
import {
77
CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE,
88
RAMP_SERVICE_ACCOUNT_PROVIDER_ID,
99
} from '@/lib/credentials/client-credential-accounts/descriptors'
1010
import * as clientAccounts from '@/lib/credentials/client-credential-accounts/server'
11+
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
1112
import {
1213
forgetRejectedClientCredentialToken,
1314
resolveServiceAccountToken,
@@ -152,3 +153,173 @@ describe('client credential token cache', () => {
152153
}
153154
})
154155
})
156+
157+
describe('client credential token refresh after a failed mint', () => {
158+
const refused = () =>
159+
new TokenServiceAccountValidationError('invalid_credentials', 401, { step: 'mint' })
160+
const unavailable = (status = 502, retryAfterMs?: number) =>
161+
new TokenServiceAccountValidationError(
162+
'provider_unavailable',
163+
status,
164+
{ step: 'mint' },
165+
{
166+
retryAfterMs,
167+
}
168+
)
169+
170+
/** Resolves with fake timers running, so an in-mint retry pause elapses without real waiting. */
171+
async function resolveFor(credentialId: string) {
172+
queueTableRows(credential, [storedCredential])
173+
const outcome = resolveServiceAccountToken(credentialId, RAMP_SERVICE_ACCOUNT_PROVIDER_ID).then(
174+
(value) => ({ value }),
175+
(error: unknown) => ({ error })
176+
)
177+
await vi.runAllTimersAsync()
178+
return outcome
179+
}
180+
181+
function minter() {
182+
const mint = vi.fn<clientAccounts.ClientCredentialAccountMinter>()
183+
vi.spyOn(clientAccounts, 'getClientCredentialAccountMinter').mockReturnValue(mint)
184+
return mint
185+
}
186+
187+
beforeEach(() => {
188+
vi.useFakeTimers()
189+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
190+
})
191+
192+
afterEach(() => {
193+
vi.useRealTimers()
194+
})
195+
196+
it('keeps serving a still-valid token while its early refresh fails transiently', async () => {
197+
const mint = minter()
198+
mint.mockResolvedValueOnce({ accessToken: 'current-token', expiresInSeconds: 600 })
199+
await expect(resolveFor('ramp-refresh-outage')).resolves.toEqual({
200+
value: { accessToken: 'current-token' },
201+
})
202+
mint.mockRejectedValue(unavailable())
203+
vi.advanceTimersByTime(6 * 60_000)
204+
205+
await expect(resolveFor('ramp-refresh-outage')).resolves.toEqual({
206+
value: { accessToken: 'current-token' },
207+
})
208+
const askedDuringRefresh = mint.mock.calls.length
209+
await expect(resolveFor('ramp-refresh-outage')).resolves.toEqual({
210+
value: { accessToken: 'current-token' },
211+
})
212+
expect(askedDuringRefresh).toBeGreaterThan(1)
213+
expect(mint).toHaveBeenCalledTimes(askedDuringRefresh)
214+
})
215+
216+
it('reports the failed refresh once the cached token has run out', async () => {
217+
const mint = minter()
218+
mint.mockResolvedValueOnce({ accessToken: 'current-token', expiresInSeconds: 600 })
219+
await resolveFor('ramp-refresh-expired')
220+
mint.mockRejectedValue(unavailable())
221+
vi.advanceTimersByTime(600_000 - 20_000)
222+
223+
await expect(resolveFor('ramp-refresh-expired')).resolves.toEqual({
224+
error: expect.objectContaining({ code: 'provider_unavailable' }),
225+
})
226+
})
227+
228+
it('stops serving the cached token once the provider refuses the credentials', async () => {
229+
const mint = minter()
230+
mint.mockResolvedValueOnce({ accessToken: 'current-token', expiresInSeconds: 600 })
231+
await resolveFor('ramp-refresh-refused')
232+
mint.mockRejectedValue(refused())
233+
vi.advanceTimersByTime(6 * 60_000)
234+
235+
await expect(resolveFor('ramp-refresh-refused')).resolves.toEqual({
236+
error: expect.objectContaining({ code: 'invalid_credentials' }),
237+
})
238+
expect(mint).toHaveBeenCalledTimes(2)
239+
})
240+
241+
it('retries a quick transient failure once inside the mint', async () => {
242+
const mint = minter()
243+
mint.mockRejectedValueOnce(unavailable(503)).mockResolvedValueOnce(token)
244+
245+
await expect(resolveFor('ramp-retry-once')).resolves.toEqual({
246+
value: { accessToken: token.accessToken },
247+
})
248+
expect(mint).toHaveBeenCalledTimes(2)
249+
})
250+
251+
it('does not retry an attempt that took as long as a provider timeout', async () => {
252+
const mint = minter()
253+
mint.mockImplementation(async () => {
254+
vi.setSystemTime(Date.now() + 10_000)
255+
throw unavailable()
256+
})
257+
258+
await expect(resolveFor('ramp-timed-out')).resolves.toEqual({
259+
error: expect.objectContaining({ code: 'provider_unavailable' }),
260+
})
261+
expect(mint).toHaveBeenCalledOnce()
262+
})
263+
264+
it('replays a refusal of the credentials for 30 seconds without retrying it', async () => {
265+
const mint = minter()
266+
mint.mockRejectedValue(refused())
267+
268+
await resolveFor('ramp-refused')
269+
vi.advanceTimersByTime(29_000)
270+
await expect(resolveFor('ramp-refused')).resolves.toEqual({
271+
error: expect.objectContaining({ code: 'invalid_credentials' }),
272+
})
273+
expect(mint).toHaveBeenCalledOnce()
274+
275+
vi.advanceTimersByTime(2_000)
276+
await resolveFor('ramp-refused')
277+
expect(mint).toHaveBeenCalledTimes(2)
278+
})
279+
280+
it.each([
281+
['as long as a throttled provider asks', 120_000, 120_000],
282+
['no longer than five minutes, whatever the provider asks', 3_600_000, 300_000],
283+
])('waits %s before asking again', async (_case, retryAfterMs, waitMs) => {
284+
const mint = minter()
285+
const credentialId = `ramp-throttled-${retryAfterMs}`
286+
mint.mockRejectedValueOnce(unavailable(429, retryAfterMs)).mockResolvedValueOnce(token)
287+
288+
await expect(resolveFor(credentialId)).resolves.toEqual({
289+
error: expect.objectContaining({ code: 'provider_unavailable', status: 429 }),
290+
})
291+
vi.advanceTimersByTime(waitMs - 1_000)
292+
await expect(resolveFor(credentialId)).resolves.toEqual({
293+
error: expect.objectContaining({ status: 429 }),
294+
})
295+
expect(mint).toHaveBeenCalledOnce()
296+
297+
vi.advanceTimersByTime(2_000)
298+
await expect(resolveFor(credentialId)).resolves.toEqual({
299+
value: { accessToken: token.accessToken },
300+
})
301+
})
302+
303+
it('backs off an outage briefly at first, longer while it lasts, and never past 30 seconds', async () => {
304+
const mint = minter()
305+
mint.mockRejectedValue(unavailable())
306+
307+
await resolveFor('ramp-outage')
308+
const firstAttempt = mint.mock.calls.length
309+
vi.advanceTimersByTime(6_001)
310+
await resolveFor('ramp-outage')
311+
const secondAttempt = mint.mock.calls.length
312+
expect(secondAttempt).toBeGreaterThan(firstAttempt)
313+
314+
vi.advanceTimersByTime(6_001)
315+
await resolveFor('ramp-outage')
316+
expect(mint).toHaveBeenCalledTimes(secondAttempt)
317+
318+
for (let attempt = 0; attempt < 6; attempt++) {
319+
const asked = mint.mock.calls.length
320+
vi.advanceTimersByTime(30_001)
321+
await resolveFor('ramp-outage')
322+
expect(mint.mock.calls.length).toBeGreaterThan(asked)
323+
}
324+
})
325+
})

0 commit comments

Comments
 (0)