Coverage for src/local_deep_research/notifications/manager.py: 99%

228 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2High-level notification manager with database integration. 

3""" 

4 

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 

11 

12from loguru import logger 

13 

14from ..security.notification_validator import parse_notification_url_list 

15from ..security.ssrf_validator import redact_url_for_log 

16from ..settings.env_registry import get_env_setting 

17from .service import NotificationService 

18from .templates import EventType 

19from .exceptions import ( 

20 RateLimitError, 

21 SendError, 

22 ServiceError, 

23 SecurityBlockError, 

24) 

25from ..utilities.type_utils import unwrap_setting 

26from ..constants import DEFAULT_SEARCH_TOOL 

27 

28 

29class NotificationReason(str, Enum): 

30 """Precise reason a notification was (not) sent. 

31 

32 Surfaced in :class:`NotificationResult` so callers and log lines can 

33 distinguish, for example, a per-user toggle being off from the 

34 server-level master switch being unset — see issue #4877. 

35 

36 Note: rate-limit refusal is signalled via :class:`RateLimitError` 

37 (preserved API for callers that catch it directly) rather than as a 

38 ``NotificationResult`` value. 

39 """ 

40 

41 SENT = "sent" 

42 SERVER_DISABLED = "server_disabled" 

43 EVENT_DISABLED = "event_disabled" 

44 UNCONFIGURED = "unconfigured" 

45 EGRESS_DENIED = "egress_denied" 

46 INVALID_URL = "invalid_url" 

47 WEBHOOK_FAILED = "webhook_failed" 

48 EXCEPTION = "exception" 

49 

50 

51@dataclass(frozen=True) 

52class NotificationResult: 

53 """Structured outcome of :meth:`NotificationManager.send_notification`. 

54 

55 ``sent`` keeps the legacy bool contract (``if result:`` keeps working — 

56 see ``__bool__``) while ``reason`` names the precise cause so the queue 

57 helpers and error reporter can log a useful, non-misleading line. 

58 ``detail`` is a short operator-facing explanation complementing reason. 

59 """ 

60 

61 sent: bool 

62 reason: NotificationReason 

63 detail: str = "" 

64 

65 def __bool__(self) -> bool: 

66 return self.sent 

67 

68 

69class NotificationManager: 

70 """ 

71 High-level notification manager that uses settings snapshots for 

72 thread-safe access to user settings. 

73 

74 This manager is designed to be used from background threads (e.g., queue 

75 processors) by passing a settings_snapshot dictionary captured from the 

76 main thread. 

77 

78 **Per-User Rate Limiting:** 

79 The rate limiter is shared across ALL NotificationManager instances as a 

80 singleton, but supports per-user rate limit configuration. Each user has 

81 their own rate limits based on their settings, which are configured when 

82 the NotificationManager is initialized with the required user_id parameter. 

83 

84 **How It Works:** 

85 - The first NotificationManager instance creates the shared RateLimiter 

86 with default limits 

87 - Each instance configures user-specific limits by passing user_id to __init__ 

88 - The rate limiter maintains separate counters and limits for each user 

89 - Users are completely isolated - one user's limit doesn't affect others 

90 

91 Example: 

92 >>> # User A with conservative limits 

93 >>> snapshot_a = {"notifications.rate_limit_per_hour": 5} 

94 >>> manager_a = NotificationManager(snapshot_a, user_id="user_a") 

95 >>> # ✅ user_a gets 5/hour 

96 >>> 

97 >>> # User B with generous limits (doesn't affect User A!) 

98 >>> snapshot_b = {"notifications.rate_limit_per_hour": 20} 

99 >>> manager_b = NotificationManager(snapshot_b, user_id="user_b") 

100 >>> # ✅ user_b gets 20/hour, user_a still has 5/hour 

101 """ 

102 

103 # Shared rate limiter instance across all NotificationManager instances 

104 # This ensures rate limits are enforced correctly even when multiple 

105 # NotificationManager instances are created 

106 _shared_rate_limiter: Optional["RateLimiter"] = None 

107 _rate_limiter_lock = threading.Lock() 

108 

109 def __init__(self, settings_snapshot: Dict[str, Any], user_id: str): 

110 """ 

111 Initialize the notification manager. 

112 

113 Args: 

114 settings_snapshot: Dictionary of settings key-value pairs captured 

115 from SettingsManager.get_settings_snapshot(). 

116 This allows thread-safe access to user settings 

117 from background threads. 

118 user_id: User identifier for per-user rate limiting. The rate limits 

119 from settings_snapshot will be configured for this user. 

120 

121 Example: 

122 >>> # In main thread with database session 

123 >>> settings_manager = SettingsManager(session) 

124 >>> snapshot = settings_manager.get_settings_snapshot() 

125 >>> 

126 >>> # In background thread (thread-safe) 

127 >>> notification_manager = NotificationManager( 

128 ... settings_snapshot=snapshot, 

129 ... user_id="user123" 

130 ... ) 

131 >>> notification_manager.send_notification(...) 

132 """ 

133 # Store settings snapshot for thread-safe access 

134 self._settings_snapshot = settings_snapshot 

135 self._user_id = user_id 

136 

137 # Security: read from server-side environment variable only — never from 

138 # user-writable DB settings. Previously this was read via 

139 # _get_setting("notifications.allow_private_ips"), which allowed any 

140 # user to bypass SSRF protection through the settings API. 

141 # Registered as env-only in settings/env_definitions/security.py 

142 # so SettingsManager will always read LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS 

143 # from the environment, never from the database. 

144 allow_private_ips = get_env_setting( 

145 "notifications.allow_private_ips", False 

146 ) 

147 

148 # Server-level master switch (env-only). Distinct from the per-user 

149 # notifications.enabled toggle in the UI: this gate is set by the 

150 # deployment operator and cannot be flipped via the user-writable 

151 # settings API. Disabled by default; enabling it accepts the 

152 # documented DNS-rebinding TOCTOU residual risk 

153 # (see SECURITY.md "Notification Webhook SSRF"). 

154 self._outbound_allowed = bool( 

155 get_env_setting("notifications.allow_outbound", False) 

156 ) 

157 

158 self.service = NotificationService( 

159 allow_private_ips=allow_private_ips, 

160 outbound_allowed=self._outbound_allowed, 

161 ) 

162 

163 # Initialize shared rate limiter on first use 

164 # The shared rate limiter now supports per-user limits, so each user's 

165 # settings are respected regardless of initialization order. 

166 with NotificationManager._rate_limiter_lock: 

167 if NotificationManager._shared_rate_limiter is None: 

168 # Create shared rate limiter with default limits 

169 # (individual users can have different limits) 

170 default_max_per_hour = self._get_setting( 

171 "notifications.rate_limit_per_hour", default=10 

172 ) 

173 default_max_per_day = self._get_setting( 

174 "notifications.rate_limit_per_day", default=50 

175 ) 

176 

177 logger.info( 

178 f"Initializing shared rate limiter with defaults: " 

179 f"{default_max_per_hour}/hour, {default_max_per_day}/day" 

180 ) 

181 

182 NotificationManager._shared_rate_limiter = RateLimiter( 

183 max_per_hour=default_max_per_hour, 

184 max_per_day=default_max_per_day, 

185 ) 

186 

187 # Use the shared instance 

188 self._rate_limiter = NotificationManager._shared_rate_limiter 

189 

190 # Configure per-user rate limits 

191 max_per_hour = self._get_setting( 

192 "notifications.rate_limit_per_hour", default=10 

193 ) 

194 max_per_day = self._get_setting( 

195 "notifications.rate_limit_per_day", default=50 

196 ) 

197 

198 self._rate_limiter.set_user_limits( 

199 user_id, max_per_hour, max_per_day 

200 ) 

201 logger.debug( 

202 f"Configured rate limits for user {user_id}: " 

203 f"{max_per_hour}/hour, {max_per_day}/day" 

204 ) 

205 

206 def _get_setting(self, key: str, default: Any = None) -> Any: 

207 """ 

208 Get a setting value from snapshot. 

209 

210 Args: 

211 key: Setting key 

212 default: Default value if not found 

213 

214 Returns: 

215 Setting value or default 

216 """ 

217 return self._settings_snapshot.get(key, default) 

218 

219 def send_notification( 

220 self, 

221 event_type: EventType, 

222 context: Dict[str, Any], 

223 force: bool = False, 

224 ) -> NotificationResult: 

225 """ 

226 Send a notification for an event. 

227 

228 Uses the user_id that was provided during initialization for 

229 rate limiting and user preferences. 

230 

231 Args: 

232 event_type: Type of event 

233 context: Context data for the notification 

234 force: If True, bypass rate limiting 

235 

236 Returns: 

237 :class:`NotificationResult` describing the outcome. The 

238 dataclass is truthy iff the notification was actually sent, 

239 so legacy ``if manager.send_notification(...):`` callers 

240 continue to work without source changes. 

241 

242 Raises: 

243 RateLimitError: If rate limit is exceeded and force=False 

244 """ 

245 logger.debug( 

246 f"Sending notification: event_type={event_type.value}, " 

247 f"user_id={self._user_id}, force={force}" 

248 ) 

249 logger.debug(f"Context keys: {list(context.keys())}") 

250 

251 # Server-level master switch (env-only). Disabled by default; 

252 # flipping it on is the operator's acknowledgement of the 

253 # documented SSRF rebinding residual risk. force=True does NOT 

254 # bypass this — it bypasses the per-user event-type and 

255 # rate-limit toggles only. WARNING level so an operator wondering 

256 # "why aren't notifications firing?" sees the actionable signal 

257 # at default log level. 

258 if not self._outbound_allowed: 

259 logger.warning( 

260 "Notification refused: outbound notifications are disabled " 

261 "at the server level. Set " 

262 "LDR_NOTIFICATIONS_ALLOW_OUTBOUND=true to enable. See " 

263 "SECURITY.md 'Notification Webhook SSRF' for the rationale " 

264 "and residual risk. (event={}, user={})", 

265 event_type.value, 

266 self._user_id, 

267 ) 

268 return NotificationResult( 

269 sent=False, 

270 reason=NotificationReason.SERVER_DISABLED, 

271 detail=( 

272 "outbound notifications are disabled at the server " 

273 "level (LDR_NOTIFICATIONS_ALLOW_OUTBOUND)" 

274 ), 

275 ) 

276 

277 # Check if notifications are enabled for this event type 

278 should_notify = self._should_notify(event_type) 

279 logger.debug( 

280 f"Notification enabled check for {event_type.value}: " 

281 f"{should_notify}" 

282 ) 

283 

284 if not force and not should_notify: 

285 logger.debug( 

286 f"Notifications disabled for event type: " 

287 f"{event_type.value} (user: {self._user_id})" 

288 ) 

289 return NotificationResult( 

290 sent=False, 

291 reason=NotificationReason.EVENT_DISABLED, 

292 detail=f"notifications.on_{event_type.value} is off", 

293 ) 

294 

295 # Check rate limit using the manager's user_id 

296 rate_limit_ok = self._rate_limiter.is_allowed(self._user_id) 

297 logger.debug(f"Rate limit check for {self._user_id}: {rate_limit_ok}") 

298 

299 if not force and not rate_limit_ok: 

300 logger.warning(f"Rate limit exceeded for user {self._user_id}") 

301 raise RateLimitError( 

302 "Notification rate limit exceeded. " 

303 "Please wait before sending more notifications." 

304 ) 

305 

306 try: 

307 # Get service URLs from settings (snapshot or database) 

308 service_urls = self._get_setting( 

309 "notifications.service_url", default="" 

310 ) 

311 

312 if not service_urls or not service_urls.strip(): 

313 logger.debug( 

314 f"No notification service URLs configured for user " 

315 f"{self._user_id}" 

316 ) 

317 return NotificationResult( 

318 sent=False, 

319 reason=NotificationReason.UNCONFIGURED, 

320 detail="notifications.service_url is not configured", 

321 ) 

322 

323 # Egress policy: classify raw HTTP(S) URLs with evaluate_url. 

324 # PRIVATE_ONLY refuses non-HTTP Apprise schemes because their 

325 # effective destinations cannot be classified reliably; other 

326 # scopes pass plugin schemes to the service's URL validator. 

327 allowed_urls = self._filter_urls_by_egress_policy(service_urls) 

328 if not allowed_urls: 

329 # Distinguish "the policy was evaluated and refused every 

330 # URL" (empty string) from "the policy itself could not be 

331 # evaluated and we failed closed" (None) so the operator 

332 # fixes the right thing — see issue #5110. 

333 if allowed_urls is None: 

334 detail = "egress policy could not be evaluated" 

335 else: 

336 detail = "all configured URLs refused by egress policy" 

337 logger.bind(policy_audit=True).warning( 

338 "notification dropped: {}", 

339 detail, 

340 user=self._user_id, 

341 event=event_type.value, 

342 ) 

343 return NotificationResult( 

344 sent=False, 

345 reason=NotificationReason.EGRESS_DENIED, 

346 detail=detail, 

347 ) 

348 

349 # Send notification with the allowed subset. 

350 logger.debug(f"Calling service.send_event for {event_type.value}") 

351 sent = self.service.send_event( 

352 event_type, context, service_urls=allowed_urls 

353 ) 

354 

355 # Log to database if enabled 

356 if sent: 

357 self._log_notification(event_type, context) 

358 logger.info( 

359 f"Notification sent: {event_type.value} to user " 

360 f"{self._user_id}" 

361 ) 

362 return NotificationResult( 

363 sent=True, 

364 reason=NotificationReason.SENT, 

365 detail="", 

366 ) 

367 # A falsy return from send_event means Apprise accepted none 

368 # of the URLs (temp_apprise.add() failed — unparseable or 

369 # unsupported scheme). Actual delivery failures raise 

370 # SendError and are handled below — see issue #5110. 

371 logger.warning( 

372 f"Notification failed: {event_type.value} to user " 

373 f"{self._user_id} — Apprise accepted none of the " 

374 f"configured service URLs" 

375 ) 

376 return NotificationResult( 

377 sent=False, 

378 reason=NotificationReason.INVALID_URL, 

379 detail="Apprise accepted none of the configured service URLs", 

380 ) 

381 

382 except SendError: 

383 # The delivery layer raises SendError ONLY from 

384 # _send_with_retry's own terminal raise once all retries are 

385 # exhausted (dead webhook, HTTP 4xx/5xx, network down) — a 

386 # transient, worth-retrying failure. NotificationService.send() 

387 # re-raises SendError unchanged (no re-wrapping) and does NOT 

388 # launder incidental/unexpected dispatch-time exceptions into 

389 # SendError — those fall through to the generic 

390 # ``except Exception`` branch below and get the EXCEPTION 

391 # reason instead, so this branch stays reserved for genuine 

392 # delivery failures. It also specifically does NOT mean a 

393 # confirmed SSRF/DNS-rebind block: NotificationService.send() 

394 # routes that non-retryable case through ServiceError instead 

395 # (see the except clause below) precisely so it is never 

396 # mislabeled as a retryable webhook failure here. Keep the 

397 # detail static — SendError's message wraps the underlying 

398 # error, which may contain webhook URLs with embedded tokens. 

399 logger.exception( 

400 f"Webhook delivery failed for {event_type.value} to user " 

401 f"{self._user_id}" 

402 ) 

403 return NotificationResult( 

404 sent=False, 

405 reason=NotificationReason.WEBHOOK_FAILED, 

406 detail="webhook delivery failed after retries", 

407 ) 

408 

409 except SecurityBlockError: 

410 # A ServiceError subclass NotificationService.send() raises 

411 # specifically for a confirmed send-time SSRF/DNS-rebind block 

412 # (destination resolved to an internal/private/metadata 

413 # address after dispatch started) — as opposed to the plain 

414 # ServiceError below, raised for a pre-dispatch validation 

415 # reject. Both are permanent, invalid-destination outcomes 

416 # that map to the same non-transient INVALID_URL reason, but 

417 # this one gets a distinct detail: the generic "check your 

418 # URL/settings" text below would misleadingly suggest a 

419 # config mistake when the true cause is an active security 

420 # block. Static detail — no URL or resolved IP — the 

421 # exception message can echo parts of the URL. 

422 logger.exception( 

423 f"Notification destination blocked by SSRF/egress " 

424 f"protection for {event_type.value} to user " 

425 f"{self._user_id}" 

426 ) 

427 return NotificationResult( 

428 sent=False, 

429 reason=NotificationReason.INVALID_URL, 

430 detail=( 

431 "notification destination blocked by egress/SSRF protection" 

432 ), 

433 ) 

434 

435 except ServiceError: 

436 # The service raises ServiceError when the URL security 

437 # validator rejects a URL before any dispatch is attempted 

438 # (structurally invalid URLs, or valid URLs whose destination 

439 # is blocked, e.g. a LAN webhook without 

440 # LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS). A confirmed send-time 

441 # SSRF/DNS-rebind block is caught above as SecurityBlockError 

442 # (a ServiceError subclass) before reaching this branch. The 

443 # detail must not claim the URL itself is malformed. Static 

444 # detail: the exception message can echo parts of the URL. 

445 logger.exception( 

446 f"Service URL rejected for {event_type.value} to user " 

447 f"{self._user_id}" 

448 ) 

449 return NotificationResult( 

450 sent=False, 

451 reason=NotificationReason.INVALID_URL, 

452 detail=( 

453 "service URL rejected by URL security validation " 

454 "(for a private/LAN webhook, check " 

455 "LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS)" 

456 ), 

457 ) 

458 

459 except Exception as exc: 

460 # Reached for unexpected failures anywhere in the block above 

461 # (settings read, egress filtering, or dispatch). The exception 

462 # string may contain webhook URLs with embedded tokens, SMTP 

463 # credentials, or payload content. Log the raw exception for 

464 # operators via logger.exception, but only expose the exception 

465 # class name (and not the message) in the structured result so 

466 # downstream callers can't echo it into logs or UI surfaces 

467 # verbatim. 

468 logger.exception( 

469 f"Error sending notification for {event_type.value} to user " 

470 f"{self._user_id}" 

471 ) 

472 return NotificationResult( 

473 sent=False, 

474 reason=NotificationReason.EXCEPTION, 

475 detail=f"unexpected {type(exc).__name__} while sending", 

476 ) 

477 

478 def test_service(self, url: str) -> Dict[str, Any]: 

479 """ 

480 Test a notification service. 

481 

482 Args: 

483 url: Service URL to test 

484 

485 Returns: 

486 Dict with test results 

487 """ 

488 # Egress policy precheck before forwarding to Apprise. 

489 # /api/notifications/test-url is a user-supplied URL endpoint. 

490 allowed = self._filter_urls_by_egress_policy(url) 

491 if allowed is None: 

492 # Fail-closed: the policy could not be evaluated at all, so 

493 # don't tell the user their scope refused the URL. 

494 return { 

495 "status": "error", 

496 "message": ( 

497 "Egress policy could not be evaluated; check the " 

498 "egress policy settings (policy.egress_scope)." 

499 ), 

500 } 

501 if not allowed: 

502 return { 

503 "status": "error", 

504 "message": ( 

505 "URL refused by egress policy. Set Egress Scope to " 

506 "'Unprotected' (or pick a local webhook) to test this URL." 

507 ), 

508 } 

509 return self.service.test_service(allowed) 

510 

511 def _filter_urls_by_egress_policy(self, service_urls: str) -> Optional[str]: 

512 """Filter an Apprise URL string by the user's egress policy. 

513 

514 Commas inside one service URL are preserved; commas or whitespace 

515 followed by another scheme delimit URLs. Returns the joined string 

516 of allowed URLs (may be empty — every URL was refused), or ``None`` 

517 when the policy itself could not be evaluated and we failed closed. 

518 Both are falsy; callers that care why nothing was allowed check for 

519 ``None``. When no snapshot / context is available, returns the 

520 input unchanged for backwards compatibility. 

521 """ 

522 snapshot = getattr(self, "_settings_snapshot", None) 

523 if not snapshot: 

524 return service_urls 

525 

526 url_entries, invalid_fragment = parse_notification_url_list( 

527 service_urls 

528 ) 

529 if invalid_fragment is not None: 

530 logger.bind(policy_audit=True).warning( 

531 "all notification URLs refused: scheme-less URL fragment", 

532 fragment=redact_url_for_log(invalid_fragment), 

533 ) 

534 return "" 

535 

536 try: 

537 from ..security.egress.policy import ( 

538 EgressScope, 

539 PolicyDeniedError, 

540 context_from_snapshot, 

541 evaluate_url, 

542 ) 

543 except ImportError: 

544 logger.debug( 

545 "egress_policy unavailable in notifications manager; " 

546 "URLs will not be scope-gated" 

547 ) 

548 return service_urls 

549 

550 from ..search_system import username_from_snapshot 

551 

552 primary_raw = unwrap_setting( 

553 snapshot.get("search.tool", DEFAULT_SEARCH_TOOL) 

554 ) 

555 try: 

556 ctx = context_from_snapshot( 

557 snapshot, 

558 primary_raw or DEFAULT_SEARCH_TOOL, 

559 username=username_from_snapshot(snapshot), 

560 ) 

561 except (PolicyDeniedError, ValueError) as exc: 

562 # Snapshot present but policy cannot be evaluated. The 

563 # previous bare-except dropped to "return service_urls" 

564 # here, which dispatched every URL unfiltered — fail-open 

565 # on a misconfigured policy. Now refuse all URLs instead 

566 # so an Apprise dispatch with no scope check is impossible. 

567 logger.bind(policy_audit=True).warning( 

568 "all notification URLs refused: egress policy could " 

569 "not be evaluated", 

570 reason=str(exc), 

571 ) 

572 return None 

573 

574 # Apprise accepts space- or comma-separated URLs and has non-HTTP 

575 # schemes (discord://, slack://, tgram://, mailto://, msteams://, 

576 # ntfy://, ...) whose effective destination may be a fixed provider, 

577 # a user-supplied authority, or a provider mapping. evaluate_url only 

578 # understands HTTP(S), so it cannot classify those modes reliably. 

579 # 

580 # Under PRIVATE_ONLY ("nothing leaves the box"), a non-HTTP scheme is 

581 # therefore refused — fail closed. Services that expose a raw HTTP(S) webhook 

582 # can use that URL and be classified by evaluate_url; Apprise SMTP and 

583 # other plugin URLs remain unavailable under PRIVATE_ONLY. Under the 

584 # other scopes the prior pass-through stands (the modeled threat there 

585 # is internal-http SSRF via a raw webhook, not vendor APIs). 

586 parts = [] 

587 for url_entry in url_entries: 

588 scheme = ( 

589 url_entry.split(":", 1)[0].lower() if ":" in url_entry else "" 

590 ) 

591 if scheme not in ("http", "https"): 

592 if ctx.scope == EgressScope.PRIVATE_ONLY: 

593 logger.bind(policy_audit=True).warning( 

594 "notification refused under PRIVATE_ONLY egress " 

595 "scope: non-http vendor scheme cannot be verified " 

596 "local", 

597 scheme=scheme or "(none)", 

598 ) 

599 continue 

600 parts.append(url_entry) 

601 continue 

602 decision = evaluate_url(url_entry, ctx) 

603 if decision.allowed: 

604 parts.append(url_entry) 

605 else: 

606 logger.bind(policy_audit=True).warning( 

607 "notification URL refused by egress policy", 

608 url=url_entry, 

609 scope=ctx.scope.value, 

610 reason=decision.reason, 

611 ) 

612 return " ".join(parts) 

613 

614 def _should_notify(self, event_type: EventType) -> bool: 

615 """ 

616 Check if notifications should be sent for this event type. 

617 

618 Uses the manager's settings snapshot to determine if the event type 

619 is enabled for the user. 

620 

621 Args: 

622 event_type: Event type to check 

623 

624 Returns: 

625 True if notifications should be sent 

626 """ 

627 try: 

628 # Check event-specific setting (from snapshot or database) 

629 setting_key = f"notifications.on_{event_type.value}" 

630 enabled = self._get_setting(setting_key, default=False) 

631 

632 return bool(enabled) 

633 

634 except Exception: 

635 logger.warning("Error checking notification preferences") 

636 # Default to disabled on error to avoid infinite loops during login 

637 return False 

638 

639 def _log_notification( 

640 self, event_type: EventType, context: Dict[str, Any] 

641 ) -> None: 

642 """ 

643 Log a sent notification (simplified logging to application logs only). 

644 

645 Uses the manager's user_id for logging. 

646 

647 Args: 

648 event_type: Event type 

649 context: Notification context 

650 """ 

651 try: 

652 title = ( 

653 context.get("query") 

654 or context.get("subscription_name") 

655 or "Unknown" 

656 ) 

657 logger.info( 

658 f"Notification sent: {event_type.value} - {title} " 

659 f"(user: {self._user_id})" 

660 ) 

661 except Exception as e: 

662 logger.debug(f"Failed to log notification: {e}") 

663 

664 

665class RateLimiter: 

666 """ 

667 Simple in-memory rate limiter for notifications with per-user limit support. 

668 

669 This rate limiter tracks notification counts per user and enforces 

670 configurable rate limits. Each user can have their own rate limits, 

671 which are stored separately from the notification counts. 

672 

673 **Per-User Limits:** 

674 Rate limits can be configured per-user using `set_user_limits()`. 

675 If no user-specific limits are set, the default limits (passed to 

676 __init__) are used. 

677 

678 **Memory Storage:** 

679 This implementation stores rate limits in memory only, which means 

680 limits are reset when the server restarts. This is acceptable for normal 

681 users since they cannot restart the server. If an admin restarts the server, 

682 rate limits reset which is reasonable behavior. 

683 

684 **Thread Safety:** 

685 This implementation is thread-safe using threading.Lock() for concurrent 

686 requests from the same user. 

687 

688 **Multi-Worker Limitation:** 

689 In multi-worker deployments, each worker process maintains its own rate 

690 limit counters. Users could potentially bypass rate limits by distributing 

691 requests across different workers, getting up to N × max_per_hour 

692 notifications (where N = number of workers). For single-worker deployments 

693 (the default for LDR), this is not a concern. For production multi-worker 

694 deployments, consider implementing Redis-based rate limiting. 

695 

696 Example: 

697 >>> limiter = RateLimiter(max_per_hour=10, max_per_day=50) 

698 >>> # Set custom limits for specific user 

699 >>> limiter.set_user_limits("user_a", max_per_hour=5, max_per_day=25) 

700 >>> limiter.set_user_limits("user_b", max_per_hour=20, max_per_day=100) 

701 >>> # Users get their configured limits 

702 >>> limiter.is_allowed("user_a") # Limited to 5/hour 

703 >>> limiter.is_allowed("user_b") # Limited to 20/hour 

704 >>> limiter.is_allowed("user_c") # Uses defaults: 10/hour 

705 """ 

706 

707 def __init__( 

708 self, 

709 max_per_hour: int = 10, 

710 max_per_day: int = 50, 

711 cleanup_interval_hours: int = 24, 

712 ): 

713 """ 

714 Initialize rate limiter with default limits. 

715 

716 Args: 

717 max_per_hour: Default maximum notifications per hour per user 

718 max_per_day: Default maximum notifications per day per user 

719 cleanup_interval_hours: How often to run cleanup of inactive users (hours) 

720 """ 

721 # Default limits used when no user-specific limits are set 

722 self.max_per_hour = max_per_hour 

723 self.max_per_day = max_per_day 

724 self.cleanup_interval_hours = cleanup_interval_hours 

725 

726 # Per-user rate limit configuration (user_id -> (max_per_hour, max_per_day)) 

727 self._user_limits: Dict[str, tuple[int, int]] = {} 

728 

729 # Per-user notification counts 

730 self._hourly_counts: Dict[str, deque] = {} 

731 self._daily_counts: Dict[str, deque] = {} 

732 

733 self._last_cleanup = datetime.now(timezone.utc) 

734 self._lock = threading.Lock() # Thread safety for all operations 

735 

736 def set_user_limits( 

737 self, user_id: str, max_per_hour: int, max_per_day: int 

738 ) -> None: 

739 """ 

740 Set rate limits for a specific user. 

741 

742 This allows each user to have their own rate limit configuration. 

743 If not set, the user will use the default limits passed to __init__. 

744 

745 Args: 

746 user_id: User identifier 

747 max_per_hour: Maximum notifications per hour for this user 

748 max_per_day: Maximum notifications per day for this user 

749 

750 Example: 

751 >>> limiter = RateLimiter(max_per_hour=10, max_per_day=50) 

752 >>> limiter.set_user_limits("power_user", max_per_hour=20, max_per_day=100) 

753 >>> limiter.set_user_limits("limited_user", max_per_hour=5, max_per_day=25) 

754 """ 

755 with self._lock: 

756 self._user_limits[user_id] = (max_per_hour, max_per_day) 

757 logger.debug( 

758 f"Set rate limits for user {user_id}: " 

759 f"{max_per_hour}/hour, {max_per_day}/day" 

760 ) 

761 

762 def get_user_limits(self, user_id: str) -> tuple[int, int]: 

763 """ 

764 Get the effective rate limits for a user. 

765 

766 Returns the user-specific limits if set, otherwise returns defaults. 

767 

768 Args: 

769 user_id: User identifier 

770 

771 Returns: 

772 Tuple of (max_per_hour, max_per_day) 

773 """ 

774 with self._lock: 

775 return self._user_limits.get( 

776 user_id, (self.max_per_hour, self.max_per_day) 

777 ) 

778 

779 def is_allowed(self, user_id: str) -> bool: 

780 """ 

781 Check if a notification is allowed for a user. 

782 

783 Uses per-user rate limits if configured via set_user_limits(), 

784 otherwise uses the default limits from __init__. 

785 

786 Args: 

787 user_id: User identifier 

788 

789 Returns: 

790 True if notification is allowed, False if rate limit exceeded 

791 """ 

792 with self._lock: 

793 now = datetime.now(timezone.utc) 

794 

795 # Periodic cleanup of inactive users 

796 self._cleanup_inactive_users_if_needed(now) 

797 

798 # Initialize queues for user if needed 

799 if user_id not in self._hourly_counts: 

800 self._hourly_counts[user_id] = deque() 

801 self._daily_counts[user_id] = deque() 

802 

803 # Clean old entries 

804 self._clean_old_entries(user_id, now) 

805 

806 # Get user-specific limits or defaults 

807 max_per_hour, max_per_day = self._user_limits.get( 

808 user_id, (self.max_per_hour, self.max_per_day) 

809 ) 

810 

811 # Check limits 

812 hourly_count = len(self._hourly_counts[user_id]) 

813 daily_count = len(self._daily_counts[user_id]) 

814 

815 if hourly_count >= max_per_hour: 

816 logger.warning( 

817 f"Hourly rate limit exceeded for user {user_id}: " 

818 f"{hourly_count}/{max_per_hour}" 

819 ) 

820 return False 

821 

822 if daily_count >= max_per_day: 

823 logger.warning( 

824 f"Daily rate limit exceeded for user {user_id}: " 

825 f"{daily_count}/{max_per_day}" 

826 ) 

827 return False 

828 

829 # Record this notification 

830 self._hourly_counts[user_id].append(now) 

831 self._daily_counts[user_id].append(now) 

832 

833 return True 

834 

835 def _clean_old_entries(self, user_id: str, now: datetime) -> None: 

836 """ 

837 Remove old entries from rate limit counters. 

838 

839 Args: 

840 user_id: User identifier 

841 now: Current time 

842 """ 

843 hour_ago = now - timedelta(hours=1) 

844 day_ago = now - timedelta(days=1) 

845 

846 # Clean hourly queue 

847 while ( 

848 self._hourly_counts[user_id] 

849 and self._hourly_counts[user_id][0] < hour_ago 

850 ): 

851 self._hourly_counts[user_id].popleft() 

852 

853 # Clean daily queue 

854 while ( 

855 self._daily_counts[user_id] 

856 and self._daily_counts[user_id][0] < day_ago 

857 ): 

858 self._daily_counts[user_id].popleft() 

859 

860 def reset(self, user_id: Optional[str] = None) -> None: 

861 """ 

862 Reset rate limits for a user or all users. 

863 

864 Args: 

865 user_id: User to reset, or None for all users 

866 """ 

867 with self._lock: 

868 if user_id: 

869 self._hourly_counts.pop(user_id, None) 

870 self._daily_counts.pop(user_id, None) 

871 else: 

872 self._hourly_counts.clear() 

873 self._daily_counts.clear() 

874 

875 def _cleanup_inactive_users_if_needed(self, now: datetime) -> None: 

876 """ 

877 Periodically clean up data for inactive users to prevent memory leaks. 

878 

879 Args: 

880 now: Current timestamp 

881 """ 

882 # Check if cleanup is needed 

883 if now - self._last_cleanup < timedelta( 

884 hours=self.cleanup_interval_hours 

885 ): 

886 return 

887 

888 logger.debug("Running periodic cleanup of inactive notification users") 

889 

890 # Define inactive threshold (users with no activity for 7 days) 

891 inactive_threshold = now - timedelta(days=7) 

892 

893 inactive_users = [] 

894 

895 # Find users with no recent activity 

896 # Convert to list to avoid "dictionary changed size during iteration" error 

897 for user_id in list(self._hourly_counts.keys()): 

898 # Check if user has any recent entries 

899 hourly_entries: list = list(self._hourly_counts.get(user_id, [])) 

900 daily_entries: list = list(self._daily_counts.get(user_id, [])) 

901 

902 # If no entries or all entries are old, mark as inactive 

903 has_recent_activity = False 

904 for entry in hourly_entries + daily_entries: 

905 if entry > inactive_threshold: 

906 has_recent_activity = True 

907 break 

908 

909 if not has_recent_activity: 

910 inactive_users.append(user_id) 

911 

912 # Remove inactive users 

913 for user_id in inactive_users: 

914 self._hourly_counts.pop(user_id, None) 

915 self._daily_counts.pop(user_id, None) 

916 logger.debug( 

917 f"Cleaned up inactive user {user_id} from rate limiter" 

918 ) 

919 

920 if inactive_users: 920 ↛ 925line 920 didn't jump to line 925 because the condition on line 920 was always true

921 logger.info( 

922 f"Cleaned up {len(inactive_users)} inactive users from rate limiter" 

923 ) 

924 

925 self._last_cleanup = now