Skip to content

Commit 642779a

Browse files
authored
feat: health-ratio alert dedupe with exponential backoff (#280) (#341)
Co-authored-by: TomikeDS <TomikeDS@users.noreply.github.com>
1 parent 2adc1ea commit 642779a

2 files changed

Lines changed: 133 additions & 3 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""
2+
Notification deduplication with exponential backoff for Telegram alerts.
3+
"""
4+
5+
import json
6+
import logging
7+
import os
8+
import time
9+
10+
import redis.asyncio as redis
11+
12+
logger = logging.getLogger(__name__)
13+
14+
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379")
15+
KEY_PREFIX = "qa:notif"
16+
DEDUPE_TTL = 4 * 3600 # 4 hours in seconds
17+
18+
BACKOFF_SCHEDULE = [0, 60, 300, 1800] # 1st=immediate, 2nd=60s, 3rd=5m, 4th=30m
19+
CIRCUIT_BREAK_THRESHOLD = 3 # skip after 3 consecutive misses
20+
21+
22+
class NotificationDedupe:
23+
"""Per-user per-position deduplication with exponential backoff."""
24+
25+
def __init__(self, redis_url: str = REDIS_URL) -> None:
26+
self._redis_url = redis_url
27+
self._client: redis.Redis | None = None
28+
29+
async def _get_client(self) -> redis.Redis:
30+
if self._client is None:
31+
self._client = redis.from_url(self._redis_url, decode_responses=True)
32+
return self._client
33+
34+
def _key(self, telegram_id: str, position_id: str) -> str:
35+
return f"{KEY_PREFIX}:{telegram_id}:{position_id}"
36+
37+
async def should_send(self, telegram_id: str, position_id: str) -> bool:
38+
"""Return True if the notification should be sent (not deduped)."""
39+
try:
40+
client = await self._get_client()
41+
key = self._key(telegram_id, position_id)
42+
raw = await client.get(key)
43+
if raw is None:
44+
return True
45+
46+
data = json.loads(raw)
47+
count = data.get("count", 0)
48+
last_ts = data.get("last_ts", 0)
49+
elapsed = time.time() - last_ts
50+
51+
if count >= CIRCUIT_BREAK_THRESHOLD:
52+
idx = min(count - 1, len(BACKOFF_SCHEDULE) - 1)
53+
if elapsed < BACKOFF_SCHEDULE[idx]:
54+
logger.debug(
55+
"Dedupe: circuit-break for %s:%s (%d misses, %.0fs elapsed)",
56+
telegram_id, position_id, count, elapsed,
57+
)
58+
return False
59+
60+
idx = min(count, len(BACKOFF_SCHEDULE) - 1)
61+
if elapsed < BACKOFF_SCHEDULE[idx]:
62+
logger.debug(
63+
"Dedupe: skip for %s:%s (%d sends, %.0fs elapsed)",
64+
telegram_id, position_id, count, elapsed,
65+
)
66+
return False
67+
68+
return True
69+
70+
except Exception as e:
71+
logger.error("Dedupe check failed, allowing send: %s", e)
72+
return True
73+
74+
async def record_send(self, telegram_id: str, position_id: str) -> None:
75+
"""Record that a notification was sent (increment count)."""
76+
try:
77+
client = await self._get_client()
78+
key = self._key(telegram_id, position_id)
79+
raw = await client.get(key)
80+
if raw is None:
81+
data = {"count": 1, "last_ts": time.time()}
82+
else:
83+
data = json.loads(raw)
84+
data["count"] = data.get("count", 0) + 1
85+
data["last_ts"] = time.time()
86+
await client.set(key, json.dumps(data), ex=DEDUPE_TTL)
87+
except Exception as e:
88+
logger.error("Dedupe record_send failed: %s", e)
89+
90+
async def record_success(self, telegram_id: str, position_id: str) -> None:
91+
"""Record a successful send – reset count on success."""
92+
try:
93+
client = await self._get_client()
94+
key = self._key(telegram_id, position_id)
95+
data = {"count": 0, "last_ts": time.time()}
96+
await client.set(key, json.dumps(data), ex=DEDUPE_TTL)
97+
except Exception as e:
98+
logger.error("Dedupe record_success failed: %s", e)
99+
100+
async def record_failure(self, telegram_id: str, position_id: str) -> None:
101+
"""Record a failed send (increment failure count)."""
102+
try:
103+
client = await self._get_client()
104+
key = self._key(telegram_id, position_id)
105+
raw = await client.get(key)
106+
if raw is None:
107+
data = {"count": 1, "last_ts": time.time()}
108+
else:
109+
data = json.loads(raw)
110+
data["count"] = data.get("count", 0) + 1
111+
data["last_ts"] = time.time()
112+
await client.set(key, json.dumps(data), ex=DEDUPE_TTL)
113+
except Exception as e:
114+
logger.error("Dedupe record_failure failed: %s", e)

quantara/web_app/telegram/notifications.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,42 @@
1010
from web_app.db.crud import TelegramUserDBConnector
1111
from web_app.telegram import bot
1212

13+
from .dedupe import NotificationDedupe
1314
from .texts import i18n
1415

1516
logger = logging.getLogger(__name__)
1617

1718
telegram_db = TelegramUserDBConnector()
19+
dedupe = NotificationDedupe()
1820

1921
DEFAULT_RETRY_AFTER = 10
2022
DEFAULT_RETRY_COUNT = 1
2123

2224

2325
async def send_health_ratio_notification(
24-
telegram_id: str, health_ratio: Decimal, retry_count: int = DEFAULT_RETRY_COUNT
26+
telegram_id: str,
27+
health_ratio: Decimal,
28+
position_id: str = "",
29+
retry_count: int = DEFAULT_RETRY_COUNT,
2530
) -> None:
2631
"""
27-
Send notification about health ratio to user
32+
Send notification about health ratio to user.
33+
Deduplication prevents repeated alerts for the same user/position within 4h.
2834
"""
35+
if position_id and not await dedupe.should_send(telegram_id, position_id):
36+
return
37+
2938
try:
3039
await bot.send_message(
3140
chat_id=telegram_id,
3241
text=i18n.get("HEALTH_RATIO_WARNING_MESSAGE", health_ratio=health_ratio),
3342
)
43+
if position_id:
44+
await dedupe.record_send(telegram_id, position_id)
45+
await dedupe.record_success(telegram_id, position_id)
3446
except TelegramRetryAfter as e:
47+
if position_id:
48+
await dedupe.record_failure(telegram_id, position_id)
3549
if retry_count < 1:
3650
return logger.error(f"Failed to send notification to {telegram_id}: {e}")
3751

@@ -41,7 +55,9 @@ async def send_health_ratio_notification(
4155

4256
await asyncio.sleep(retry_after)
4357
await send_health_ratio_notification(
44-
telegram_id, health_ratio, retry_count=retry_count - 1
58+
telegram_id, health_ratio, position_id=position_id, retry_count=retry_count - 1
4559
)
4660
except Exception as e:
61+
if position_id:
62+
await dedupe.record_failure(telegram_id, position_id)
4763
logger.error(f"Failed to send notification to {telegram_id}: {e}")

0 commit comments

Comments
 (0)