Repository navigation
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.
|
@indykoning - gentle push on this one. |
Summary
Growatt reports rate limiting in the response body, not as an HTTP status: the request returns HTTP 200 and the payload carries
success: falsewithmsg: "507". That is the shape behind theConfigEntryError: Growatt login failed: 507tracebacks in home-assistant/core#176831 and home-assistant/core#174789 (see also #55). Reports indicate a 507 precedes an approximately 24 hour lockout rather than a short cooldown, so callers such as Home Assistant'sgrowatt_serverintegration need to tell it apart from bad credentials or a malformed response.Thanks to @johanzander for the correction: the first version of this PR handled HTTP 429/507 status codes and
Retry-After, which Growatt does not send. That design was dropped; details in the thread below.What this PR does
GrowattRateLimitError(error_code, error_msg=None)inexceptions.py, following theGrowattV1ApiErrorshape: callers readexc.error_code("507") andexc.error_msginstead of parsing a message. The message is generic:Growatt rate limit reached: [507]._raise_if_rate_limited(response)inbase_api.py, called from the session's existing response hook beforeresponse.raise_for_status(). Growatt rate limits each endpoint separately, so the check covers every call on the session, not onlylogin(). (OpenApiV1subclassesGrowattApi, so its responses pass through the hook too; V1 error bodies useerror_code/error_msg, so they do not match.)success: false+msg: "507"combination (msgcompared as a string), readingbackwhen it is a dict and the top level otherwise. Non-JSON bodies, non-dict bodies,backas a list (plant_list) and bodies without asuccesskey fall through untouched.GrowattRateLimitErrorfrom the package__init__.py.Behavior change
For a body with
success: falseandmsg: "507",login()now raisesGrowattRateLimitErrorinstead of returning the dict, and any other method on the session raises in the same case. Every other failure (e.g.msg: "501", wrong password) still returns the dict as before, and HTTP errors still raiserequests.exceptions.HTTPError. A consumer that inspectedmsg == "507"on the returned dict needs to catch the exception instead, so this probably deserves a line in the release notes.Out of scope
Session/cookie persistence across restarts (the fix for the login frequency that causes the lockouts) is not part of this PR; the constructor-injected
sessionparameter is in #162.What I validated
tests/test_login_rate_limit.py: 10 tests built on realrequests.Responseobjects and a small session stand-in that runs the session's response hooks the wayrequestsdoes (a plainMagicMocksession would skip the hook). Covers: login and a data call (plant_list) raise on the 507 body;error_codeand the exact message; numericmsg; a body without thebackwrapper; and the fall-through cases (msg: "501", successful login,backas a list, non-JSON body, HTTP 500 still raisingHTTPError).pytest tests/: 11 passed (the 10 above plus the existingtest_exceptions.py). With the_raise_if_rate_limited(response)call removed, the 5 tests that assert a raise fail and the other 6 pass.ruff check ./growattServerwith the pinned 0.15.16 andmypy --ignore-missing-imports growattServer/(whatruff.ymlandmypy.ymlrun): both clean, and both CI checks are green on the current head.Checklist
_raise_if_rate_limited; no README/docs reference existing exception types, so none needed updating there)🤖 Generated with Claude Code