diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-23 11:44:41 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-23 11:44:41 +0200 |
| commit | d0875a51b2f07766b339b34a580d4e6e9fab348d (patch) | |
| tree | 2e03e2714afdbf81c05662edc511b0b8b04c7919 /web/sync.go | |
| parent | f7264b8f594859c1430bd13e765db03002842d38 (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.
Diffstat (limited to 'web/sync.go')
| -rw-r--r-- | web/sync.go | 122 |
1 files changed, 118 insertions, 4 deletions
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 { |
