Add OpenF1 request pacing, 429 retry, and hub completeness guard

Concurrent fan-outs could burst past the free-tier rate limit; callers
swallowed the 429s as missing data, and the championship hub cached the
resulting partial season for its full TTL (observed live: 8 of 22
rounds, zero wins/podiums/form).

- requestPacer spaces live requests (350ms anonymous, 100ms with key);
  cache hits never wait
- get() retries 429s up to 3 times honouring Retry-After (capped 10s)
- hub tracks per-meeting fetch failures and caches incomplete
  aggregates for only 2 minutes instead of 15min/24h

Verified against the live API: 2026 hub now reports all 22 rounds with
wins/podiums/form populated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 00:47:52 -04:00
parent 5cfaed30ba
commit 7f0c338a57
5 changed files with 221 additions and 17 deletions

View File

@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
readability "codeberg.org/readeck/go-readability/v2"
@@ -655,6 +656,9 @@ const champHubWorkers = 5
const (
champHubCurrentTTL = 15 * time.Minute
champHubPastTTL = 24 * time.Hour
// champHubIncompleteTTL keeps a partially-fetched aggregate around just
// long enough to absorb page-load bursts while retrying soon after.
champHubIncompleteTTL = 2 * time.Minute
)
// champHubTTL returns the in-memory cache TTL for a season's hub response.
@@ -687,13 +691,13 @@ func (c *champHubCache) get(year int, now time.Time) (champHubResponse, bool) {
return e.resp, true
}
func (c *champHubCache) put(year int, resp champHubResponse, now time.Time) {
func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if c.entries == nil {
c.entries = map[int]champHubEntry{}
}
c.entries[year] = champHubEntry{resp: resp, expires: now.Add(champHubTTL(year, now))}
c.entries[year] = champHubEntry{resp: resp, expires: now.Add(ttl)}
}
// fetchMeetingRaces fans fetch out across meetings with bounded concurrency.
@@ -763,9 +767,14 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
sort.Slice(meetings, func(i, j int) bool { return meetings[i].DateStart < meetings[j].DateStart })
// Any per-meeting fetch failure (network, rate limit) yields an incomplete
// aggregate: serve it so the page still renders, but cache it only briefly
// so a partial view of the season doesn't stick around for the full TTL.
var incomplete atomic.Bool
races := fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
if serr != nil {
incomplete.Store(true)
return meetingRace{}, false
}
raceKey := 0
@@ -778,13 +787,20 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
if raceKey == 0 {
return meetingRace{}, false // not a GP meeting (e.g. pre-season testing)
}
results, _ := s.client.GetSessionResult(raceKey)
grid, _ := s.client.GetStartingGrid(raceKey)
results, rerr := s.client.GetSessionResult(raceKey)
grid, gerr := s.client.GetStartingGrid(raceKey)
if rerr != nil || gerr != nil {
incomplete.Store(true)
}
return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true
})
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
s.hubCache.put(year, resp, time.Now())
ttl := champHubTTL(year, time.Now())
if incomplete.Load() {
ttl = champHubIncompleteTTL
}
s.hubCache.put(year, resp, time.Now(), ttl)
writeJSON(w, resp)
}

View File

@@ -230,8 +230,8 @@ func TestChampHubCache(t *testing.T) {
t.Fatal("empty cache should miss")
}
c.put(2026, champHubResponse{Season: 2026, Round: 10}, now)
c.put(2024, champHubResponse{Season: 2024, Round: 24}, now)
c.put(2026, champHubResponse{Season: 2026, Round: 10}, now, champHubTTL(2026, now))
c.put(2024, champHubResponse{Season: 2024, Round: 24}, now, champHubTTL(2024, now))
// Current-year entry: hit within 15 min, miss after.
if resp, ok := c.get(2026, now.Add(14*time.Minute)); !ok || resp.Round != 10 {
@@ -250,8 +250,17 @@ func TestChampHubCache(t *testing.T) {
}
// Re-put refreshes the entry.
c.put(2026, champHubResponse{Season: 2026, Round: 11}, now.Add(20*time.Minute))
c.put(2026, champHubResponse{Season: 2026, Round: 11}, now.Add(20*time.Minute), champHubTTL(2026, now))
if resp, ok := c.get(2026, now.Add(30*time.Minute)); !ok || resp.Round != 11 {
t.Errorf("refreshed entry = (%+v, %v), want hit with Round 11", resp, ok)
}
// Incomplete aggregates get the short TTL: hit right away, gone in 3 min.
c.put(2023, champHubResponse{Season: 2023, Round: 5}, now, champHubIncompleteTTL)
if _, ok := c.get(2023, now.Add(time.Minute)); !ok {
t.Error("incomplete entry should hit within its short TTL")
}
if _, ok := c.get(2023, now.Add(3*time.Minute)); ok {
t.Error("incomplete entry should expire after champHubIncompleteTTL")
}
}