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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
- Cleared entry runtime data when platform forwarding fails to avoid leaving a partially initialized state.
- Hardened `forecast_days` parsing during coordinator and sensor setup to tolerate malformed stored values without crashing.
- Improved test isolation by avoiding unconditional replacement of the global `aiohttp` module stub.
- Accepted numeric-string RGB channels from API color payloads by relying on shared
channel normalization, while still ignoring non-numeric strings.
- Hardened HTTP 429 backoff by validating `Retry-After` values (rejecting non-finite,
negative, and stale date-based delays) and clamping retry sleep to a safe bounded
range.

### Changed
- Switched sensor setup iteration to use a validated local data snapshot for
Expand Down
11 changes: 8 additions & 3 deletions custom_components/pollenlevels/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import logging
import math
import random
from typing import Any

Expand Down Expand Up @@ -40,12 +41,15 @@ def _parse_retry_after(self, retry_after_raw: str) -> float:
"""Translate a Retry-After header into a delay in seconds."""

try:
return float(retry_after_raw)
parsed = float(retry_after_raw)
if math.isfinite(parsed) and parsed > 0:
return parsed
Comment on lines +45 to +46

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept Retry-After value of zero

The new parsed > 0 check treats Retry-After: 0 as invalid and forces the 2-second fallback, but a zero delay is a valid throttling response meaning "retry immediately". In environments where the upstream sends 0 during transient rate limits, this adds an unnecessary fixed delay on every retry and slows recovery compared to the previous behavior.

Useful? React with 👍 / 👎.

return 2.0
Comment on lines 43 to +47
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _parse_retry_after function now correctly handles non-finite and negative float values for Retry-After by returning a default of 2.0. This is a good defensive programming practice to prevent unexpected behavior from malformed or malicious Retry-After headers.

References
  1. Defensively validate and normalize user input even when it is expected to be valid from UI controls, as it can be malformed due to persisted data or external integrations.

except (TypeError, ValueError):
retry_at = dt_util.parse_http_date(retry_after_raw)
if retry_at is not None:
delay = (retry_at - dt_util.utcnow()).total_seconds()
if delay > 0:
if math.isfinite(delay) and delay > 0:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Adding math.isfinite(delay) to the condition for date-based Retry-After values ensures that infinite or NaN delays are also handled gracefully, preventing potential issues with asyncio.sleep.

References
  1. Defensively validate and normalize user input even when it is expected to be valid from UI controls, as it can be malformed due to persisted data or external integrations.

return delay

return 2.0
Expand Down Expand Up @@ -118,7 +122,8 @@ async def async_fetch_pollen_data(
delay = 2.0
if retry_after_raw:
delay = self._parse_retry_after(retry_after_raw)
delay = min(delay, 5.0) + random.uniform(0.0, 0.4)
delay = delay + random.uniform(0.0, 0.4)
delay = max(0.0, min(delay, 5.0))
Comment on lines +125 to +126
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The clamping logic for the retry delay has been improved by applying max(0.0, min(delay, 5.0)) after adding jitter. This ensures the delay is always within the safe bounded range of 0.0 to 5.0 seconds, regardless of the Retry-After value or jitter. This is a critical improvement for stability.

References
  1. Defensively validate and normalize user input even when it is expected to be valid from UI controls, as it can be malformed due to persisted data or external integrations.

_LOGGER.warning(
"Pollen API 429 — retrying in %.2fs (attempt %d/%d)",
delay,
Expand Down
11 changes: 2 additions & 9 deletions custom_components/pollenlevels/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,15 @@ def _rgb_from_api(color: dict[str, Any] | None) -> tuple[int, int, int] | None:
"""Build an (R, G, B) tuple from API color dict.

Rules:
- If color is not a dict, or an empty dict, or has no numeric channels at all,
return None (meaning "no color provided by API").
- If color is not a dict, or an empty dict, return None
(meaning "no color provided by API").
- If only some channels are present, missing ones are treated as 0 (black baseline)
but ONLY when at least one channel exists. This preserves partial colors like
{green, blue} without inventing a color for {}.
"""
if not isinstance(color, dict) or not color:
return None

# Check if any of the channels is actually provided as numeric
has_any_channel = any(
isinstance(color.get(k), (int, float)) for k in ("red", "green", "blue")
)
if not has_any_channel:
return None

r = _normalize_channel(color.get("red"))
g = _normalize_channel(color.get("green"))
b = _normalize_channel(color.get("blue"))
Comment on lines 60 to 62
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The removal of the has_any_channel check simplifies the logic in _rgb_from_api. The _normalize_channel function is now solely responsible for validating and normalizing individual color channels, making the code cleaner and more focused.

Expand Down
175 changes: 175 additions & 0 deletions tests/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,106 @@ def test_plant_forecast_matches_codes_case_insensitively() -> None:
assert entry["tomorrow_value"] == 4


def test_coordinator_accepts_numeric_string_color_channels() -> None:
"""Numeric string channels should be normalized into RGB/hex values."""

payload = {
"dailyInfo": [
{
"date": {"year": 2025, "month": 7, "day": 1},
"pollenTypeInfo": [
{
"code": "GRASS",
"displayName": "Grass",
"indexInfo": {
"value": 1,
"category": "LOW",
"color": {"red": "1", "green": "0", "blue": "0"},
},
}
],
}
]
}

fake_session = FakeSession(payload)
client = client_mod.GooglePollenApiClient(fake_session, "test")

loop = asyncio.new_event_loop()
hass = DummyHass(loop)
coordinator = coordinator_mod.PollenDataUpdateCoordinator(
hass=hass,
api_key="test",
lat=1.0,
lon=2.0,
hours=12,
language=None,
entry_id="entry",
forecast_days=1,
create_d1=False,
create_d2=False,
client=client,
)

try:
data = loop.run_until_complete(coordinator._async_update_data())
finally:
loop.close()

assert data["type_grass"]["color_hex"] == "#FF0000"
assert data["type_grass"]["color_rgb"] == [255, 0, 0]
Comment on lines +1096 to +1143
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This new test case test_coordinator_accepts_numeric_string_color_channels effectively verifies that numeric strings for color channels are correctly parsed and converted to RGB/hex values. This is important for robust handling of API payloads that might send numbers as strings.

References
  1. Defensively validate and normalize user input even when it is expected to be valid from UI controls, as it can be malformed due to persisted data or external integrations.



def test_coordinator_ignores_invalid_string_color_channels() -> None:
"""Non-numeric string channels should not emit RGB/hex values."""

payload = {
"dailyInfo": [
{
"date": {"year": 2025, "month": 7, "day": 1},
"pollenTypeInfo": [
{
"code": "GRASS",
"displayName": "Grass",
"indexInfo": {
"value": 1,
"category": "LOW",
"color": {"red": "foo"},
},
}
],
}
]
}

fake_session = FakeSession(payload)
client = client_mod.GooglePollenApiClient(fake_session, "test")

loop = asyncio.new_event_loop()
hass = DummyHass(loop)
coordinator = coordinator_mod.PollenDataUpdateCoordinator(
hass=hass,
api_key="test",
lat=1.0,
lon=2.0,
hours=12,
language=None,
entry_id="entry",
forecast_days=1,
create_d1=False,
create_d2=False,
client=client,
)

try:
data = loop.run_until_complete(coordinator._async_update_data())
finally:
loop.close()

assert data["type_grass"]["color_hex"] is None
assert data["type_grass"]["color_rgb"] is None
Comment on lines +1146 to +1193
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The test_coordinator_ignores_invalid_string_color_channels test case is well-designed to ensure that non-numeric strings in color channels are gracefully ignored, preventing crashes and ensuring None is returned for color_hex and color_rgb. This improves the robustness of the color parsing logic.

References
  1. Defensively validate and normalize user input even when it is expected to be valid from UI controls, as it can be malformed due to persisted data or external integrations.



def test_coordinator_ignores_nonfinite_color_channels() -> None:
"""Non-finite color channel values should not crash or emit invalid colors."""

Expand Down Expand Up @@ -1460,6 +1560,81 @@ async def _fast_sleep(delay: float) -> None:
assert delays == [5.0]


@pytest.mark.parametrize(
("retry_after", "now"),
[
("-10", None),
("nan", None),
("inf", None),
(
"Wed, 10 Dec 2025 12:00:00 GMT",
datetime.datetime(2025, 12, 10, 12, 0, 5, tzinfo=datetime.UTC),
),
],
)
def test_coordinator_retry_after_invalid_values_use_safe_default(
monkeypatch: pytest.MonkeyPatch,
retry_after: str,
now: datetime.datetime | None,
) -> None:
"""Invalid Retry-After values should fall back to a safe finite delay."""

session = SequenceSession(
[
ResponseSpec(
status=429,
payload={"error": {"message": "Quota exceeded"}},
headers={"Retry-After": retry_after},
),
ResponseSpec(
status=429,
payload={"error": {"message": "Quota exceeded"}},
headers={"Retry-After": retry_after},
),
]
)
delays: list[float] = []

async def _fast_sleep(delay: float) -> None:
assert isinstance(delay, float)
assert delay == delay
assert delay != float("inf")
assert delay != float("-inf")
delays.append(delay)

monkeypatch.setattr(client_mod.asyncio, "sleep", _fast_sleep)
monkeypatch.setattr(client_mod.random, "uniform", lambda *_args, **_kwargs: 0.0)
if now is not None:
monkeypatch.setattr(client_mod.dt_util, "utcnow", lambda: now)

client = client_mod.GooglePollenApiClient(session, "test")

loop = asyncio.new_event_loop()
hass = DummyHass(loop)
coordinator = coordinator_mod.PollenDataUpdateCoordinator(
hass=hass,
api_key="test",
lat=1.0,
lon=2.0,
hours=12,
language=None,
entry_id="entry",
forecast_days=1,
create_d1=False,
create_d2=False,
client=client,
)

try:
with pytest.raises(client_mod.UpdateFailed, match="Quota exceeded"):
loop.run_until_complete(coordinator._async_update_data())
finally:
loop.close()

assert session.calls == 2
assert delays == [2.0]
Comment on lines +1563 to +1635
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The new parameterized test test_coordinator_retry_after_invalid_values_use_safe_default provides excellent coverage for various invalid Retry-After values, including negative, NaN, infinity, and stale date-based values. This ensures that the system consistently falls back to a safe default delay, significantly improving the reliability of the backoff mechanism.

References
  1. Defensively validate and normalize user input even when it is expected to be valid from UI controls, as it can be malformed due to persisted data or external integrations.



def test_coordinator_retries_then_raises_on_server_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down