From 279cffc57a766919bd0f5d0e0dee67a53164b459 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:24:46 -0700 Subject: [PATCH 1/4] feat(auth): raise a typed error when registration is rate-limited register_v2 raises SpanPanelRateLimitError, a SpanPanelAPIError, when the panel answers HTTP 429, and does not retry. retry_after_s carries the wait the panel asked for in seconds, from Retry-After: delta-seconds as given, an HTTP-date as the seconds until that time, and None when the header is absent, negative, infinite or neither form. The body and the passphrase stay out of the message, as for every other refusal. The CA download's 429 retry reads Retry-After through the same parser, so it now honours an HTTP-date and no longer waits on an infinite value. The passphrase rotation docstring names 504 beside 500 as an unknown outcome. SpanPanelRateLimitError is exported. Part of the spanos3/r202639/03 batch 2 work. --- src/span_panel_api/__init__.py | 5 +++ src/span_panel_api/auth.py | 57 +++++++++++++++++++++++------- src/span_panel_api/exceptions.py | 19 ++++++++++ tests/test_public_api_unchanged.py | 4 +++ 4 files changed, 72 insertions(+), 13 deletions(-) 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/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* From 062e06336a6d156a28abd9853dd4da41499cc099 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:25:28 -0700 Subject: [PATCH 2/4] test: register the registration rate-limit divergences Two acceptance tests read the delay as retry_after and expect None for an HTTP-date Retry-After. This library names it retry_after_s, in seconds, and reads an HTTP-date as the seconds until that time, so both are known divergences. The rows are inert until the tests land. A row may now name a method of a Test class. Part of the spanos3/r202639/03 batch 2 work. --- tests/conftest.py | 14 ++++++++++++-- tests/expected_failures.py | 18 ++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) 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..6b9cf36 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 the cases after the acceptance tests in +`test_register_rate_limit.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" From 15bfd305f6f871cb56644a01c1f14f30df02a3e2 Mon Sep 17 00:00:00 2001 From: Donald Clark Jackson Date: Fri, 9 Oct 2026 08:09:32 -0700 Subject: [PATCH 3/4] test: Don's acceptance tests for registration rate limits Don's acceptance tests, ported unchanged from his work: test_register_rate_limit.py. Tests this line does not pass yet are listed in tests/expected_failures.py. Part of the spanos3/r202639/03 batch 2 work. --- tests/test_register_rate_limit.py | 87 +++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/test_register_rate_limit.py 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 From 18d43358cc59be5907f373a8d51882bf04c586c2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:14:22 -0700 Subject: [PATCH 4/4] test: pin the registration delay in seconds and the statuses around 429 Companions to the registration acceptance tests, in their own file so those stay unchanged: Retry-After as delta-seconds and as an HTTP-date, future or passed, read as seconds; negative, infinite, NaN and unparseable values read as None; a rejected credential is still an auth error and another 4xx is not a rate limit; and the CA download backoff takes an HTTP-date and never waits forever. They pin what the two divergent acceptance tests check after their divergent line. Part of the spanos3/r202639/03 batch 2 work. --- tests/expected_failures.py | 4 +- tests/test_register_rate_limit_companions.py | 92 ++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/test_register_rate_limit_companions.py diff --git a/tests/expected_failures.py b/tests/expected_failures.py index 6b9cf36..b89a428 100644 --- a/tests/expected_failures.py +++ b/tests/expected_failures.py @@ -11,8 +11,8 @@ have; they stay until the test or the decision changes. A strict xfail stops at 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 the cases after the acceptance tests in -`test_register_rate_limit.py` for registration. +device-tree tests, and `test_register_rate_limit_companions.py` for +registration. """ from __future__ import annotations 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