From 2d7d8cd374ad75ba55260ad2de1b5906ded3f276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Achmu=CC=88ller?= Date: Wed, 25 Feb 2026 12:38:49 +0100 Subject: [PATCH] Handle 429 rate-limit responses in request flow --- PyTado/exceptions.py | 4 ++++ PyTado/http.py | 25 ++++++++++++++++++++++++- tests/test_http.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/PyTado/exceptions.py b/PyTado/exceptions.py index 5f2e7dc..03c24c6 100644 --- a/PyTado/exceptions.py +++ b/PyTado/exceptions.py @@ -19,3 +19,7 @@ class TadoNoCredentialsException(TadoCredentialsException): class TadoWrongCredentialsException(TadoCredentialsException): """Exception to indicate wrong credentials""" + + +class TadoRateLimitException(TadoException): + """Exception to indicate API rate limit has been exceeded.""" diff --git a/PyTado/http.py b/PyTado/http.py index 5945b7e..bd8650d 100644 --- a/PyTado/http.py +++ b/PyTado/http.py @@ -23,7 +23,11 @@ from PyTado import __version__ from PyTado.const import CLIENT_ID_DEVICE, HTTP_CODES_OK -from PyTado.exceptions import TadoException, TadoWrongCredentialsException +from PyTado.exceptions import ( + TadoException, + TadoRateLimitException, + TadoWrongCredentialsException, +) from PyTado.logger import Logger _LOGGER = Logger(__name__) @@ -317,6 +321,25 @@ def request(self, request: TadoRequest) -> dict[str, Any] | list[Any] | str: _LOGGER.error("Max retries exceeded: %s", e) raise TadoException(e) from e + if response.status_code == 429: + details = [] + rate_limit_policy = response.headers.get("RateLimit-Policy") + rate_limit = response.headers.get("RateLimit") + retry_after = response.headers.get("Retry-After") + + if rate_limit_policy: + details.append(f"policy={rate_limit_policy}") + if rate_limit: + details.append(f"limit={rate_limit}") + if retry_after: + details.append(f"retry_after={retry_after}") + + message = "Request failed with status code 429" + if details: + message += f" ({', '.join(details)})" + + raise TadoRateLimitException(message) + if response.text == "": if response.status_code == 204: # Tado changed some (all?) APIs from HTTP 200 to HTTP 204. diff --git a/tests/test_http.py b/tests/test_http.py index 20ce1cb..c0fbda6 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -9,7 +9,7 @@ import responses from PyTado.const import CLIENT_ID_DEVICE -from PyTado.exceptions import TadoException +from PyTado.exceptions import TadoException, TadoRateLimitException from PyTado.http import Domain, Endpoint, Http, TadoRequest from . import common @@ -346,3 +346,33 @@ def test_request_returns_success_for_204_status( result = http.request(request) self.assertEqual(result, {"success": True}) + + @mock.patch("PyTado.http.Http._refresh_token", return_value=True) + @mock.patch("PyTado.http.Http._device_ready") + @mock.patch("PyTado.http.Http._load_token") + @mock.patch("PyTado.http.Http._login_device_flow") + def test_request_raises_rate_limit_exception_for_429_status( + self, + mock_load_token, + mock_login_device_flow, + mock_device_ready, + mock_refresh_token, + ): + """Raise a clear rate-limit exception for exhausted API quota.""" + http = Http() + http._id = 1234 + + mock_response = mock.Mock() + mock_response.status_code = 429 + mock_response.text = "" + mock_response.headers = { + "RateLimit-Policy": '"perday";q=1000;w=86400', + "RateLimit": '"perday";r=0;t=1301', + } + + with mock.patch.object(http._session, "send", return_value=mock_response): + request = TadoRequest(command="test", domain=Domain.HOME) + with self.assertRaises(TadoRateLimitException) as err: + http.request(request) + + self.assertIn('"perday";r=0;t=1301', str(err.exception))