aboutsummaryrefslogtreecommitdiff
path: root/strava
diff options
context:
space:
mode:
Diffstat (limited to 'strava')
-rw-r--r--strava/strava.go171
1 files changed, 167 insertions, 4 deletions
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
+}