aboutsummaryrefslogtreecommitdiff
path: root/web/sync.go
diff options
context:
space:
mode:
Diffstat (limited to 'web/sync.go')
-rw-r--r--web/sync.go122
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 {