aboutsummaryrefslogtreecommitdiff
path: root/web
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 /web
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.
Diffstat (limited to 'web')
-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
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 {