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

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