Coverage for src/local_deep_research/notifications/manager.py: 98%
209 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1"""
2High-level notification manager with database integration.
3"""
5from dataclasses import dataclass
6from datetime import datetime, timezone, timedelta
7from enum import Enum
8from typing import Dict, Optional, Any
9from collections import deque
10import threading
12from loguru import logger
14from ..settings.env_registry import get_env_setting
15from .service import NotificationService
16from .templates import EventType
17from .exceptions import RateLimitError
18from ..utilities.type_utils import unwrap_setting
19from ..constants import DEFAULT_SEARCH_TOOL
22class NotificationReason(str, Enum):
23 """Precise reason a notification was (not) sent.
25 Surfaced in :class:`NotificationResult` so callers and log lines can
26 distinguish, for example, a per-user toggle being off from the
27 server-level master switch being unset — see issue #4877.
29 Note: rate-limit refusal is signalled via :class:`RateLimitError`
30 (preserved API for callers that catch it directly) rather than as a
31 ``NotificationResult`` value.
32 """
34 SENT = "sent"
35 SERVER_DISABLED = "server_disabled"
36 EVENT_DISABLED = "event_disabled"
37 UNCONFIGURED = "unconfigured"
38 EGRESS_DENIED = "egress_denied"
39 WEBHOOK_FAILED = "webhook_failed"
40 EXCEPTION = "exception"
43@dataclass(frozen=True)
44class NotificationResult:
45 """Structured outcome of :meth:`NotificationManager.send_notification`.
47 ``sent`` keeps the legacy bool contract (``if result:`` keeps working —
48 see ``__bool__``) while ``reason`` names the precise cause so the queue
49 helpers and error reporter can log a useful, non-misleading line.
50 ``detail`` is a short operator-facing explanation complementing reason.
51 """
53 sent: bool
54 reason: NotificationReason
55 detail: str = ""
57 def __bool__(self) -> bool:
58 return self.sent
61class NotificationManager:
62 """
63 High-level notification manager that uses settings snapshots for
64 thread-safe access to user settings.
66 This manager is designed to be used from background threads (e.g., queue
67 processors) by passing a settings_snapshot dictionary captured from the
68 main thread.
70 **Per-User Rate Limiting:**
71 The rate limiter is shared across ALL NotificationManager instances as a
72 singleton, but supports per-user rate limit configuration. Each user has
73 their own rate limits based on their settings, which are configured when
74 the NotificationManager is initialized with the required user_id parameter.
76 **How It Works:**
77 - The first NotificationManager instance creates the shared RateLimiter
78 with default limits
79 - Each instance configures user-specific limits by passing user_id to __init__
80 - The rate limiter maintains separate counters and limits for each user
81 - Users are completely isolated - one user's limit doesn't affect others
83 Example:
84 >>> # User A with conservative limits
85 >>> snapshot_a = {"notifications.rate_limit_per_hour": 5}
86 >>> manager_a = NotificationManager(snapshot_a, user_id="user_a")
87 >>> # ✅ user_a gets 5/hour
88 >>>
89 >>> # User B with generous limits (doesn't affect User A!)
90 >>> snapshot_b = {"notifications.rate_limit_per_hour": 20}
91 >>> manager_b = NotificationManager(snapshot_b, user_id="user_b")
92 >>> # ✅ user_b gets 20/hour, user_a still has 5/hour
93 """
95 # Shared rate limiter instance across all NotificationManager instances
96 # This ensures rate limits are enforced correctly even when multiple
97 # NotificationManager instances are created
98 _shared_rate_limiter: Optional["RateLimiter"] = None
99 _rate_limiter_lock = threading.Lock()
101 def __init__(self, settings_snapshot: Dict[str, Any], user_id: str):
102 """
103 Initialize the notification manager.
105 Args:
106 settings_snapshot: Dictionary of settings key-value pairs captured
107 from SettingsManager.get_settings_snapshot().
108 This allows thread-safe access to user settings
109 from background threads.
110 user_id: User identifier for per-user rate limiting. The rate limits
111 from settings_snapshot will be configured for this user.
113 Example:
114 >>> # In main thread with database session
115 >>> settings_manager = SettingsManager(session)
116 >>> snapshot = settings_manager.get_settings_snapshot()
117 >>>
118 >>> # In background thread (thread-safe)
119 >>> notification_manager = NotificationManager(
120 ... settings_snapshot=snapshot,
121 ... user_id="user123"
122 ... )
123 >>> notification_manager.send_notification(...)
124 """
125 # Store settings snapshot for thread-safe access
126 self._settings_snapshot = settings_snapshot
127 self._user_id = user_id
129 # Security: read from server-side environment variable only — never from
130 # user-writable DB settings. Previously this was read via
131 # _get_setting("notifications.allow_private_ips"), which allowed any
132 # user to bypass SSRF protection through the settings API.
133 # Registered as env-only in settings/env_definitions/security.py
134 # so SettingsManager will always read LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS
135 # from the environment, never from the database.
136 allow_private_ips = get_env_setting(
137 "notifications.allow_private_ips", False
138 )
140 # Server-level master switch (env-only). Distinct from the per-user
141 # notifications.enabled toggle in the UI: this gate is set by the
142 # deployment operator and cannot be flipped via the user-writable
143 # settings API. Disabled by default; enabling it accepts the
144 # documented DNS-rebinding TOCTOU residual risk
145 # (see SECURITY.md "Notification Webhook SSRF").
146 self._outbound_allowed = bool(
147 get_env_setting("notifications.allow_outbound", False)
148 )
150 self.service = NotificationService(
151 allow_private_ips=allow_private_ips,
152 outbound_allowed=self._outbound_allowed,
153 )
155 # Initialize shared rate limiter on first use
156 # The shared rate limiter now supports per-user limits, so each user's
157 # settings are respected regardless of initialization order.
158 with NotificationManager._rate_limiter_lock:
159 if NotificationManager._shared_rate_limiter is None:
160 # Create shared rate limiter with default limits
161 # (individual users can have different limits)
162 default_max_per_hour = self._get_setting(
163 "notifications.rate_limit_per_hour", default=10
164 )
165 default_max_per_day = self._get_setting(
166 "notifications.rate_limit_per_day", default=50
167 )
169 logger.info(
170 f"Initializing shared rate limiter with defaults: "
171 f"{default_max_per_hour}/hour, {default_max_per_day}/day"
172 )
174 NotificationManager._shared_rate_limiter = RateLimiter(
175 max_per_hour=default_max_per_hour,
176 max_per_day=default_max_per_day,
177 )
179 # Use the shared instance
180 self._rate_limiter = NotificationManager._shared_rate_limiter
182 # Configure per-user rate limits
183 max_per_hour = self._get_setting(
184 "notifications.rate_limit_per_hour", default=10
185 )
186 max_per_day = self._get_setting(
187 "notifications.rate_limit_per_day", default=50
188 )
190 self._rate_limiter.set_user_limits(
191 user_id, max_per_hour, max_per_day
192 )
193 logger.debug(
194 f"Configured rate limits for user {user_id}: "
195 f"{max_per_hour}/hour, {max_per_day}/day"
196 )
198 def _get_setting(self, key: str, default: Any = None) -> Any:
199 """
200 Get a setting value from snapshot.
202 Args:
203 key: Setting key
204 default: Default value if not found
206 Returns:
207 Setting value or default
208 """
209 return self._settings_snapshot.get(key, default)
211 def send_notification(
212 self,
213 event_type: EventType,
214 context: Dict[str, Any],
215 force: bool = False,
216 ) -> NotificationResult:
217 """
218 Send a notification for an event.
220 Uses the user_id that was provided during initialization for
221 rate limiting and user preferences.
223 Args:
224 event_type: Type of event
225 context: Context data for the notification
226 force: If True, bypass rate limiting
228 Returns:
229 :class:`NotificationResult` describing the outcome. The
230 dataclass is truthy iff the notification was actually sent,
231 so legacy ``if manager.send_notification(...):`` callers
232 continue to work without source changes.
234 Raises:
235 RateLimitError: If rate limit is exceeded and force=False
236 """
237 logger.debug(
238 f"Sending notification: event_type={event_type.value}, "
239 f"user_id={self._user_id}, force={force}"
240 )
241 logger.debug(f"Context keys: {list(context.keys())}")
243 # Server-level master switch (env-only). Disabled by default;
244 # flipping it on is the operator's acknowledgement of the
245 # documented SSRF rebinding residual risk. force=True does NOT
246 # bypass this — it bypasses the per-user event-type and
247 # rate-limit toggles only. WARNING level so an operator wondering
248 # "why aren't notifications firing?" sees the actionable signal
249 # at default log level.
250 if not self._outbound_allowed:
251 logger.warning(
252 "Notification refused: outbound notifications are disabled "
253 "at the server level. Set "
254 "LDR_NOTIFICATIONS_ALLOW_OUTBOUND=true to enable. See "
255 "SECURITY.md 'Notification Webhook SSRF' for the rationale "
256 "and residual risk. (event={}, user={})",
257 event_type.value,
258 self._user_id,
259 )
260 return NotificationResult(
261 sent=False,
262 reason=NotificationReason.SERVER_DISABLED,
263 detail="LDR_NOTIFICATIONS_ALLOW_OUTBOUND is not set",
264 )
266 # Check if notifications are enabled for this event type
267 should_notify = self._should_notify(event_type)
268 logger.debug(
269 f"Notification enabled check for {event_type.value}: "
270 f"{should_notify}"
271 )
273 if not force and not should_notify:
274 logger.debug(
275 f"Notifications disabled for event type: "
276 f"{event_type.value} (user: {self._user_id})"
277 )
278 return NotificationResult(
279 sent=False,
280 reason=NotificationReason.EVENT_DISABLED,
281 detail=f"notifications.on_{event_type.value} is off",
282 )
284 # Check rate limit using the manager's user_id
285 rate_limit_ok = self._rate_limiter.is_allowed(self._user_id)
286 logger.debug(f"Rate limit check for {self._user_id}: {rate_limit_ok}")
288 if not force and not rate_limit_ok:
289 logger.warning(f"Rate limit exceeded for user {self._user_id}")
290 raise RateLimitError(
291 "Notification rate limit exceeded. "
292 "Please wait before sending more notifications."
293 )
295 try:
296 # Get service URLs from settings (snapshot or database)
297 service_urls = self._get_setting(
298 "notifications.service_url", default=""
299 )
301 if not service_urls or not service_urls.strip():
302 logger.debug(
303 f"No notification service URLs configured for user "
304 f"{self._user_id}"
305 )
306 return NotificationResult(
307 sent=False,
308 reason=NotificationReason.UNCONFIGURED,
309 detail="notifications.service_url is not configured",
310 )
312 # Egress policy: gate each parsed webhook URL against the
313 # user's scope. Apprise URLs can be Slack/Discord/SMTP/HTTP —
314 # we treat anything with a host as an HTTP egress and ask
315 # evaluate_url. Schemes without a hostname (e.g. mailto)
316 # pass through; SSRF + provider-side checks still apply.
317 allowed_urls = self._filter_urls_by_egress_policy(service_urls)
318 if not allowed_urls:
319 logger.bind(policy_audit=True).warning(
320 "all notification URLs refused by egress policy",
321 user=self._user_id,
322 event=event_type.value,
323 )
324 return NotificationResult(
325 sent=False,
326 reason=NotificationReason.EGRESS_DENIED,
327 detail="all configured URLs refused by egress policy",
328 )
330 # Send notification with the allowed subset.
331 logger.debug(f"Calling service.send_event for {event_type.value}")
332 sent = self.service.send_event(
333 event_type, context, service_urls=allowed_urls
334 )
336 # Log to database if enabled
337 if sent:
338 self._log_notification(event_type, context)
339 logger.info(
340 f"Notification sent: {event_type.value} to user "
341 f"{self._user_id}"
342 )
343 return NotificationResult(
344 sent=True,
345 reason=NotificationReason.SENT,
346 detail="",
347 )
348 logger.warning(
349 f"Notification failed: {event_type.value} to user "
350 f"{self._user_id}"
351 )
352 return NotificationResult(
353 sent=False,
354 reason=NotificationReason.WEBHOOK_FAILED,
355 detail="Apprise dispatch returned False",
356 )
358 except Exception as exc:
359 # The exception string may contain webhook URLs with embedded
360 # tokens, SMTP credentials, or payload content. Log the raw
361 # exception for operators via logger.exception above, but only
362 # expose the exception class name (and not the message) in the
363 # structured result so downstream callers can't echo it into
364 # logs or UI surfaces verbatim.
365 logger.exception(
366 f"Error sending notification for {event_type.value} to user "
367 f"{self._user_id}"
368 )
369 return NotificationResult(
370 sent=False,
371 reason=NotificationReason.EXCEPTION,
372 detail=f"dispatch raised {type(exc).__name__}",
373 )
375 def test_service(self, url: str) -> Dict[str, Any]:
376 """
377 Test a notification service.
379 Args:
380 url: Service URL to test
382 Returns:
383 Dict with test results
384 """
385 # Egress policy precheck before forwarding to Apprise.
386 # /api/notifications/test-url is a user-supplied URL endpoint.
387 allowed = self._filter_urls_by_egress_policy(url)
388 if not allowed:
389 return {
390 "status": "error",
391 "message": (
392 "URL refused by egress policy. Set Egress Scope to "
393 "'Unprotected' (or pick a local webhook) to test this URL."
394 ),
395 }
396 return self.service.test_service(allowed)
398 def _filter_urls_by_egress_policy(self, service_urls: str) -> str:
399 """Filter an Apprise URL string (space- or comma-separated) by
400 the user's egress policy. Returns the joined string of allowed
401 URLs (may be empty). When no snapshot / context is available,
402 returns the input unchanged (back-compat — the snapshot-less
403 path was already used in older callers).
404 """
405 snapshot = getattr(self, "_settings_snapshot", None)
406 if not snapshot:
407 return service_urls
408 try:
409 from ..security.egress.policy import (
410 EgressScope,
411 PolicyDeniedError,
412 context_from_snapshot,
413 evaluate_url,
414 )
415 except ImportError:
416 logger.debug(
417 "egress_policy unavailable in notifications manager; "
418 "URLs will not be scope-gated"
419 )
420 return service_urls
422 primary_raw = unwrap_setting(
423 snapshot.get("search.tool", DEFAULT_SEARCH_TOOL)
424 )
425 try:
426 ctx = context_from_snapshot(
427 snapshot, primary_raw or DEFAULT_SEARCH_TOOL
428 )
429 except (PolicyDeniedError, ValueError) as exc:
430 # Snapshot present but policy cannot be evaluated. The
431 # previous bare-except dropped to "return service_urls"
432 # here, which dispatched every URL unfiltered — fail-open
433 # on a misconfigured policy. Now refuse all URLs instead
434 # so an Apprise dispatch with no scope check is impossible.
435 logger.bind(policy_audit=True).warning(
436 "all notification URLs refused: egress policy could "
437 "not be evaluated",
438 reason=str(exc),
439 )
440 return ""
442 # Apprise accepts space- or comma- separated URLs. Apprise has
443 # its own non-http schemes (discord://, slack://, telegram://,
444 # mailto://, msteams://, ntfy://, ...) that dispatch to EXTERNAL
445 # vendor APIs. evaluate_url only understands http/https.
446 #
447 # Under PRIVATE_ONLY ("nothing leaves the box") we cannot verify a
448 # vendor token resolves to a local endpoint, so a non-http scheme is
449 # refused — fail closed. A self-hosted notifier (local ntfy, SMTP)
450 # should be addressed by its http(s):// URL, which evaluate_url then
451 # allows as a private host. Under the other scopes the prior
452 # pass-through stands (the modeled threat there is internal-http
453 # SSRF via a raw webhook, not vendor APIs).
454 parts = []
455 for url_entry in service_urls.replace(",", " ").split():
456 url_entry = url_entry.strip()
457 if not url_entry: 457 ↛ 458line 457 didn't jump to line 458 because the condition on line 457 was never true
458 continue
459 scheme = (
460 url_entry.split(":", 1)[0].lower() if ":" in url_entry else ""
461 )
462 if scheme not in ("http", "https"):
463 if ctx.scope == EgressScope.PRIVATE_ONLY:
464 logger.bind(policy_audit=True).warning(
465 "notification refused under PRIVATE_ONLY egress "
466 "scope: non-http vendor scheme cannot be verified "
467 "local",
468 scheme=scheme or "(none)",
469 )
470 continue
471 parts.append(url_entry)
472 continue
473 decision = evaluate_url(url_entry, ctx)
474 if decision.allowed:
475 parts.append(url_entry)
476 else:
477 logger.bind(policy_audit=True).warning(
478 "notification URL refused by egress policy",
479 url=url_entry,
480 scope=ctx.scope.value,
481 reason=decision.reason,
482 )
483 return " ".join(parts)
485 def _should_notify(self, event_type: EventType) -> bool:
486 """
487 Check if notifications should be sent for this event type.
489 Uses the manager's settings snapshot to determine if the event type
490 is enabled for the user.
492 Args:
493 event_type: Event type to check
495 Returns:
496 True if notifications should be sent
497 """
498 try:
499 # Check event-specific setting (from snapshot or database)
500 setting_key = f"notifications.on_{event_type.value}"
501 enabled = self._get_setting(setting_key, default=False)
503 return bool(enabled)
505 except Exception:
506 logger.warning("Error checking notification preferences")
507 # Default to disabled on error to avoid infinite loops during login
508 return False
510 def _log_notification(
511 self, event_type: EventType, context: Dict[str, Any]
512 ) -> None:
513 """
514 Log a sent notification (simplified logging to application logs only).
516 Uses the manager's user_id for logging.
518 Args:
519 event_type: Event type
520 context: Notification context
521 """
522 try:
523 title = (
524 context.get("query")
525 or context.get("subscription_name")
526 or "Unknown"
527 )
528 logger.info(
529 f"Notification sent: {event_type.value} - {title} "
530 f"(user: {self._user_id})"
531 )
532 except Exception as e:
533 logger.debug(f"Failed to log notification: {e}")
536class RateLimiter:
537 """
538 Simple in-memory rate limiter for notifications with per-user limit support.
540 This rate limiter tracks notification counts per user and enforces
541 configurable rate limits. Each user can have their own rate limits,
542 which are stored separately from the notification counts.
544 **Per-User Limits:**
545 Rate limits can be configured per-user using `set_user_limits()`.
546 If no user-specific limits are set, the default limits (passed to
547 __init__) are used.
549 **Memory Storage:**
550 This implementation stores rate limits in memory only, which means
551 limits are reset when the server restarts. This is acceptable for normal
552 users since they cannot restart the server. If an admin restarts the server,
553 rate limits reset which is reasonable behavior.
555 **Thread Safety:**
556 This implementation is thread-safe using threading.Lock() for concurrent
557 requests from the same user.
559 **Multi-Worker Limitation:**
560 In multi-worker deployments, each worker process maintains its own rate
561 limit counters. Users could potentially bypass rate limits by distributing
562 requests across different workers, getting up to N × max_per_hour
563 notifications (where N = number of workers). For single-worker deployments
564 (the default for LDR), this is not a concern. For production multi-worker
565 deployments, consider implementing Redis-based rate limiting.
567 Example:
568 >>> limiter = RateLimiter(max_per_hour=10, max_per_day=50)
569 >>> # Set custom limits for specific user
570 >>> limiter.set_user_limits("user_a", max_per_hour=5, max_per_day=25)
571 >>> limiter.set_user_limits("user_b", max_per_hour=20, max_per_day=100)
572 >>> # Users get their configured limits
573 >>> limiter.is_allowed("user_a") # Limited to 5/hour
574 >>> limiter.is_allowed("user_b") # Limited to 20/hour
575 >>> limiter.is_allowed("user_c") # Uses defaults: 10/hour
576 """
578 def __init__(
579 self,
580 max_per_hour: int = 10,
581 max_per_day: int = 50,
582 cleanup_interval_hours: int = 24,
583 ):
584 """
585 Initialize rate limiter with default limits.
587 Args:
588 max_per_hour: Default maximum notifications per hour per user
589 max_per_day: Default maximum notifications per day per user
590 cleanup_interval_hours: How often to run cleanup of inactive users (hours)
591 """
592 # Default limits used when no user-specific limits are set
593 self.max_per_hour = max_per_hour
594 self.max_per_day = max_per_day
595 self.cleanup_interval_hours = cleanup_interval_hours
597 # Per-user rate limit configuration (user_id -> (max_per_hour, max_per_day))
598 self._user_limits: Dict[str, tuple[int, int]] = {}
600 # Per-user notification counts
601 self._hourly_counts: Dict[str, deque] = {}
602 self._daily_counts: Dict[str, deque] = {}
604 self._last_cleanup = datetime.now(timezone.utc)
605 self._lock = threading.Lock() # Thread safety for all operations
607 def set_user_limits(
608 self, user_id: str, max_per_hour: int, max_per_day: int
609 ) -> None:
610 """
611 Set rate limits for a specific user.
613 This allows each user to have their own rate limit configuration.
614 If not set, the user will use the default limits passed to __init__.
616 Args:
617 user_id: User identifier
618 max_per_hour: Maximum notifications per hour for this user
619 max_per_day: Maximum notifications per day for this user
621 Example:
622 >>> limiter = RateLimiter(max_per_hour=10, max_per_day=50)
623 >>> limiter.set_user_limits("power_user", max_per_hour=20, max_per_day=100)
624 >>> limiter.set_user_limits("limited_user", max_per_hour=5, max_per_day=25)
625 """
626 with self._lock:
627 self._user_limits[user_id] = (max_per_hour, max_per_day)
628 logger.debug(
629 f"Set rate limits for user {user_id}: "
630 f"{max_per_hour}/hour, {max_per_day}/day"
631 )
633 def get_user_limits(self, user_id: str) -> tuple[int, int]:
634 """
635 Get the effective rate limits for a user.
637 Returns the user-specific limits if set, otherwise returns defaults.
639 Args:
640 user_id: User identifier
642 Returns:
643 Tuple of (max_per_hour, max_per_day)
644 """
645 with self._lock:
646 return self._user_limits.get(
647 user_id, (self.max_per_hour, self.max_per_day)
648 )
650 def is_allowed(self, user_id: str) -> bool:
651 """
652 Check if a notification is allowed for a user.
654 Uses per-user rate limits if configured via set_user_limits(),
655 otherwise uses the default limits from __init__.
657 Args:
658 user_id: User identifier
660 Returns:
661 True if notification is allowed, False if rate limit exceeded
662 """
663 with self._lock:
664 now = datetime.now(timezone.utc)
666 # Periodic cleanup of inactive users
667 self._cleanup_inactive_users_if_needed(now)
669 # Initialize queues for user if needed
670 if user_id not in self._hourly_counts:
671 self._hourly_counts[user_id] = deque()
672 self._daily_counts[user_id] = deque()
674 # Clean old entries
675 self._clean_old_entries(user_id, now)
677 # Get user-specific limits or defaults
678 max_per_hour, max_per_day = self._user_limits.get(
679 user_id, (self.max_per_hour, self.max_per_day)
680 )
682 # Check limits
683 hourly_count = len(self._hourly_counts[user_id])
684 daily_count = len(self._daily_counts[user_id])
686 if hourly_count >= max_per_hour:
687 logger.warning(
688 f"Hourly rate limit exceeded for user {user_id}: "
689 f"{hourly_count}/{max_per_hour}"
690 )
691 return False
693 if daily_count >= max_per_day:
694 logger.warning(
695 f"Daily rate limit exceeded for user {user_id}: "
696 f"{daily_count}/{max_per_day}"
697 )
698 return False
700 # Record this notification
701 self._hourly_counts[user_id].append(now)
702 self._daily_counts[user_id].append(now)
704 return True
706 def _clean_old_entries(self, user_id: str, now: datetime) -> None:
707 """
708 Remove old entries from rate limit counters.
710 Args:
711 user_id: User identifier
712 now: Current time
713 """
714 hour_ago = now - timedelta(hours=1)
715 day_ago = now - timedelta(days=1)
717 # Clean hourly queue
718 while (
719 self._hourly_counts[user_id]
720 and self._hourly_counts[user_id][0] < hour_ago
721 ):
722 self._hourly_counts[user_id].popleft()
724 # Clean daily queue
725 while (
726 self._daily_counts[user_id]
727 and self._daily_counts[user_id][0] < day_ago
728 ):
729 self._daily_counts[user_id].popleft()
731 def reset(self, user_id: Optional[str] = None) -> None:
732 """
733 Reset rate limits for a user or all users.
735 Args:
736 user_id: User to reset, or None for all users
737 """
738 with self._lock:
739 if user_id:
740 self._hourly_counts.pop(user_id, None)
741 self._daily_counts.pop(user_id, None)
742 else:
743 self._hourly_counts.clear()
744 self._daily_counts.clear()
746 def _cleanup_inactive_users_if_needed(self, now: datetime) -> None:
747 """
748 Periodically clean up data for inactive users to prevent memory leaks.
750 Args:
751 now: Current timestamp
752 """
753 # Check if cleanup is needed
754 if now - self._last_cleanup < timedelta(
755 hours=self.cleanup_interval_hours
756 ):
757 return
759 logger.debug("Running periodic cleanup of inactive notification users")
761 # Define inactive threshold (users with no activity for 7 days)
762 inactive_threshold = now - timedelta(days=7)
764 inactive_users = []
766 # Find users with no recent activity
767 # Convert to list to avoid "dictionary changed size during iteration" error
768 for user_id in list(self._hourly_counts.keys()):
769 # Check if user has any recent entries
770 hourly_entries: list = list(self._hourly_counts.get(user_id, []))
771 daily_entries: list = list(self._daily_counts.get(user_id, []))
773 # If no entries or all entries are old, mark as inactive
774 has_recent_activity = False
775 for entry in hourly_entries + daily_entries:
776 if entry > inactive_threshold:
777 has_recent_activity = True
778 break
780 if not has_recent_activity:
781 inactive_users.append(user_id)
783 # Remove inactive users
784 for user_id in inactive_users:
785 self._hourly_counts.pop(user_id, None)
786 self._daily_counts.pop(user_id, None)
787 logger.debug(
788 f"Cleaned up inactive user {user_id} from rate limiter"
789 )
791 if inactive_users: 791 ↛ 796line 791 didn't jump to line 796 because the condition on line 791 was always true
792 logger.info(
793 f"Cleaned up {len(inactive_users)} inactive users from rate limiter"
794 )
796 self._last_cleanup = now