diff --git a/backend/services/garmin.py b/backend/services/garmin.py index f4dc325..74cf88b 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -21,6 +21,7 @@ so passing it a date silently asks for activity number "2026-08-23". """ import datetime import json +import logging import os import threading @@ -97,14 +98,11 @@ class RateLimited(RuntimeError): # Retrying while rate limited is what deepens the limit, so once Garmin says -# 429 the whole process stands down until this passes. Bumped from 30 to 60 -# minutes: the account was stuck for days because every hourly tick re-hit it, -# so a longer cooldown gives Garmin's window room to actually close. -# How long we stand down after a Garmin 429. Garmin's own throttle window runs -# well past an hour for a repeat offender, so a short backoff just expires, lets -# the scheduler re-hit, and keeps the limit alive forever. Six hours of quiet is -# what actually lets the window close. -RATE_LIMIT_BACKOFF = datetime.timedelta(hours=6) +# 429 the whole process stands down until this passes. The account was stuck for +# days because the backoff kept expiring before Garmin's own (multi-hour) window +# closed, so every tick re-hit it and the limit never lifted. A 24h stand-down +# is what actually outlasts the throttle and lets the window close for good. +RATE_LIMIT_BACKOFF = datetime.timedelta(hours=24) # In-process cache of the cooldown, kept in sync with the DB copy below and # still the lever the tests reach for via _rate_limited_until.clear(). _rate_limited_until = {} @@ -132,19 +130,23 @@ def _parse_dt(v): def rate_limited_until(user_id): """When the account is still cooling down, as a UTC datetime (or None). - The cooldown is persisted to the database so every gunicorn worker and a - process restart see the same deadline — a process-local dict alone let each - worker re-hit Garmin and keep the limit alive forever. + The cooldown lives in the database and is the single source of truth, so + every gunicorn worker and a restart agree on it. A process-local dict caused + a nasty "stuck forever" bug: a worker would remember a future deadline whose + DB write had silently failed to persist, and go on blocking even after the + real deadline had passed. We therefore trust the DB row, falling back to the + in-memory cache only when no row exists yet. """ - mem = _rate_limited_until.get(user_id) try: row = query_one( "SELECT rate_limited_until FROM sync_status WHERE user_id = ?", [user_id] ) except Exception: row = None - candidates = [c for c in (mem, _parse_dt(row["rate_limited_until"] if row else None)) if c] - return max(candidates) if candidates else None + db_val = _parse_dt(row["rate_limited_until"] if row else None) + if db_val is not None: + return db_val + return _rate_limited_until.get(user_id) def _note_rate_limit(user_id): @@ -167,8 +169,10 @@ def _note_rate_limit(user_id): user_id, cur_status, now.isoformat(timespec="seconds"), rate_limited_until=until.isoformat(timespec="seconds"), ) - except Exception: - pass + except Exception as e: # no cover - surfaced so a persist failure is visible + logging.getLogger(__name__).warning( + "rate-limit cooldown failed to persist for %s: %s", user_id, e + ) def _rate_limit_block(user_id):