Skip to content

Commit aeccd3e

Browse files
committed
fix: accurate rate-limit timestamps, dynamic token attempts, env-var reconciliation
Signed-off-by: Joana Maia <jmaia@contractor.linuxfoundation.org>
1 parent fd4dd19 commit aeccd3e

2 files changed

Lines changed: 60 additions & 29 deletions

File tree

services/apps/security_best_practices_worker/src/activities/index.ts

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,18 @@ export async function saveOSPSBaselineInsightsToRedis(
187187
// Only rate-limit responses should trigger the 1h token-rotation cooldown; permission failures
188188
// must be marked as invalid so the token isn't retried in a loop.
189189
function classifyTokenError(output: string, source: string): void {
190+
// Wall-clock timestamp of the failure. Passed via ApplicationFailure.details so the
191+
// workflow can persist an accurate rateLimitedAt — workflow code can't call Date.now()
192+
// itself (Temporal determinism rule; workflowInfo().startTime skews on long batches).
193+
const failedAtMs = Date.now()
194+
190195
if (output.includes('401 Unauthorized') || output.includes('401 Bad credentials')) {
191196
svc.log.warn(`Detected 401 error in ${source} - token invalid or expired!`)
192197
throw ApplicationFailure.create({
193198
message: 'GitHub token invalid or expired',
194199
type: 'TokenAuthError',
195200
nonRetryable: true,
201+
details: [failedAtMs],
196202
})
197203
}
198204
if (!output.includes('403')) return
@@ -207,13 +213,15 @@ function classifyTokenError(output: string, source: string): void {
207213
message: 'GitHub token rate-limited',
208214
type: 'Token403Error',
209215
nonRetryable: true,
216+
details: [failedAtMs],
210217
})
211218
}
212219
svc.log.warn(`Detected 403 permission error in ${source} - token lacks required access!`)
213220
throw ApplicationFailure.create({
214221
message: 'GitHub token lacks required permissions',
215222
type: 'TokenAuthError',
216223
nonRetryable: true,
224+
details: [failedAtMs],
217225
})
218226
}
219227

@@ -297,27 +305,35 @@ export async function initializeTokenInfos(): Promise<ITokenInfo[]> {
297305
const redisCache = new RedisCache(`osps-baseline-insights`, svc.redis, svc.log)
298306

299307
const tokenInfosInRedis = await redisCache.get('tokenInfos')
300-
301-
if (tokenInfosInRedis) {
302-
const parsed: ITokenInfo[] = JSON.parse(tokenInfosInRedis)
303-
return parsed.map((t) => ({
304-
...t,
305-
// Reset inUse — workflow processes may have crashed leaving stale in-use flags.
308+
const cached: ITokenInfo[] = tokenInfosInRedis ? JSON.parse(tokenInfosInRedis) : []
309+
const cachedByToken = new Map(cached.map((t) => [t.token, t]))
310+
311+
// Env var is authoritative for pool membership so PAT rotation is picked up on next run:
312+
// a new token in CROWD_GITHUB_PERSONAL_ACCESS_TOKENS joins the pool fresh, and a removed
313+
// token drops out even if its cached entry is still in Redis.
314+
const envTokens = process.env['CROWD_GITHUB_PERSONAL_ACCESS_TOKENS'].split(',')
315+
316+
return envTokens.map((token) => {
317+
const t = cachedByToken.get(token)
318+
if (t) {
319+
return {
320+
...t,
321+
// Reset inUse — workflow processes may have crashed leaving stale in-use flags.
322+
inUse: false,
323+
// Backward compat: legacy entries have isRateLimited=true with no rateLimitedAt timestamp.
324+
// Without a timestamp the 1-hour expiry can't apply and the token would be stuck forever.
325+
// Clear stale rate-limits that lack a timestamp so they can be retried on this run.
326+
isRateLimited: t.isRateLimited && !!t.rateLimitedAt,
327+
rateLimitedAt: t.isRateLimited && t.rateLimitedAt ? t.rateLimitedAt : undefined,
328+
}
329+
}
330+
return {
331+
token,
306332
inUse: false,
307-
// Backward compat: legacy entries have isRateLimited=true with no rateLimitedAt timestamp.
308-
// Without a timestamp the 1-hour expiry can't apply and the token would be stuck forever.
309-
// Clear stale rate-limits that lack a timestamp so they can be retried on this run.
310-
isRateLimited: t.isRateLimited && !!t.rateLimitedAt,
311-
rateLimitedAt: t.isRateLimited && t.rateLimitedAt ? t.rateLimitedAt : undefined,
312-
}))
313-
}
314-
315-
return process.env['CROWD_GITHUB_PERSONAL_ACCESS_TOKENS'].split(',').map((token) => ({
316-
token,
317-
inUse: false,
318-
lastUsed: new Date(),
319-
isRateLimited: false,
320-
}))
333+
lastUsed: new Date(),
334+
isRateLimited: false,
335+
}
336+
})
321337
}
322338

323339
export async function updateTokenInfos(tokenInfos: ITokenInfo[]): Promise<void> {

services/apps/security_best_practices_worker/src/workflows/triggerSecurityInsightsCheckForRepos.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,25 @@ export async function triggerSecurityInsightsCheckForRepos(
2828
const REPOS_OBSOLETE_AFTER_SECONDS = 30 * 24 * 60 * 60 // 30 days
2929
const LIMIT_REPOS_TO_CHECK_PER_RUN = 100
3030
const MAX_PARALLEL_CHILDREN = 5
31-
const MAX_TOKEN_ATTEMPTS = 5
3231

3332
const info = workflowInfo()
3433
const failedRepoUrls = args?.failedRepoUrls || []
3534

36-
// Use workflowInfo().startTime as the replay-safe time reference — Temporal workflows must
37-
// be deterministic, so Date.now()/new Date() are banned by services-checklist.md:9-12.
38-
// startTime is stable within a run and advances on each continueAsNew batch.
35+
// Use workflowInfo().startTime as the replay-safe time reference for rate-limit expiry
36+
// checks — Temporal workflows must be deterministic, so Date.now()/new Date() are banned
37+
// (services-checklist.md:9-12). Actual rate-limit failure timestamps come from the
38+
// ApplicationFailure.details payload set inside the activity, so long batches don't skew
39+
// the persisted rateLimitedAt.
3940
const nowMs = info.startTime.getTime()
40-
const nowIso = info.startTime.toISOString()
4141

4242
// Token state is persisted in Redis via updateTokenInfos so isRateLimited (with rateLimitedAt
4343
// for expiry) and isInvalid survive across continueAsNew batches.
4444
const tokenInfos: ITokenInfo[] = await initializeTokenInfos()
4545

46+
// Scale attempts to pool size so a repo isn't marked failed while untried tokens remain
47+
// (e.g. 6 PATs where the first 5 attempts all 403 on different tokens).
48+
const MAX_TOKEN_ATTEMPTS = Math.max(5, tokenInfos.length)
49+
4650
const repos = await findObsoleteRepos(
4751
REPOS_OBSOLETE_AFTER_SECONDS,
4852
failedRepoUrls,
@@ -105,7 +109,10 @@ export async function triggerSecurityInsightsCheckForRepos(
105109
const appFailure = unwrapApplicationFailure(error)
106110
if (appFailure?.type === 'Token403Error') {
107111
tokenInfo.isRateLimited = true
108-
tokenInfo.rateLimitedAt = nowIso
112+
// Activity captures the wall-clock time of the 403 in details[0]; fall back to the
113+
// batch startTime when details are missing so we still record something.
114+
tokenInfo.rateLimitedAt =
115+
extractFailureTimestamp(appFailure) ?? info.startTime.toISOString()
109116
attempts++
110117
continue // retry with a different token
111118
} else if (appFailure?.type === 'TokenAuthError') {
@@ -124,7 +131,7 @@ export async function triggerSecurityInsightsCheckForRepos(
124131
break
125132
}
126133
} finally {
127-
await releaseToken(tokenInfos, tokenInfo.token)
134+
await releaseToken(tokenInfos, tokenInfo.token, nowMs)
128135
}
129136
}
130137

@@ -228,11 +235,19 @@ function unwrapApplicationFailure(error: unknown): ApplicationFailure | null {
228235
return null
229236
}
230237

231-
async function releaseToken(tokenInfos: ITokenInfo[], token: string): Promise<void> {
238+
// Activity records the wall-clock failure time via ApplicationFailure.details[0] (ms).
239+
function extractFailureTimestamp(appFailure: ApplicationFailure): string | null {
240+
const details = appFailure.details as unknown[] | undefined
241+
const ts = details?.[0]
242+
if (typeof ts !== 'number' || !Number.isFinite(ts)) return null
243+
return new Date(ts).toISOString()
244+
}
245+
246+
async function releaseToken(tokenInfos: ITokenInfo[], token: string, nowMs: number): Promise<void> {
232247
const tokenInfo = tokenInfos.find((tokenInfo) => tokenInfo.token === token)
233248
if (tokenInfo) {
234249
tokenInfo.inUse = false
235-
tokenInfo.lastUsed = new Date()
250+
tokenInfo.lastUsed = new Date(nowMs)
236251
}
237252
}
238253

0 commit comments

Comments
 (0)