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:
ericwyuan
2026-09-04 06:13:52 +08:00
parent 682936b0b6
commit fd5e3363fd
5 changed files with 204 additions and 5 deletions

View File

@@ -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