mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
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:
@@ -35,6 +35,45 @@ func IsLiveSessionError(err error) bool {
|
||||
return errors.Is(err, ErrLiveSessionLocked)
|
||||
}
|
||||
|
||||
// max429Retries is how many times doPaced re-attempts a request that came
|
||||
// back 429 before giving up and letting the caller fall back to stale data.
|
||||
const max429Retries = 3
|
||||
|
||||
// retryAfter429 extracts the server-requested backoff from a 429 response,
|
||||
// with a sane default and cap so a hostile header can't stall the app.
|
||||
func retryAfter429(resp *http.Response) time.Duration {
|
||||
delay := time.Second
|
||||
if header := resp.Header.Get("Retry-After"); header != "" {
|
||||
if seconds, err := strconv.Atoi(header); err == nil && seconds > 0 {
|
||||
delay = time.Duration(seconds) * time.Second
|
||||
}
|
||||
}
|
||||
if delay > 10*time.Second {
|
||||
delay = 10 * time.Second
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
// doPaced executes req through the client's request pacer and transparently
|
||||
// retries 429 responses (honouring Retry-After) up to max429Retries times.
|
||||
// Without this, concurrent fan-outs (championship hub, track prefetch) burst
|
||||
// past the free-tier limit and callers silently treat 429s as missing data.
|
||||
func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
c.pacer.wait()
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusTooManyRequests || attempt >= max429Retries {
|
||||
return resp, nil
|
||||
}
|
||||
delay := retryAfter429(resp)
|
||||
resp.Body.Close()
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
// get performs a GET request and returns the response body, or an error if the
|
||||
// status code is not 200 OK. It checks the SQLite cache before making a
|
||||
// network request.
|
||||
@@ -59,7 +98,7 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
resp, err := c.doPaced(req)
|
||||
if err != nil {
|
||||
return c.tryStale(url, err)
|
||||
}
|
||||
@@ -114,20 +153,14 @@ func (c *OpenF1Client) FetchStrict(url string) ([]byte, error) {
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
resp, err := c.doPaced(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryAfterDur := 500 * time.Millisecond
|
||||
if retryAfterHeader := resp.Header.Get("Retry-After"); retryAfterHeader != "" {
|
||||
if seconds, err := strconv.Atoi(retryAfterHeader); err == nil {
|
||||
retryAfterDur = time.Duration(seconds) * time.Second
|
||||
}
|
||||
}
|
||||
return nil, &RateLimitError{RetryAfter: retryAfterDur}
|
||||
return nil, &RateLimitError{RetryAfter: retryAfter429(resp)}
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
|
||||
Reference in New Issue
Block a user