Skip to content

Commit c5f2e1f

Browse files
committed
fix(credentials): pace throttled and Vanta token retries and replay remembered failures without the lock
- A 429 that names no Retry-After is replayed for at least a minute, doubling up to the five-minute cap, instead of the 5 s outage curve. - The outage backoff never undercuts Entra's 5 s first-retry floor through jitter. - Vanta asks for a token at most once per 15 s while exchanges fail (no in-request retry, replay floored), within its documented 5 tokens a minute, through a new optional minExchangeIntervalMs on the coordinated resolver. - A remembered failure and the still-valid fallback are served from committed rows before the application lock is taken, so a refresh outage no longer serializes every resolution behind the lock. - retryAfterMs now only carries a provider's Retry-After; a replayed memo reports its remainder as the additive replayForMs, which the per-instance layer honors exactly. - One decode/verify path for stored tokens; the stored-failure permanence check uses isPermanentTokenFailureCode instead of building an error.
1 parent 5d21da6 commit c5f2e1f

8 files changed

Lines changed: 361 additions & 99 deletions

File tree

‎apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,15 @@ beforeAll(async () => {
142142
response.end(JSON.stringify({ error: 'rate_limited' }))
143143
return
144144
}
145+
if (parsed.client_id.endsWith('-rate-limited')) {
146+
response.writeHead(429, { 'Content-Type': 'application/json' })
147+
response.end(JSON.stringify({ error: 'rate_limited' }))
148+
return
149+
}
150+
if (parsed.client_id.endsWith('-outage')) {
151+
response.writeHead(502).end()
152+
return
153+
}
145154
await sleep(15)
146155
const identity = `${request.headers['x-provider-origin']}:${parsed.client_id}`
147156
const issued = (applications.get(identity)?.issued ?? 0) + 1
@@ -344,6 +353,33 @@ describe('Vanta application token lifecycle against shared storage', () => {
344353
expect(stored.expiresAt.getTime() - Date.now()).toBeLessThanOrEqual(90_000)
345354
})
346355

356+
it('waits at least a minute after a Vanta 429 that names no Retry-After', async () => {
357+
const input = fields('rate-limited')
358+
const keys = previousBuildKeys(input, READ)
359+
const before = Date.now()
360+
361+
await expect(resolveVantaClientToken(input)).rejects.toMatchObject({ status: 429 })
362+
363+
const stored = await readStoredRow(keys.failureKey)
364+
expect(stored.expiresAt.getTime() - before).toBeGreaterThanOrEqual(60_000)
365+
})
366+
367+
it('asks a failing Vanta for a token at most once per 15 seconds, within its 5 tokens a minute', async () => {
368+
const input = fields('outage')
369+
const keys = previousBuildKeys(input, READ)
370+
const count = tokenRequests
371+
const before = Date.now()
372+
373+
await expect(resolveVantaClientToken(input)).rejects.toMatchObject({
374+
code: 'provider_unavailable',
375+
status: 502,
376+
})
377+
378+
expect(tokenRequests).toBe(count + 1)
379+
const stored = await readStoredRow(keys.failureKey)
380+
expect(stored.expiresAt.getTime() - before).toBeGreaterThanOrEqual(15_000)
381+
})
382+
347383
it('fails closed when coordination storage fails', async () => {
348384
const count = tokenRequests
349385
vi.spyOn(db, 'transaction').mockRejectedValueOnce(new Error('database unavailable'))

‎apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ import {
1515

1616
const SCOPES = VANTA_PERMISSION_OPTIONS.map((option) => option.value)
1717
const EXPIRY_BUFFER_MS = 30_000
18+
/**
19+
* Vanta answers 429 when an application mints more than 5 tokens a minute, and every token it
20+
* issues revokes the previous one, so a failing exchange is retried at most every 15 s (4 a minute).
21+
*/
22+
const MIN_EXCHANGE_INTERVAL_MS = 15_000
1823
const STEP = 'vanta_token_mint'
1924

2025
export interface VantaClientTokenFields {
@@ -152,6 +157,7 @@ export async function resolveVantaClientToken(
152157
step: STEP,
153158
signal: options.signal,
154159
minRemainingMs: EXPIRY_BUFFER_MS,
160+
minExchangeIntervalMs: MIN_EXCHANGE_INTERVAL_MS,
155161
failureKeyQualifier: scope,
156162
accessTokenDigestTag: apiDomain,
157163
pinnedState: {

‎apps/sim/lib/credentials/coordinated-client-token.integration.ts‎

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createHash } from 'node:crypto'
22
import { db } from '@sim/db'
33
import { clientCredentialToken } from '@sim/db/schema'
4+
import { createDeferred } from '@sim/testing/helpers/deferred'
45
import { sleep } from '@sim/utils/helpers'
56
import { generateId } from '@sim/utils/id'
67
import { isRecordLike } from '@sim/utils/object'
@@ -14,6 +15,7 @@ import {
1415
} from '@/lib/credentials/coordinated-client-token'
1516
import { verifyAndBuildServiceAccountSecret } from '@/lib/credentials/service-account-secret'
1617
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
18+
import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
1719

1820
const NAMESPACE = `fixture${generateId().replaceAll('-', '')}`
1921
const ownedKeyPrefixes = new Set<string>([`${NAMESPACE}:`])
@@ -181,9 +183,11 @@ describe('coordinated client-credentials token against shared storage', () => {
181183
await expect(
182184
resolveCoordinatedClientToken(input('memo', exchange, 'revoked'))
183185
).rejects.toMatchObject({ code: 'invalid_credentials' })
184-
await expect(
185-
resolveCoordinatedClientToken(input('memo', exchange, 'revoked'))
186-
).rejects.toMatchObject({ code: 'invalid_credentials' })
186+
const replayed = await resolveCoordinatedClientToken(input('memo', exchange, 'revoked')).catch(
187+
(error: unknown) => error
188+
)
189+
expect(replayed).toMatchObject({ code: 'invalid_credentials', replayForMs: expect.any(Number) })
190+
expect(replayed).not.toHaveProperty('retryAfterMs', expect.anything())
187191
expect(exchange).toHaveBeenCalledOnce()
188192

189193
await db
@@ -371,7 +375,7 @@ describe('coordinated token resolution while the provider fails', () => {
371375
).rejects.toMatchObject({ code: 'provider_unavailable', status: 429 })
372376
await expect(
373377
resolveCoordinatedClientToken(input(application, exchange))
374-
).rejects.toMatchObject({ status: 429, retryAfterMs: expect.any(Number) })
378+
).rejects.toMatchObject({ status: 429, replayForMs: expect.any(Number) })
375379

376380
expect(exchange).toHaveBeenCalledOnce()
377381
const stored = await failureRowOf(application)
@@ -380,6 +384,87 @@ describe('coordinated token resolution while the provider fails', () => {
380384
}
381385
)
382386

387+
it('replays a throttled exchange that names no Retry-After for at least a minute', async () => {
388+
const exchange = failing(429)
389+
const before = Date.now()
390+
391+
await expect(
392+
resolveCoordinatedClientToken(input('throttled-bare', exchange))
393+
).rejects.toMatchObject({ code: 'provider_unavailable', status: 429 })
394+
395+
expect(exchange).toHaveBeenCalledOnce()
396+
expect(
397+
(await failureRowOf('throttled-bare')).expiresAt.getTime() - before
398+
).toBeGreaterThanOrEqual(60_000)
399+
})
400+
401+
it.each([
402+
[
403+
'the still-valid token while its refresh is remembered failing',
404+
'unlocked-fallback',
405+
'outage',
406+
],
407+
['a remembered refusal', 'unlocked-refusal', 'refusal'],
408+
] as const)(
409+
'serves %s without waiting for the application lock',
410+
async (_case, application, remembered) => {
411+
const appKey = applicationKey(NAMESPACE, [application])
412+
const fingerprint = sha256Json([application, 'secret-1'])
413+
const token = await encryptSecret(
414+
JSON.stringify({ accessToken: `${application}:token-1`, secretFingerprint: fingerprint })
415+
)
416+
const failure = await encryptSecret(
417+
JSON.stringify(
418+
remembered === 'outage'
419+
? { code: 'provider_unavailable', status: 503, consecutiveFailures: 1 }
420+
: { code: 'invalid_credentials', status: 401 }
421+
)
422+
)
423+
await db.insert(clientCredentialToken).values([
424+
{
425+
id: appKey,
426+
encryptedValue: token.encrypted,
427+
accessTokenDigest: sha256Json(['coordinated_client_token', `${application}:token-1`]),
428+
expiresAt: new Date(Date.now() + 4 * 60_000),
429+
},
430+
{
431+
id: `${appKey}:${sha256Json(fingerprint)}`,
432+
encryptedValue: failure.encrypted,
433+
expiresAt: new Date(Date.now() + 60_000),
434+
},
435+
])
436+
const { exchange } = provider(application)
437+
const lockHeld = createDeferred<void>()
438+
const release = createDeferred<void>()
439+
const holder = db.transaction(async (tx) => {
440+
await acquireAdvisoryXactLock(tx, `${NAMESPACE}_client_token`, appKey)
441+
lockHeld.resolve()
442+
await release.promise
443+
})
444+
445+
try {
446+
await lockHeld.promise
447+
const outcome = await Promise.race([
448+
resolveCoordinatedClientToken(input(application, exchange)).then(
449+
(value) => ({ value }),
450+
(error: unknown) => ({ error })
451+
),
452+
sleep(3_000).then(() => 'waited on the lock' as const),
453+
])
454+
455+
expect(outcome).toEqual(
456+
remembered === 'outage'
457+
? { value: expect.objectContaining({ accessToken: `${application}:token-1` }) }
458+
: { error: expect.objectContaining({ code: 'invalid_credentials' }) }
459+
)
460+
expect(exchange).not.toHaveBeenCalled()
461+
} finally {
462+
release.resolve()
463+
await holder
464+
}
465+
}
466+
)
467+
383468
it('backs off an outage from a few seconds, longer while it continues, never past 30 seconds', async () => {
384469
const exchange = failing(502)
385470

@@ -393,6 +478,7 @@ describe('coordinated token resolution while the provider fails', () => {
393478
await expireFailureRowsOf('outage')
394479
}
395480

481+
expect(replays[0]).toBeGreaterThanOrEqual(5_000)
396482
expect(replays[0]).toBeLessThanOrEqual(6_000 + 2_500)
397483
expect(replays[1]).toBeGreaterThanOrEqual(8_000)
398484
expect(Math.max(...replays)).toBeLessThanOrEqual(30_000 + 2_500)

0 commit comments

Comments
 (0)