fix(garmin): 重新绑定走的是同一个被封的登录接口,也得拦住
用户问:限流了,重新输账号密码验证码换个新令牌行不行。
不行,而且是最糟的一种试法。`garth.login()` 和 `refresh_oauth2()` 打的是
同一个 SSO 端点,流程还更重;限流按**账号**计(不是按 IP、按 UA),换设备
换网络都绕不开;而窗口内每次尝试都会把窗口往后推。
而这正是被卡住时第一个会去试的操作,代码里却只有 `_connect` 的刷新有闸门,
重新绑定那条路照发不误。
- start_login 在 sso 冷却窗口内直接拒绝,不建会话行、不碰网络
- 错误信息说清三件事:为什么现在不试、什么时候恢复、换设备没用
- 路由返 429(请求本身没毛病,是该晚点再来)并带 retryAfterSeconds
- 数据端点的 429 不参与拦截,force 可以推翻
前端补上 UI:报错文案早先承诺了「同步页选择强制重试」,但那个按钮不存在。
现在只在被冷却拒绝之后才出现,样式刻意做得不像第二个「开始同步」——它是给
估算失准时的出口,不是随手可点的第二选择。
顺带修一个正要被我引入的 bug:`onClick={syncHistory}` 会把 MouseEvent 当成
force 传进去,等于每次点开始同步都跳过冷却。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -115,7 +115,18 @@ def login():
|
|||||||
if not garmin_email:
|
if not garmin_email:
|
||||||
return jsonify({"error": "缺少 Garmin 邮箱"}), 400
|
return jsonify({"error": "缺少 Garmin 邮箱"}), 400
|
||||||
|
|
||||||
session_id = garmin_auth.start_login(g.user_id, garmin_email, password)
|
try:
|
||||||
|
session_id = garmin_auth.start_login(
|
||||||
|
g.user_id, garmin_email, password, force=bool(data.get("force"))
|
||||||
|
)
|
||||||
|
except garmin_auth.LoginRateLimited as e:
|
||||||
|
# 429, not 400: the request was well-formed and the client should
|
||||||
|
# retry later — and the body says when, and why not now.
|
||||||
|
return jsonify({
|
||||||
|
"error": str(e),
|
||||||
|
"status": "rate_limited",
|
||||||
|
"retryAfterSeconds": garmin_auth.retry_after_seconds(g.user_id),
|
||||||
|
}), 429
|
||||||
return jsonify({"session": session_id, "status": "starting"}), 202
|
return jsonify({"session": session_id, "status": "starting"}), 202
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -129,8 +129,47 @@ def _run_login(session_id, user_id, garmin_email, password, is_cn, import_garmin
|
|||||||
_set(session_id, "failed", error=error, code=None)
|
_set(session_id, "failed", error=error, code=None)
|
||||||
|
|
||||||
|
|
||||||
def start_login(user_id, garmin_email, password, import_garmin=None, is_cn=None):
|
class LoginRateLimited(Exception):
|
||||||
"""Kick off a login in the background. Returns the session id."""
|
"""Refused before contacting Garmin, because a login 429 is still active."""
|
||||||
|
|
||||||
|
|
||||||
|
def retry_after_seconds(user_id):
|
||||||
|
"""Seconds until the login cooldown lapses, or None."""
|
||||||
|
blocked = garmin_svc.sso_cooldown(user_id)
|
||||||
|
if not blocked:
|
||||||
|
return None
|
||||||
|
return max(0, int((blocked - datetime.datetime.utcnow()).total_seconds()))
|
||||||
|
|
||||||
|
|
||||||
|
def start_login(user_id, garmin_email, password, import_garmin=None,
|
||||||
|
is_cn=None, force=False):
|
||||||
|
"""Kick off a login in the background. Returns the session id.
|
||||||
|
|
||||||
|
Refuses while a login 429 is still in its window. Re-binding is the
|
||||||
|
obvious thing to try when syncing is blocked — "just get a fresh token" —
|
||||||
|
but it goes through `garth.login()`, which is the *same* SSO endpoint that
|
||||||
|
is doing the blocking, by a heavier path than the token refresh. The limit
|
||||||
|
is keyed to the account, so a new password entry, a new device or a new
|
||||||
|
network reaches the same wall, and each attempt pushes the window out.
|
||||||
|
|
||||||
|
`force` overrules the recorded deadline, which is this app's own 24h guess
|
||||||
|
rather than anything Garmin stated.
|
||||||
|
"""
|
||||||
|
if force:
|
||||||
|
garmin_svc.clear_rate_limit(user_id)
|
||||||
|
else:
|
||||||
|
blocked = garmin_svc.sso_cooldown(user_id)
|
||||||
|
if blocked:
|
||||||
|
minutes = int(
|
||||||
|
(blocked - datetime.datetime.utcnow()).total_seconds() // 60
|
||||||
|
)
|
||||||
|
raise LoginRateLimited(
|
||||||
|
"Garmin 正在限制该账号的登录请求。重新绑定走的是同一个登录接口,"
|
||||||
|
f"现在重试只会延长封锁。预计 {blocked.isoformat(timespec='minutes')} "
|
||||||
|
f"UTC 之后恢复(约 {minutes} 分钟)。"
|
||||||
|
"限流按账号计算,换设备或换网络都绕不开。"
|
||||||
|
)
|
||||||
|
|
||||||
_cleanup(user_id)
|
_cleanup(user_id)
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
|
|||||||
@@ -276,3 +276,52 @@ class TestEndpoints:
|
|||||||
assert wait_status(sid, "done", "failed") == "done"
|
assert wait_status(sid, "done", "failed") == "done"
|
||||||
assert client.get("/api/garmin/auth-status", headers=auth).get_json()[
|
assert client.get("/api/garmin/auth-status", headers=auth).get_json()[
|
||||||
"hasToken"] is True
|
"hasToken"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestRebindingIsGatedToo:
|
||||||
|
""""Just re-enter the password and get a fresh token" is the obvious thing
|
||||||
|
to try when syncing is blocked — and it is the worst thing to try.
|
||||||
|
|
||||||
|
`garth.login()` is the same SSO endpoint that is doing the blocking, by a
|
||||||
|
heavier path than the token refresh, and the limit is keyed to the account
|
||||||
|
so a new device or network reaches the same wall.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_login_is_refused_inside_the_window(self, db, user):
|
||||||
|
garmin_svc._note_rate_limit(user["id"], "sso")
|
||||||
|
with pytest.raises(garmin_auth.LoginRateLimited):
|
||||||
|
garmin_auth.start_login(user["id"], "a@example.com", "pw")
|
||||||
|
|
||||||
|
def test_no_session_row_is_created_by_a_refusal(self, db, user):
|
||||||
|
garmin_svc._note_rate_limit(user["id"], "sso")
|
||||||
|
with pytest.raises(garmin_auth.LoginRateLimited):
|
||||||
|
garmin_auth.start_login(user["id"], "a@example.com", "pw")
|
||||||
|
rows = db.query_all(
|
||||||
|
"SELECT id FROM garmin_mfa_sessions WHERE user_id = ?", [user["id"]])
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
def test_a_data_429_does_not_block_rebinding(self, db, user, monkeypatch):
|
||||||
|
started = []
|
||||||
|
monkeypatch.setattr(garmin_auth.threading, "Thread",
|
||||||
|
lambda **kw: type("T", (), {"start": lambda s: started.append(1)})())
|
||||||
|
garmin_svc._note_rate_limit(user["id"], "data")
|
||||||
|
garmin_auth.start_login(user["id"], "a@example.com", "pw")
|
||||||
|
assert started == [1]
|
||||||
|
|
||||||
|
def test_force_overrules_the_estimate(self, db, user, monkeypatch):
|
||||||
|
started = []
|
||||||
|
monkeypatch.setattr(garmin_auth.threading, "Thread",
|
||||||
|
lambda **kw: type("T", (), {"start": lambda s: started.append(1)})())
|
||||||
|
garmin_svc._note_rate_limit(user["id"], "sso")
|
||||||
|
garmin_auth.start_login(user["id"], "a@example.com", "pw", force=True)
|
||||||
|
assert started == [1]
|
||||||
|
|
||||||
|
def test_the_endpoint_answers_429_with_a_retry_hint(self, client, auth, db, user):
|
||||||
|
garmin_svc._note_rate_limit(user["id"], "sso")
|
||||||
|
resp = client.post("/api/garmin/login",
|
||||||
|
json={"garminPassword": "pw", "garminEmail": "a@example.com"},
|
||||||
|
headers=auth)
|
||||||
|
assert resp.status_code == 429
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["retryAfterSeconds"] > 0
|
||||||
|
assert "延长封锁" in body["error"]
|
||||||
|
|||||||
@@ -322,3 +322,23 @@
|
|||||||
.sync-records-link:active {
|
.sync-records-link:active {
|
||||||
background: var(--surface-0);
|
background: var(--surface-0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shown only after the cooldown refused a sync. Deliberately plain and set
|
||||||
|
apart from the primary actions: it exists as a way out of a wrong estimate,
|
||||||
|
not as a second 同步 button — pressing it inside Garmin's real window is
|
||||||
|
what extends the lockout. */
|
||||||
|
.sync-force {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-force:disabled { color: var(--text-muted); cursor: default; }
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ function SyncPage() {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
/* Whether the last attempt was refused by the recorded cooldown. Gates the
|
||||||
|
强制重试 button: offering it unconditionally would invite the very thing
|
||||||
|
the cooldown prevents. */
|
||||||
|
const [blocked, setBlocked] = useState(false);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
const pollRef = useRef<number | null>(null);
|
const pollRef = useRef<number | null>(null);
|
||||||
@@ -233,20 +237,26 @@ function SyncPage() {
|
|||||||
}).open();
|
}).open();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The one manual sync: the chosen range, in the background, with progress. */
|
/** The one manual sync: the chosen range, in the background, with progress.
|
||||||
const syncHistory = async () => {
|
*
|
||||||
|
* `force` discards the recorded cooldown first. Offered only after a
|
||||||
|
* refusal, and never as the default: the cooldown exists because trying
|
||||||
|
* again inside Garmin's login window is what extends it. */
|
||||||
|
const syncHistory = async (force?: boolean) => {
|
||||||
setError('');
|
setError('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
setBlocked(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 0 means "everything" and -1 "since the last sync"; the backend caps
|
// 0 means "everything" and -1 "since the last sync"; the backend caps
|
||||||
// the former at what Garmin will actually serve.
|
// the former at what Garmin will actually serve.
|
||||||
const started = await apiClient.syncGarminData(range);
|
const started = await apiClient.syncGarminData(range, undefined, force);
|
||||||
const s = await refresh();
|
const s = await refresh();
|
||||||
// A refused start (Garmin is throttling us) used to be swallowed: the
|
// A refused start (Garmin is throttling us) used to be swallowed: the
|
||||||
// button did nothing, no progress bar appeared, and no reason was shown.
|
// button did nothing, no progress bar appeared, and no reason was shown.
|
||||||
if (started.status === 'rate_limited' || s?.status === 'rate_limited') {
|
if (started.status === 'rate_limited' || s?.status === 'rate_limited') {
|
||||||
setError(started.message || s?.lastError || 'Garmin 正在限流,稍后会自动恢复。');
|
setError(started.message || s?.lastError || 'Garmin 正在限流,稍后会自动恢复。');
|
||||||
|
setBlocked(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
beginSyncPolling();
|
beginSyncPolling();
|
||||||
@@ -311,7 +321,20 @@ function SyncPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen title="数据同步" backLink>
|
<Screen title="数据同步" backLink>
|
||||||
{error && <div className="screen-error">{error}</div>}
|
{error && (
|
||||||
|
<div className="screen-error">
|
||||||
|
{error}
|
||||||
|
{blocked && (
|
||||||
|
<button
|
||||||
|
className="sync-force"
|
||||||
|
onClick={() => syncHistory(true)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
我确认已恢复,强制重试
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{message && <div className="screen-ok">{message}</div>}
|
{message && <div className="screen-ok">{message}</div>}
|
||||||
|
|
||||||
{/* Step 1 — link the Garmin account, once. */}
|
{/* Step 1 — link the Garmin account, once. */}
|
||||||
@@ -445,7 +468,10 @@ function SyncPage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-large sync-primary"
|
className="btn btn-primary btn-large sync-primary"
|
||||||
onClick={syncHistory}
|
/* Wrapped, not passed directly: React hands the handler a
|
||||||
|
MouseEvent, which as `force` is truthy — every ordinary
|
||||||
|
sync would then bypass the cooldown. */
|
||||||
|
onClick={() => syncHistory()}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
{loading ? '正在启动…' : '开始同步'}
|
{loading ? '正在启动…' : '开始同步'}
|
||||||
|
|||||||
@@ -613,13 +613,21 @@ class ApiClient {
|
|||||||
* plaintext password must be supplied because only a hash is kept — and an
|
* plaintext password must be supplied because only a hash is kept — and an
|
||||||
* MFA-protected account cannot log in this way at all (see garmin_login.py).
|
* MFA-protected account cannot log in this way at all (see garmin_login.py).
|
||||||
*/
|
*/
|
||||||
/** Starts a sync in the background; poll getGarminSyncStatus for progress. */
|
/**
|
||||||
async syncGarminData(days?: number, garminPassword?: string) {
|
* Starts a sync in the background; poll getGarminSyncStatus for progress.
|
||||||
|
*
|
||||||
|
* `force` discards the recorded rate-limit cooldown before trying. That
|
||||||
|
* deadline is the app's own 24h guess, not something Garmin stated, so the
|
||||||
|
* user must be able to overrule it — but only deliberately: attempting a
|
||||||
|
* login inside Garmin's real window extends the window.
|
||||||
|
*/
|
||||||
|
async syncGarminData(days?: number, garminPassword?: string, force?: boolean) {
|
||||||
const { data } = await this.client.post<{
|
const { data } = await this.client.post<{
|
||||||
status: string; days?: number; message?: string; retryAfterSeconds?: number;
|
status: string; days?: number; message?: string; retryAfterSeconds?: number;
|
||||||
}>(
|
}>(
|
||||||
'/garmin/sync',
|
'/garmin/sync',
|
||||||
{
|
{
|
||||||
|
...(force ? { force: true } : {}),
|
||||||
// `days` must survive being 0 — that is "全部历史", not "unset".
|
// `days` must survive being 0 — that is "全部历史", not "unset".
|
||||||
// `days ? …` dropped it, so the backend fell back to its 7-day
|
// `days ? …` dropped it, so the backend fell back to its 7-day
|
||||||
// default and a full backfill silently pulled a week.
|
// default and a full backfill silently pulled a week.
|
||||||
|
|||||||
Reference in New Issue
Block a user