Skip to content

Commit 4911dc1

Browse files
authored
improvement(credentials): keep valid client-credential tokens through refresh outages and pace token retries by failure kind (#9925)
* feat(credentials): carry a provider's Retry-After on token request failures A 429 or 503 token answer now records its Retry-After (delay-seconds or HTTP-date, RFC 9110 section 10.2.3) as an optional retryAfterMs on TokenServiceAccountValidationError, through throwForProviderResponse and the shared client-credentials grant, whose provider classifier receives it. * 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. * 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. * 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. * fix(credentials): honor every minter's Retry-After and close token fallback gaps - Carry the token endpoint's Retry-After on 429/503 failures from every bespoke client-credential minter (Box, Zoom, DocuSign, NetSuite, Help Scout, Ramp, Jama, Zendesk, Salesforce, Zoho Desk) and through the CrowdStrike, SailPoint and ZoomInfo client errors, so their throttled answers wait as long as the provider asks instead of the fixed backoff. - Recheck the 30 s expiry margin when the per-instance cache falls back to a still-valid token after a slow failed refresh. - Map an unreadable Vanta token body to provider_unavailable so it is remembered for every worker and paced at 15 s like any failed exchange. - Remember a refusal whose code the shared failure row cannot store with an additive `refusal` flag, so a replay never serves the stale token. - Rename the credentials helper to throttledRetryAfterMs so it no longer shares a name with providers/retry.ts. * fix(credentials): keep throttling and outage backoff streaks apart and redact the fallback log in selector mode * fix(credentials): end a refused token, read legacy refusals as refusals, and keep throttle delays through SailPoint and CrowdStrike - A refusal of the credentials drops the per-instance cached token and expires the shared token minted with that secret, so a later outage cannot serve it again. - A previous build's provider_unavailable memo with a non-transient 4xx status replays as a refusal instead of serving the stored token. - The SailPoint saved-credential minter returns the first 429 with its Retry-After instead of retrying until the exchange timeout swallows it. - A CrowdStrike error whose body cannot be read keeps its status and Retry-After. - Vanta logs a malformed or oversized token response apart from an unreadable stream. * fix(credentials): keep SailPoint throttle delays through unreadable error bodies and replay only coordinated failures locally - A SailPoint token error whose body cannot be read keeps its status and Retry-After, as CrowdStrike's does; caller cancellation still propagates. - A shared-token (Vanta) mint failure is replayed per instance only when the coordinated layer remembers it (replayForMs); a failure it does not remember asks again at once. * fix(credentials): skip the fallback token once a provider refused it during the failed refresh
1 parent bcadf23 commit 4911dc1

41 files changed

Lines changed: 1856 additions & 233 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/sim/lib/credentials/client-credential-accounts/minters/box.test.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,18 @@ describe('mintBoxServiceAccountToken', () => {
9999
})
100100
})
101101

102-
it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => {
103-
mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'rate_limit_exceeded' }, 429))
102+
it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit, with its Retry-After', async () => {
103+
mockFetch.mockResolvedValueOnce(
104+
jsonResponse(
105+
{ error: 'rate_limit_exceeded' },
106+
{ status: 429, headers: { 'Retry-After': '7' } }
107+
)
108+
)
104109

105110
await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({
106111
code: 'provider_unavailable',
107112
status: 429,
113+
retryAfterMs: 7_000,
108114
})
109115
})
110116
})

‎apps/sim/lib/credentials/client-credential-accounts/minters/box.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
readProviderErrorSnippet,
1515
requireClientSecret,
1616
TokenServiceAccountValidationError,
17+
throttledRetryAfterMs,
1718
} from '@/lib/credentials/token-service-accounts/errors'
1819

1920
const logger = createLogger('BoxServiceAccountMinter')
@@ -176,10 +177,12 @@ export async function mintBoxServiceAccountToken(
176177
...(hint ? { hint } : {}),
177178
})
178179
}
179-
throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
180-
step: 'box_token_mint',
181-
body,
182-
})
180+
throw new TokenServiceAccountValidationError(
181+
'provider_unavailable',
182+
res.status,
183+
{ step: 'box_token_mint', body },
184+
{ retryAfterMs: throttledRetryAfterMs(res) }
185+
)
183186
}
184187

185188
const payload = await parseProviderJson<BoxTokenResponse>(res, 'box_token_mint')

‎apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,39 @@ describe('CrowdStrike API client token exchange', () => {
8484
await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ code })
8585
})
8686

87+
it("carries the token endpoint's Retry-After on a throttled answer", async () => {
88+
mockFetch.mockResolvedValue(
89+
jsonResponse(
90+
{ errors: [{ code: 429, message: 'rate limited' }] },
91+
{ status: 429, headers: { 'Retry-After': '7' } }
92+
)
93+
)
94+
95+
await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({
96+
code: 'provider_unavailable',
97+
retryAfterMs: 7_000,
98+
})
99+
})
100+
101+
it("keeps a throttled answer's status and Retry-After when its error body cannot be read", async () => {
102+
mockFetch.mockResolvedValue(
103+
new Response(
104+
new ReadableStream({
105+
start(controller) {
106+
controller.error(new TypeError('terminated'))
107+
},
108+
}),
109+
{ status: 429, headers: { 'Retry-After': '7' } }
110+
)
111+
)
112+
113+
await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({
114+
code: 'provider_unavailable',
115+
status: 429,
116+
retryAfterMs: 7_000,
117+
})
118+
})
119+
87120
it("reports a redirect to the API client's own cloud as a wrong cloud and never follows it", async () => {
88121
mockFetch.mockResolvedValue(
89122
new Response(null, {

‎apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ export async function mintCrowdStrikeServiceAccountToken(
8282
throw new TokenServiceAccountValidationError(
8383
providerFailureCode(error.status),
8484
error.status,
85-
{ step: STEP, cloud }
85+
{ step: STEP, cloud },
86+
{ retryAfterMs: error.retryAfterMs }
8687
)
8788
}
8889
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {

‎apps/sim/lib/credentials/client-credential-accounts/minters/docusign.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,17 @@ describe('mintDocuSignServiceAccountToken', () => {
254254
})
255255
})
256256

257+
it("carries the token endpoint's Retry-After on a throttled answer", async () => {
258+
mockFetch.mockResolvedValueOnce(
259+
jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
260+
)
261+
262+
await expect(mintDocuSignServiceAccountToken(FIELDS)).rejects.toMatchObject({
263+
code: 'provider_unavailable',
264+
retryAfterMs: 7_000,
265+
})
266+
})
267+
257268
it('keeps the key, assertion, and access token out of every failure', async () => {
258269
const failures: unknown[] = []
259270
mockFetch.mockResolvedValueOnce(

‎apps/sim/lib/credentials/client-credential-accounts/minters/docusign.ts‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
parseProviderJson,
2020
readProviderErrorSnippet,
2121
TokenServiceAccountValidationError,
22+
throttledRetryAfterMs,
2223
} from '@/lib/credentials/token-service-accounts/errors'
2324

2425
const TOKEN_STEP = 'docusign_token_mint'
@@ -266,11 +267,12 @@ export async function mintDocuSignServiceAccountToken(
266267
{ step: TOKEN_STEP, environment, body, ...(hint ? { hint } : {}) }
267268
)
268269
}
269-
throw new TokenServiceAccountValidationError('provider_unavailable', tokenRes.status, {
270-
step: TOKEN_STEP,
271-
environment,
272-
body,
273-
})
270+
throw new TokenServiceAccountValidationError(
271+
'provider_unavailable',
272+
tokenRes.status,
273+
{ step: TOKEN_STEP, environment, body },
274+
{ retryAfterMs: throttledRetryAfterMs(tokenRes) }
275+
)
274276
}
275277

276278
const payload = await parseProviderJson<DocuSignTokenResponse>(tokenRes, TOKEN_STEP)
@@ -297,11 +299,12 @@ export async function mintDocuSignServiceAccountToken(
297299
)
298300
if (!userinfoRes.ok) {
299301
const body = await readProviderErrorSnippet(userinfoRes)
300-
throw new TokenServiceAccountValidationError('provider_unavailable', userinfoRes.status, {
301-
step: USERINFO_STEP,
302-
environment,
303-
body,
304-
})
302+
throw new TokenServiceAccountValidationError(
303+
'provider_unavailable',
304+
userinfoRes.status,
305+
{ step: USERINFO_STEP, environment, body },
306+
{ retryAfterMs: throttledRetryAfterMs(userinfoRes) }
307+
)
305308
}
306309
const userinfo = await parseProviderJson<DocuSignUserinfoResponse>(userinfoRes, USERINFO_STEP)
307310
const accounts = (Array.isArray(userinfo.accounts) ? userinfo.accounts : []).filter(
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { jsonResponse } from '@sim/testing/helpers/http'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { mintHelpScoutServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/helpscout'
4+
5+
const FIELDS = { clientId: 'helpscout-client', clientSecret: 'helpscout-secret', orgId: '' }
6+
7+
const mockFetch = vi.fn<typeof fetch>()
8+
9+
describe('Help Scout client credential exchange', () => {
10+
beforeEach(() => {
11+
mockFetch.mockReset()
12+
vi.stubGlobal('fetch', mockFetch)
13+
})
14+
15+
it("carries the token endpoint's Retry-After on a throttled answer", async () => {
16+
mockFetch.mockResolvedValueOnce(
17+
jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
18+
)
19+
20+
await expect(
21+
mintHelpScoutServiceAccountToken(FIELDS, { skipIdentity: true })
22+
).rejects.toMatchObject({ code: 'provider_unavailable', status: 429, retryAfterMs: 7_000 })
23+
})
24+
})

‎apps/sim/lib/credentials/client-credential-accounts/minters/helpscout.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
isTransientProviderStatus,
1212
requireClientSecret,
1313
TokenServiceAccountValidationError,
14+
throttledRetryAfterMs,
1415
} from '@/lib/credentials/token-service-accounts/errors'
1516

1617
const MAX_HELPSCOUT_AUTH_RESPONSE_BYTES = 64 * 1024
@@ -28,7 +29,8 @@ async function readHelpScoutAuthResponse(
2829
? 'invalid_credentials'
2930
: 'provider_unavailable',
3031
response.status,
31-
{ step }
32+
{ step },
33+
{ retryAfterMs: throttledRetryAfterMs(response) }
3234
)
3335
}
3436

‎apps/sim/lib/credentials/client-credential-accounts/minters/jama.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,18 @@ describe('Jama credential validation', () => {
3030
).rejects.toMatchObject({ code: expectedCode, status })
3131
}
3232
)
33+
34+
it("carries the token endpoint's Retry-After on a throttled answer", async () => {
35+
inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValueOnce(
36+
Response.json({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
37+
)
38+
39+
await expect(
40+
mintJamaServiceAccountToken({
41+
orgId: 'https://example.jamacloud.com',
42+
clientId: 'client-id',
43+
clientSecret: 'client-secret',
44+
})
45+
).rejects.toMatchObject({ code: 'provider_unavailable', status: 429, retryAfterMs: 7_000 })
46+
})
3347
})

‎apps/sim/lib/credentials/client-credential-accounts/minters/jama.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
isTransientProviderStatus,
1111
requireClientSecret,
1212
TokenServiceAccountValidationError,
13+
throttledRetryAfterMs,
1314
} from '@/lib/credentials/token-service-accounts/errors'
1415
import { normalizeJamaInstanceUrl } from '@/lib/jama/client'
1516

@@ -49,7 +50,9 @@ export async function mintJamaServiceAccountToken(
4950
!isTransientProviderStatus(response.status)
5051
? 'invalid_credentials'
5152
: 'provider_unavailable',
52-
response.status
53+
response.status,
54+
undefined,
55+
{ retryAfterMs: throttledRetryAfterMs(response) }
5356
)
5457
}
5558
return toRecord(await response.json())

0 commit comments

Comments
 (0)