@@ -504,26 +504,48 @@ func enrichH1ScopeCounts(programs []ProgramSummary, auth string) {
504504// request fails or H1 returns a non-200 (e.g. 429 rate-limit / 403) — the caller must
505505// NOT cache a !ok result, otherwise a transient failure would hide a program's real
506506// scope (showing "—") for the whole cache TTL.
507+ //
508+ // 429s are retried with exponential backoff (honoring Retry-After when supplied) so
509+ // the warmer doesn't routinely leave hundreds of programs with stale empty scopes.
507510func fetchH1ScopeSummary (handle , auth string ) (ProgramSummary , bool ) {
508511 url := fmt .Sprintf ("https://api.hackerone.com/v1/hackers/programs/%s/structured_scopes?page%%5Bsize%%5D=100" , handle )
509- req , err := http .NewRequest ("GET" , url , nil )
510- if err != nil {
511- return ProgramSummary {}, false
512- }
513- req .Header .Set ("Accept" , "application/json" )
514- req .Header .Set ("Authorization" , "Basic " + auth )
515-
516512 client := & http.Client {Timeout : 15 * time .Second }
517- resp , err := client .Do (req )
518- if err != nil {
519- return ProgramSummary {}, false
513+
514+ var body []byte
515+ var statusCode int
516+ const maxAttempts = 3
517+ backoff := 2 * time .Second
518+ for attempt := 0 ; attempt < maxAttempts ; attempt ++ {
519+ req , err := http .NewRequest ("GET" , url , nil )
520+ if err != nil {
521+ return ProgramSummary {}, false
522+ }
523+ req .Header .Set ("Accept" , "application/json" )
524+ req .Header .Set ("Authorization" , "Basic " + auth )
525+
526+ resp , err := client .Do (req )
527+ if err != nil {
528+ return ProgramSummary {}, false
529+ }
530+ body , _ = io .ReadAll (resp .Body )
531+ statusCode = resp .StatusCode
532+ // Honor an explicit Retry-After (seconds) if H1 sent one.
533+ retryAfter := backoff
534+ if ra := resp .Header .Get ("Retry-After" ); ra != "" {
535+ if secs , err := strconv .Atoi (strings .TrimSpace (ra )); err == nil && secs > 0 && secs < 30 {
536+ retryAfter = time .Duration (secs ) * time .Second
537+ }
538+ }
539+ resp .Body .Close ()
540+
541+ if statusCode != http .StatusTooManyRequests || attempt == maxAttempts - 1 {
542+ break // 200 (success), 4xx that isn't 429 (auth/permission), or out of retries
543+ }
544+ time .Sleep (retryAfter )
545+ backoff *= 2
520546 }
521- body , _ := io .ReadAll (resp .Body )
522- resp .Body .Close ()
523547
524- if resp .StatusCode != http .StatusOK {
525- // 429 (rate-limited by hammering the API), 403, 401, etc. — signal failure so
526- // the empty result isn't cached and the next refresh retries.
548+ if statusCode != http .StatusOK {
527549 return ProgramSummary {}, false
528550 }
529551
0 commit comments