Add dedup, daily cap, and 429 backoff to ntfy notifier

Prevents notification spam from repeated ClickUp poll cycles finding the
same tasks with missing fields. Dedup suppresses identical messages within
a 60-min window, daily cap stops at 200 sends (under ntfy.sh 250 free
tier), and 429 responses suppress all sends for the rest of the day.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix/customer-field-migration
PeninsulaInd 2026-03-07 17:32:52 -06:00
parent 1e26969ff8
commit 9102657c15
2 changed files with 200 additions and 2 deletions

View File

@ -6,10 +6,13 @@ topics based on category and message-pattern matching.
from __future__ import annotations from __future__ import annotations
import hashlib
import logging import logging
import re import re
import threading import threading
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date
import httpx import httpx
@ -48,8 +51,24 @@ class NtfyChannel:
class NtfyNotifier: class NtfyNotifier:
"""Posts notifications to ntfy.sh topics.""" """Posts notifications to ntfy.sh topics."""
def __init__(self, channels: list[NtfyChannel]): def __init__(
self,
channels: list[NtfyChannel],
*,
daily_cap: int = 200,
dedup_window_secs: int = 3600,
):
self._channels = [ch for ch in channels if ch.topic] self._channels = [ch for ch in channels if ch.topic]
self._daily_cap = daily_cap
self._dedup_window_secs = dedup_window_secs
self._lock = threading.Lock()
# dedup: hash(channel.name + message) -> last-sent epoch
self._recent: dict[str, float] = {}
# daily cap tracking
self._daily_count = 0
self._daily_date = ""
# 429 backoff: date string when rate-limited
self._rate_limited_until = ""
if self._channels: if self._channels:
log.info( log.info(
"ntfy notifier initialized with %d channel(s): %s", "ntfy notifier initialized with %d channel(s): %s",
@ -61,6 +80,59 @@ class NtfyNotifier:
def enabled(self) -> bool: def enabled(self) -> bool:
return bool(self._channels) return bool(self._channels)
def _today(self) -> str:
return date.today().isoformat()
def _check_and_track(self, channel_name: str, message: str) -> bool:
"""Return True if this message should be sent. Updates internal state."""
now = time.monotonic()
today = self._today()
with self._lock:
# 429 backoff: skip all sends for rest of day
if self._rate_limited_until == today:
return False
# Reset daily counter on date rollover
if self._daily_date != today:
self._daily_date = today
self._daily_count = 0
self._rate_limited_until = ""
self._recent.clear()
# Daily cap check
if self._daily_count >= self._daily_cap:
return False
# Dedup check
key = hashlib.md5(
(channel_name + "\0" + message).encode()
).hexdigest()
last_sent = self._recent.get(key)
if last_sent is not None and (now - last_sent) < self._dedup_window_secs:
log.debug(
"ntfy dedup: suppressed duplicate to '%s'", channel_name,
)
return False
# All checks passed — record send
self._recent[key] = now
self._daily_count += 1
if self._daily_count == self._daily_cap:
log.warning(
"ntfy daily cap reached (%d). No more sends today.",
self._daily_cap,
)
return True
def _mark_rate_limited(self) -> None:
"""Flag that we got a 429 — suppress all sends for rest of day."""
with self._lock:
self._rate_limited_until = self._today()
log.warning("ntfy 429 received. Suppressing all sends for rest of day.")
def notify(self, message: str, category: str) -> None: def notify(self, message: str, category: str) -> None:
"""Route a notification to matching ntfy channels. """Route a notification to matching ntfy channels.
@ -70,6 +142,8 @@ class NtfyNotifier:
""" """
for channel in self._channels: for channel in self._channels:
if channel.accepts(message, category): if channel.accepts(message, category):
if not self._check_and_track(channel.name, message):
continue
t = threading.Thread( t = threading.Thread(
target=self._post, target=self._post,
args=(channel, message, category), args=(channel, message, category),
@ -93,7 +167,9 @@ class NtfyNotifier:
headers=headers, headers=headers,
timeout=10.0, timeout=10.0,
) )
if resp.status_code >= 400: if resp.status_code == 429:
self._mark_rate_limited()
elif resp.status_code >= 400:
log.warning( log.warning(
"ntfy '%s' returned %d: %s", "ntfy '%s' returned %d: %s",
channel.name, resp.status_code, resp.text[:200], channel.name, resp.status_code, resp.text[:200],

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import time
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import httpx import httpx
@ -288,3 +289,124 @@ class TestPostFormat:
t.join(timeout=2) t.join(timeout=2)
assert mock_post.call_args[1]["headers"]["Priority"] == "urgent" assert mock_post.call_args[1]["headers"]["Priority"] == "urgent"
# ---------------------------------------------------------------------------
# Dedup window
# ---------------------------------------------------------------------------
def _make_channel(**overrides) -> NtfyChannel:
defaults = dict(
name="errors",
server="https://ntfy.sh",
topic="test-topic",
categories=["alert"],
)
defaults.update(overrides)
return NtfyChannel(**defaults)
class TestDedup:
def test_first_message_goes_through(self):
notifier = NtfyNotifier([_make_channel()], dedup_window_secs=3600)
assert notifier._check_and_track("errors", "task X skipped") is True
def test_duplicate_within_window_suppressed(self):
notifier = NtfyNotifier([_make_channel()], dedup_window_secs=3600)
assert notifier._check_and_track("errors", "task X skipped") is True
assert notifier._check_and_track("errors", "task X skipped") is False
def test_duplicate_after_window_passes(self):
notifier = NtfyNotifier([_make_channel()], dedup_window_secs=60)
assert notifier._check_and_track("errors", "task X skipped") is True
# Simulate time passing beyond the window
key = list(notifier._recent.keys())[0]
notifier._recent[key] = time.monotonic() - 120
assert notifier._check_and_track("errors", "task X skipped") is True
def test_different_messages_not_deduped(self):
notifier = NtfyNotifier([_make_channel()], dedup_window_secs=3600)
assert notifier._check_and_track("errors", "task A skipped") is True
assert notifier._check_and_track("errors", "task B skipped") is True
def test_same_message_different_channel_not_deduped(self):
notifier = NtfyNotifier([_make_channel()], dedup_window_secs=3600)
assert notifier._check_and_track("errors", "task X skipped") is True
assert notifier._check_and_track("alerts", "task X skipped") is True
# ---------------------------------------------------------------------------
# Daily cap
# ---------------------------------------------------------------------------
class TestDailyCap:
def test_sends_up_to_cap(self):
notifier = NtfyNotifier([_make_channel()], daily_cap=3)
for i in range(3):
assert notifier._check_and_track("errors", f"msg {i}") is True
assert notifier._check_and_track("errors", "msg 3") is False
def test_cap_resets_on_new_day(self):
notifier = NtfyNotifier([_make_channel()], daily_cap=2)
assert notifier._check_and_track("errors", "msg 0") is True
assert notifier._check_and_track("errors", "msg 1") is True
assert notifier._check_and_track("errors", "msg 2") is False
with patch.object(notifier, "_today", return_value="2099-01-01"):
assert notifier._check_and_track("errors", "msg 2") is True
# ---------------------------------------------------------------------------
# 429 backoff
# ---------------------------------------------------------------------------
class TestRateLimitBackoff:
def test_429_suppresses_rest_of_day(self):
notifier = NtfyNotifier([_make_channel()])
notifier._mark_rate_limited()
assert notifier._check_and_track("errors", "new message") is False
def test_429_resets_next_day(self):
notifier = NtfyNotifier([_make_channel()])
notifier._mark_rate_limited()
assert notifier._check_and_track("errors", "blocked") is False
with patch.object(notifier, "_today", return_value="2099-01-01"):
assert notifier._check_and_track("errors", "unblocked") is True
def test_post_sets_rate_limit_on_429(self):
channel = _make_channel()
notifier = NtfyNotifier([channel])
mock_resp = MagicMock(status_code=429, text="Rate limited")
with patch("cheddahbot.ntfy.httpx.post", return_value=mock_resp):
notifier._post(channel, "test msg", "alert")
assert notifier._rate_limited_until == notifier._today()
# ---------------------------------------------------------------------------
# Notify integration with dedup
# ---------------------------------------------------------------------------
class TestNotifyDedup:
@patch("cheddahbot.ntfy.httpx.post")
def test_notify_skips_deduped_messages(self, mock_post):
mock_post.return_value = MagicMock(status_code=200)
channel = _make_channel()
notifier = NtfyNotifier([channel])
notifier.notify("same msg", "alert")
notifier.notify("same msg", "alert")
import threading
for t in threading.enumerate():
if t.daemon and t.is_alive():
t.join(timeout=2)
# Only one post — second was deduped
mock_post.assert_called_once()