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:
122
backend/tests/test_garmin_throttle.py
Normal file
122
backend/tests/test_garmin_throttle.py
Normal 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
|
||||
Reference in New Issue
Block a user