diff --git a/growattServer/__init__.py b/growattServer/__init__.py index e6914e8..c119dbd 100755 --- a/growattServer/__init__.py +++ b/growattServer/__init__.py @@ -7,6 +7,7 @@ from .exceptions import ( GrowattError, GrowattParameterError, + GrowattRateLimitError, GrowattV1ApiError, GrowattV1ApiErrorCode, ) @@ -20,6 +21,7 @@ "GrowattApi", "GrowattError", "GrowattParameterError", + "GrowattRateLimitError", "GrowattV1ApiError", "GrowattV1ApiErrorCode", "OpenApiV1", diff --git a/growattServer/base_api.py b/growattServer/base_api.py index 327d9f8..7b5e75e 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -15,7 +15,7 @@ import requests -from .exceptions import GrowattError +from .exceptions import GrowattError, GrowattRateLimitError name = "growattServer" @@ -23,6 +23,42 @@ BATT_MODE_BATTERY_FIRST = 1 BATT_MODE_GRID_FIRST = 2 +# Growatt reports rate limiting in the response body, not as an HTTP status: +# HTTP 200 with `success: false` and `msg: "507"`. See GrowattRateLimitError +# for the reports behind this. +RATE_LIMITED_CODE = "507" + + +def _raise_if_rate_limited(response: requests.Response) -> None: + """ + Raise GrowattRateLimitError when a response body carries Growatt's 507 code. + + Growatt answers HTTP 200 and puts the refusal in the payload, so the body + of every response has to be read. Growatt rate limits each endpoint + separately, so this lives in the session hook rather than in `login()`: + the reports we have are all logins, but a data call can be refused the + same way. + + Body shapes vary -- most endpoints wrap their payload in `back`, but + `plant_list` returns `back` as a list, and some responses are not JSON at + all. Since this runs on every response, anything unexpected falls through + untouched instead of raising. + """ + 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) + def hash_password(password: str) -> str: """ @@ -74,6 +110,7 @@ def __init__(self, add_random_user_id: bool = False, agent_identifier: str | Non def _raise_for_status(response, *args: object, **kwargs: object) -> None: _ = args _ = kwargs + _raise_if_rate_limited(response) response.raise_for_status() self.session.hooks = {"response": [_raise_for_status]} diff --git a/growattServer/exceptions.py b/growattServer/exceptions.py index 4b9520f..a42a4f9 100644 --- a/growattServer/exceptions.py +++ b/growattServer/exceptions.py @@ -66,3 +66,45 @@ def __init__(self, message: str, error_code: int, error_msg: str) -> None: super().__init__(f"{message}: [{error_code}] {error_msg}") self.error_code = error_code self.error_msg = error_msg + + +class GrowattRateLimitError(GrowattError): + """ + Raised when Growatt refuses a request because the account is rate limited. + + Growatt signals this **in the response body**, not as an HTTP status: the + request succeeds with HTTP 200 and the payload carries ``success: false`` + with ``msg: "507"``. That is the shape behind the + ``ConfigEntryError: Growatt login failed: 507`` tracebacks in + home-assistant/core#176831 and home-assistant/core#174789. + + Every reported case so far is a login, but Growatt rate limits each + endpoint separately, so detection sits in the session response hook and + any call can raise this. + + A 507 has been observed to precede an approximately 24 hour lockout rather + than a short cooldown, so this library never retries on its own: retrying + immediately is what deepens the lockout. Callers get a distinct exception + type so they can back off for hours instead of treating it like bad + credentials or a malformed response. + + Follows the :class:`GrowattV1ApiError` shape so consumers can read the code + off the exception rather than parsing a message. + """ + + def __init__(self, error_code: str, error_msg: str | None = None) -> None: + """ + Initialize the GrowattRateLimitError. + + Args: + error_code: The application-level code from the response body, + e.g. ``"507"``. + error_msg: Optional human-readable detail, when the body carries one. + + """ + message = f"Growatt rate limit reached: [{error_code}]" + if error_msg: + message += f" {error_msg}" + super().__init__(message) + self.error_code = error_code + self.error_msg = error_msg diff --git a/tests/test_login_rate_limit.py b/tests/test_login_rate_limit.py new file mode 100644 index 0000000..44d22cd --- /dev/null +++ b/tests/test_login_rate_limit.py @@ -0,0 +1,144 @@ +"""Growatt reports rate limiting in the body, not as an HTTP status.""" +import json + +import pytest +import requests + +from growattServer import GrowattApi, GrowattRateLimitError + + +def _response(payload=None, *, body=None, status=200): + """Build a real Response -- the check parses the body itself.""" + response = requests.Response() + response.status_code = status + if body is None: + body = json.dumps(payload) + response._content = body.encode() + response.headers["Content-Type"] = "application/json" + return response + + +class _HookedSession: + """A session stand-in that runs the response hooks, as requests does. + + The 507 check lives in the hook now, so a plain MagicMock session would + skip it and every test here would pass for the wrong reason. + """ + + def __init__(self, hooks, response): + self.hooks = hooks + self.headers = {} + self._response = response + + def _dispatch(self, *args, **kwargs): + for hook in self.hooks["response"]: + hook(self._response) + return self._response + + get = _dispatch + post = _dispatch + + +def _api_returning(response): + """An API whose every call answers with `response`, hooks included.""" + api = GrowattApi() + api.session = _HookedSession(api.session.hooks, response) + return api + + +def _api_with_login_body(body): + """An API whose login answers with the given `back` payload.""" + return _api_returning(_response({"back": body})) + + +def test_login_raises_on_507_in_the_body(): + """`success: false` + `msg: "507"` is the real lockout signal.""" + api = _api_with_login_body({"success": False, "msg": "507"}) + + with pytest.raises(GrowattRateLimitError) as exc: + api.login("user", "pass") + + assert exc.value.error_code == "507" + + +def test_the_code_is_readable_off_the_exception(): + """Callers branch on `error_code`, never on the message wording.""" + api = _api_with_login_body({"success": False, "msg": "507"}) + + with pytest.raises(GrowattRateLimitError) as exc: + api.login("user", "pass") + + assert exc.value.error_code == "507" + assert str(exc.value) == "Growatt rate limit reached: [507]" + + +def test_a_data_call_is_covered_too(): + """Growatt rate limits per endpoint, so this cannot be login-only.""" + api = _api_returning(_response({"back": {"success": False, "msg": "507"}})) + + with pytest.raises(GrowattRateLimitError): + api.plant_list("user_1") + + +def test_other_failures_are_left_alone(): + """A wrong password must keep returning the dict, not raise.""" + api = _api_with_login_body({"success": False, "msg": "501"}) + + result = api.login("user", "pass") + + assert result["success"] is False + assert result["msg"] == "501" + + +def test_numeric_msg_is_still_recognised(): + """Defensive: the code is compared as a string.""" + api = _api_with_login_body({"success": False, "msg": 507}) + + with pytest.raises(GrowattRateLimitError): + api.login("user", "pass") + + +def test_successful_login_is_unaffected(): + """The happy path keeps its existing shape.""" + api = _api_with_login_body({ + "success": True, + "user": {"id": "u1", "rightlevel": 1}, + }) + + result = api.login("user", "pass") + + assert result["userId"] == "u1" + assert result["userLevel"] == 1 + + +def test_a_list_back_does_not_raise(): + """`plant_list` answers with `back` as a list -- the guard must hold.""" + api = _api_returning(_response({"back": [{"success": False, "msg": "507"}]})) + + assert api.plant_list("user_1") == [{"success": False, "msg": "507"}] + + +def test_a_non_json_body_does_not_raise(): + """The hook sees every response, including ones that are not JSON.""" + response = _response(body="maintenance") + response.headers["Content-Type"] = "text/html" + api = _api_returning(response) + + for hook in api.session.hooks["response"]: + hook(response) + + +def test_a_bare_body_without_the_back_wrapper_is_checked(): + """Not every endpoint wraps its payload, so read the top level too.""" + api = _api_returning(_response({"success": False, "msg": "507"})) + + with pytest.raises(GrowattRateLimitError): + api.plant_list("user_1") + + +def test_http_errors_still_raise(): + """The rate-limit check must not shadow raise_for_status.""" + api = _api_returning(_response({"back": {"success": True}}, status=500)) + + with pytest.raises(requests.exceptions.HTTPError): + api.plant_list("user_1")