Skip to content

Commit 37e9d4b

Browse files
danieldotnlclaude
andauthored
Fix notification flow by removing backwards dependency chain (#553) (#571)
Replace the 4-layer notify_scrape_exception() chain with a forward-only flow where the coordinator owns a _force_reauth flag and passes it to ContentRequestManager.get_content() at request time. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e07c4e0 commit 37e9d4b

8 files changed

Lines changed: 59 additions & 41 deletions

File tree

custom_components/multiscrape/binary_sensor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ def _update_sensor(self):
151151
self._attr_is_on,
152152
)
153153
except Exception as exception:
154-
self.coordinator.notify_scrape_exception()
154+
self.coordinator.request_reauth()
155155

156156
if self._sensor_selector.on_error.log not in [False, "false", "False"]:
157157
level = LOG_LEVELS[self._sensor_selector.on_error.log]

custom_components/multiscrape/coordinator.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,11 @@ def __init__(
5454
self._session = session
5555
self._resource_renderer = resource_renderer
5656

57-
def notify_scrape_exception(self):
58-
"""Notify the session of an exception so it will re-submit next trigger."""
59-
self._session.notify_scrape_exception()
60-
61-
async def get_content(self) -> str:
57+
async def get_content(self, force_reauth: bool = False) -> str:
6258
"""Retrieve the content of a url and first submit a form if required."""
59+
if force_reauth:
60+
self._session.invalidate_auth()
61+
6362
resource = self._resource_renderer()
6463

6564
try:
@@ -128,6 +127,7 @@ def __init__(
128127
self.update_error = False
129128
self._resource = None
130129
self._retry_count: int = 0
130+
self._force_reauth: bool = False
131131

132132
if self._update_interval == timedelta(seconds=0):
133133
self._update_interval = None
@@ -155,15 +155,18 @@ async def _on_hass_start(_: Event) -> None:
155155

156156
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _on_hass_start)
157157

158-
def notify_scrape_exception(self) -> None:
159-
"""Notify the ContentRequestManager of a scrape exception so it can notify the FormSubmitter."""
160-
self._request_manager.notify_scrape_exception()
158+
def request_reauth(self) -> None:
159+
"""Flag that re-authentication is needed on the next update cycle."""
160+
self._force_reauth = True
161161

162162
async def _async_update_data(self) -> None:
163163
await self._prepare_new_run()
164164

165165
try:
166-
response = await self._request_manager.get_content()
166+
response = await self._request_manager.get_content(
167+
force_reauth=self._force_reauth
168+
)
169+
self._force_reauth = False
167170
await self._scraper.set_content(response)
168171
_LOGGER.debug(
169172
"%s # Data successfully refreshed. Sensors will now start scraping to update.",
@@ -172,6 +175,7 @@ async def _async_update_data(self) -> None:
172175
self._retry_count = 0
173176

174177
except Exception as ex:
178+
self._force_reauth = True
175179
_LOGGER.error(
176180
"%s # Updating failed with exception: %s",
177181
self._config_name,

custom_components/multiscrape/form_auth.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,11 @@ async def ensure_authenticated(self, main_resource: str) -> str | None:
140140
return response.text
141141
return None
142142

143-
def notify_scrape_exception(self):
144-
"""Re-submit form after a scrape exception if configured."""
143+
def invalidate(self):
144+
"""Mark session as invalid so the form will be re-submitted next interval."""
145145
if self._config.resubmit_on_error:
146146
_LOGGER.debug(
147-
"%s # Exception occurred while scraping, will try to resubmit the form next interval.",
147+
"%s # Session invalidated, will re-submit the form next interval.",
148148
self._config_name,
149149
)
150150
self._should_submit = True

custom_components/multiscrape/http_session.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,10 +214,10 @@ async def ensure_authenticated(self, main_resource: str) -> str | None:
214214
return None
215215
return await self._form_authenticator.ensure_authenticated(main_resource)
216216

217-
def notify_scrape_exception(self):
218-
"""Re-submit form after a scrape exception if configured."""
217+
def invalidate_auth(self):
218+
"""Invalidate the current authentication so the form will be re-submitted."""
219219
if self._form_authenticator:
220-
self._form_authenticator.notify_scrape_exception()
220+
self._form_authenticator.invalidate()
221221

222222
@property
223223
def form_variables(self) -> dict[str, Any]:

custom_components/multiscrape/sensor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def _update_sensor(self):
153153
value, self.entity_id, self.device_class
154154
)
155155
except Exception as exception:
156-
self.coordinator.notify_scrape_exception()
156+
self.coordinator.request_reauth()
157157

158158
if self._sensor_selector.on_error.log not in [False, "false", "False"]:
159159
level = LOG_LEVELS[self._sensor_selector.on_error.log]

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def mock_http_session(mock_http_response):
104104
))
105105
mock.ensure_authenticated = AsyncMock(return_value=None)
106106
mock.form_variables = {}
107-
mock.notify_scrape_exception = MagicMock()
107+
mock.invalidate_auth = MagicMock()
108108
mock.async_close = AsyncMock()
109109
return mock
110110

tests/test_coordinator.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ async def test_content_request_manager_with_form_submission(
4242
return_value="<html>Form Response</html>"
4343
)
4444
mock_session.form_variables = {"var": "value"}
45-
mock_session.notify_scrape_exception = MagicMock()
45+
mock_session.invalidate_auth = MagicMock()
4646
mock_session.async_request = AsyncMock()
4747

4848
manager = ContentRequestManager(
@@ -72,7 +72,7 @@ async def test_content_request_manager_form_submission_no_result(
7272
mock_session = AsyncMock()
7373
mock_session.ensure_authenticated = AsyncMock(return_value=None)
7474
mock_session.form_variables = {"var": "value"}
75-
mock_session.notify_scrape_exception = MagicMock()
75+
mock_session.invalidate_auth = MagicMock()
7676
mock_session.async_request = AsyncMock(
7777
return_value=mock_http_response(text="<html>Page Content</html>")
7878
)
@@ -128,15 +128,35 @@ async def test_coordinator_update_failure(coordinator, mock_http_session):
128128
@pytest.mark.unit
129129
@pytest.mark.async_test
130130
@pytest.mark.timeout(5)
131-
async def test_coordinator_notify_scrape_exception(
131+
async def test_coordinator_request_reauth(
132132
coordinator, mock_http_session
133133
):
134-
"""Test that scrape exceptions are properly notified."""
134+
"""Test that request_reauth sets the force_reauth flag."""
135135
# Act
136-
coordinator.notify_scrape_exception()
136+
coordinator.request_reauth()
137137

138138
# Assert
139-
mock_http_session.notify_scrape_exception.assert_called_once()
139+
assert coordinator._force_reauth is True
140+
141+
142+
@pytest.mark.integration
143+
@pytest.mark.async_test
144+
@pytest.mark.timeout(10)
145+
async def test_coordinator_force_reauth_lifecycle(
146+
coordinator, mock_http_session
147+
):
148+
"""Verify full flag lifecycle: request_reauth -> pass force_reauth -> invalidate_auth -> reset."""
149+
# Simulate entity reporting a scrape exception
150+
coordinator.request_reauth()
151+
assert coordinator._force_reauth is True
152+
153+
# Next update should pass force_reauth=True, then reset the flag
154+
await coordinator._async_update_data()
155+
156+
# invalidate_auth should have been called (via ContentRequestManager.get_content)
157+
mock_http_session.invalidate_auth.assert_called_once()
158+
# Flag should be reset after successful update
159+
assert coordinator._force_reauth is False
140160

141161

142162
@pytest.mark.integration

tests/test_http_session.py

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -510,8 +510,8 @@ async def test_form_auth_resubmit_on_error(hass: HomeAssistant):
510510
await sess.ensure_authenticated("https://example.com/main")
511511
assert not sess._form_authenticator._should_submit
512512

513-
# Notify scrape exception
514-
sess.notify_scrape_exception()
513+
# Invalidate auth (simulating coordinator passing force_reauth)
514+
sess.invalidate_auth()
515515
assert sess._form_authenticator._should_submit
516516

517517
# Now it should submit again
@@ -763,19 +763,19 @@ def test_no_form_auth_returns_empty_variables(session):
763763

764764

765765
@pytest.mark.unit
766-
def test_notify_scrape_exception_without_form(session):
767-
"""Test notify_scrape_exception is a no-op without form auth."""
766+
def test_invalidate_auth_without_form(session):
767+
"""Test invalidate_auth is a no-op without form auth."""
768768
# Should not raise
769-
session.notify_scrape_exception()
769+
session.invalidate_auth()
770770

771771

772772
@pytest.mark.unit
773-
def test_notify_scrape_exception_resubmit_disabled(hass: HomeAssistant):
774-
"""Test notify_scrape_exception does nothing when resubmit_on_error is False."""
773+
def test_invalidate_auth_resubmit_disabled(hass: HomeAssistant):
774+
"""Test invalidate_auth does nothing when resubmit_on_error is False."""
775775
form_config = make_form_config(resubmit_on_error=False)
776776
sess = make_form_session(hass, form_config)
777777
sess._form_authenticator._should_submit = False
778-
sess.notify_scrape_exception()
778+
sess.invalidate_auth()
779779
assert not sess._form_authenticator._should_submit
780780

781781

@@ -1140,11 +1140,8 @@ async def test_e2e_form_resubmit_on_error(hass: HomeAssistant):
11401140
await request_manager.get_content()
11411141
assert login_route.call_count == 1
11421142

1143-
# Simulate scrape exception notification
1144-
request_manager.notify_scrape_exception()
1145-
1146-
# Third call — form re-submits after error
1147-
await request_manager.get_content()
1143+
# Simulate coordinator passing force_reauth after scrape exception
1144+
await request_manager.get_content(force_reauth=True)
11481145
assert login_route.call_count == 2
11491146
finally:
11501147
await session.async_close()
@@ -1704,15 +1701,12 @@ async def test_e2e_form_variables_updated_on_resubmit(hass: HomeAssistant):
17041701
await request_manager.get_content()
17051702
assert session.form_variables["api_token"] == "token_v1"
17061703

1707-
# Trigger resubmit
1708-
request_manager.notify_scrape_exception()
1709-
17101704
# Second auth returns token_v2
17111705
submit_route.mock(
17121706
return_value=respx.MockResponse(200, text=FORM_RESPONSE_V2)
17131707
)
17141708

1715-
await request_manager.get_content()
1709+
await request_manager.get_content(force_reauth=True)
17161710
assert session.form_variables["api_token"] == "token_v2"
17171711
finally:
17181712
await session.async_close()

0 commit comments

Comments
 (0)