Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/span_panel_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
SpanPanelError,
SpanPanelInsufficientPrivilegeError,
SpanPanelPassphraseUnavailableError,
SpanPanelRateLimitError,
SpanPanelSchemaVersionError,
SpanPanelServerError,
SpanPanelStaleDataError,
Expand Down Expand Up @@ -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
Expand Down
57 changes: 44 additions & 13 deletions src/span_panel_api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -24,6 +27,7 @@
SpanPanelAuthError,
SpanPanelInsufficientPrivilegeError,
SpanPanelPassphraseUnavailableError,
SpanPanelRateLimitError,
SpanPanelServerError,
)
from .models import HomieSchemaTypes, PassphraseRotation, V2AuthResponse, V2HomieSchema, V2StatusInfo
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/span_panel_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 12 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 14 additions & 4 deletions tests/expected_failures.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
"""Tests expected to fail on this line, each with the reason it does.

Keyed by pytest node id without parameters (`<file>::<function>`), so every
parametrized case of a listed function is expected to fail. `conftest.py` marks
Keyed by pytest node id without parameters or class (`<file>::<function>`), 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.

`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
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions tests/test_public_api_unchanged.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
87 changes: 87 additions & 0 deletions tests/test_register_rate_limit.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading