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>
This commit is contained in:
@@ -1128,3 +1128,114 @@ class TestRefreshedTokenIsKept:
|
||||
with pytest.raises(garmin_svc.RateLimited):
|
||||
garmin_svc._connect({}, user["id"])
|
||||
assert garmin_svc.rate_limited_until(user["id"]) is not None
|
||||
|
||||
|
||||
class TestSsoCooldownIsRespected:
|
||||
"""Garmin's *login* limit is extended by every attempt inside its window.
|
||||
|
||||
Measured on 2026-09-03: one test sync moved a recorded deadline from
|
||||
00:41 to 15:26. That is why this one cooldown refuses rather than probes —
|
||||
and why the refusal is as narrow as it can be.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _garmin(refreshes, oauth2_expired=True):
|
||||
class StubOAuth2:
|
||||
expired = oauth2_expired
|
||||
|
||||
class StubGarth:
|
||||
profile = {"displayName": "Tester"}
|
||||
|
||||
def __init__(self):
|
||||
self.oauth2_token = StubOAuth2()
|
||||
|
||||
def configure(self, **kwargs):
|
||||
pass
|
||||
|
||||
def loads(self, token):
|
||||
pass
|
||||
|
||||
def refresh_oauth2(self):
|
||||
refreshes.append(1)
|
||||
self.oauth2_token = None
|
||||
|
||||
def dumps(self):
|
||||
return "fresh"
|
||||
|
||||
class StubGarmin:
|
||||
def __init__(self, is_cn=False):
|
||||
self.garth = StubGarth()
|
||||
|
||||
return StubGarmin
|
||||
|
||||
def test_no_login_request_is_made_inside_the_window(
|
||||
self, db, user, monkeypatch
|
||||
):
|
||||
refreshes = []
|
||||
monkeypatch.setattr(garmin_svc, "_import_garmin",
|
||||
lambda: self._garmin(refreshes))
|
||||
garmin_svc.save_token(user["id"], "stored", "a@example.com")
|
||||
garmin_svc._note_rate_limit(user["id"], "sso")
|
||||
|
||||
with pytest.raises(garmin_svc.RateLimited):
|
||||
garmin_svc._connect({}, user["id"])
|
||||
assert refreshes == [], "every attempt inside the window extends it"
|
||||
|
||||
def test_a_data_429_does_not_block_a_token_refresh(self, db, user, monkeypatch):
|
||||
"""Stranding an account for a day over a metric call would be worse
|
||||
than the problem."""
|
||||
refreshes = []
|
||||
monkeypatch.setattr(garmin_svc, "_import_garmin",
|
||||
lambda: self._garmin(refreshes))
|
||||
garmin_svc.save_token(user["id"], "stored", "a@example.com")
|
||||
garmin_svc._note_rate_limit(user["id"], "data")
|
||||
|
||||
garmin_svc._connect({}, user["id"])
|
||||
assert refreshes == [1]
|
||||
|
||||
def test_a_valid_token_syncs_whatever_the_cooldown_says(
|
||||
self, db, user, monkeypatch
|
||||
):
|
||||
"""The gate covers the refresh, not the account: a stored token that
|
||||
has not expired needs no login request at all."""
|
||||
refreshes = []
|
||||
monkeypatch.setattr(
|
||||
garmin_svc, "_import_garmin",
|
||||
lambda: self._garmin(refreshes, oauth2_expired=False))
|
||||
garmin_svc.save_token(user["id"], "stored", "a@example.com")
|
||||
garmin_svc._note_rate_limit(user["id"], "sso")
|
||||
|
||||
garmin_svc._connect({}, user["id"]) # must not raise
|
||||
assert refreshes == []
|
||||
|
||||
def test_the_message_says_when_and_why(self, db, user, monkeypatch):
|
||||
monkeypatch.setattr(garmin_svc, "_import_garmin",
|
||||
lambda: self._garmin([]))
|
||||
garmin_svc.save_token(user["id"], "stored", "a@example.com")
|
||||
garmin_svc._note_rate_limit(user["id"], "sso")
|
||||
|
||||
with pytest.raises(garmin_svc.RateLimited) as caught:
|
||||
garmin_svc._connect({}, user["id"])
|
||||
text = str(caught.value)
|
||||
assert "延长封锁" in text, "the reason for not retrying must be stated"
|
||||
assert "强制重试" in text, "and the way out of it"
|
||||
|
||||
def test_the_user_can_overrule_the_estimate(self, db, user, monkeypatch):
|
||||
refreshes = []
|
||||
monkeypatch.setattr(garmin_svc, "_import_garmin",
|
||||
lambda: self._garmin(refreshes))
|
||||
garmin_svc.save_token(user["id"], "stored", "a@example.com")
|
||||
garmin_svc._note_rate_limit(user["id"], "sso")
|
||||
|
||||
garmin_svc.clear_rate_limit(user["id"])
|
||||
garmin_svc._connect({}, user["id"])
|
||||
assert refreshes == [1]
|
||||
|
||||
def test_sync_force_clears_the_cooldown(self, client, auth, db, user, monkeypatch):
|
||||
garmin_svc.save_token(user["id"], "stored", "a@example.com")
|
||||
garmin_svc._note_rate_limit(user["id"], "sso")
|
||||
monkeypatch.setattr(garmin_svc, "start_sync",
|
||||
lambda *a, **k: {"status": "syncing"})
|
||||
|
||||
client.post("/api/garmin/sync", json={"force": True}, headers=auth)
|
||||
assert garmin_svc.sso_cooldown(user["id"]) is None
|
||||
|
||||
Reference in New Issue
Block a user