生产实测:登录时 garth 把 429 折进 RetryError('too many 429 error responses'),无 status_code、无 'rate limit' 字样,旧检测漏判 → 走 else 分支存原始报错、不调 _note_rate_limit,冷却永远不落库,用户可反复撞 Garmin。\n\n- garmin.py: _is_rate_limited 沿 __cause__ 链找 429 响应,并接受文本中的 '429' 标记。\n- garmin_auth.py: 退避文案 6h 改为 24h。\n- 生产:已补挂 24h 冷却(2026-08-30 10:00 北京),RetryError 形状验证识别为 429。
183 lines
6.5 KiB
Python
183 lines
6.5 KiB
Python
"""
|
||
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 限流(登录接口)。已自动退避 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],
|
||
)
|