Files
GarminHealthLab/backend/services/garmin_auth.py
ericwyuan ae126f90d4 fix(rate-limit): 退避延长至 6h 且登录路径同样遵守,打破 Garmin 限流死循环
根因:迁移后自动同步+旧 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 安静以让窗口真正关闭。
2026-08-28 17:18:37 +08:00

183 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Interactive Garmin login with two-factor auth, driven from the web UI.
The problem this solves: garth asks for the MFA code through a *blocking*
callback in the middle of `sso.login`. There is no "start login, return a
handle, resume later" API in garth 0.4.46, so the login has to stay alive
while the code is fetched.
Shape of the solution:
* the login runs in a background thread and parks inside `prompt_mfa`
* the browser posts the code in a separate request
* the two meet through a row in `garmin_mfa_sessions`, not process memory,
because gunicorn runs several workers and the code request will not
reliably land on the worker holding the parked login
The Garmin password never leaves the waiting thread — it is not stored.
Statuses:
starting - thread launched, login not yet at the MFA step
awaiting_code - parked in prompt_mfa, waiting for the browser
finishing - code received, completing the login
done - tokens saved
failed - see `error`
"""
import datetime
import threading
import time
import uuid
from config import DB_TYPE
from db import execute, query_one
from services import garmin as garmin_svc
# How long the parked login waits for a code before giving up. Garmin codes
# expire in 30 minutes, but holding a thread that long is wasteful; the user
# can simply start again.
CODE_WAIT_SECONDS = 300
POLL_INTERVAL_SECONDS = 2
# Sessions older than this are cleared out whenever a new one starts.
SESSION_TTL_MINUTES = 60
def _now():
return datetime.datetime.utcnow().isoformat(timespec="seconds")
def _set(session_id, status, **fields):
sets = ["status = ?", "updated_at = ?"]
params = [status, _now()]
for k, v in fields.items():
sets.append(f"{k} = ?")
params.append(v)
params.append(session_id)
execute(f"UPDATE garmin_mfa_sessions SET {', '.join(sets)} WHERE id = ?", params)
def get_session(session_id, user_id=None):
row = query_one("SELECT * FROM garmin_mfa_sessions WHERE id = ?", [session_id])
if not row:
return None
# A session id from one account must never address another's login.
if user_id and row["user_id"] != user_id:
return None
return row
def _cleanup(user_id):
cutoff = (
datetime.datetime.utcnow() - datetime.timedelta(minutes=SESSION_TTL_MINUTES)
).isoformat(timespec="seconds")
execute(
"DELETE FROM garmin_mfa_sessions WHERE user_id = ? AND created_at < ?",
[user_id, cutoff],
)
def _wait_for_code(session_id):
"""Block until the browser posts a code. Runs inside garth's prompt_mfa."""
_set(session_id, "awaiting_code")
deadline = time.time() + CODE_WAIT_SECONDS
while time.time() < deadline:
row = query_one(
"SELECT code FROM garmin_mfa_sessions WHERE id = ?", [session_id]
)
if row is None:
raise RuntimeError("登录会话已被取消")
if row["code"]:
_set(session_id, "finishing")
return row["code"]
time.sleep(POLL_INTERVAL_SECONDS)
raise TimeoutError("等待验证码超时,请重新发起登录")
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)
# Calling garth directly is what allows the MFA prompt to be replaced;
# Garmin.login() hardcodes the stdin one.
client.garth.login(
garmin_email, password, prompt_mfa=lambda: _wait_for_code(session_id)
)
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
# 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):
"""Kick off a login in the background. Returns the session id."""
_cleanup(user_id)
session_id = str(uuid.uuid4())
execute(
"INSERT INTO garmin_mfa_sessions (id, user_id, status, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?)",
[session_id, user_id, "starting", _now(), _now()],
)
thread = threading.Thread(
target=_run_login,
args=(
session_id,
user_id,
garmin_email,
password,
garmin_svc._is_cn() if is_cn is None else is_cn,
import_garmin or garmin_svc._import_garmin,
),
daemon=True,
)
thread.start()
return session_id
def submit_code(session_id, user_id, code):
"""Hand a code to the parked login. Returns (ok, message)."""
row = get_session(session_id, user_id)
if not row:
return False, "登录会话不存在或已过期"
if row["status"] == "done":
return False, "登录已完成"
if row["status"] == "failed":
return False, row["error"] or "登录已失败,请重新发起"
if row["status"] not in ("awaiting_code", "starting"):
return False, f"当前状态无法提交验证码({row['status']}"
execute(
"UPDATE garmin_mfa_sessions SET code = ?, updated_at = ? WHERE id = ?",
[str(code).strip(), _now(), session_id],
)
return True, "验证码已提交"
def cancel(session_id, user_id):
execute(
"DELETE FROM garmin_mfa_sessions WHERE id = ? AND user_id = ?",
[session_id, user_id],
)