Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,18 @@ describe('mintBoxServiceAccountToken', () => {
})
})

it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'rate_limit_exceeded' }, 429))
it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit, with its Retry-After', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse(
{ error: 'rate_limit_exceeded' },
{ status: 429, headers: { 'Retry-After': '7' } }
)
)

await expect(mintBoxServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
status: 429,
retryAfterMs: 7_000,
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
readProviderErrorSnippet,
requireClientSecret,
TokenServiceAccountValidationError,
throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'

const logger = createLogger('BoxServiceAccountMinter')
Expand Down Expand Up @@ -176,10 +177,12 @@ export async function mintBoxServiceAccountToken(
...(hint ? { hint } : {}),
})
}
throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
step: 'box_token_mint',
body,
})
throw new TokenServiceAccountValidationError(
'provider_unavailable',
res.status,
{ step: 'box_token_mint', body },
{ retryAfterMs: throttledRetryAfterMs(res) }
)
}

const payload = await parseProviderJson<BoxTokenResponse>(res, 'box_token_mint')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,39 @@ describe('CrowdStrike API client token exchange', () => {
await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ code })
})

it("carries the token endpoint's Retry-After on a throttled answer", async () => {
mockFetch.mockResolvedValue(
jsonResponse(
{ errors: [{ code: 429, message: 'rate limited' }] },
{ status: 429, headers: { 'Retry-After': '7' } }
)
)

await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
retryAfterMs: 7_000,
})
})

it("keeps a throttled answer's status and Retry-After when its error body cannot be read", async () => {
mockFetch.mockResolvedValue(
new Response(
new ReadableStream({
start(controller) {
controller.error(new TypeError('terminated'))
},
}),
{ status: 429, headers: { 'Retry-After': '7' } }
)
)

await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
status: 429,
retryAfterMs: 7_000,
})
})

it("reports a redirect to the API client's own cloud as a wrong cloud and never follows it", async () => {
mockFetch.mockResolvedValue(
new Response(null, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ export async function mintCrowdStrikeServiceAccountToken(
throw new TokenServiceAccountValidationError(
providerFailureCode(error.status),
error.status,
{ step: STEP, cloud }
{ step: STEP, cloud },
{ retryAfterMs: error.retryAfterMs }
)
}
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,17 @@ describe('mintDocuSignServiceAccountToken', () => {
})
})

it("carries the token endpoint's Retry-After on a throttled answer", async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
)

await expect(mintDocuSignServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
retryAfterMs: 7_000,
})
})

it('keeps the key, assertion, and access token out of every failure', async () => {
const failures: unknown[] = []
mockFetch.mockResolvedValueOnce(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
parseProviderJson,
readProviderErrorSnippet,
TokenServiceAccountValidationError,
throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'

const TOKEN_STEP = 'docusign_token_mint'
Expand Down Expand Up @@ -266,11 +267,12 @@ export async function mintDocuSignServiceAccountToken(
{ step: TOKEN_STEP, environment, body, ...(hint ? { hint } : {}) }
)
}
throw new TokenServiceAccountValidationError('provider_unavailable', tokenRes.status, {
step: TOKEN_STEP,
environment,
body,
})
throw new TokenServiceAccountValidationError(
'provider_unavailable',
tokenRes.status,
{ step: TOKEN_STEP, environment, body },
{ retryAfterMs: throttledRetryAfterMs(tokenRes) }
)
}

const payload = await parseProviderJson<DocuSignTokenResponse>(tokenRes, TOKEN_STEP)
Expand All @@ -297,11 +299,12 @@ export async function mintDocuSignServiceAccountToken(
)
if (!userinfoRes.ok) {
const body = await readProviderErrorSnippet(userinfoRes)
throw new TokenServiceAccountValidationError('provider_unavailable', userinfoRes.status, {
step: USERINFO_STEP,
environment,
body,
})
throw new TokenServiceAccountValidationError(
'provider_unavailable',
userinfoRes.status,
{ step: USERINFO_STEP, environment, body },
{ retryAfterMs: throttledRetryAfterMs(userinfoRes) }
)
}
const userinfo = await parseProviderJson<DocuSignUserinfoResponse>(userinfoRes, USERINFO_STEP)
const accounts = (Array.isArray(userinfo.accounts) ? userinfo.accounts : []).filter(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { jsonResponse } from '@sim/testing/helpers/http'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mintHelpScoutServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/helpscout'

const FIELDS = { clientId: 'helpscout-client', clientSecret: 'helpscout-secret', orgId: '' }

const mockFetch = vi.fn<typeof fetch>()

describe('Help Scout client credential exchange', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.stubGlobal('fetch', mockFetch)
})

it("carries the token endpoint's Retry-After on a throttled answer", async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
)

await expect(
mintHelpScoutServiceAccountToken(FIELDS, { skipIdentity: true })
).rejects.toMatchObject({ code: 'provider_unavailable', status: 429, retryAfterMs: 7_000 })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isTransientProviderStatus,
requireClientSecret,
TokenServiceAccountValidationError,
throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'

const MAX_HELPSCOUT_AUTH_RESPONSE_BYTES = 64 * 1024
Expand All @@ -28,7 +29,8 @@ async function readHelpScoutAuthResponse(
? 'invalid_credentials'
: 'provider_unavailable',
response.status,
{ step }
{ step },
{ retryAfterMs: throttledRetryAfterMs(response) }
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,18 @@ describe('Jama credential validation', () => {
).rejects.toMatchObject({ code: expectedCode, status })
}
)

it("carries the token endpoint's Retry-After on a throttled answer", async () => {
inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValueOnce(
Response.json({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
)

await expect(
mintJamaServiceAccountToken({
orgId: 'https://example.jamacloud.com',
clientId: 'client-id',
clientSecret: 'client-secret',
})
).rejects.toMatchObject({ code: 'provider_unavailable', status: 429, retryAfterMs: 7_000 })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isTransientProviderStatus,
requireClientSecret,
TokenServiceAccountValidationError,
throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'
import { normalizeJamaInstanceUrl } from '@/lib/jama/client'

Expand Down Expand Up @@ -49,7 +50,9 @@ export async function mintJamaServiceAccountToken(
!isTransientProviderStatus(response.status)
? 'invalid_credentials'
: 'provider_unavailable',
response.status
response.status,
undefined,
{ retryAfterMs: throttledRetryAfterMs(response) }
)
}
return toRecord(await response.json())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,22 @@ describe('mintNetSuiteServiceAccountToken', () => {
await expect(mintNetSuiteServiceAccountToken(FIELDS)).rejects.toMatchObject({ code, status })
})

it("carries the token endpoint's Retry-After on a throttled answer", async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
)
)

await expect(mintNetSuiteServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
retryAfterMs: 7_000,
})
})

it('redacts credentials and a reflected assertion from provider error metadata', async () => {
let assertion = ''
vi.stubGlobal(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { tenantPrincipal } from '@/lib/credentials/principal'
import {
isTransientProviderStatus,
TokenServiceAccountValidationError,
throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'

const TOKEN_PATH = '/services/rest/auth/oauth2/v1/token'
Expand Down Expand Up @@ -141,10 +142,12 @@ async function exchangeNetSuiteToken(
response.status >= 400 && response.status < 500 && !isTransientProviderStatus(response.status)
? 'invalid_credentials'
: 'provider_unavailable'
throw new TokenServiceAccountValidationError(code, response.status, {
step: TOKEN_MINT_STEP,
body: sanitizeNetSuiteTokenError(body, fields, assertion),
})
throw new TokenServiceAccountValidationError(
code,
response.status,
{ step: TOKEN_MINT_STEP, body: sanitizeNetSuiteTokenError(body, fields, assertion) },
{ retryAfterMs: throttledRetryAfterMs(response) }
)
}

let payload: NetSuiteTokenResponse
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,16 @@ describe('Ramp client credential exchange', () => {
}
)

it("carries the token endpoint's Retry-After on a throttled answer", async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({ error: 'busy' }, { status: 429, headers: { 'Retry-After': '7' } })
)

await expect(mintRampServiceAccountToken(FIELDS, { skipIdentity: true })).rejects.toMatchObject(
{ code: 'provider_unavailable', retryAfterMs: 7_000 }
)
})

it.each([400, 401, 403, 408, 429, 500])(
'classifies provider failure %s without retaining its secret-bearing body',
async (status) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isTransientProviderStatus,
requireClientSecret,
TokenServiceAccountValidationError,
throttledRetryAfterMs,
} from '@/lib/credentials/token-service-accounts/errors'
import { getScopesForService } from '@/lib/oauth/utils'

Expand All @@ -29,7 +30,8 @@ async function readRampAuthResponse(
? 'invalid_credentials'
: 'provider_unavailable',
response.status,
{ step }
{ step },
{ retryAfterMs: throttledRetryAfterMs(response) }
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,49 @@ describe('SailPoint API client token exchange', () => {
await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ code })
})

it("carries the token endpoint's Retry-After on an unavailable answer", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ error: 'unavailable' }, { status: 503, headers: { 'Retry-After': '7' } })
)

await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
retryAfterMs: 7_000,
})
})

it('returns the first throttled answer with its Retry-After instead of retrying it', async () => {
mockFetch.mockImplementation(async () =>
jsonResponse({ error: 'rate limited' }, { status: 429, headers: { 'Retry-After': '120' } })
)

await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
status: 429,
retryAfterMs: 120_000,
})
expect(mockFetch).toHaveBeenCalledOnce()
})

it("keeps a throttled answer's status and Retry-After when its error body cannot be read", async () => {
mockFetch.mockResolvedValue(
new Response(
new ReadableStream({
start(controller) {
controller.error(new TypeError('terminated'))
},
}),
{ status: 429, headers: { 'Retry-After': '120' } }
)
)

await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({
code: 'provider_unavailable',
status: 429,
retryAfterMs: 120_000,
})
})

it('treats a network failure or refused redirect as a provider failure', async () => {
mockFetch.mockRejectedValue(new TypeError('fetch failed: unexpected redirect'))

Expand Down
Loading
Loading