Skip to content

Commit 5d21da6

Browse files
committed
feat(credentials): share token refresh resilience across workers in the coordinated resolver
The coordinated client-credentials resolver (Shopify, Linear, Atlassian, Vanta) now applies the same policy as the per-instance mint cache, through the shared row every worker reads: - a quick outage is retried once under the application lock, within the 15 s its waiters allow - a refusal is remembered 30 s, a throttled answer for its Retry-After (1 s to 5 min, Vanta's now included), an outage 5 s growing to 30 s while it lasts - while an outage or throttling is remembered, the stored token for the same secret is served until 30 s before it expires; a refusal fails at once Rolling deploys stay safe: a refusal is stored exactly as before, and outages add only a consecutiveFailures key that previous decoders ignore (both prior decoders are pinned in the integration tests). The replay length rides on the row's existing expiresAt. Expired memos are kept five more minutes so the next failure can extend the backoff; an older build's sweep only resets it.
1 parent 3c307df commit 5d21da6

4 files changed

Lines changed: 464 additions & 97 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ beforeAll(async () => {
137137
)
138138
return
139139
}
140+
if (parsed.client_id.endsWith('-throttled')) {
141+
response.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '90' })
142+
response.end(JSON.stringify({ error: 'rate_limited' }))
143+
return
144+
}
140145
await sleep(15)
141146
const identity = `${request.headers['x-provider-origin']}:${parsed.client_id}`
142147
const issued = (applications.get(identity)?.issued ?? 0) + 1
@@ -321,6 +326,24 @@ describe('Vanta application token lifecycle against shared storage', () => {
321326
expect(await isActive(next.accessToken)).toBe(true)
322327
})
323328

329+
it('waits as long as a throttled Vanta asks before any worker asks again', async () => {
330+
const input = fields('throttled')
331+
const keys = previousBuildKeys(input, READ)
332+
const count = tokenRequests
333+
const before = Date.now()
334+
335+
await expect(resolveVantaClientToken(input)).rejects.toMatchObject({
336+
code: 'provider_unavailable',
337+
status: 429,
338+
})
339+
await expect(resolveVantaClientToken(input)).rejects.toMatchObject({ status: 429 })
340+
341+
expect(tokenRequests).toBe(count + 1)
342+
const stored = await readStoredRow(keys.failureKey)
343+
expect(stored.expiresAt.getTime() - before).toBeGreaterThanOrEqual(90_000)
344+
expect(stored.expiresAt.getTime() - Date.now()).toBeLessThanOrEqual(90_000)
345+
})
346+
324347
it('fails closed when coordination storage fails', async () => {
325348
const count = tokenRequests
326349
vi.spyOn(db, 'transaction').mockRejectedValueOnce(new Error('database unavailable'))

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import {
99
fetchProvider,
1010
isTransientProviderStatus,
11+
providerRetryAfterMs,
1112
requireClientSecret,
1213
TokenServiceAccountValidationError,
1314
} from '@/lib/credentials/token-service-accounts/errors'
@@ -91,7 +92,8 @@ async function exchangeToken(
9192
? 'invalid_credentials'
9293
: 'provider_unavailable',
9394
response.status,
94-
{ step: STEP, reason: 'provider rejected token exchange' }
95+
{ step: STEP, reason: 'provider rejected token exchange' },
96+
{ retryAfterMs: providerRetryAfterMs(response) }
9597
)
9698
}
9799
const payload = await readResponseJsonWithLimit<unknown>(response, {

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

Lines changed: 257 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { db } from '@sim/db'
33
import { clientCredentialToken } from '@sim/db/schema'
44
import { sleep } from '@sim/utils/helpers'
55
import { generateId } from '@sim/utils/id'
6+
import { isRecordLike } from '@sim/utils/object'
67
import { eq, like, or } from 'drizzle-orm'
78
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
8-
import { decryptSecret } from '@/lib/core/security/encryption'
9+
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
910
import {
1011
type CoordinatedClientTokenInput,
1112
invalidateCoordinatedClientToken,
@@ -63,6 +64,68 @@ function rejecting() {
6364
})
6465
}
6566

67+
function failing(status: number, retryAfterMs?: number) {
68+
return vi.fn(async (): Promise<{ accessToken: string; expiresAt: Date }> => {
69+
throw new TokenServiceAccountValidationError(
70+
'provider_unavailable',
71+
status,
72+
{ step: 'mint' },
73+
{
74+
retryAfterMs,
75+
}
76+
)
77+
})
78+
}
79+
80+
/** An exchange that issues one token valid for `lifetimeMs`, then fails with `then`. */
81+
function issuingOnce(
82+
application: string,
83+
lifetimeMs: number,
84+
then: CoordinatedClientTokenInput['exchange']
85+
) {
86+
return vi
87+
.fn<CoordinatedClientTokenInput['exchange']>()
88+
.mockResolvedValueOnce({
89+
accessToken: `${application}:token-1`,
90+
expiresAt: new Date(Date.now() + lifetimeMs),
91+
})
92+
.mockImplementation(then)
93+
}
94+
95+
async function failureRowOf(application: string) {
96+
const rows = await db
97+
.select()
98+
.from(clientCredentialToken)
99+
.where(like(clientCredentialToken.id, `${applicationKey(NAMESPACE, [application])}:%`))
100+
if (rows.length !== 1) throw new Error(`Expected one failure row, found ${rows.length}`)
101+
return readStoredRow(rows[0].id)
102+
}
103+
104+
async function expireFailureRowsOf(application: string) {
105+
await db
106+
.update(clientCredentialToken)
107+
.set({ expiresAt: new Date(Date.now() - 1000) })
108+
.where(like(clientCredentialToken.id, `${applicationKey(NAMESPACE, [application])}:%`))
109+
}
110+
111+
/** The previous build's failure decoders, which must keep accepting every failure this build stores. */
112+
const previousBuildDecodesFailure = {
113+
coordinated: (value: unknown) =>
114+
isRecordLike(value) &&
115+
typeof value.code === 'string' &&
116+
[
117+
'invalid_credentials',
118+
'grant_not_permitted',
119+
'site_not_found',
120+
'provider_unavailable',
121+
].includes(value.code) &&
122+
typeof value.status === 'number',
123+
vantaBeforeCoordination: (value: unknown) =>
124+
isRecordLike(value) &&
125+
(value.code === 'invalid_credentials' || value.code === 'provider_unavailable') &&
126+
typeof value.status === 'number',
127+
}
128+
66129
afterEach(() => {
67130
vi.unstubAllGlobals()
68131
})
@@ -204,27 +267,207 @@ describe('coordinated client-credentials token against shared storage', () => {
204267
).resolves.toMatchObject({ accessToken: 'fresh-rejection:token-2' })
205268
})
206269

207-
it('sweeps expired failure memos', async () => {
270+
it('sweeps failure memos once they can no longer extend a backoff', async () => {
271+
for (const application of ['swept', 'retained']) {
272+
await expect(
273+
resolveCoordinatedClientToken(input(application, rejecting(), 'revoked'))
274+
).rejects.toMatchObject({ code: 'invalid_credentials' })
275+
}
276+
const failurePrefix = (application: string) => `${applicationKey(NAMESPACE, [application])}:`
277+
const expire = (application: string, agoMs: number) =>
278+
db
279+
.update(clientCredentialToken)
280+
.set({ expiresAt: new Date(Date.now() - agoMs) })
281+
.where(like(clientCredentialToken.id, `${failurePrefix(application)}%`))
282+
await expire('swept', 6 * 60_000)
283+
await expire('retained', 60_000)
284+
285+
await resolveCoordinatedClientToken(input('sweeper', provider('sweeper').exchange))
286+
287+
const remaining = async (application: string) =>
288+
db
289+
.select({ id: clientCredentialToken.id })
290+
.from(clientCredentialToken)
291+
.where(like(clientCredentialToken.id, `${failurePrefix(application)}%`))
292+
expect(await remaining('swept')).toEqual([])
293+
expect(await remaining('retained')).toHaveLength(1)
294+
})
295+
})
296+
297+
describe('coordinated token resolution while the provider fails', () => {
298+
it('serves the stored token while it is still valid when its early refresh is throttled', async () => {
299+
const exchange = issuingOnce('throttled-refresh', 4 * 60_000, failing(429))
300+
await resolveCoordinatedClientToken(input('throttled-refresh', exchange))
301+
302+
for (let call = 0; call < 3; call++) {
303+
await expect(
304+
resolveCoordinatedClientToken(input('throttled-refresh', exchange))
305+
).resolves.toMatchObject({ accessToken: 'throttled-refresh:token-1' })
306+
}
307+
expect(exchange).toHaveBeenCalledTimes(2)
308+
})
309+
310+
it('reports the failed refresh once the stored token has run out', async () => {
311+
const exchange = issuingOnce('expired-refresh', 20_000, failing(429))
312+
await resolveCoordinatedClientToken(input('expired-refresh', exchange))
313+
208314
await expect(
209-
resolveCoordinatedClientToken(input('swept', rejecting(), 'revoked'))
315+
resolveCoordinatedClientToken(input('expired-refresh', exchange))
316+
).rejects.toMatchObject({ code: 'provider_unavailable', status: 429 })
317+
})
318+
319+
it('stops serving the stored token once the provider refuses the credentials', async () => {
320+
const exchange = issuingOnce('refused-refresh', 4 * 60_000, rejecting())
321+
await resolveCoordinatedClientToken(input('refused-refresh', exchange))
322+
323+
await expect(
324+
resolveCoordinatedClientToken(input('refused-refresh', exchange))
210325
).rejects.toMatchObject({ code: 'invalid_credentials' })
211-
const failurePrefix = `${applicationKey(NAMESPACE, ['swept'])}:`
212-
await db
213-
.update(clientCredentialToken)
214-
.set({ expiresAt: new Date(Date.now() - 1000) })
215-
.where(like(clientCredentialToken.id, `${failurePrefix}%`))
326+
await expect(
327+
resolveCoordinatedClientToken(input('refused-refresh', exchange))
328+
).rejects.toMatchObject({ code: 'invalid_credentials' })
329+
expect(exchange).toHaveBeenCalledTimes(2)
330+
})
216331

217-
await resolveCoordinatedClientToken(input('sweeper', provider('sweeper').exchange))
332+
it('never serves a still-valid token to a different secret whose refresh fails', async () => {
333+
const exchange = issuingOnce('rotated-refresh', 4 * 60_000, failing(429))
334+
await resolveCoordinatedClientToken(input('rotated-refresh', exchange))
218335

219-
const rows = await db
220-
.select({ id: clientCredentialToken.id })
221-
.from(clientCredentialToken)
222-
.where(like(clientCredentialToken.id, `${failurePrefix}%`))
223-
expect(rows).toEqual([])
336+
await expect(
337+
resolveCoordinatedClientToken(input('rotated-refresh', exchange, 'secret-2'))
338+
).rejects.toMatchObject({ code: 'provider_unavailable' })
339+
})
340+
341+
it('retries a quick outage once under the application lock, for every waiting resolution', async () => {
342+
const { exchange: issue } = provider('retried')
343+
const exchange = vi
344+
.fn<CoordinatedClientTokenInput['exchange']>()
345+
.mockRejectedValueOnce(
346+
new TokenServiceAccountValidationError('provider_unavailable', 503, { step: 'mint' })
347+
)
348+
.mockImplementation(issue)
349+
350+
const results = await Promise.all(
351+
Array.from({ length: 5 }, () => resolveCoordinatedClientToken(input('retried', exchange)))
352+
)
353+
354+
expect(new Set(results.map((result) => result.accessToken))).toEqual(
355+
new Set(['retried:token-1'])
356+
)
357+
expect(exchange).toHaveBeenCalledTimes(2)
358+
})
359+
360+
it.each([
361+
['as long as the provider asks', 'throttled-90s', 90_000, 90_000],
362+
['for at most five minutes', 'throttled-1h', 3_600_000, 300_000],
363+
])(
364+
"shares a throttled exchange's Retry-After with every worker %s",
365+
async (_case, application, retryAfterMs, replayMs) => {
366+
const exchange = failing(429, retryAfterMs)
367+
const before = Date.now()
368+
369+
await expect(
370+
resolveCoordinatedClientToken(input(application, exchange))
371+
).rejects.toMatchObject({ code: 'provider_unavailable', status: 429 })
372+
await expect(
373+
resolveCoordinatedClientToken(input(application, exchange))
374+
).rejects.toMatchObject({ status: 429, retryAfterMs: expect.any(Number) })
375+
376+
expect(exchange).toHaveBeenCalledOnce()
377+
const stored = await failureRowOf(application)
378+
expect(stored.expiresAt.getTime() - before).toBeGreaterThanOrEqual(replayMs)
379+
expect(stored.expiresAt.getTime() - Date.now()).toBeLessThanOrEqual(replayMs)
380+
}
381+
)
382+
383+
it('backs off an outage from a few seconds, longer while it continues, never past 30 seconds', async () => {
384+
const exchange = failing(502)
385+
386+
const replays: number[] = []
387+
for (let attempt = 0; attempt < 5; attempt++) {
388+
const before = Date.now()
389+
await expect(resolveCoordinatedClientToken(input('outage', exchange))).rejects.toMatchObject({
390+
code: 'provider_unavailable',
391+
})
392+
replays.push((await failureRowOf('outage')).expiresAt.getTime() - before)
393+
await expireFailureRowsOf('outage')
394+
}
395+
396+
expect(replays[0]).toBeLessThanOrEqual(6_000 + 2_500)
397+
expect(replays[1]).toBeGreaterThanOrEqual(8_000)
398+
expect(Math.max(...replays)).toBeLessThanOrEqual(30_000 + 2_500)
399+
})
400+
401+
it('starts the backoff over after the provider recovers', async () => {
402+
const { exchange: issue } = provider('recovered')
403+
const exchange = vi
404+
.fn<CoordinatedClientTokenInput['exchange']>()
405+
.mockImplementation(failing(502))
406+
for (let attempt = 0; attempt < 3; attempt++) {
407+
await expect(resolveCoordinatedClientToken(input('recovered', exchange))).rejects.toThrow()
408+
await expireFailureRowsOf('recovered')
409+
}
410+
exchange.mockImplementationOnce(issue)
411+
const { accessToken } = await resolveCoordinatedClientToken(input('recovered', exchange))
412+
await invalidateCoordinatedClientToken(accessToken)
413+
exchange.mockImplementation(failing(502))
414+
415+
const before = Date.now()
416+
await expect(resolveCoordinatedClientToken(input('recovered', exchange))).rejects.toThrow()
417+
418+
expect((await failureRowOf('recovered')).expiresAt.getTime() - before).toBeLessThanOrEqual(
419+
6_000 + 2_500
420+
)
224421
})
225422
})
226423

227424
describe('default token rows shared with the previous build during a rolling deploy', () => {
425+
it('serves a still-valid token when the previous build remembered an outage', async () => {
426+
const application = 'legacy-outage'
427+
const appKey = applicationKey(NAMESPACE, [application])
428+
const fingerprint = sha256Json([application, 'secret-1'])
429+
const token = await encryptSecret(
430+
JSON.stringify({ accessToken: 'legacy-token', secretFingerprint: fingerprint })
431+
)
432+
const outage = await encryptSecret(
433+
JSON.stringify({ code: 'provider_unavailable', status: 503 })
434+
)
435+
await db.insert(clientCredentialToken).values([
436+
{
437+
id: appKey,
438+
encryptedValue: token.encrypted,
439+
accessTokenDigest: sha256Json(['coordinated_client_token', 'legacy-token']),
440+
expiresAt: new Date(Date.now() + 4 * 60_000),
441+
},
442+
{
443+
id: `${appKey}:${sha256Json(fingerprint)}`,
444+
encryptedValue: outage.encrypted,
445+
expiresAt: new Date(Date.now() + 20_000),
446+
},
447+
])
448+
const { exchange } = provider(application)
449+
450+
await expect(
451+
resolveCoordinatedClientToken(input(application, exchange))
452+
).resolves.toMatchObject({ accessToken: 'legacy-token' })
453+
expect(exchange).not.toHaveBeenCalled()
454+
})
455+
456+
it('stores every outage and throttled answer as a failure the previous build reads', async () => {
457+
for (const [application, exchange] of [
458+
['legacy-readable-outage', failing(502)],
459+
['legacy-readable-throttle', failing(429, 60_000)],
460+
] as const) {
461+
await expect(resolveCoordinatedClientToken(input(application, exchange))).rejects.toThrow()
462+
463+
const stored = await failureRowOf(application)
464+
const payload: unknown = JSON.parse(stored.decrypted)
465+
expect(previousBuildDecodesFailure.coordinated(payload)).toBe(true)
466+
expect(previousBuildDecodesFailure.vantaBeforeCoordination(payload)).toBe(true)
467+
expect(stored.accessTokenDigest).toBeNull()
468+
}
469+
})
470+
228471
it('stores tokens and refusals under the ids, digest, and payloads the previous build used', async () => {
229472
const application = ['legacy.myshopify.com', 'legacy-client']
230473
const appKey = applicationKey(NAMESPACE, application)

0 commit comments

Comments
 (0)