fix(garmin): 刷新出来的令牌从来没写回库,于是每次连接都重换一次

「又被限流了」的根因找到了,不是请求量,是令牌。

`_connect` 里 `refresh_oauth2()` 换来的新 OAuth2 令牌只活在进程内存里——
`save_token` 只在绑定账号时调用过一次。于是每次客户端缓存过期(15 分钟)、
每个 gunicorn worker、每次部署重启,都从库里读回**同一个已过期的令牌**,
然后再做一次真实 SSO 换令牌。而 SSO 端点是按账号限流最狠的那个,社区报告能
封 48 小时(garth #217、python-garminconnect #337)。我今天为了部署重启了
八次服务,每次都清掉缓存。

- `refresh_oauth2()` 成功后 `_persist_token()` 写回。拆出这个函数是因为它和
  `save_token` 想要的正好相反:重新绑定要作废现有会话,持久化刷新结果必须
  保住刚刚产出它的那个会话
- 写回时不带 garmin_email,否则 upsert 会把绑定邮箱刷成 NULL,数据同步页会
  忘记绑的是哪个账号
- 刷新加进程内锁,并在拿到锁后重读一次库:另一个线程刚换过就直接用它的,
  不再自己去换一次
- 五条测试盯住这个不变量,包括「冷缓存不该再换一次」(这条如果回归,就是同一
  个 bug 再来一遍)

顺带把数据端点也节流了——那是另外一半问题,不是这次的病因,但一天历史要 9 次
调用,730 天全历史 6600 个请求全速打出去,不该指望佳明一直容忍:

- services/garmin_throttle.py:代理包住 client,所有调用(含以后新加的)都经
  同一个收口,按间隔排队并计数
- 0.5s 是查过的:garmin-data-export 默认 0.15s、garmin-connect-scraper 默认
  3s、官方合作方 API 100 次/分钟(0.6s)。依据写在文件顶部
- 单次同步 1200 个请求预算,跑满就干净收尾、下次接着跑(已存的天数本来就跳过)
- 运动详情每次最多补 40 条——新账号几百条,不限量就是一次性打光预算

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-09-03 23:24:48 +08:00
parent 57c236ba16
commit 682936b0b6
7 changed files with 606 additions and 26 deletions

View File

@@ -31,6 +31,11 @@ from auth import sign_token # noqa: E402
# process — a developer's real AI_MODEL_CHAIN or API keys would silently change
# what the suite exercises (and could bill real API calls). Clear them here;
# individual tests opt back in through the `keys` / `gateway` fixtures.
# Pacing is real time: at the default 0.5s a single 30-day sync test would
# sleep for over two minutes. Tests exercise the *accounting* (budgets, counts)
# with the wait set to zero.
os.environ.setdefault("GARMIN_MIN_INTERVAL_SECONDS", "0")
_AI_ENV_VARS = (
"AI_MODEL_CHAIN",
"AI_DAY_BUDGET",

View File

@@ -999,3 +999,132 @@ class TestSyncHistory:
r = client.get("/api/garmin/sync-history", headers=auth)
assert r.status_code == 200
assert r.get_json() == {"items": []}
class TestRefreshedTokenIsKept:
"""The account's repeated lockouts came from here.
`refresh_oauth2()` mints a token against Garmin's SSO endpoint — the one
that limits per account and blocks for hours. The refreshed token used to
live only in the process's memory, so every cold client cache (a 15-minute
timeout, a second gunicorn worker, a deploy restart) re-read the same
expired token from the database and minted another one.
"""
@staticmethod
def _garmin(refreshes, expired_after_load=True):
class StubOAuth2:
def __init__(self, expired):
self.expired = expired
class StubGarth:
def __init__(self):
self.oauth2_token = None
self.profile = {"displayName": "Tester"}
self.dumped = "refreshed-token"
def configure(self, **kwargs):
pass
def loads(self, token):
self.oauth2_token = StubOAuth2(
expired_after_load if token == "stored-token" else False
)
def refresh_oauth2(self):
refreshes.append(1)
self.oauth2_token = StubOAuth2(False)
def dumps(self):
return self.dumped
class StubGarmin:
def __init__(self, is_cn=False):
self.garth = StubGarth()
self.display_name = None
return StubGarmin
def test_the_refreshed_token_is_written_back(self, db, user, monkeypatch):
refreshes = []
monkeypatch.setattr(garmin_svc, "_import_garmin",
lambda: self._garmin(refreshes))
garmin_svc.save_token(user["id"], "stored-token", "a@example.com")
garmin_svc._connect({}, user["id"])
assert refreshes == [1]
assert garmin_svc.load_token(user["id"]) == "refreshed-token", (
"a refresh that is not persisted makes the next connect mint "
"another token against the endpoint that blocks accounts"
)
def test_persisting_a_refresh_keeps_the_bound_email(self, db, user, monkeypatch):
refreshes = []
monkeypatch.setattr(garmin_svc, "_import_garmin",
lambda: self._garmin(refreshes))
garmin_svc.save_token(user["id"], "stored-token", "a@example.com")
garmin_svc.forget_client(user["id"])
garmin_svc._connect({}, user["id"])
assert garmin_svc.get_remembered_email(user["id"]) == "a@example.com", (
"the 数据同步 page shows this; a refresh must not blank it"
)
def test_a_cold_cache_does_not_refresh_again(self, db, user, monkeypatch):
"""The whole point: the second connect reads a *valid* stored token."""
refreshes = []
monkeypatch.setattr(garmin_svc, "_import_garmin",
lambda: self._garmin(refreshes))
garmin_svc.save_token(user["id"], "stored-token", "a@example.com")
garmin_svc._connect({}, user["id"])
garmin_svc.forget_client(user["id"]) # as a restart or TTL would
garmin_svc._connect({}, user["id"])
assert refreshes == [1], f"minted {len(refreshes)} SSO tokens, expected 1"
def test_persisting_the_refresh_does_not_drop_the_live_session(
self, db, user, monkeypatch
):
"""`save_token` invalidates the cached client; the refresh path must
not, or every connect would throw its own session away."""
refreshes = []
monkeypatch.setattr(garmin_svc, "_import_garmin",
lambda: self._garmin(refreshes))
garmin_svc.save_token(user["id"], "stored-token", "a@example.com")
first = garmin_svc._connect({}, user["id"])
second = garmin_svc._connect({}, user["id"])
assert second is first, "the session was cached, not rebuilt"
def test_a_rate_limited_refresh_still_surfaces_as_rate_limited(
self, db, user, monkeypatch
):
class StubGarth:
oauth2_token = None
profile = {"displayName": "Tester"}
def configure(self, **kwargs):
pass
def loads(self, token):
pass
def refresh_oauth2(self):
raise Exception("429 Client Error: Too Many Requests")
def dumps(self):
return "unused"
class StubGarmin:
def __init__(self, is_cn=False):
self.garth = StubGarth()
monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
garmin_svc.save_token(user["id"], "stored-token", "a@example.com")
with pytest.raises(garmin_svc.RateLimited):
garmin_svc._connect({}, user["id"])
assert garmin_svc.rate_limited_until(user["id"]) is not None

View File

@@ -0,0 +1,122 @@
"""
Unit tests for the Garmin request pacer.
Nothing here touches the network, and the interval is zero (see conftest) so
the accounting is tested without the waiting.
"""
import time
import pytest
from services import garmin_throttle as throttle
class Recorder:
"""Stands in for a garminconnect client."""
def __init__(self):
self.calls = []
self.display_name = "Tester"
self.garth = object()
def get_user_summary(self, date):
self.calls.append(date)
return {"date": date}
def get_sleep_data(self, date):
self.calls.append(date)
return {}
class TestPacedClient:
def test_calls_reach_the_wrapped_client(self):
inner = Recorder()
client = throttle.pace(inner, "u")
assert client.get_user_summary("2026-09-01") == {"date": "2026-09-01"}
assert inner.calls == ["2026-09-01"]
def test_every_call_is_counted(self):
client = throttle.pace(Recorder(), "u")
client.get_user_summary("d")
client.get_sleep_data("d")
assert client.limiter.used == 2
def test_non_callables_pass_through(self):
"""`display_name` is read by the library on every request, and `garth`
carries the login flow — neither is an API call."""
inner = Recorder()
client = throttle.pace(inner, "u")
assert client.display_name == "Tester"
assert client.garth is inner.garth
assert client.limiter.used == 0
def test_attribute_writes_reach_the_client(self):
inner = Recorder()
client = throttle.pace(inner, "u")
client.display_name = "Someone"
assert inner.display_name == "Someone"
def test_wrapping_twice_does_not_stack_two_waits(self):
once = throttle.pace(Recorder(), "u")
twice = throttle.pace(once, "u")
assert twice is once
def test_a_missing_method_still_raises_attribute_error(self):
client = throttle.pace(Recorder(), "u")
with pytest.raises(AttributeError):
client.get_something_that_does_not_exist
class TestBudget:
def test_the_budget_stops_the_run(self):
client = throttle.pace(Recorder(), "u", budget=3)
for _ in range(3):
client.get_sleep_data("d")
with pytest.raises(throttle.BudgetExhausted):
client.get_sleep_data("d")
def test_remaining_counts_down(self):
client = throttle.pace(Recorder(), "u", budget=5)
client.get_sleep_data("d")
assert client.limiter.remaining == 4
def test_a_new_run_resets_the_budget(self):
client = throttle.pace(Recorder(), "u", budget=2)
client.get_sleep_data("d")
client.get_sleep_data("d")
throttle.pace(client, "u", budget=2)
client.get_sleep_data("d") # must not raise
assert client.limiter.used == 1
def test_no_budget_means_unlimited(self):
client = throttle.pace(Recorder(), "u", budget=0)
# 0 is falsy but explicit; only None means unlimited.
with pytest.raises(throttle.BudgetExhausted):
client.get_sleep_data("d")
limiter = throttle.limiter_for("u")
limiter.start_run(None)
client.get_sleep_data("d")
assert limiter.remaining is None
class TestSpacing:
def test_calls_are_spaced_by_the_interval(self, monkeypatch):
monkeypatch.setenv("GARMIN_MIN_INTERVAL_SECONDS", "0.05")
client = throttle.pace(Recorder(), f"spacing-{time.monotonic()}")
started = time.monotonic()
for _ in range(4):
client.get_sleep_data("d")
# Three gaps between four calls; the first claims a free slot.
assert time.monotonic() - started >= 0.05 * 3
def test_the_interval_is_read_per_call(self, monkeypatch):
monkeypatch.setenv("GARMIN_MIN_INTERVAL_SECONDS", "2.5")
assert throttle.min_interval() == 2.5
monkeypatch.delenv("GARMIN_MIN_INTERVAL_SECONDS")
assert throttle.min_interval() == 0.5, "the researched default"
def test_accounts_are_paced_independently(self):
a = throttle.limiter_for("account-a")
b = throttle.limiter_for("account-b")
assert a is not b
assert throttle.limiter_for("account-a") is a