11import { createHash } from 'node:crypto'
2- import { sleep } from '@sim/utils/helpers'
2+ import { interruptibleSleep } from '@sim/utils/helpers'
33import { isRecordLike } from '@sim/utils/object'
44import { backoffWithJitter , parseRetryAfter } from '@sim/utils/retry'
55import { MAX_JSON_API_RESPONSE_BYTES } from '@/lib/core/security/input-validation.server'
@@ -37,10 +37,23 @@ const MAX_FETCH_RETRIES = 4
3737const MAX_TOKEN_CACHE_ENTRIES = 100
3838const MAX_TOKEN_EXCHANGES = 100
3939const MAX_TOKEN_RESPONSE_BYTES = 1024 * 1024
40+ const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000
4041const TOKEN_EXPIRY_BUFFER_MS = 60_000
4142const tokenCache = new Map < string , CachedToken > ( )
4243const tokenExchanges = new Map < string , Promise < string > > ( )
4344
45+ async function waitForPromiseWithSignal < T > ( promise : Promise < T > , signal ?: AbortSignal ) : Promise < T > {
46+ if ( ! signal ) return promise
47+ signal . throwIfAborted ( )
48+
49+ return new Promise < T > ( ( resolve , reject ) => {
50+ const onAbort = ( ) =>
51+ reject ( signal . reason ?? new DOMException ( 'The operation was aborted' , 'AbortError' ) )
52+ signal . addEventListener ( 'abort' , onAbort , { once : true } )
53+ promise . then ( resolve , reject ) . finally ( ( ) => signal . removeEventListener ( 'abort' , onAbort ) )
54+ } )
55+ }
56+
4457export function resolveSailPointHosts ( tenant : string ) : SailPointHosts {
4558 let host = tenant . trim ( ) . replace ( / ^ h t t p s ? : \/ \/ / i, '' )
4659 host = host
@@ -155,14 +168,16 @@ async function exchangeAccessToken(
155168 client_secret : credentials . clientSecret ,
156169 } ) . toString ( ) ,
157170 cache : 'no-store' ,
171+ redirect : 'error' ,
158172 signal,
159173 } )
160174
161175 if ( response . status === 429 && attempt < MAX_FETCH_RETRIES ) {
162176 const retryAfterMs = parseRetryAfter ( response . headers . get ( 'retry-after' ) )
163177 await consumeOrCancelBody ( response , DEFAULT_MAX_ERROR_BODY_BYTES )
164178 attempt += 1
165- await sleep ( backoffWithJitter ( attempt , retryAfterMs ) )
179+ await interruptibleSleep ( backoffWithJitter ( attempt , retryAfterMs ) , signal )
180+ signal ?. throwIfAborted ( )
166181 continue
167182 }
168183
@@ -210,16 +225,19 @@ export async function getSailPointAccessToken(
210225 if ( cached ) tokenCache . delete ( key )
211226
212227 const existing = tokenExchanges . get ( key )
213- if ( existing ) return existing
228+ if ( existing ) return waitForPromiseWithSignal ( existing , signal )
214229 if ( tokenExchanges . size >= MAX_TOKEN_EXCHANGES ) {
215230 throw new Error ( 'Too many concurrent SailPoint token exchanges' )
216231 }
217232
218- const exchange = exchangeAccessToken ( credentials , signal ) . finally ( ( ) => {
233+ const exchange = exchangeAccessToken (
234+ credentials ,
235+ AbortSignal . timeout ( TOKEN_EXCHANGE_TIMEOUT_MS )
236+ ) . finally ( ( ) => {
219237 tokenExchanges . delete ( key )
220238 } )
221239 tokenExchanges . set ( key , exchange )
222- return exchange
240+ return waitForPromiseWithSignal ( exchange , signal )
223241}
224242
225243export async function sailpointFetch (
@@ -244,6 +262,7 @@ export async function sailpointFetch(
244262 ...init ,
245263 cache : 'no-store' ,
246264 headers,
265+ redirect : 'error' ,
247266 signal : options . signal ,
248267 } )
249268
@@ -257,7 +276,8 @@ export async function sailpointFetch(
257276 const retryAfterMs = parseRetryAfter ( response . headers . get ( 'retry-after' ) )
258277 await consumeOrCancelBody ( response , DEFAULT_MAX_ERROR_BODY_BYTES )
259278 attempt += 1
260- await sleep ( backoffWithJitter ( attempt , retryAfterMs ) )
279+ await interruptibleSleep ( backoffWithJitter ( attempt , retryAfterMs ) , options . signal )
280+ options . signal ?. throwIfAborted ( )
261281 continue
262282 }
263283
@@ -279,7 +299,7 @@ export function readTotalCount(headers: Headers): number | null {
279299 const raw = headers . get ( 'x-total-count' )
280300 if ( ! raw ) return null
281301 const parsed = Number ( raw )
282- return Number . isFinite ( parsed ) && parsed >= 0 ? parsed : null
302+ return Number . isInteger ( parsed ) && parsed >= 0 ? parsed : null
283303}
284304
285305/** Clears process-local authentication state for deterministic tests. */
0 commit comments