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: