From d0875a51b2f07766b339b34a580d4e6e9fab348d Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 23 Aug 2026 11:44:41 +0200 Subject: Handle Strava rate limiting with abort, retry time and daily usage display Strava sync for 167 rides exceeds the 100/15-min read limit (2 calls per ride). Previously a 429 was treated as a skipped ride, so every remaining ride also failed silently. Now GetJSON returns a typed RateLimitError with RetryAfter computed from Retry-After/Date and X-RateLimit headers (15-min window resets at :00/:15/:30/:45, daily at midnight UTC) and tracks Read/Overall usage. syncRides aborts on 429 with partial progress instead of skipping, and handleSync emits an SSE error with retry time, daily usage vs limit and structured rateLimit snapshot; progress and complete events also carry the snapshot for the UI to display. Frontend shows Read daily/15-min usage in progress details and on completion. --- strava/strava.go | 171 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 167 insertions(+), 4 deletions(-) (limited to 'strava/strava.go') diff --git a/strava/strava.go b/strava/strava.go index 7c555be..309d0ee 100644 --- a/strava/strava.go +++ b/strava/strava.go @@ -6,6 +6,8 @@ import ( "io" "net/http" "net/url" + "strconv" + "strings" "time" "github.com/martinlehoux/kagamigo/kcore" @@ -93,11 +95,24 @@ func requestToken(form url.Values) (Token, error) { }, nil } +type RateLimit struct { + Limit15 int `json:"limit15"` + Usage15 int `json:"usage15"` + LimitDaily int `json:"limitDaily"` + UsageDaily int `json:"usageDaily"` +} + +type RateLimitState struct { + Read RateLimit `json:"read"` + Overall RateLimit `json:"overall"` +} + type Client struct { - ClientID string - ClientSecret string - HTTP *http.Client - tokens Token + ClientID string + ClientSecret string + HTTP *http.Client + tokens Token + lastRateLimit RateLimitState } func NewClient(clientID, clientSecret string, tokens Token) *Client { @@ -108,6 +123,10 @@ func (c *Client) Tokens() Token { return c.tokens } +func (c *Client) RateLimitState() RateLimitState { + return c.lastRateLimit +} + func (c *Client) RefreshIfNeeded() (bool, error) { if time.Until(c.tokens.ExpiresAt) > 5*time.Minute { return false, nil @@ -120,6 +139,30 @@ func (c *Client) RefreshIfNeeded() (bool, error) { return true, nil } +type RateLimitError struct { + Path string + StatusCode int + Body string + RetryAfter time.Time + IsDaily bool + State RateLimitState +} + +func (e *RateLimitError) Error() string { + if !e.RetryAfter.IsZero() { + until := time.Until(e.RetryAfter).Truncate(time.Second) + if until < 0 { + until = 0 + } + scope := "15-minute" + if e.IsDaily { + scope = "daily" + } + return fmt.Sprintf("strava API %s rate limited (HTTP 429): %s; %s limit resets at %s (in %s)", e.Path, e.Body, scope, e.RetryAfter.UTC().Format(time.RFC3339), until) + } + return fmt.Sprintf("strava API %s rate limited (HTTP %d): %s", e.Path, e.StatusCode, e.Body) +} + func (c *Client) GetJSON(path string, out any) error { if _, err := c.RefreshIfNeeded(); err != nil { return err @@ -138,6 +181,20 @@ func (c *Client) GetJSON(path string, out any) error { if err != nil { return kcore.Wrap(err, "failed to read Strava API response") } + state := parseRateLimitState(resp) + if !isZeroRateLimitState(state) { + c.lastRateLimit = state + } + if resp.StatusCode == http.StatusTooManyRequests { + return &RateLimitError{ + Path: path, + StatusCode: resp.StatusCode, + Body: string(body), + RetryAfter: rateLimitRetryAfter(resp), + IsDaily: isDailyRateLimited(resp), + State: state, + } + } if resp.StatusCode != http.StatusOK { return fmt.Errorf("strava API %s failed (HTTP %d): %s", path, resp.StatusCode, body) } @@ -146,3 +203,109 @@ func (c *Client) GetJSON(path string, out any) error { } return nil } + +func rateLimitRetryAfter(resp *http.Response) time.Time { + if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { + if secs, err := strconv.Atoi(strings.TrimSpace(retryAfter)); err == nil { + return time.Now().UTC().Add(time.Duration(secs) * time.Second) + } + if t, err := http.ParseTime(retryAfter); err == nil { + return t.UTC() + } + } + responseTime := time.Now().UTC() + if date := resp.Header.Get("Date"); date != "" { + if t, err := http.ParseTime(date); err == nil { + responseTime = t.UTC() + } + } + if isDailyRateLimited(resp) { + return nextMidnightUTC(responseTime) + } + return nextQuarterHour(responseTime) +} + +func isDailyRateLimited(resp *http.Response) bool { + for _, pair := range [][2]string{ + {resp.Header.Get("X-ReadRateLimit-Limit"), resp.Header.Get("X-ReadRateLimit-Usage")}, + {resp.Header.Get("X-RateLimit-Limit"), resp.Header.Get("X-RateLimit-Usage")}, + } { + limitPair := pair[0] + usagePair := pair[1] + if limitPair == "" || usagePair == "" { + continue + } + _, dailyLimit, ok1 := parseRateLimitPair(limitPair) + _, dailyUsage, ok2 := parseRateLimitPair(usagePair) + if !ok1 || !ok2 { + continue + } + if dailyLimit > 0 && dailyUsage >= dailyLimit { + return true + } + } + return false +} + +func parseRateLimitPair(value string) (quarter int, daily int, ok bool) { + parts := strings.Split(value, ",") + if len(parts) != 2 { + return 0, 0, false + } + q, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) + d, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return q, d, true +} + +func nextQuarterHour(t time.Time) time.Time { + t = t.UTC().Truncate(time.Second) + minute := t.Minute() + var nextMinute int + switch { + case minute < 15: + nextMinute = 15 + case minute < 30: + nextMinute = 30 + case minute < 45: + nextMinute = 45 + default: + t = t.Add(time.Hour) + nextMinute = 0 + } + return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), nextMinute, 0, 0, time.UTC) +} + +func nextMidnightUTC(t time.Time) time.Time { + t = t.UTC() + return time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, time.UTC) +} + +func parseRateLimitState(resp *http.Response) RateLimitState { + var state RateLimitState + if limit, usage, ok := parseRateLimitHeaders(resp.Header.Get("X-ReadRateLimit-Limit"), resp.Header.Get("X-ReadRateLimit-Usage")); ok { + state.Read = RateLimit{Limit15: limit[0], LimitDaily: limit[1], Usage15: usage[0], UsageDaily: usage[1]} + } + if limit, usage, ok := parseRateLimitHeaders(resp.Header.Get("X-RateLimit-Limit"), resp.Header.Get("X-RateLimit-Usage")); ok { + state.Overall = RateLimit{Limit15: limit[0], LimitDaily: limit[1], Usage15: usage[0], UsageDaily: usage[1]} + } + return state +} + +func parseRateLimitHeaders(limitValue, usageValue string) ([2]int, [2]int, bool) { + if limitValue == "" || usageValue == "" { + return [2]int{}, [2]int{}, false + } + l0, l1, ok1 := parseRateLimitPair(limitValue) + u0, u1, ok2 := parseRateLimitPair(usageValue) + if !ok1 || !ok2 { + return [2]int{}, [2]int{}, false + } + return [2]int{l0, l1}, [2]int{u0, u1}, true +} + +func isZeroRateLimitState(state RateLimitState) bool { + return state.Read.Limit15 == 0 && state.Read.LimitDaily == 0 && state.Overall.Limit15 == 0 && state.Overall.LimitDaily == 0 +} -- cgit v1.3