""" Client-side pacing for Garmin Connect. Why this exists --------------- A sync is not a handful of requests. Each day costs **nine** API calls (`_extract_daily` makes six, `daily_extras` three), short syncs add five more for the within-day curves, every activity without a stored detail costs one, and five account-wide calls finish the run. So: 7 days ~ 100 requests 30 days ~ 300 requests 730 days ~ 6600 requests and until now they were fired as fast as the network allowed. That is what walks the account into a 429 — repeatedly, because the only defence was a 24h cooldown *after* Garmin had already punished us. Backing off after the fact does not stop it happening again; spacing the requests does. Garmin publishes no limit for these endpoints, so the default comes from what other consumers actually get away with (checked 2026-09-03): * `sirredbeard/garmin-data-export` paces at **0.15s** by default, backs off on a 429, ramps back up, and pauses every 250 calls. Its README suggests `--delay 1.0` "if you want to play it safe". * `evg656e/garmin-connect-scraper` defaults to **3s**, warning that smaller values "may result in timeout penalties from Garmin". * Garmin's *official* (partner) API allows **100 requests/minute** — 0.6s each — which is the closest thing to a number Garmin itself stands behind. 0.5s sits between them: slower than the official allowance, three times more cautious than the aggressive scraper, six times faster than the timid one. Worth being clear about what this does and does not fix. The brutal multi-hour blocks reported across those projects are on the **SSO/login** endpoint and are keyed per account; this app's own lockout came from there, not from data volume (it was re-minting a token on every connect — see `_connect` in garmin.py). Pacing the data calls is the other half: a full backfill firing 6600 requests flat out is not something to rely on Garmin tolerating. What it does ------------ * **Spacing.** Every call waits until at least `min_interval` has passed since the previous one, per account. * **A per-run budget.** A run may issue at most N requests; the sync then stops cleanly and resumes next time (it already skips days it has stored). Without this, "全部历史" is a single unbounded burst no matter how well spaced. Both apply at one choke point — a proxy around the client object — so every call site is covered, including ones added later. Anything that reaches Garmin without going through the client is not paced, which today means only the login/token calls on `client.garth`: a couple per sync, not thousands. """ import functools import os import threading import time class BudgetExhausted(Exception): """This run has issued as many requests as it is allowed. Not an error: the caller keeps what it stored and continues next run. """ def min_interval(): """Seconds between requests. Read per call so it is tunable without a restart, and so tests can set it to 0.""" return float(os.environ.get("GARMIN_MIN_INTERVAL_SECONDS") or 0.5) def default_budget(): """Requests one sync run may issue. 1200 at 0.5s is about 10 minutes of wall clock — long enough to cover four months of history in one go (9 requests per day), short enough that a full backfill is a handful of background runs rather than one long hammering. """ return int(os.environ.get("GARMIN_REQUEST_BUDGET") or 1200) class Limiter: """Per-account spacing and per-run request accounting.""" def __init__(self, interval=None): self._interval = interval self._lock = threading.Lock() self._next_at = 0.0 self.used = 0 self.budget = None @property def interval(self): return min_interval() if self._interval is None else self._interval def start_run(self, budget=None): """Begin a run. `budget` None means unlimited (a short, known-size job).""" with self._lock: self.used = 0 self.budget = budget @property def remaining(self): if self.budget is None: return None return max(0, self.budget - self.used) def acquire(self): """Claim a slot, waiting for it if necessary.""" with self._lock: if self.budget is not None and self.used >= self.budget: raise BudgetExhausted( f"本次同步已达请求预算上限({self.budget} 次)" ) self.used += 1 now = time.monotonic() wait = self._next_at - now # Reserve this call's slot before releasing the lock, so two # threads get consecutive slots rather than the same one. self._next_at = max(now, self._next_at) + self.interval # Slept outside the lock: two callers should wait out their own # (non-overlapping) slots concurrently, not queue behind each other's. if wait > 0: time.sleep(wait) _limiters = {} _limiters_lock = threading.Lock() def limiter_for(user_id): """The limiter for one account, created on first use. Per account and per process. Two Gunicorn workers therefore pace independently — acceptable because a given account syncs from one place at a time (the scheduler claims its tick through the database, and a manual sync sets the status to `syncing`), and the budget bounds the damage if that ever stops holding. """ key = user_id or "_anon" with _limiters_lock: found = _limiters.get(key) if found is None: found = Limiter() _limiters[key] = found return found class PacedClient: """Attribute proxy that paces every public method call. Non-callables (`display_name`, `garth`) pass through untouched, as do private names, and attribute writes reach the wrapped client — `_connect` sets `client.display_name` and the library reads it back on every request. """ def __init__(self, inner, limiter): object.__setattr__(self, "_inner", inner) object.__setattr__(self, "_limiter", limiter) @property def limiter(self): return object.__getattribute__(self, "_limiter") @property def inner(self): return object.__getattribute__(self, "_inner") def __getattr__(self, name): attr = getattr(object.__getattribute__(self, "_inner"), name) if name.startswith("_") or not callable(attr): return attr limiter = object.__getattribute__(self, "_limiter") @functools.wraps(attr) def paced(*args, **kwargs): limiter.acquire() return attr(*args, **kwargs) return paced def __setattr__(self, name, value): setattr(object.__getattribute__(self, "_inner"), name, value) def __repr__(self): inner = object.__getattribute__(self, "_inner") return f"" def pace(client, user_id, budget=None): """Wrap `client` so its calls are spaced, and start a run. Idempotent: wrapping an already-wrapped client returns it (with the run restarted) rather than stacking two layers of waiting. """ limiter = limiter_for(user_id) limiter.start_run(default_budget() if budget is None else budget) if isinstance(client, PacedClient): return client return PacedClient(client, limiter)