Alerts: a delivery no channel took leaves the cooldown unspent

This commit is contained in:
2026-11-01 10:59:28 +01:00
parent 83c5f2dfcf
commit 7a7315043d
3 changed files with 41 additions and 6 deletions
+17 -5
View File
@@ -306,8 +306,8 @@ class AlertManager:
# Held back, not spent: the cooldown only starts once it is sent.
self._suppressed[key] = self._suppressed.get(key, 0) + 1
return
previous = self._last_sent.get(key)
now = self._stamp(key)
await self._remember(key, now)
held = self._suppressed.pop(key, 0)
if held:
@@ -316,9 +316,19 @@ class AlertManager:
)
names = {name for rule in rules for name in rule.channels}
delivered = False
for channel in self.config.channels:
if channel.enabled and channel.name in names:
await self.send(channel, alert)
delivered = await self.send(channel, alert) or delivered
if delivered:
await self._remember(key, now)
else:
# Nobody heard it, so the next one tries again rather than wait
# out a cooldown nothing started.
if previous is None:
self._last_sent.pop(key, None)
else:
self._last_sent[key] = previous
# -------------------------------------------------------------------------
# Deciding whether to speak
@@ -391,8 +401,8 @@ class AlertManager:
async def send(
self, channel: Channel, alert: Alert, raise_on_error: bool = False
) -> None:
"""Deliver one alert.
) -> bool:
"""Deliver one alert, and say whether it went.
A failure is logged and swallowed, because one dead channel must not
stop the others hearing about the same fault. The test button passes
@@ -405,7 +415,7 @@ class AlertManager:
if raise_on_error:
raise
logger.error("Channel '%s' has unusable settings: %s", channel.name, exc)
return
return False
try:
if channel.kind == "ntfy":
@@ -424,6 +434,8 @@ class AlertManager:
if raise_on_error:
raise
logger.error("Could not alert through '%s': %s", channel.name, exc)
return False
return True
async def _send_ntfy(self, config: dict[str, Any], alert: Alert) -> None:
server = str(config.get("server") or "https://ntfy.sh").rstrip("/")
+22
View File
@@ -60,6 +60,7 @@ def manager(clock: Clock) -> tuple[AlertManager, list[Alert]]:
async def capture(channel, alert):
sent.append(alert)
return True
alerts.send = capture # type: ignore[method-assign]
return alerts, sent
@@ -264,6 +265,27 @@ def test_a_ledger_that_fails_does_not_stop_the_alert():
assert len(sent) == 1
def test_an_alert_no_channel_took_does_not_spend_its_cooldown():
clock = Clock()
alerts, sent = manager(clock)
delivered = iter([False, True])
async def flaky(channel, alert):
sent.append(alert)
return next(delivered)
alerts.send = flaky # type: ignore[method-assign]
async def scenario():
await alerts.handle(error_event())
clock.advance(10)
await alerts.handle(error_event())
asyncio.run(scenario())
assert len(sent) == 2
def test_an_alert_held_back_by_the_ceiling_does_not_spend_its_cooldown():
clock = Clock()
alerts, sent = manager(clock)
+2 -1
View File
@@ -141,8 +141,9 @@ def test_a_device_away_after_a_restart_alerts_nobody(tmp_path: Path) -> None:
),
)
async def capture(channel: Channel, alert: Alert) -> None:
async def capture(channel: Channel, alert: Alert) -> bool:
sent.append(alert)
return True
alerts.send = capture # type: ignore[method-assign]