Skip to content

Commit f03ef72

Browse files
h0tak88rclaude
andcommitted
fix(programs): manual Refresh now actually re-fetches from H1/BC/IT (+ 429 retry)
Two bugs around the user expectation "click Refresh, get fresh scope": 1) The top-right ↻ Refresh button on the Programs page called loadPrograms() (no force=true), which only re-read the cached payload from disk. Result: clicking Refresh did NOT trigger a backend warmer rebuild, so stale scope (e.g. Ryan-BBP added new assets today but AutoAR's view didn't reflect it) stayed stale. Wire refreshCurrentView() for the Programs view through ProgramsPage.refreshNow(), which triggers /api/scope/programs?refresh=true → forces the warmer rebuild. 2) Even with that fix, the warmer was bailing out on rate-limited programs. fetchH1ScopeSummary now retries up to 3 times with exponential backoff on 429 (2s, then 4s), and honors a sensible Retry-After header (≤30s) when H1 provides one. So the warmer doesn't routinely leave hundreds of programs with stale empty scope when the H1 token is being hammered. Net effect: clicking Refresh on the Programs page kicks off a real rebuild, and the rebuild is far more likely to actually capture the current scope for every program — which means the scope-update watch sees Ryan's new asset on the next refresh and fires the Discord alert. Verified: go build/vet/node --check clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6f094b6 commit f03ef72

2 files changed

Lines changed: 43 additions & 16 deletions

File tree

internal/api/programs_api.go

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
507510
func 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

internal/api/ui/pages/polling.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@
4444
case 'domains': window.loadDomains(); break;
4545
case 'subdomains': window.loadSubdomains(); break;
4646
case 'targets': window.loadTargetsPlatforms(); break;
47-
case 'programs': window.ProgramsPage.loadPrograms(); break;
47+
case 'programs':
48+
// Manual Refresh on Programs should actually pull fresh scope from the
49+
// platforms — not just re-read the cached payload. refreshNow() triggers a
50+
// backend warmer rebuild, then re-renders the table once it's done.
51+
window.ProgramsPage.refreshNow();
52+
break;
4853
case 'monitor': window.loadMonitor(); break;
4954
case 'keyhacks': window.loadKeyhacks(); break;
5055
case 'report-templates': window.renderReportTemplates(); break;

0 commit comments

Comments
 (0)