fix: 本地限流估算不再拦截同步——一律真实请求 Garmin,仅真实 429 记录冷却并退避
此前 sync_data/start_sync/_connect/scheduler 四处会在请求前按本地 rate_limited_until 估算直接拒绝同步,用户看到'立即同步→被限流'实际是 本地拦截、零请求。若 Garmin 已恢复,陈旧估算会让账号一直闲置。 2026-09-02 起冷却只是信息不是闸门: - start_sync / sync_data 开头移除本地拒绝,_connect 移除冷却提前抛错, scheduler 不再跳过冷却中的账号 - 真实 429(refresh_oauth2 / 拉取中途)仍 _note_rate_limit 写 24h 冷却 并 stand down,中途退避分支保留用冷却算恢复时间 - 成功收尾 _clear_rate_limit 退休陈旧冷却,避免误导后续诊断 - 测试:blocked→仍会真实请求;stale cooldown→healthy connect 放行; 真实 429 mid-run 仍记 rate_limited;成功清除冷却(596 passed)
This commit is contained in:
@@ -691,18 +691,27 @@ class TestRateLimiting:
|
||||
until = garmin_svc.rate_limited_until(user["id"])
|
||||
assert until is not None and until > datetime.datetime.utcnow()
|
||||
|
||||
def test_a_blocked_account_does_not_call_garmin_again(self, db, user, monkeypatch):
|
||||
"""The retry is what deepens the limit, so it must not happen."""
|
||||
def test_a_recorded_cooldown_no_longer_blocks_connect(self, db, user, monkeypatch):
|
||||
"""Since 2026-09-02 the cooldown is information, not a gate: a stale
|
||||
local estimate must not keep an account idle after Garmin recovered,
|
||||
so _connect issues the refresh and trusts the live answer. Only a real
|
||||
429 (raised by refresh_oauth2) records a fresh cooldown and surfaces
|
||||
as RateLimited."""
|
||||
garmin_svc.save_token(user["id"], "token-blob")
|
||||
garmin_svc._note_rate_limit(user["id"])
|
||||
garmin_svc._note_rate_limit(user["id"]) # a future deadline is recorded
|
||||
|
||||
calls = []
|
||||
|
||||
def refresh(self): # bound by the stub instance -> receives self
|
||||
calls.append(1)
|
||||
raise RuntimeError("too many 429 error responses")
|
||||
|
||||
class Stub:
|
||||
def __init__(self, *a, **k):
|
||||
self.garth = type("G", (), {
|
||||
"configure": lambda *a, **k: None,
|
||||
"loads": lambda *a: None,
|
||||
"refresh_oauth2": lambda *a: (_ for _ in ()).throw(
|
||||
AssertionError("must not reach Garmin while backing off")),
|
||||
"refresh_oauth2": refresh,
|
||||
"oauth2_token": None,
|
||||
"sess": type("S", (), {"headers": {}})(),
|
||||
})()
|
||||
@@ -710,6 +719,31 @@ class TestRateLimiting:
|
||||
monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: Stub)
|
||||
with pytest.raises(garmin_svc.RateLimited):
|
||||
garmin_svc._connect({}, user_id=user["id"])
|
||||
assert calls == [1], "the real request must still be attempted"
|
||||
assert garmin_svc.rate_limited_until(user["id"]) is not None
|
||||
|
||||
def test_a_stale_cooldown_does_not_stop_a_healthy_connect(self, db, user, monkeypatch):
|
||||
"""The mirror image: with a cooldown recorded but Garmin healthy, the
|
||||
refresh succeeds and the connect proceeds (no local refusal)."""
|
||||
garmin_svc.save_token(user["id"], "token-blob")
|
||||
garmin_svc._note_rate_limit(user["id"])
|
||||
calls = []
|
||||
|
||||
class Stub:
|
||||
def __init__(self, *a, **k):
|
||||
unexpired = type("T", (), {"expired": False})()
|
||||
self.garth = type("G", (), {
|
||||
"configure": lambda *a, **k: None,
|
||||
"loads": lambda *a: None,
|
||||
"refresh_oauth2": lambda *a: calls.append(1),
|
||||
"oauth2_token": unexpired,
|
||||
"profile": {"displayName": "x"},
|
||||
"sess": type("S", (), {"headers": {}})(),
|
||||
})()
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: Stub)
|
||||
garmin_svc._connect({}, user_id=user["id"])
|
||||
assert calls == [], "an unexpired token needs no refresh"
|
||||
|
||||
def test_a_valid_token_is_not_refreshed(self, db, user, monkeypatch):
|
||||
"""Refreshing on every connect spends quota for nothing — and that is
|
||||
@@ -902,13 +936,36 @@ class TestSyncHistory:
|
||||
assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "rate_limited"
|
||||
garmin_svc._rate_limited_until.clear()
|
||||
|
||||
def test_a_refused_sync_is_still_a_record(self, db, user, monkeypatch):
|
||||
def test_a_recorded_cooldown_does_not_refuse_a_sync(self, db, user, monkeypatch):
|
||||
"""The local cooldown is an estimate, not a gate (2026-09-02): a sync
|
||||
with a recorded deadline still runs for real and records its actual
|
||||
outcome — Garmin's live answer decides, not our guess."""
|
||||
future = datetime.datetime.utcnow() + datetime.timedelta(hours=1)
|
||||
monkeypatch.setitem(garmin_svc._rate_limited_until, user["id"], future)
|
||||
garmin_svc.save_token(user["id"], "token-blob")
|
||||
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||
assert out["status"] == "success", "a stale cooldown must not block the run"
|
||||
row = garmin_svc.get_sync_history(user["id"])[0]
|
||||
assert row["status"] == "success"
|
||||
# A success retires the recorded cooldown.
|
||||
assert garmin_svc.rate_limited_until(user["id"]) is None
|
||||
|
||||
def test_a_real_429_mid_run_is_still_a_rate_limited_record(self, db, user, monkeypatch):
|
||||
"""Only a genuine 429 stands a run down — and it writes a fresh
|
||||
cooldown instead of relying on whatever stale estimate was there."""
|
||||
future = datetime.datetime.utcnow() + datetime.timedelta(hours=1)
|
||||
monkeypatch.setitem(garmin_svc._rate_limited_until, user["id"], future)
|
||||
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||
class Throttled(StubClient):
|
||||
def get_user_summary(self, cdate):
|
||||
self.calls.append(("summary", cdate))
|
||||
raise RuntimeError("too many 429 error responses")
|
||||
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=2, client=Throttled())
|
||||
assert out["status"] == "rate_limited"
|
||||
assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "rate_limited"
|
||||
assert garmin_svc.rate_limited_until(user["id"]) is not None
|
||||
|
||||
def test_history_is_per_user(self, db, user, make_user):
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||
|
||||
Reference in New Issue
Block a user