From a4bedc263fe8b767c0559c13cbe3809a34c6b41f Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Fri, 28 Aug 2026 14:27:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(sync):=20=E9=99=90=E6=B5=81=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E6=BC=8F=E6=8E=89=E4=BA=86=E5=AE=83=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E7=9A=84=E6=A0=B7=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版加了 429 识别,但点同步按钮报的还是 JSONDecodeError——因为检测 只看 `e.response.status_code` 和异常消息,而真实到达我们手里的异常两样 都不满足:garth 对着 429 调 .json(),抛出的 JSONDecodeError 里状态码已经 丢了,消息是无用的 "Expecting value: line 1 column 1"。 响应体「Rate limited」唯一幸存的地方是异常的 `doc` 属性,改为从那里取。 同时改正上一版的测试:它用 ValueError("Rate limited") 构造,那个形态现实中 根本不会出现,所以测试通过、检测却漏掉了每一个真实的限流。现在用真的 json.JSONDecodeError 构造,并先断言「光看消息不够」。 实测同步现在返回: RateLimited: Garmin 暂时限制了请求频率…令牌本身没有失效 Co-Authored-By: Claude Haiku 4.5 --- backend/services/garmin.py | 13 +++++++++++-- backend/tests/test_garmin_sync.py | 12 ++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/services/garmin.py b/backend/services/garmin.py index 8e099c4..0ebbced 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -111,11 +111,20 @@ def _note_rate_limit(user_id): def _is_rate_limited(e): - """429 from Garmin, however it happens to be dressed.""" + """429 from Garmin, however it happens to be dressed. + + Usually it arrives as a JSONDecodeError, because garth calls .json() on a + 429 whose body is the plain text "Rate limited" — the status code is gone + by the time the exception reaches us, and the message is the useless + "Expecting value: line 1 column 1". What survives is the body itself, on + the exception's `doc` attribute, so that is where to look. + """ response = getattr(e, "response", None) if response is not None and getattr(response, "status_code", None) == 429: return True - return "rate limit" in str(e).lower() + + haystacks = [str(e), str(getattr(e, "doc", "") or "")] + return any("rate limit" in h.lower() for h in haystacks) class MFARequired(RuntimeError): diff --git a/backend/tests/test_garmin_sync.py b/backend/tests/test_garmin_sync.py index 8f9c0c6..89d4190 100644 --- a/backend/tests/test_garmin_sync.py +++ b/backend/tests/test_garmin_sync.py @@ -619,8 +619,16 @@ class TestRateLimiting: def teardown_method(self): garmin_svc._rate_limited_until.clear() - def test_plain_text_body_is_recognised(self): - assert garmin_svc._is_rate_limited(ValueError("Rate limited")) + def test_the_real_shape_is_recognised(self): + """garth calls .json() on the 429, so what actually reaches us is a + JSONDecodeError whose message says nothing and whose `doc` holds the + body. An earlier version of this test asserted on a hand-made + ValueError("Rate limited") — a shape that never occurs — and passed + while the detector missed every real one.""" + import json + err = json.JSONDecodeError("Expecting value", "Rate limited", 0) + assert "rate limit" not in str(err).lower(), "the message alone is not enough" + assert garmin_svc._is_rate_limited(err) def test_status_code_is_recognised(self): class Resp: