@@ -44,25 +44,12 @@ export async function getOSPSBaselineInsights(repoUrl: string, token: string): P
4444
4545 const combinedOutput = `${ stdout } \n${ stderr } `
4646
47- if ( combinedOutput . includes ( '403' ) ) {
48- svc . log . warn ( 'Detected 403 error in privateer output!' )
49- throw ApplicationFailure . create ( {
50- message : 'GitHub token rate-limited' ,
51- type : 'Token403Error' ,
52- } )
53- }
47+ classifyTokenError ( combinedOutput , 'privateer output' )
5448 } catch ( err ) {
5549 svc . log . error ( `Privateer run failed: ${ err . message } ` )
5650
57- // check for 403 in captured output if available
5851 const output = `${ err . stdout || '' } \n${ err . stderr || '' } `
59- if ( output . includes ( '403' ) ) {
60- svc . log . warn ( 'Detected 403 error in failed privateer output!' )
61- throw ApplicationFailure . create ( {
62- message : 'GitHub token rate-limited' ,
63- type : 'Token403Error' ,
64- } )
65- }
52+ classifyTokenError ( output , 'failed privateer output' )
6653 throw err
6754 }
6855
@@ -196,6 +183,40 @@ export async function saveOSPSBaselineInsightsToRedis(
196183 await redisCache . set ( key , JSON . stringify ( insights ) , 60 * 60 * 24 ) // 1 day
197184}
198185
186+ function classifyTokenError ( output : string , source : string ) : void {
187+ const failedAtMs = Date . now ( )
188+
189+ if ( output . includes ( '401 Unauthorized' ) || output . includes ( '401 Bad credentials' ) ) {
190+ svc . log . warn ( `Detected 401 error in ${ source } - token invalid or expired!` )
191+ throw ApplicationFailure . create ( {
192+ message : 'GitHub token invalid or expired' ,
193+ type : 'TokenAuthError' ,
194+ nonRetryable : true ,
195+ details : [ failedAtMs ] ,
196+ } )
197+ }
198+
199+ // Word-boundary match so unrelated numbers don't get misclassified as HTTP 429/403.
200+ const has403 = / \b 4 0 3 \b / . test ( output )
201+ const has429 = / \b 4 2 9 \b / . test ( output )
202+ if ( ! has403 && ! has429 ) return
203+
204+ // 429 is always rate-limit. 403 with rate-limit body is rate-limit. 403 without is a
205+ // permission problem (SAML, missing scopes) — log it but don't throw; the child workflow's
206+ // retry policy will exhaust retries and the repo will be deferred to the next scheduled run.
207+ const isRateLimit = has429 || / r a t e l i m i t | r a t e _ l i m i t | s e c o n d a r y r a t e / i. test ( output )
208+ if ( isRateLimit ) {
209+ svc . log . warn ( `Detected rate-limit in ${ source } - token rate-limited!` )
210+ throw ApplicationFailure . create ( {
211+ message : 'GitHub token rate-limited' ,
212+ type : 'Token403Error' ,
213+ nonRetryable : true ,
214+ details : [ failedAtMs ] ,
215+ } )
216+ }
217+ svc . log . warn ( `Detected 403 permission error in ${ source } - token may lack access to this repo` )
218+ }
219+
199220function computeRunDuration ( start : string | undefined , end : string | undefined ) : string {
200221 if ( ! start || ! end ) return ''
201222 const startMs = new Date ( start ) . getTime ( )
@@ -258,13 +279,13 @@ async function runBinary(
258279 resolve ( { stdout, stderr } )
259280 } else {
260281 const truncated = ( s : string ) => ( s . length > 500 ? s . slice ( 0 , 500 ) + '…' : s )
261- const truncStdout = truncated ( stdout )
262- const truncStderr = truncated ( stderr )
282+ // Attach full stdout/stderr so classifyTokenError sees rate-limit markers that may
283+ // appear after the truncation cut. Message is still truncated for log readability.
263284 const err = Object . assign (
264285 new Error (
265- `Binary exited with code ${ code } \nStderr:\n${ truncStderr } \nStdout:\n${ truncStdout } ` ,
286+ `Binary exited with code ${ code } \nStderr:\n${ truncated ( stderr ) } \nStdout:\n${ truncated ( stdout ) } ` ,
266287 ) ,
267- { stdout : truncStdout , stderr : truncStderr } ,
288+ { stdout, stderr } ,
268289 )
269290 reject ( err )
270291 }
@@ -276,20 +297,45 @@ export async function initializeTokenInfos(): Promise<ITokenInfo[]> {
276297 const redisCache = new RedisCache ( `osps-baseline-insights` , svc . redis , svc . log )
277298
278299 const tokenInfosInRedis = await redisCache . get ( 'tokenInfos' )
279-
280- if ( tokenInfosInRedis ) {
281- return JSON . parse ( tokenInfosInRedis )
282- }
283-
284- return process . env [ 'CROWD_GITHUB_PERSONAL_ACCESS_TOKENS' ] . split ( ',' ) . map ( ( token ) => ( {
285- token,
286- inUse : false ,
287- lastUsed : new Date ( ) ,
288- isRateLimited : false ,
289- } ) )
300+ const cached : ITokenInfo [ ] = tokenInfosInRedis ? JSON . parse ( tokenInfosInRedis ) : [ ]
301+ const cachedByToken = new Map ( cached . map ( ( t ) => [ t . token , t ] ) )
302+
303+ // Env var is authoritative for pool membership so PAT rotation is picked up on next run:
304+ // a new token in CROWD_GITHUB_PERSONAL_ACCESS_TOKENS joins the pool fresh, and a removed
305+ // token drops out even if its cached entry is still in Redis.
306+ const envTokens = process . env [ 'CROWD_GITHUB_PERSONAL_ACCESS_TOKENS' ] . split ( ',' )
307+
308+ return envTokens . map ( ( token ) => {
309+ const t = cachedByToken . get ( token )
310+ if ( t ) {
311+ return {
312+ ...t ,
313+ // Reset inUse — workflow processes may have crashed leaving stale in-use flags.
314+ inUse : false ,
315+ // Backward compat: legacy entries have isRateLimited=true with no rateLimitedAt timestamp.
316+ // Without a timestamp the 1-hour expiry can't apply and the token would be stuck forever.
317+ // Clear stale rate-limits that lack a timestamp so they can be retried on this run.
318+ isRateLimited : t . isRateLimited && ! ! t . rateLimitedAt ,
319+ rateLimitedAt : t . isRateLimited && t . rateLimitedAt ? t . rateLimitedAt : undefined ,
320+ }
321+ }
322+ return {
323+ token,
324+ inUse : false ,
325+ lastUsed : new Date ( ) ,
326+ isRateLimited : false ,
327+ }
328+ } )
290329}
291330
292331export async function updateTokenInfos ( tokenInfos : ITokenInfo [ ] ) : Promise < void > {
293332 const redisCache = new RedisCache ( `osps-baseline-insights` , svc . redis , svc . log )
294333 await redisCache . set ( 'tokenInfos' , JSON . stringify ( tokenInfos ) , 60 * 60 * 24 ) // 1 day
295334}
335+
336+ // Wall-clock time via activity — workflow code can't call Date.now() (determinism rule),
337+ // but rate-limit cooldowns need real elapsed time to expire mid-run rather than only at
338+ // the next continueAsNew batch boundary.
339+ export async function getCurrentTimeMs ( ) : Promise < number > {
340+ return Date . now ( )
341+ }
0 commit comments