@@ -183,9 +183,10 @@ export async function saveOSPSBaselineInsightsToRedis(
183183 await redisCache . set ( key , JSON . stringify ( insights ) , 60 * 60 * 24 ) // 1 day
184184}
185185
186- // GitHub returns 403 for both rate-limit exhaustion AND persistent permission/SAML failures.
187- // Only rate-limit responses should trigger the 1h token-rotation cooldown; permission failures
188- // must be marked as invalid so the token isn't retried in a loop.
186+ // GitHub returns 403 for rate-limit exhaustion AND persistent permission/SAML failures, and
187+ // 429 for primary rate-limit hits. Only 401 proves the token is globally invalid — SAML/repo
188+ // permissions vary per org, so a permission 403 on one repo doesn't mean the token is unusable
189+ // elsewhere.
189190function classifyTokenError ( output : string , source : string ) : void {
190191 // Wall-clock timestamp of the failure. Passed via ApplicationFailure.details so the
191192 // workflow can persist an accurate rateLimitedAt — workflow code can't call Date.now()
@@ -201,25 +202,30 @@ function classifyTokenError(output: string, source: string): void {
201202 details : [ failedAtMs ] ,
202203 } )
203204 }
204- if ( ! output . includes ( '403' ) ) return
205205
206- // Rate-limit 403s from GitHub carry a distinctive body: "API rate limit exceeded" or
207- // "secondary rate limit". Anything else (SAML enforcement, missing scopes, resource
208- // not accessible) is a permission problem and the token won't recover.
209- const isRateLimit = / 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 )
206+ const has403 = output . includes ( '403' )
207+ const has429 = output . includes ( '429' )
208+ if ( ! has403 && ! has429 ) return
209+
210+ // Rate-limit responses carry a distinctive body: "API rate limit exceeded" or
211+ // "secondary rate limit". 429 is always a rate-limit signal. 403 without those markers
212+ // is a permission problem (SAML enforcement, missing scopes, resource not accessible).
213+ 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 )
210214 if ( isRateLimit ) {
211- svc . log . warn ( `Detected 403 rate-limit in ${ source } - token rate-limited!` )
215+ svc . log . warn ( `Detected rate-limit in ${ source } - token rate-limited!` )
212216 throw ApplicationFailure . create ( {
213217 message : 'GitHub token rate-limited' ,
214218 type : 'Token403Error' ,
215219 nonRetryable : true ,
216220 details : [ failedAtMs ] ,
217221 } )
218222 }
219- svc . log . warn ( `Detected 403 permission error in ${ source } - token lacks required access!` )
223+ // Permission 403: this token can't access THIS repo, but may work for others. Signal the
224+ // workflow to try a different token without marking this one globally invalid.
225+ svc . log . warn ( `Detected 403 permission error in ${ source } - token lacks access to this repo!` )
220226 throw ApplicationFailure . create ( {
221- message : 'GitHub token lacks required permissions' ,
222- type : 'TokenAuthError ' ,
227+ message : 'GitHub token lacks required permissions for this repo ' ,
228+ type : 'TokenRepoAccessError ' ,
223229 nonRetryable : true ,
224230 details : [ failedAtMs ] ,
225231 } )
0 commit comments