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 2 commits 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. |
Growatt rate limits each endpoint separately, so guarding login() alone misses refusals on data calls. The response hook already sees every response for the session, which makes it one place to extend instead of ~40 endpoint methods that each parse their own shape. The hook runs on everything, so it falls through untouched on shapes it does not recognise: plant_list receives `back` as a list, and non-JSON bodies are possible. Only the exact `success: false` + `msg: "507"` combination raises. Also drops the observed-lockout prose from the exception message, since that is behaviour that can change and it ended up in every log line. It lives in the docstring now, and the test asserts on error_code rather than message wording. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both in, thanks — the hook is the right home for this and the reasoning about per-endpoint limiting convinced me. Pushed as a separate commit so the delta is easy to read. 1. Detection moved to the hook. I kept your guards and named why each one is there, since the next person to touch this will be tempted to simplify them away: try:
data = response.json()
except ValueError:
return
if not isinstance(data, dict):
return
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)
2. Message is generic now: On the tests — the old ones would have passed for the wrong reason. They replaced Eleven tests now, including the cases your guards exist for:
Verification: with 🤖 Generated with Claude Code |
johanzander
left a comment
There was a problem hiding this comment.
This looks good now — approving. I pulled the branch and verified rather than just reading the diff:
- All 11 tests pass,
ruff checkclean. - Confirmed your "5 of 11 fail" claim: with the
_raise_if_rate_limited(response)call commented out, exactly the five raise-asserting tests fail and the five guard tests pass either way, as intended. - The test rework was the right call — you're correct that the old
MagicMocksession would have skipped the hook entirely and passed for the wrong reason. The_HookedSessionstand-in dispatching realrequests.Responseobjects through the actual hooks is exactly what this needed. login()untouched, guards match the shapes in this codebase (back-as-list, non-JSON, bare body),successdefaulting toTrueso unknown bodies fall through, and HTTP errors still surface asHTTPError.
One correction for the record on something I wrote earlier: I said the V1 API "keeps its own session" — that's wrong. OpenApiV1 subclasses GrowattApi and inherits the session, so V1 responses also pass through this hook. That's harmless-to-good: V1 error bodies use error_code/error_msg rather than success + msg, so no false positive is possible, and if the 507 shape ever did appear on a V1 call, raising would be the correct behavior.
Two notes for the record, neither blocking:
- Since
login()now raises where it previously returned a dict for this case, this deserves a line in the release notes, as you flagged. I'll update the Home Assistant integration to catchGrowattRateLimitErroronce this is released. - Looking forward to the session-injection follow-up. One thing to keep in mind there:
OpenApiV1.__init__only takestokenand doesn't forward constructor args, so if thesessionparameter should reach V1 too, it needs threading through — though for V1 (stateless token auth, no cookies) it's only a connection-pooling convenience, not part of the lockout fix. The classic API is where it matters.
Thanks for the thorough iteration on this one.
@indykoning this one is ready for a maintainer look when you have a moment — background in the comment thread above: Growatt signals its 507 rate-limit/lockout inside the JSON body (HTTP 200, success: false, msg: "507"), not as an HTTP status, and this PR surfaces that as a typed exception. It's directly relevant to the Home Assistant lockout reports (home-assistant/core#174789 / #176831), and I'd like to build on it from the HA side once released.
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