Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions growattServer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .exceptions import (
GrowattError,
GrowattParameterError,
GrowattRateLimitError,
GrowattV1ApiError,
GrowattV1ApiErrorCode,
)
Expand All @@ -20,6 +21,7 @@
"GrowattApi",
"GrowattError",
"GrowattParameterError",
"GrowattRateLimitError",
"GrowattV1ApiError",
"GrowattV1ApiErrorCode",
"OpenApiV1",
Expand Down
39 changes: 38 additions & 1 deletion growattServer/base_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,50 @@

import requests

from .exceptions import GrowattError
from .exceptions import GrowattError, GrowattRateLimitError

name = "growattServer"

BATT_MODE_LOAD_FIRST = 0
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:
"""
Expand Down Expand Up @@ -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]}
Expand Down
42 changes: 42 additions & 0 deletions growattServer/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
144 changes: 144 additions & 0 deletions tests/test_login_rate_limit.py
Original file line number Diff line number Diff line change
@@ -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="<html>maintenance</html>")
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")
Loading