From ae126f90d47b4f09787674340f09aae0ec84dd64 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Fri, 28 Aug 2026 17:18:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(rate-limit):=20=E9=80=80=E9=81=BF=E5=BB=B6?= =?UTF-8?q?=E9=95=BF=E8=87=B3=206h=20=E4=B8=94=E7=99=BB=E5=BD=95=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=90=8C=E6=A0=B7=E9=81=B5=E5=AE=88=EF=BC=8C=E6=89=93?= =?UTF-8?q?=E7=A0=B4=20Garmin=20=E9=99=90=E6=B5=81=E6=AD=BB=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:迁移后自动同步+旧 oauth2 刷新轰炸把 Garmin 账号撞进限流黑洞;退避原仅 60min 且登录路径完全不检查,导致每小时退避到期又撞一次 429、登录也直接连 Garmin 被 429,窗口永远退不出。 - garmin.py: RATE_LIMIT_BACKOFF 60min->6h;_note_rate_limit 改为固定 now+6h(不再短延期)。 - garmin_auth.py: _run_login 入口先查 rate_limited_until 拦截(不撞 Garmin),遇 429 也调 _note_rate_limit 延长退避。 - 已在生产把 sync_status.rate_limited_until 设到 now+6h,强制 Garmin 安静以让窗口真正关闭。 --- backend/services/garmin.py | 19 ++++++++++++++----- backend/services/garmin_auth.py | 24 +++++++++++++++++++++++- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/backend/services/garmin.py b/backend/services/garmin.py index f64e1ae..f4dc325 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -100,7 +100,11 @@ class RateLimited(RuntimeError): # 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. -RATE_LIMIT_BACKOFF = datetime.timedelta(minutes=60) +# 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) # 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 = {} @@ -144,11 +148,16 @@ def rate_limited_until(user_id): def _note_rate_limit(user_id): - """Record a rate-limit cooldown, extending it if one is already running.""" + """Record a rate-limit cooldown. + + A 429 means Garmin is throttling this account/IP, and every further request + just extends the window — so we stand down for a long, fixed stretch + (RATE_LIMIT_BACKOFF) rather than a short one that expires before Garmin's own + throttle clears. The cooldown is persisted so every gunicorn worker and a + restart agree on it. + """ now = datetime.datetime.utcnow() - existing = rate_limited_until(user_id) - until = (existing + datetime.timedelta(minutes=30)) if existing and existing > now \ - else (now + RATE_LIMIT_BACKOFF) + until = now + RATE_LIMIT_BACKOFF _rate_limited_until[user_id] = until try: # Persist alongside the current status so the cooldown survives across diff --git a/backend/services/garmin_auth.py b/backend/services/garmin_auth.py index bf6f297..b91b853 100644 --- a/backend/services/garmin_auth.py +++ b/backend/services/garmin_auth.py @@ -93,6 +93,17 @@ def _wait_for_code(session_id): def _run_login(session_id, user_id, garmin_email, password, is_cn, import_garmin): + # Don't even attempt if we're in a rate-limit cooldown: slamming Garmin's + # login endpoint just extends the throttle. Fail fast with a clear message so + # the user isn't left watching a spinner that will only 429 anyway. + if garmin_svc.rate_limited_until(user_id): + _set( + session_id, + "failed", + error="Garmin 仍在限制请求频率,请等待冷却窗口结束后再登录。" + "反复点击登录会越撞越久,令牌本身没有失效。", + ) + return try: Garmin = import_garmin() client = Garmin(is_cn=is_cn) @@ -104,7 +115,18 @@ def _run_login(session_id, user_id, garmin_email, password, is_cn, import_garmin garmin_svc.save_token(user_id, client.garth.dumps(), garmin_email) _set(session_id, "done", code=None) except Exception as e: # noqa: BLE001 - surfaced to the user via the row - _set(session_id, "failed", error=f"{type(e).__name__}: {e}"[:500], code=None) + # A 429 during login extends the cooldown the same way a sync 429 does, + # so the scheduler and future logins back off instead of re-hammering and + # keeping Garmin's throttle alive forever. + if garmin_svc._is_rate_limited(e): + garmin_svc._note_rate_limit(user_id) + error = ( + "Garmin 返回 429 限流(登录接口)。已自动退避 6 小时," + "请等待冷却窗口结束后再试——反复尝试会越撞越久。" + ) + else: + error = f"{type(e).__name__}: {e}"[:500] + _set(session_id, "failed", error=error, code=None) def start_login(user_id, garmin_email, password, import_garmin=None, is_cn=None):