diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/frontend/sync.ts | 22 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 16 | ||||
| -rw-r--r-- | web/sync.go | 122 | ||||
| -rw-r--r-- | web/views.go | 15 |
4 files changed, 164 insertions, 11 deletions
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 { |
