Distinguish HTTP 429/507 rate-limit responses with a typed exception - #160
Distinguish HTTP 429/507 rate-limit responses with a typed exception#160proscar87 wants to merge 1 commit into
Conversation
|
Thanks for the detailed writeup — the goal here (letting callers distinguish "rate-limited/locked out, back off for a long time" from other failures) is exactly right, but I believe the premise about how Growatt signals the 507 is incorrect, which unfortunately makes this implementation a no-op for the real failure mode. Growatt returns 507 as an application-level error code in the JSON body, not as an HTTP status code. I've investigated this while maintaining the Home Assistant That exception can only be reached when the login HTTP request succeeded (HTTP 200), the JSON parsed fine, and the body contained If Growatt actually sent HTTP status 507, the flow would look completely different: this library's session hook already calls Consequences for this PR as written:
I'd suggest reworking this at the response-body level instead: in On the session-persistence follow-up you offered: yes, please — that would genuinely help. In the HA integration today, |
Reworked after @johanzander pointed out the premise was wrong: Growatt does not signal 507 as an HTTP status. The request succeeds with HTTP 200 and the body carries `success: false` with `msg: "507"`, which is the only shape that can produce the `ConfigEntryError: Growatt login failed: 507` traceback in home-assistant/core#176831 and #174789. login() confirms this by design -- it goes straight to response.json()["back"] without ever reading response.status_code, so a transport-level check could not have fired. The previous transport-level handling and its Retry-After parsing are dropped entirely rather than kept "just in case": there is no evidence Growatt ever sends that shape, and speculative handling would just be untested code that looks like coverage. login() now raises GrowattRateLimitError(error_code="507") on that body, following the GrowattV1ApiError shape so consumers read the code off the exception instead of parsing a message. Every other failure keeps returning the dict unchanged. Three of the five tests fail against unmodified login(); all pass with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e4e54e4 to
2a2c03a
Compare
|
You're right, and the PR as written was a no-op. Reworked and force-pushed. What convinced me is in response = self.session.post(self.get_url("newTwoLoginAPI.do"), data={...})
data = response.json()["back"]It never reads What changed
I dropped the transport-level handling and the Five tests, mocking the body shape rather than the transport: three of them fail against unmodified Worth flagging explicitly, since it's a behaviour change: On the session parameterYes, I'll do it, and I agree the constructor-injected I'll send it as a separate PR so this one stays reviewable on its own. And you're right that it's the more valuable of the two: this PR only reports the lockout, the session work attacks the login frequency that causes it. Thanks for the correction — the "Not validated" caveat in my original description was doing far less work than it should have. I had no sample of the 507 shape and built the implementation around the assumption anyway. |
|
Thanks for the quick rework — this now catches the real signal, and I verified locally that the tests fail against unmodified 1. Consider centralizing the check in the session response hook instead of The lockout reports we have are all from login, but Growatt rate-limits each endpoint individually, so it's plausible (I'd say likely) the same def _raise_for_status(response, *args, **kwargs):
try:
data = response.json()
except ValueError:
data = {}
if isinstance(data, dict):
back = data.get("back")
if isinstance(back, dict):
data = back
if not data.get("success", True) and str(data.get("msg", "")) == RATE_LIMITED_CODE:
raise GrowattRateLimitError(error_code=RATE_LIMITED_CODE)
response.raise_for_status()The guards aren't decorative: the hook must never raise on an unexpected shape, and shapes vary — e.g. 2. Nit: make the exception message generic. I'd drop the "approximately 24 hour lockout … do not retry immediately" prose from the exception message — that's observed behavior that may change under our feet, and it ends up in every log line. Keep that context in the docstring, and let the message be something like Neither point changes the substance — the detection is right now. Happy to approve once these are in. |
Summary
Growatt returns HTTP 507 (and sometimes 429) when a client exceeds the request rate. This is not a normal rate limit: reports (see #55, and home-assistant/core#176831) indicate a 507 precedes an approximately 24 hour account lockout, not a short cooldown. This matters a lot for consumers like Home Assistant's
growatt_serverintegration, which currently callslogin()again on every startup — a restart while already close to the limit can tip the account into a day-long block. There's already a mitigation PR open on the HA side (home-assistant/core#177068, "retry in 4 hours"), but that only works around the symptom from outside; the actual signal (that this specific response means "stop, and stop for a long time") is something only this library can expose reliably.Root cause
growattServer/base_api.py:GrowattApi.__init__(~line 72) creates a freshrequests.Session()on every instantiation, and cookies/tokens are never persisted across process restarts — every app startup is effectively a brand-new login._raise_for_status(~lines 74-79) unconditionally calledresponse.raise_for_status(). A 507 came out as a plainrequests.exceptions.HTTPError, indistinguishable from any other 5xx, and anyRetry-Afterheader was silently discarded.By contrast, the V1 API (
open_api_v1/__init__.py+exceptions.py) already has a typed error (GrowattV1ApiError) with structurederror_code/error_msg. The classic API never got the same treatment.What this PR does (scoped, safe part)
GrowattRateLimitError(status_code, retry_after)inexceptions.py, following the same pattern asGrowattV1ApiError.base_api.py's response hook now raisesGrowattRateLimitErrorfor HTTP 429 and 507 before falling back toraise_for_status()for everything else — so existing behavior for all other status codes (including plain 5xx) is unchanged.Retry-Afterwhen the server sends it (both the delay-seconds form and the HTTP-date form per RFC 9110) and exposes it asexc.retry_after(float seconds, orNoneif absent/unparseable — Growatt doesn't reliably send this header, especially on 507s).GrowattRateLimitErrorfrom the package__init__.py.This is additive: a new exception class, a new (private) helper function, and a status-code check inserted before the existing
raise_for_status()call. No existing signatures changed, no existing exception types changed for non-rate-limit cases.What I deliberately left out of scope
Session/cookie persistence across restarts (the actual fix for "HA re-logs in every startup near the limit") was evaluated but not implemented as code, to keep this diff small and reviewable:
self.session(a plainrequests.Session) is already a public, mutable attribute — a consumer can already save/restoresession.cookies(aRequestsCookieJar) across restarts today, without any library change, to skip callinglogin()when they already hold valid cookies. I added a short doc comment aboveself.session = requests.Session()pointing this out, since it wasn't discoverable before.session: requests.Session | Noneconstructor param for dependency injection, orsave_session()/load_session()helpers) would be a public-API change and a design decision I don't think belongs bundled into an error-handling fix. Happy to open a follow-up PR for this specifically if a maintainer confirms which shape they'd want — a constructor-injected session vs. explicit save/load helpers have different tradeoffs (the former is more flexible; the latter is more discoverable and harder to misuse).home-assistant/corehas its own AI-authored-PR policy).What I validated
tests/test_rate_limit.pymock the HTTP transport withrequests_mock(not any of this library's own functions), so they exercise the real path:GrowattApi.login()→requests.Session.post()→ the session's response hook → the raised exception. Covers: 507 and 429 both raiseGrowattRateLimitErrorwith the rightstatus_code;Retry-Afterparsed correctly in both the seconds and HTTP-date forms; missing/unparseable header →retry_after is None; exactly one request is made (no silent retry); non-rate-limit errors (tested with 500) still raise plainrequests.exceptions.HTTPErroras before; a normal successful login is unaffected.git stashthat every new test fails against the pre-fix code (import error / wrong exception type raised) and passes after the fix — this repo doesn't run pytest in CI, so I ran the suite locally (pytest tests/, 10 passed).ruff check growattServerandmypy --ignore-missing-imports growattServer/locally (matching what.github/workflows/ruff.ymlandmypy.ymlrun in CI) — both clean.Retry-Afteron a real 507) that would help decide whetherretry_afterneeds an additional fallback later. If not, an isolated repro is welcome but not something worth asking a user to trigger deliberately.Checklist
self.session; no README/docs reference existing exception types, so none needed updating there)🤖 Generated with Claude Code