Skip to content

Commit 7b7eb35

Browse files
committed
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.
1 parent c5f2e1f commit 7b7eb35

37 files changed

Lines changed: 344 additions & 79 deletions

‎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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ 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+
87101
it("reports a redirect to the API client's own cloud as a wrong cloud and never follows it", async () => {
88102
mockFetch.mockResolvedValue(
89103
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)