Skip to content

Commit c6c8771

Browse files
committed
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.
1 parent d014680 commit c6c8771

4 files changed

Lines changed: 64 additions & 8 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,25 @@ describe('SailPoint API client token exchange', () => {
126126
expect(mockFetch).toHaveBeenCalledOnce()
127127
})
128128

129+
it("keeps a throttled answer's status and Retry-After when its error body cannot be read", async () => {
130+
mockFetch.mockResolvedValue(
131+
new Response(
132+
new ReadableStream({
133+
start(controller) {
134+
controller.error(new TypeError('terminated'))
135+
},
136+
}),
137+
{ status: 429, headers: { 'Retry-After': '120' } }
138+
)
139+
)
140+
141+
await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({
142+
code: 'provider_unavailable',
143+
status: 429,
144+
retryAfterMs: 120_000,
145+
})
146+
})
147+
129148
it('treats a network failure or refused redirect as a provider failure', async () => {
130149
mockFetch.mockRejectedValue(new TypeError('fetch failed: unexpected redirect'))
131150

‎apps/sim/lib/internal/sailpoint/client.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ async function readBoundedBody(
185185
* endpoint (`client_credentials` grant, credentials in the form body), with
186186
* redirects refused and up to four backoff retries on 429 (`maxThrottleRetries`).
187187
* A refusal throws {@link SailPointAuthError} with the provider's status,
188-
* message, and `Retry-After`.
188+
* message, and `Retry-After`, even when its error body cannot be read.
189189
*
190190
* @see https://developer.sailpoint.com/docs/api/authentication
191191
*/
@@ -225,11 +225,10 @@ export async function requestSailPointToken(
225225
continue
226226
}
227227

228-
const data = await readBoundedBody(
229-
response,
230-
response.ok ? MAX_TOKEN_RESPONSE_BYTES : DEFAULT_MAX_ERROR_BODY_BYTES,
231-
signal
232-
)
228+
const data = response.ok
229+
? await readBoundedBody(response, MAX_TOKEN_RESPONSE_BYTES, signal)
230+
: await readBoundedBody(response, DEFAULT_MAX_ERROR_BODY_BYTES, signal).catch(() => null)
231+
signal?.throwIfAborted()
233232
if (!response.ok) {
234233
throw new SailPointAuthError(
235234
getSailPointErrorMessage(data, 'Failed to authenticate with SailPoint'),

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,4 +446,27 @@ describe('client credential token refresh after a failed mint', () => {
446446
resolveFor('vanta-coordinated-replay', VANTA_SERVICE_ACCOUNT_PROVIDER_ID)
447447
).resolves.toEqual({ value: { accessToken: token.accessToken } })
448448
})
449+
450+
it('asks the coordinated layer again at once after a failure it does not remember', async () => {
451+
encryptionMockFns.mockDecryptSecret.mockResolvedValue({
452+
decrypted: JSON.stringify({
453+
type: CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE,
454+
providerId: VANTA_SERVICE_ACCOUNT_PROVIDER_ID,
455+
clientId: 'vanta-client',
456+
clientSecret: 'vanta-client-secret',
457+
}),
458+
})
459+
const mint = minter()
460+
mint
461+
.mockRejectedValueOnce(
462+
new TokenServiceAccountValidationError('provider_unavailable', 503, { step: 'mint' })
463+
)
464+
.mockResolvedValueOnce(token)
465+
466+
await resolveFor('vanta-coordination-failed', VANTA_SERVICE_ACCOUNT_PROVIDER_ID)
467+
468+
await expect(
469+
resolveFor('vanta-coordination-failed', VANTA_SERVICE_ACCOUNT_PROVIDER_ID)
470+
).resolves.toEqual({ value: { accessToken: token.accessToken } })
471+
})
449472
})

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
isTokenServiceAccountProviderId,
3636
SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID,
3737
} from '@/lib/credentials/token-service-accounts/descriptors'
38+
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
3839
import {
3940
parseStoredSecretMode,
4041
parseTokenServiceAccountSecret,
@@ -741,6 +742,16 @@ function secretFingerprintOf(encryptedServiceAccountKey: string): string {
741742
return encryptedServiceAccountKey.slice(0, 32)
742743
}
743744

745+
/**
746+
* Whether a `sharedToken` mint failed with a failure the coordinated layer remembers for every
747+
* worker. Only those are replayed here, for exactly as long as that layer still replays them; a
748+
* failure it does not remember (a lock timeout, a database error) may already be over for
749+
* another worker, so the next resolution asks again.
750+
*/
751+
function isCoordinatedReplay(error: unknown): boolean {
752+
return error instanceof TokenServiceAccountValidationError && error.replayForMs !== undefined
753+
}
754+
744755
/**
745756
* Resolves a stored secret that mints short-lived tokens, read-through the
746757
* per-instance cache: re-reads the credential row, serves a cached token minted
@@ -751,8 +762,8 @@ function secretFingerprintOf(encryptedServiceAccountKey: string): string {
751762
* `coalesceLocally` so concurrent block executions on one instance share a
752763
* single mint. `sharedToken` providers coordinate one token per application
753764
* themselves (including the in-request retry and the fallback), so their tokens
754-
* are never cached here and a failure is replayed only as long as their own
755-
* replay reports; a caller that already read the secret to choose its mode
765+
* are never cached here and only a failure their own layer remembers is
766+
* replayed, for as long as it reports; a caller that already read the secret to choose its mode
756767
* passes it as `stored` rather than reading it twice.
757768
*/
758769
async function resolveMintedServiceAccountToken(
@@ -820,6 +831,10 @@ async function resolveMintedServiceAccountToken(
820831
return { ...minted.token }
821832
} catch (error) {
822833
if (error instanceof Error && error.name === 'AbortError') throw error
834+
if (sharedToken && !isCoordinatedReplay(error)) {
835+
clientCredentialMintFailureCache.delete(cacheIdentity)
836+
throw error
837+
}
823838
const failure = assessTokenRequestFailure(error, failed)
824839
const retryAtMs = Date.now() + failure.replayMs
825840
clientCredentialMintFailureCache.set(cacheIdentity, {

0 commit comments

Comments
 (0)