diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index c5f77c1..1cd8de2 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -29,6 +29,7 @@ SpanPanelError, SpanPanelInsufficientPrivilegeError, SpanPanelPassphraseUnavailableError, + SpanPanelRateLimitError, SpanPanelSchemaVersionError, SpanPanelServerError, SpanPanelStaleDataError, @@ -216,6 +217,10 @@ # keep their meaning; deliberately not a SpanPanelAuthError, because the # passphrase the user supplied may be correct. "SpanPanelPassphraseUnavailableError", + # Registration refused with HTTP 429, carrying the panel's `Retry-After` in + # seconds. A subclass of SpanPanelAPIError, so existing except clauses keep + # their meaning; not a SpanPanelAuthError, because no credential was judged. + "SpanPanelRateLimitError", "SpanPanelServerError", "SpanPanelStaleDataError", # Added 2026-08-31 (3.4.0): a bootstrap REST call that failed verification diff --git a/src/span_panel_api/auth.py b/src/span_panel_api/auth.py index df8a1c1..011c16a 100644 --- a/src/span_panel_api/auth.py +++ b/src/span_panel_api/auth.py @@ -10,9 +10,12 @@ import asyncio from collections.abc import Collection, Mapping +from datetime import UTC, datetime +from email.utils import parsedate_to_datetime import hashlib import json import logging +import math import ssl import uuid @@ -24,6 +27,7 @@ SpanPanelAuthError, SpanPanelInsufficientPrivilegeError, SpanPanelPassphraseUnavailableError, + SpanPanelRateLimitError, SpanPanelServerError, ) from .models import HomieSchemaTypes, PassphraseRotation, V2AuthResponse, V2HomieSchema, V2StatusInfo @@ -216,21 +220,37 @@ def _error_detail(response: httpx.Response) -> str | None: CA_CERT_BACKOFF_S = 1.5 -def _retry_delay(retry_after: str | None, attempt: int, backoff_s: float) -> float: - """Return how long to wait before retrying a rate-limited request. +def _retry_after_s(retry_after: str | None, now: datetime) -> float | None: + """The wait a ``Retry-After`` header asks for, in seconds, or None if it asks for none. - Prefers the panel's ``Retry-After`` header (delta-seconds form) and falls - back to exponential backoff. Malformed or negative values fall back too, - so a bad header can never stall or skip the retry. + Both forms RFC 9110 allows: delta-seconds, taken as given, and an HTTP-date, + taken as the seconds from `now` until then (0.0 once it has passed). A + negative, infinite or unparseable value is no answer, so a bad header can + never stall or skip a retry. """ - fallback = backoff_s * (2.0 ** (attempt - 1)) if retry_after is None: - return fallback + return None try: - parsed = float(retry_after) - except (TypeError, ValueError): - return fallback - return parsed if parsed >= 0 else fallback + seconds = float(retry_after) + except ValueError: + try: + until = parsedate_to_datetime(retry_after) + except (TypeError, ValueError): + return None + if until.tzinfo is None: + return None + return max((until - now).total_seconds(), 0.0) + return seconds if math.isfinite(seconds) and seconds >= 0 else None + + +def _retry_delay(retry_after: str | None, attempt: int, backoff_s: float) -> float: + """Return how long to wait before retrying a rate-limited request. + + Prefers the panel's ``Retry-After`` header and falls back to exponential + backoff where it gives no usable wait. + """ + parsed = _retry_after_s(retry_after, datetime.now(UTC)) + return backoff_s * (2.0 ** (attempt - 1)) if parsed is None else parsed async def register_v2( @@ -276,6 +296,7 @@ async def register_v2( Raises: SpanPanelAuthError: Invalid passphrase or auth failure SpanPanelPassphraseUnavailableError: The panel cannot read its own passphrase + SpanPanelRateLimitError: HTTP 429; the panel is limiting registrations. Not retried SpanPanelServerError: The panel is not ready to register clients (any 5xx, including the 503 it answers before its serial number is known); retryable SpanPanelConnectionError: Cannot reach panel @@ -314,6 +335,16 @@ async def register_v2( status_code=reply.status_code, ) + if reply.status_code == HTTP_TOO_MANY_REQUESTS: + # Not retried: the panel limits registrations per client address, and a + # retry here would spend the caller's next attempt on its behalf. + _log_auth_failure(reply.endpoint, reply.response, sent) + raise SpanPanelRateLimitError( + f"Panel is rate-limiting registration (HTTP {reply.status_code})", + status_code=reply.status_code, + retry_after_s=_retry_after_s(reply.headers.get("retry-after"), datetime.now(UTC)), + ) + if reply.status_code == 422 and _error_detail(reply.response) == REGISTRATION_UNAVAILABLE_DETAIL: # Checked before the general 422 below, which means "credential not # accepted". This one is the panel failing to read its own passphrase, @@ -613,8 +644,8 @@ async def rotate_passphrase( Raises: SpanPanelInsufficientPrivilegeError: HTTP 403; the token is reduced-privilege SpanPanelAuthError: HTTP 401 (token invalid or stale) or 412 (no bearer token) - SpanPanelServerError: HTTP 5xx. After a 500 the outcome is unknown: the - passphrase may or may not have changed. + SpanPanelServerError: HTTP 5xx. After a 500 or a 504 the outcome is unknown: + the passphrase may or may not have changed. SpanPanelConnectionError: Cannot reach panel SpanPanelTimeoutError: Request timed out SpanPanelAPIError: Unexpected response diff --git a/src/span_panel_api/exceptions.py b/src/span_panel_api/exceptions.py index 8bcf8eb..84f6343 100644 --- a/src/span_panel_api/exceptions.py +++ b/src/span_panel_api/exceptions.py @@ -68,6 +68,25 @@ class SpanPanelServerError(SpanPanelAPIError): """ +class SpanPanelRateLimitError(SpanPanelAPIError): + """The panel is limiting the client's requests and refused this one (HTTP 429). + + Raised by ``register_v2``, which does not retry: the caller decides when to + try again. ``retry_after_s`` is the wait the panel asked for, in seconds, + from its ``Retry-After`` header: a delta-seconds value as given, an HTTP-date + as the seconds until that time. ``None`` when the header is absent or holds + neither. + + A `SpanPanelAPIError`, so an existing clause catching that class still + catches this, and not a `SpanPanelAuthError`: the credential was never + judged. + """ + + def __init__(self, message: str, status_code: int | None = None, retry_after_s: float | None = None) -> None: + super().__init__(message, status_code) + self.retry_after_s = retry_after_s + + class SpanPanelPassphraseUnavailableError(SpanPanelAPIError): """The panel cannot read its own passphrase, so it cannot issue broker credentials. diff --git a/tests/conftest.py b/tests/conftest.py index fcd50dc..aca4275 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -76,8 +76,18 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: def _is_test_function(module: object, name: str) -> bool: - """Whether `name` is a function pytest collects from `module` under the default `test` prefix.""" - return name.startswith("test") and callable(getattr(module, name, None)) + """Whether `name` is a function pytest collects from `module` under the default `test` prefix. + + At module level or as a method of one of the module's `Test` classes. + """ + if not name.startswith("test"): + return False + if callable(getattr(module, name, None)): + return True + return any( + isinstance(owner, type) and owner_name.startswith("Test") and callable(getattr(owner, name, None)) + for owner_name, owner in vars(module).items() + ) @pytest.fixture(autouse=True) diff --git a/tests/expected_failures.py b/tests/expected_failures.py index b6613f1..b89a428 100644 --- a/tests/expected_failures.py +++ b/tests/expected_failures.py @@ -1,7 +1,7 @@ """Tests expected to fail on this line, each with the reason it does. -Keyed by pytest node id without parameters (`::`), so every -parametrized case of a listed function is expected to fail. `conftest.py` marks +Keyed by pytest node id without parameters or class (`::`), so +every parametrized case of a listed function is expected to fail. `conftest.py` marks each one `xfail(strict=True)`: a listed test that starts passing fails the run until its row is deleted, and a row naming anything but a test function of its module stops the run. @@ -9,8 +9,10 @@ `PENDING` rows assert behaviour a later change brings; that change deletes them. `DIVERGENT` rows assert behaviour this library deliberately does not have; they stay until the test or the decision changes. A strict xfail stops at -its first failing line, so `test_schema_one_acceptance_companions.py` asserts -what each `DIVERGENT` test checks after its divergent line. +its first failing line, so a companion asserts what each `DIVERGENT` test checks +after its divergent line: `test_schema_one_acceptance_companions.py` for the +device-tree tests, and `test_register_rate_limit_companions.py` for +registration. """ from __future__ import annotations @@ -19,6 +21,7 @@ from typing import Final _ACCEPTANCE = "tests/test_schema_one_other_models.py" +_RATE_LIMIT = "tests/test_register_rate_limit.py" _COMPANIONS = "tests/test_schema_one_acceptance_companions.py" _POSITIONS = "the breaker position range" @@ -76,9 +79,16 @@ _DEVICE_TYPE_AS_PUBLISHED = "the library keeps device_type as published; a SOLAR feeds-role is carried by feeds_role" +_RETRY_AFTER_IN_SECONDS = ( + "the delay is SpanPanelRateLimitError.retry_after_s, in seconds, and an HTTP-date Retry-After " + "is read as the seconds until that time" +) + DIVERGENT: Final[Mapping[str, str]] = { f"{_ACCEPTANCE}::test_a_circuit_whose_feeds_role_is_solar_is_labeled_pv": _DEVICE_TYPE_AS_PUBLISHED, f"{_ACCEPTANCE}::test_the_shipped_tree_has_the_other_model_shape": _DEVICE_TYPE_AS_PUBLISHED, + f"{_RATE_LIMIT}::test_raises_the_rate_limit_class_with_its_status": _RETRY_AFTER_IN_SECONDS, + f"{_RATE_LIMIT}::test_retry_after_is_none_without_a_usable_header": _RETRY_AFTER_IN_SECONDS, } assert not PENDING.keys() & DIVERGENT.keys(), "a test is either pending or divergent, never both" diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index a60e476..24bed3b 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -166,6 +166,10 @@ # SpanPanelAuthError, so no existing except clause starts telling a user # their passphrase is wrong. "SpanPanelPassphraseUnavailableError", + # Added 2026-10-09: registration refused with HTTP 429. A subclass of + # SpanPanelAPIError, which register_v2 already raised for a 429, so every + # existing except clause keeps catching it. + "SpanPanelRateLimitError", "SpanPanelServerError", "SpanPanelStaleDataError", # Added 2026-08-31 (3.4.0): a bootstrap REST call that failed *verification* diff --git a/tests/test_register_rate_limit.py b/tests/test_register_rate_limit.py new file mode 100644 index 0000000..5aad801 --- /dev/null +++ b/tests/test_register_rate_limit.py @@ -0,0 +1,87 @@ +"""Registration refused with HTTP 429. + +The panel limits registrations per client address and answers 429 with a +`RateLimitOut` body once the limit is reached. The library does not retry; it +raises a distinct class carrying `Retry-After` so the caller can decide when. +""" + +from __future__ import annotations + +import json +import logging +from unittest.mock import AsyncMock + +import httpx +import pytest + +from span_panel_api import SpanPanelRateLimitError +from span_panel_api.auth import register_v2 +from span_panel_api.exceptions import SpanPanelAPIError, SpanPanelAuthError + +HOST = "panel.invalid" +SECRET = "correct-horse-battery-staple" + + +def _client(headers: dict[str, str] | None = None, body: object | None = None) -> AsyncMock: + payload = {"error": "rate limit exceeded"} if body is None else body + response = httpx.Response( + 429, + content=json.dumps(payload).encode(), + headers={"content-type": "application/json", **(headers or {})}, + request=httpx.Request("POST", f"http://{HOST}/api/v2/auth/register"), + ) + injected = AsyncMock(spec=httpx.AsyncClient) + injected.post = AsyncMock(return_value=response) + return injected + + +async def _register(client: AsyncMock) -> None: + await register_v2(HOST, "home-assistant", SECRET, httpx_client=client) + + +class TestRateLimited: + @pytest.mark.asyncio + async def test_raises_the_rate_limit_class_with_its_status(self) -> None: + with pytest.raises(SpanPanelRateLimitError) as caught: + await _register(_client({"Retry-After": "30"})) + assert caught.value.status_code == 429 + assert caught.value.retry_after == 30.0 + + @pytest.mark.asyncio + async def test_is_an_api_error_and_not_an_auth_error(self) -> None: + """Existing `except SpanPanelAPIError` clauses keep catching it; nothing calls the passphrase wrong.""" + with pytest.raises(SpanPanelAPIError) as caught: + await _register(_client()) + assert not isinstance(caught.value, SpanPanelAuthError) + + @pytest.mark.asyncio + async def test_is_not_retried(self) -> None: + client = _client({"Retry-After": "1"}) + with pytest.raises(SpanPanelRateLimitError): + await _register(client) + assert client.post.await_count == 1 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "headers", + [ + {}, + {"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}, + {"Retry-After": "-5"}, + {"Retry-After": "inf"}, + {"Retry-After": "nan"}, + ], + ids=["absent", "http-date", "negative", "infinite", "nan"], + ) + async def test_retry_after_is_none_without_a_usable_header(self, headers: dict[str, str]) -> None: + with pytest.raises(SpanPanelRateLimitError) as caught: + await _register(_client(headers)) + assert caught.value.retry_after is None + + @pytest.mark.asyncio + async def test_the_body_stays_out_of_the_message(self, caplog: pytest.LogCaptureFixture) -> None: + body = {"error": f"limit for {SECRET}"} + with caplog.at_level(logging.DEBUG, logger="span_panel_api.auth"), pytest.raises(SpanPanelRateLimitError) as caught: + await _register(_client(body=body)) + assert "limit for" not in str(caught.value) + assert SECRET not in caplog.text diff --git a/tests/test_register_rate_limit_companions.py b/tests/test_register_rate_limit_companions.py new file mode 100644 index 0000000..d461559 --- /dev/null +++ b/tests/test_register_rate_limit_companions.py @@ -0,0 +1,92 @@ +"""What the divergent registration acceptance tests assert after their divergent line, and more. + +`expected_failures.py` lists two tests in `test_register_rate_limit.py` as +divergent: they read the delay as `retry_after` and expect `None` for an +HTTP-date, where this library reports `retry_after_s` in seconds and reads an +HTTP-date as the seconds until then. A strict xfail stops at that line, so the +cases here pin the delay and the statuses around 429, over the same responses. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from email.utils import format_datetime +import json +from unittest.mock import AsyncMock + +import httpx +import pytest + +from span_panel_api import SpanPanelRateLimitError +from span_panel_api.auth import _retry_delay +from span_panel_api.exceptions import SpanPanelAPIError, SpanPanelAuthError +from test_register_rate_limit import HOST, _client, _register + + +def _future(seconds: float) -> str: + """An HTTP-date `seconds` from now.""" + return format_datetime(datetime.now(UTC) + timedelta(seconds=seconds), usegmt=True) + + +class TestRetryAfterSeconds: + @pytest.mark.asyncio + async def test_delta_seconds_are_the_delay_and_the_status_is_429(self) -> None: + with pytest.raises(SpanPanelRateLimitError) as caught: + await _register(_client({"Retry-After": "30"})) + assert caught.value.status_code == 429 + assert caught.value.retry_after_s == 30.0 + + @pytest.mark.asyncio + async def test_an_http_date_is_the_seconds_until_then(self) -> None: + with pytest.raises(SpanPanelRateLimitError) as caught: + await _register(_client({"Retry-After": _future(120)})) + assert caught.value.retry_after_s == pytest.approx(120, abs=5) + + @pytest.mark.asyncio + async def test_an_http_date_already_passed_is_no_wait(self) -> None: + with pytest.raises(SpanPanelRateLimitError) as caught: + await _register(_client({"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"})) + assert caught.value.retry_after_s == 0.0 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "headers", + [{}, {"Retry-After": "-5"}, {"Retry-After": "inf"}, {"Retry-After": "nan"}, {"Retry-After": "soon"}], + ids=["absent", "negative", "infinite", "nan", "neither-form"], + ) + async def test_the_delay_is_none_without_a_usable_header(self, headers: dict[str, str]) -> None: + with pytest.raises(SpanPanelRateLimitError) as caught: + await _register(_client(headers)) + assert caught.value.retry_after_s is None + + +class TestOtherStatusesUnchanged: + @pytest.mark.asyncio + @pytest.mark.parametrize("status", [401, 403, 422]) + async def test_a_rejected_credential_is_still_an_auth_error(self, status: int) -> None: + with pytest.raises(SpanPanelAuthError): + await _register(_answering(status)) + + @pytest.mark.asyncio + async def test_another_4xx_is_still_an_api_error_and_not_a_rate_limit(self) -> None: + with pytest.raises(SpanPanelAPIError) as caught: + await _register(_answering(400)) + assert not isinstance(caught.value, SpanPanelRateLimitError) + + +def _answering(status: int) -> AsyncMock: + response = httpx.Response( + status, + content=json.dumps({"detail": "refused"}).encode(), + headers={"content-type": "application/json"}, + request=httpx.Request("POST", f"http://{HOST}/api/v2/auth/register"), + ) + injected = AsyncMock(spec=httpx.AsyncClient) + injected.post = AsyncMock(return_value=response) + return injected + + +def test_the_retry_backoff_takes_an_http_date_and_never_waits_forever() -> None: + """The CA download's 429 retry reads `Retry-After` through the same parser.""" + assert _retry_delay(_future(60), attempt=1, backoff_s=1.5) == pytest.approx(60, abs=5) + assert _retry_delay("inf", attempt=2, backoff_s=1.5) == 3.0