Files
GarminHealthLab/backend/services/garmin_auth.py
ericwyuan fd5e3363fd fix(garmin): 登录端点的 429 在封锁期内再试会延长封锁,唯独这一处要停手
实测记录:昨天为了验证令牌写回的修复,我发了一次 1 天同步,把 rate_limited_until
从 09-04T00:41 推到了 09-04T15:26——一次尝试,延长十五小时。

Garmin 的登录/换令牌端点和数据端点是两套规则:
- 数据端点的 429:本地冷却只是估算,过一会儿发个真实请求正是确认它有没有解除
  的唯一办法,没解除也不吃亏
- 登录端点的 429:窗口内每次尝试都把窗口往后推。这也是这个账号一直卡着出不来
  的原因——每次同步都去换一次令牌,每次都把封锁续上

所以:
- sync_status 加 rate_limit_source 列,记住 429 是哪个端点给的
- 只有 source=sso 且窗口未过时,才拒绝**刷新令牌**这一个动作

这个闸门刻意做得很窄,因为上一次的教训是「一刀切的闸门会让健康账号白白闲置」:
- 只拦刷新,不拦同步。库里的令牌只要还没过期,冷却期内照常同步
- 数据端点的 429 不参与判断——为了一次指标调用把账号锁一天,比问题本身更糟
- 用户可以推翻它:同步请求带 force 就先清掉冷却记录。那个截止时间是我们自己
  猜的 24 小时,不是佳明说的,所以必须能被推翻

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 06:13:52 +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, "sso")
error = (
"Garmin 返回 429 限流(登录接口)。已自动退避 24 小时,"
"请等待冷却窗口结束后再试——反复尝试会越撞越久。"
)
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],
)