Add retry logic and resilience for Withings webhook subscription (#162189)

Co-authored-by: delize <4028612+delize@users.noreply.github.com>
Co-authored-by: abmantis <amfcalt@gmail.com>
This commit is contained in:
Andrew Doering
2026-03-25 19:42:10 +01:00
committed by GitHub
co-authored by delize abmantis
parent cd142833e7
commit faaa87e36f
4 changed files with 256 additions and 35 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ async def setup_integration(
async def prepare_webhook_setup(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Prepare webhooks are registered by waiting a second."""
"""Prepare webhooks by advancing past the registration delay."""
freezer.tick(timedelta(seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
+6 -4
View File
@@ -196,9 +196,7 @@ def mock_withings():
@pytest.fixture(name="disable_webhook_delay", autouse=True)
def disable_webhook_delay():
"""Disable webhook delay."""
mock = AsyncMock()
"""Disable webhook delays for faster tests."""
with (
patch(
"homeassistant.components.withings.SUBSCRIBE_DELAY",
@@ -208,5 +206,9 @@ def disable_webhook_delay():
"homeassistant.components.withings.UNSUBSCRIBE_DELAY",
timedelta(seconds=0),
),
patch(
"homeassistant.components.withings.WEBHOOK_REGISTER_DELAY",
0,
),
):
yield mock
yield
+122
View File
@@ -11,6 +11,9 @@ from aiowithings import (
NotificationCategory,
WithingsAuthenticationFailedError,
WithingsConnectionError,
WithingsError,
WithingsInvalidParamsError,
WithingsTooManyRequestsError,
WithingsUnauthorizedError,
)
from freezegun.api import FrozenDateTimeFactory
@@ -725,3 +728,122 @@ async def test_oauth_implementation_not_available(
await hass.async_block_till_done()
assert webhook_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_webhook_subscription_retry_on_failure(
hass: HomeAssistant,
withings: AsyncMock,
webhook_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test webhook subscription is retried on failure."""
call_count = 0
async def subscribe_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count <= 2:
raise WithingsError("The callback URL is either absent or incorrect")
withings.subscribe_notification.side_effect = subscribe_side_effect
await setup_integration(hass, webhook_config_entry)
await prepare_webhook_setup(hass, freezer)
# Trigger first retry (30s delay for attempt 0)
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Trigger second retry (60s delay for attempt 1)
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert withings.subscribe_notification.call_count >= 6
async def test_webhook_subscription_continues_retrying(
hass: HomeAssistant,
withings: AsyncMock,
webhook_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test webhook subscription continues retrying with exponential backoff."""
withings.subscribe_notification.side_effect = WithingsError(
"The callback URL is either absent or incorrect"
)
await setup_integration(hass, webhook_config_entry)
await prepare_webhook_setup(hass, freezer)
# Tick through retry delays with exponential backoff: 30, 60, 120, 240, 480
for delay in (30, 60, 120, 240, 480):
freezer.tick(timedelta(seconds=delay))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert "Failed to subscribe to Withings webhooks" in caplog.text
async def test_webhook_subscription_rate_limited(
hass: HomeAssistant,
withings: AsyncMock,
webhook_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test webhook subscription handles rate limiting."""
withings.subscribe_notification.side_effect = WithingsTooManyRequestsError(
"Too many requests"
)
await setup_integration(hass, webhook_config_entry)
await prepare_webhook_setup(hass, freezer)
assert "Rate limited by Withings API" in caplog.text
async def test_webhook_subscription_auth_failure(
hass: HomeAssistant,
withings: AsyncMock,
webhook_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test webhook subscription stops on auth failure and triggers reauth."""
withings.subscribe_notification.side_effect = WithingsAuthenticationFailedError(
"Authentication failed"
)
await setup_integration(hass, webhook_config_entry)
await prepare_webhook_setup(hass, freezer)
assert "Authentication failed while subscribing to webhooks" in caplog.text
assert withings.subscribe_notification.call_count == 1
flows = hass.config_entries.flow.async_progress()
assert any(
flow["handler"] == DOMAIN and flow["context"]["source"] == "reauth"
for flow in flows
)
async def test_webhook_subscription_invalid_params(
hass: HomeAssistant,
withings: AsyncMock,
webhook_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test invalid params error stops retrying."""
withings.subscribe_notification.side_effect = WithingsInvalidParamsError(
"Invalid callback URL"
)
await setup_integration(hass, webhook_config_entry)
await prepare_webhook_setup(hass, freezer)
assert "Webhook URL rejected by Withings" in caplog.text
assert withings.subscribe_notification.call_count == 1