aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-23 11:44:41 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-23 11:44:41 +0200
commitd0875a51b2f07766b339b34a580d4e6e9fab348d (patch)
tree2e03e2714afdbf81c05662edc511b0b8b04c7919
parentf7264b8f594859c1430bd13e765db03002842d38 (diff)
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.
-rw-r--r--strava/strava.go171
-rw-r--r--web/frontend/sync.ts22
-rw-r--r--web/frontend/types.d.ts16
-rw-r--r--web/sync.go122
-rw-r--r--web/views.go15
5 files changed, 331 insertions, 15 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
+}
diff --git a/web/frontend/sync.ts b/web/frontend/sync.ts
index 000dcfc..f58d224 100644
--- a/web/frontend/sync.ts
+++ b/web/frontend/sync.ts
@@ -1,4 +1,4 @@
-import type { SyncErrorEvent, SyncProgress } from "./types.js";
+import type { RateLimitSnapshot, SyncErrorEvent, SyncProgress } from "./types.js";
import { requireElement } from "./dom.js";
const isSyncProgress = (data: SyncProgress | SyncErrorEvent): data is SyncProgress => "total" in data;
@@ -18,12 +18,28 @@ export const mountSync = (): void => {
error.textContent = message;
error.hidden = false;
};
+ const getRateLimit = (data: SyncProgress | SyncErrorEvent): RateLimitSnapshot | undefined => {
+ if ("rateLimit" in data && data.rateLimit) return data.rateLimit;
+ if ("progress" in data && data.progress?.rateLimit) return data.progress.rateLimit;
+ return undefined;
+ };
+
+ const formatRateLimit = (data: SyncProgress | SyncErrorEvent): string => {
+ const rl = getRateLimit(data);
+ if (!rl) return "";
+ const parts: string[] = [];
+ if (rl.read?.limitDaily) parts.push(`Read ${rl.read.usageDaily}/${rl.read.limitDaily} daily`);
+ if (rl.read?.limit15) parts.push(`${rl.read.usage15}/${rl.read.limit15} (15-min)`);
+ if (parts.length === 0 && rl.overall?.limitDaily) parts.push(`Overall ${rl.overall.usageDaily}/${rl.overall.limitDaily} daily`);
+ return parts.length ? ` · ${parts.join(" · ")}` : "";
+ };
+
const updateProgress = (progress: SyncProgress): void => {
progressPanel.hidden = false;
progressBar.max = Math.max(progress.total, 1);
progressBar.value = progress.completed;
progressCount.textContent = `${progress.completed} / ${progress.total}`;
- progressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`;
+ progressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped${formatRateLimit(progress)}`;
};
const handleEvent = (eventName: string, data: SyncProgress | SyncErrorEvent): boolean => {
if (eventName === "progress") {
@@ -34,7 +50,7 @@ export const mountSync = (): void => {
if (eventName === "complete") {
if (!isSyncProgress(data)) throw new Error("Invalid Strava sync completion event");
updateProgress(data);
- notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`;
+ notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.${formatRateLimit(data)}`;
notice.hidden = false;
return true;
}
diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts
index 3c2a918..6ff3dcf 100644
--- a/web/frontend/types.d.ts
+++ b/web/frontend/types.d.ts
@@ -124,14 +124,30 @@ export interface AnalyticsPoint {
movingHours: number;
}
+export interface RateLimit {
+ limit15: number;
+ usage15: number;
+ limitDaily: number;
+ usageDaily: number;
+}
+
+export interface RateLimitSnapshot {
+ read: RateLimit;
+ overall: RateLimit;
+}
+
export interface SyncProgress {
total: number;
completed: number;
imported: number;
skipped: number;
+ rateLimit?: RateLimitSnapshot;
}
export interface SyncErrorEvent {
message: string;
progress?: SyncProgress;
+ retryAfter?: string;
+ isRateLimited?: boolean;
+ rateLimit?: RateLimitSnapshot;
}
diff --git a/web/sync.go b/web/sync.go
index b491ee0..fdb3c4e 100644
--- a/web/sync.go
+++ b/web/sync.go
@@ -79,19 +79,39 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "text/event-stream")
progress, err := s.syncRides(client, appConfig.Storage.GPXDir, from, to, func(progress SyncProgress) error {
+ enrichProgressRateLimit(&progress, client)
return writeSyncEvent(w, flusher, "progress", progress)
})
if err != nil {
- _ = writeSyncEvent(w, flusher, "error", syncErrorEvent{Message: err.Error(), Progress: progress})
+ var rateErr *strava.RateLimitError
+ if errors.As(err, &rateErr) {
+ slog.Warn("Strava sync rate limited", "total", progress.Total, "imported", progress.Imported, "retryAfter", rateErr.RetryAfter, "isDaily", rateErr.IsDaily, "readDaily", fmt.Sprintf("%d/%d", rateErr.State.Read.UsageDaily, rateErr.State.Read.LimitDaily), "overallDaily", fmt.Sprintf("%d/%d", rateErr.State.Overall.UsageDaily, rateErr.State.Overall.LimitDaily))
+ enrichProgressWithState(&progress, rateErr.State)
+ _ = writeSyncEvent(w, flusher, "error", syncErrorEvent{
+ Message: rateLimitMessage(rateErr, progress),
+ Progress: progress,
+ RetryAfter: formatRetryAfter(rateErr.RetryAfter),
+ IsRateLimited: true,
+ RateLimit: progress.RateLimit,
+ })
+ return
+ }
+ enrichProgressRateLimit(&progress, client)
+ _ = writeSyncEvent(w, flusher, "error", syncErrorEvent{Message: err.Error(), Progress: progress, RateLimit: progress.RateLimit})
return
}
- slog.Info("Completed Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total, "imported", progress.Imported, "skipped", progress.Skipped)
+ enrichProgressRateLimit(&progress, client)
+ slog.Info("Completed Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total, "imported", progress.Imported, "skipped", progress.Skipped, "readDaily", rateLimitLogValue(progress.RateLimit, true), "overallDaily", rateLimitLogValue(progress.RateLimit, false))
_ = writeSyncEvent(w, flusher, "complete", progress)
}
func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, report func(SyncProgress) error) (SyncProgress, error) {
activities, err := client.List(from, to)
if err != nil {
+ var rateErr *strava.RateLimitError
+ if errors.As(err, &rateErr) {
+ slog.Warn("Strava sync rate limited while listing activities", "retryAfter", rateErr.RetryAfter, "isDaily", rateErr.IsDaily, "error", err)
+ }
return SyncProgress{}, err
}
progress := SyncProgress{Total: len(activities)}
@@ -118,6 +138,11 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time,
}
activity, gpxData, err := client.Get(summary.ID)
if err != nil {
+ var rateErr *strava.RateLimitError
+ if errors.As(err, &rateErr) {
+ slog.Warn("Strava sync rate limited", "activity", summary.ID, "name", summary.Name, "retryAfter", rateErr.RetryAfter, "isDaily", rateErr.IsDaily, "completed", progress.Completed, "total", progress.Total)
+ return progress, err
+ }
if errors.Is(err, strava.ErrNoTrackPoints) {
_ = os.Remove(filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", summary.ID)))
if saveErr := ride.Save(s.db, rideFromActivity(externalID, "", activity)); saveErr != nil {
@@ -214,8 +239,97 @@ func (s *Server) endSync() {
}
type syncErrorEvent struct {
- Message string `json:"message"`
- Progress SyncProgress `json:"progress"`
+ Message string `json:"message"`
+ Progress SyncProgress `json:"progress"`
+ RetryAfter string `json:"retryAfter,omitempty"`
+ IsRateLimited bool `json:"isRateLimited,omitempty"`
+ RateLimit *RateLimitSnapshot `json:"rateLimit,omitempty"`
+}
+
+func enrichProgressRateLimit(progress *SyncProgress, client syncClient) {
+ if provider, ok := client.(interface{ RateLimitState() strava.RateLimitState }); ok {
+ state := provider.RateLimitState()
+ if isZeroRateLimit(state) {
+ return
+ }
+ enrichProgressWithState(progress, state)
+ }
+}
+
+func enrichProgressWithState(progress *SyncProgress, state strava.RateLimitState) {
+ progress.RateLimit = &RateLimitSnapshot{Read: state.Read, Overall: state.Overall}
+}
+
+func isZeroRateLimit(state strava.RateLimitState) bool {
+ return state.Read.Limit15 == 0 && state.Read.LimitDaily == 0 && state.Overall.Limit15 == 0 && state.Overall.LimitDaily == 0
+}
+
+func rateLimitLogValue(snapshot *RateLimitSnapshot, read bool) string {
+ if snapshot == nil {
+ return ""
+ }
+ if read {
+ if snapshot.Read.LimitDaily == 0 {
+ return ""
+ }
+ return fmt.Sprintf("%d/%d", snapshot.Read.UsageDaily, snapshot.Read.LimitDaily)
+ }
+ if snapshot.Overall.LimitDaily == 0 {
+ return ""
+ }
+ return fmt.Sprintf("%d/%d", snapshot.Overall.UsageDaily, snapshot.Overall.LimitDaily)
+}
+
+func rateLimitMessage(rateErr *strava.RateLimitError, progress SyncProgress) string {
+ scope := "15-minute"
+ if rateErr.IsDaily {
+ scope = "daily"
+ }
+ retryText := ""
+ if !rateErr.RetryAfter.IsZero() {
+ until := time.Until(rateErr.RetryAfter).Truncate(time.Second)
+ if until < 0 {
+ until = 0
+ }
+ retryText = fmt.Sprintf(" Retry after %s (in %s).", rateErr.RetryAfter.UTC().Format(time.RFC3339), until)
+ if rateErr.IsDaily {
+ retryText = fmt.Sprintf(" Daily limit resets at %s (in %s).", rateErr.RetryAfter.UTC().Format(time.RFC3339), until)
+ }
+ }
+ usageText := formatRateLimitUsage(rateErr.State)
+ if usageText != "" {
+ usageText = " " + usageText
+ }
+ progressText := ""
+ if progress.Total > 0 {
+ remaining := progress.Total - progress.Completed
+ progressText = fmt.Sprintf(" Progress: %d/%d completed, %d imported, %d skipped, %d remaining.", progress.Completed, progress.Total, progress.Imported, progress.Skipped, remaining)
+ }
+ return fmt.Sprintf("Strava %s rate limit reached.%s%s%s Re-run sync after the reset time to import remaining rides.", scope, progressText, usageText, retryText)
+}
+
+func formatRateLimitUsage(state strava.RateLimitState) string {
+ var parts []string
+ if state.Read.LimitDaily > 0 {
+ parts = append(parts, fmt.Sprintf("Read daily %d/%d", state.Read.UsageDaily, state.Read.LimitDaily))
+ }
+ if state.Read.Limit15 > 0 {
+ parts = append(parts, fmt.Sprintf("Read 15-min %d/%d", state.Read.Usage15, state.Read.Limit15))
+ }
+ if state.Overall.LimitDaily > 0 && (state.Overall.LimitDaily != state.Read.LimitDaily || state.Overall.UsageDaily != state.Read.UsageDaily) {
+ parts = append(parts, fmt.Sprintf("Overall daily %d/%d", state.Overall.UsageDaily, state.Overall.LimitDaily))
+ }
+ if len(parts) == 0 {
+ return ""
+ }
+ return "Usage: " + strings.Join(parts, ", ") + "."
+}
+
+func formatRetryAfter(t time.Time) string {
+ if t.IsZero() {
+ return ""
+ }
+ return t.UTC().Format(time.RFC3339)
}
func writeSyncEvent(w http.ResponseWriter, flusher http.Flusher, event string, data any) error {
diff --git a/web/views.go b/web/views.go
index 6fd5adf..cfb31e5 100644
--- a/web/views.go
+++ b/web/views.go
@@ -9,6 +9,7 @@ import (
"github.com/martinlehoux/biking_home/official_climb"
"github.com/martinlehoux/biking_home/ride"
"github.com/martinlehoux/biking_home/rideanalysis"
+ "github.com/martinlehoux/biking_home/strava"
)
type RideView struct {
@@ -376,11 +377,17 @@ type SyncPageData struct {
HasAuth bool
}
+type RateLimitSnapshot struct {
+ Read strava.RateLimit `json:"read"`
+ Overall strava.RateLimit `json:"overall"`
+}
+
type SyncProgress struct {
- Total int `json:"total"`
- Completed int `json:"completed"`
- Imported int `json:"imported"`
- Skipped int `json:"skipped"`
+ Total int `json:"total"`
+ Completed int `json:"completed"`
+ Imported int `json:"imported"`
+ Skipped int `json:"skipped"`
+ RateLimit *RateLimitSnapshot `json:"rateLimit,omitempty"`
}
func formatRideDate(value time.Time) string {