Coverage for src/local_deep_research/web_search_engines/rate_limiting/tracker.py: 97%

329 statements  

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

1""" 

2Adaptive rate limit tracker that learns optimal retry wait times for each search engine. 

3""" 

4 

5import random 

6import threading 

7import time 

8from collections import deque 

9from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple 

10 

11from cachetools import LRUCache 

12 

13if TYPE_CHECKING: 

14 from sqlalchemy.orm import Session 

15from ...security.secure_logging import logger 

16 

17from ...config.thread_settings import ( 

18 NoSettingsContextError, 

19 get_setting_from_snapshot, 

20 get_settings_context, 

21) 

22from ...settings.env_registry import is_ci_environment 

23from ...utilities.thread_context import get_search_context 

24 

25# Lazy imports to avoid database initialization in programmatic mode 

26_db_imports = None 

27 

28 

29def _get_db_imports(): 

30 """Lazy load database imports only when needed.""" 

31 global _db_imports 

32 if _db_imports is None: 

33 try: 

34 from ...database.models import RateLimitAttempt, RateLimitEstimate 

35 from ...database.session_context import get_user_db_session 

36 

37 _db_imports = { 

38 "RateLimitAttempt": RateLimitAttempt, 

39 "RateLimitEstimate": RateLimitEstimate, 

40 "get_user_db_session": get_user_db_session, 

41 } 

42 except (ImportError, RuntimeError): 

43 # Database not available - programmatic mode 

44 _db_imports = {} 

45 return _db_imports 

46 

47 

48class AdaptiveRateLimitTracker: 

49 """ 

50 Tracks and learns optimal retry wait times for each search engine. 

51 Persists learned patterns to the main application database using SQLAlchemy. 

52 """ 

53 

54 def __init__(self, settings_snapshot=None, programmatic_mode=False): 

55 self.settings_snapshot = settings_snapshot or {} 

56 self.programmatic_mode = programmatic_mode 

57 

58 # Helper function to get settings with defaults 

59 def get_setting_or_default(key, default, type_fn=None): 

60 try: 

61 value = get_setting_from_snapshot( 

62 key, 

63 settings_snapshot=self.settings_snapshot, 

64 ) 

65 # Handle None values - return default instead of calling type_fn(None) 

66 if value is None: 

67 return default 

68 return type_fn(value) if type_fn else value 

69 except NoSettingsContextError: 

70 return default 

71 

72 # Get settings with explicit defaults 

73 self.memory_window = get_setting_or_default( 

74 "rate_limiting.memory_window", 100, int 

75 ) 

76 self.exploration_rate = get_setting_or_default( 

77 "rate_limiting.exploration_rate", 0.1, float 

78 ) 

79 self.learning_rate = get_setting_or_default( 

80 "rate_limiting.learning_rate", 0.3, float 

81 ) 

82 self.decay_per_day = get_setting_or_default( 

83 "rate_limiting.decay_per_day", 0.95, float 

84 ) 

85 

86 # In programmatic mode, default to disabled 

87 self.enabled = get_setting_or_default( 

88 "rate_limiting.enabled", 

89 not self.programmatic_mode, # Default based on mode 

90 bool, 

91 ) 

92 

93 profile = get_setting_or_default("rate_limiting.profile", "balanced") 

94 

95 if self.programmatic_mode and self.enabled: 

96 logger.info( 

97 "Rate limiting enabled in programmatic mode - using memory-only tracking without persistence" 

98 ) 

99 

100 # Apply rate limiting profile 

101 self._apply_profile(profile) 

102 

103 # Bounded in-memory caches for fast access (prevent memory leaks) 

104 # Lock required: LRUCache is not thread-safe (mutates on read via LRU reordering) 

105 self._cache_lock = threading.Lock() 

106 self.recent_attempts: LRUCache = LRUCache(maxsize=100) 

107 self.current_estimates: LRUCache = LRUCache(maxsize=100) 

108 

109 # Initialize the _estimates_loaded flag 

110 self._estimates_loaded = False 

111 

112 # Load estimates from database 

113 self._load_estimates() 

114 

115 logger.info( 

116 f"AdaptiveRateLimitTracker initialized: enabled={self.enabled}, profile={profile}" 

117 ) 

118 

119 def _apply_profile(self, profile: str) -> None: 

120 """Apply rate limiting profile settings.""" 

121 if profile == "conservative": 

122 # More conservative: lower exploration, slower learning 

123 self.exploration_rate = min( 

124 self.exploration_rate * 0.5, 0.05 

125 ) # 5% max exploration 

126 self.learning_rate = min( 

127 self.learning_rate * 0.7, 0.2 

128 ) # Slower learning 

129 logger.info("Applied conservative rate limiting profile") 

130 elif profile == "aggressive": 

131 # More aggressive: higher exploration, faster learning 

132 self.exploration_rate = min( 

133 self.exploration_rate * 1.5, 0.2 

134 ) # Up to 20% exploration 

135 self.learning_rate = min( 

136 self.learning_rate * 1.3, 0.5 

137 ) # Faster learning 

138 logger.info("Applied aggressive rate limiting profile") 

139 else: # balanced 

140 # Use settings as-is 

141 logger.info("Applied balanced rate limiting profile") 

142 

143 def _load_estimates(self) -> None: 

144 """Load estimates from database into memory.""" 

145 # Skip database operations in programmatic mode 

146 if self.programmatic_mode: 

147 logger.debug( 

148 "Skipping rate limit estimate loading in programmatic mode" 

149 ) 

150 self._estimates_loaded = ( 

151 True # Mark as loaded to skip future attempts 

152 ) 

153 return 

154 

155 # During initialization, we don't have user context yet 

156 # Mark that we need to load estimates when user context becomes available 

157 self._estimates_loaded = False 

158 logger.debug( 

159 "Rate limit estimates will be loaded on-demand when user context is available" 

160 ) 

161 

162 def _ensure_estimates_loaded(self) -> None: 

163 """Load estimates from user's encrypted database if not already loaded.""" 

164 # Early return if already loaded or should skip 

165 if self._estimates_loaded or self.programmatic_mode: 

166 if not self._estimates_loaded: 

167 self._estimates_loaded = True 

168 return 

169 

170 # Get database imports 

171 db_imports = _get_db_imports() 

172 RateLimitEstimate = ( 

173 db_imports.get("RateLimitEstimate") if db_imports else None 

174 ) 

175 

176 if not db_imports or not RateLimitEstimate: 

177 # Database not available 

178 self._estimates_loaded = True 

179 return 

180 

181 # Try to get research context from search tracker 

182 

183 context = get_search_context() 

184 if not context: 

185 # No context available (e.g., in tests or programmatic access) 

186 self._estimates_loaded = True 

187 return 

188 

189 username = context.get("username") 

190 password = context.get("user_password") 

191 

192 if username and password: 

193 try: 

194 # Use thread-safe metrics writer to read from user's encrypted database 

195 from ...database.thread_metrics import metrics_writer 

196 

197 # Set password for this thread 

198 metrics_writer.set_user_password(username, password) 

199 

200 session: "Session" 

201 with metrics_writer.get_session(username) as session: 

202 estimates: list[Any] = session.query( 

203 RateLimitEstimate 

204 ).all() 

205 

206 for estimate in estimates: 

207 # Apply decay for old estimates 

208 age_hours = (time.time() - estimate.last_updated) / 3600 

209 decay = self.decay_per_day ** (age_hours / 24) 

210 

211 with self._cache_lock: 

212 self.current_estimates[estimate.engine_type] = { 

213 "base": estimate.base_wait_seconds, 

214 "min": estimate.min_wait_seconds, 

215 "max": estimate.max_wait_seconds, 

216 "confidence": decay, 

217 } 

218 

219 logger.debug( 

220 f"Loaded estimate for {estimate.engine_type}: base={estimate.base_wait_seconds:.2f}s, confidence={decay:.2f}" 

221 ) 

222 

223 self._estimates_loaded = True 

224 logger.info( 

225 f"Loaded {len(estimates)} rate limit estimates from encrypted database" 

226 ) 

227 

228 except Exception: 

229 logger.warning("Could not load rate limit estimates") 

230 # Mark as loaded anyway to avoid repeated attempts 

231 self._estimates_loaded = True 

232 

233 def get_wait_time(self, engine_type: str) -> float: 

234 """ 

235 Get adaptive wait time for a search engine. 

236 Includes exploration to discover better rates. 

237 

238 Args: 

239 engine_type: Name of the search engine 

240 

241 Returns: 

242 Wait time in seconds 

243 """ 

244 # If rate limiting is disabled, return minimal wait time 

245 if not self.enabled: 

246 return 0.1 

247 

248 # Check if we have a user context - if not, handle appropriately 

249 context = get_search_context() 

250 if not context and not self.programmatic_mode: 

251 # No context and not in programmatic mode - this is unexpected 

252 logger.warning( 

253 f"No user context available for rate limiting on {engine_type} " 

254 "but programmatic_mode=False. Disabling rate limiting. " 

255 "This may indicate a configuration issue." 

256 ) 

257 return 0.0 

258 

259 # In programmatic mode, we continue with memory-only rate limiting even without context 

260 

261 # Ensure estimates are loaded from database 

262 self._ensure_estimates_loaded() 

263 

264 with self._cache_lock: 

265 if engine_type not in self.current_estimates: 

266 # First time seeing this engine - start optimistic and learn from real responses 

267 # Use engine-specific optimistic defaults only for what we know for sure 

268 optimistic_defaults = { 

269 "SearXNGSearchEngine": 0.1, # Self-hosted default engine 

270 } 

271 

272 wait_time = optimistic_defaults.get( 

273 engine_type, 0.1 

274 ) # Default optimistic for others 

275 logger.info( 

276 f"No rate limit data for {engine_type}, starting optimistic with {wait_time}s" 

277 ) 

278 return wait_time 

279 

280 estimate = self.current_estimates[engine_type] 

281 base_wait = estimate["base"] 

282 

283 # Security: random used for non-security rate-limit jitter, not tokens or secrets 

284 # Exploration vs exploitation 

285 if random.random() < self.exploration_rate: 

286 # Explore: try a faster rate to see if API limits have relaxed 

287 wait_time = base_wait * random.uniform(0.5, 0.9) 

288 logger.debug( 

289 f"Exploring faster rate for {engine_type}: {wait_time:.2f}s" 

290 ) 

291 else: 

292 # Exploit: use learned estimate with jitter 

293 wait_time = base_wait * random.uniform(0.9, 1.1) 

294 

295 # Enforce bounds 

296 wait_time = max( 

297 float(estimate["min"]), min(wait_time, float(estimate["max"])) 

298 ) 

299 return float(wait_time) 

300 

301 def apply_rate_limit(self, engine_type: str) -> float: 

302 """ 

303 Apply rate limiting for the given engine type. 

304 This is a convenience method that combines checking if rate limiting 

305 is enabled, getting the wait time, and sleeping if necessary. 

306 

307 Args: 

308 engine_type: The type of search engine 

309 

310 Returns: 

311 The wait time that was applied (0 if rate limiting is disabled) 

312 """ 

313 if not self.enabled: 

314 return 0.0 

315 

316 wait_time = self.get_wait_time(engine_type) 

317 if wait_time > 0: 

318 logger.debug( 

319 f"{engine_type} waiting {wait_time:.2f}s before request" 

320 ) 

321 time.sleep(wait_time) 

322 return wait_time 

323 

324 def record_outcome( 

325 self, 

326 engine_type: str, 

327 wait_time: float, 

328 success: bool, 

329 retry_count: int, 

330 error_type: Optional[str] = None, 

331 search_result_count: Optional[int] = None, 

332 ) -> None: 

333 """ 

334 Record the outcome of a retry attempt. 

335 

336 Args: 

337 engine_type: Name of the search engine 

338 wait_time: How long we waited before this attempt 

339 success: Whether the attempt succeeded 

340 retry_count: Which retry attempt this was (1, 2, 3, etc.) 

341 error_type: Type of error if failed 

342 search_result_count: Number of search results returned (for quality monitoring) 

343 """ 

344 # If rate limiting is disabled, don't record outcomes 

345 if not self.enabled: 

346 logger.info( 

347 f"Rate limiting disabled - not recording outcome for {engine_type}" 

348 ) 

349 return 

350 

351 logger.debug( 

352 f"Recording rate limit outcome for {engine_type}: success={success}, wait_time={wait_time}s" 

353 ) 

354 timestamp = time.time() 

355 

356 # NOTE: Database writes for rate limiting are disabled to prevent 

357 # database locking issues under heavy parallel search load. 

358 # Rate limiting still works via in-memory tracking below. 

359 # Historical rate limit data is not persisted to DB. 

360 

361 # Update in-memory tracking 

362 with self._cache_lock: 

363 if engine_type not in self.recent_attempts: 

364 # Get current memory window setting from thread context 

365 settings_context = get_settings_context() 

366 if settings_context: 

367 current_memory_window = int( 

368 settings_context.get_setting( 

369 "rate_limiting.memory_window", self.memory_window 

370 ) 

371 ) 

372 else: 

373 current_memory_window = self.memory_window 

374 

375 self.recent_attempts[engine_type] = deque( 

376 maxlen=current_memory_window 

377 ) 

378 

379 self.recent_attempts[engine_type].append( 

380 { 

381 "wait_time": wait_time, 

382 "success": success, 

383 "timestamp": timestamp, 

384 "retry_count": retry_count, 

385 "search_result_count": search_result_count, 

386 } 

387 ) 

388 

389 # Update estimates 

390 self._update_estimate(engine_type) 

391 

392 def _update_estimate(self, engine_type: str) -> None: 

393 """Update wait time estimate based on recent attempts.""" 

394 with self._cache_lock: 

395 if ( 

396 engine_type not in self.recent_attempts 

397 or len(self.recent_attempts[engine_type]) < 3 

398 ): 

399 attempt_count = len(self.recent_attempts.get(engine_type) or []) 

400 logger.info( 

401 f"Not updating estimate for {engine_type} - only {attempt_count} attempts (need 3)" 

402 ) 

403 return 

404 

405 attempts = list(self.recent_attempts[engine_type]) 

406 old_estimate = self.current_estimates.get(engine_type) 

407 

408 # Calculate success rate and optimal wait time 

409 successful_waits = [a["wait_time"] for a in attempts if a["success"]] 

410 failed_waits = [a["wait_time"] for a in attempts if not a["success"]] 

411 

412 if not successful_waits: 

413 # All attempts failed - increase wait time with a cap 

414 new_base = max(failed_waits) * 1.5 if failed_waits else 10.0 

415 # Cap the base wait time to prevent runaway growth 

416 new_base = min(new_base, 10.0) # Max 10 seconds base when all fail 

417 else: 

418 # Use 50th percentile (median) of successful waits for more stability 

419 # This provides a balanced approach between speed and reliability 

420 successful_waits.sort() 

421 # Lower-median index: `(n - 1) // 2` is the middle element for odd n 

422 # and the lower of the two middles for even n. The prior 

423 # `int(n * 0.50) - 1` (left from a 25th-percentile version) sat below 

424 # the median for odd n: at n == 3, the minimum qualifying sample, it 

425 # picked index 0, the fastest wait, not the median. 

426 median_index = (len(successful_waits) - 1) // 2 

427 new_base = successful_waits[median_index] 

428 

429 # Update estimate with learning rate (exponential moving average) 

430 if old_estimate is not None: 

431 old_base = old_estimate["base"] 

432 # Get current learning rate from settings context 

433 settings_context = get_settings_context() 

434 if settings_context: 

435 current_learning_rate = float( 

436 settings_context.get_setting( 

437 "rate_limiting.learning_rate", self.learning_rate 

438 ) 

439 ) 

440 else: 

441 current_learning_rate = self.learning_rate 

442 

443 new_base = ( 

444 1 - current_learning_rate 

445 ) * old_base + current_learning_rate * new_base 

446 

447 # Apply absolute cap to prevent extreme wait times 

448 new_base = min(new_base, 10.0) # Cap base at 10 seconds 

449 

450 # Calculate bounds with more reasonable limits 

451 min_wait = max(0.01, new_base * 0.5) 

452 max_wait = min(10.0, new_base * 3.0) # Max 10 seconds absolute cap 

453 

454 # Update in memory 

455 with self._cache_lock: 

456 self.current_estimates[engine_type] = { 

457 "base": new_base, 

458 "min": min_wait, 

459 "max": max_wait, 

460 "confidence": min(len(attempts) / 20.0, 1.0), 

461 } 

462 

463 # Persist to database 

464 success_rate = len(successful_waits) / len(attempts) if attempts else 0 

465 

466 # Skip database operations in programmatic mode 

467 if self.programmatic_mode: 

468 logger.debug( 

469 f"Skipping estimate persistence in programmatic mode for {engine_type}" 

470 ) 

471 else: 

472 # Try to get research context from search tracker 

473 

474 context = get_search_context() 

475 username = None 

476 password = None 

477 if context is not None: 

478 username = context.get("username") 

479 password = context.get("user_password") 

480 

481 if username and password: 

482 try: 

483 # Use thread-safe metrics writer to save to user's encrypted database 

484 from ...database.thread_metrics import metrics_writer 

485 

486 # Set password for this thread if not already set 

487 metrics_writer.set_user_password(username, password) 

488 

489 db_imports = _get_db_imports() 

490 RateLimitEstimate = db_imports.get("RateLimitEstimate") 

491 

492 session_inner: "Session" 

493 with metrics_writer.get_session(username) as session_inner: 

494 # Check if estimate exists 

495 estimate: Any = ( 

496 session_inner.query(RateLimitEstimate) 

497 .filter_by(engine_type=engine_type) 

498 .first() 

499 ) 

500 

501 if estimate: 

502 # Update existing estimate 

503 estimate.base_wait_seconds = new_base 

504 estimate.min_wait_seconds = min_wait 

505 estimate.max_wait_seconds = max_wait 

506 estimate.last_updated = time.time() 

507 estimate.total_attempts = len(attempts) 

508 estimate.success_rate = success_rate 

509 else: 

510 # Create new estimate 

511 estimate = RateLimitEstimate( 

512 engine_type=engine_type, 

513 base_wait_seconds=new_base, 

514 min_wait_seconds=min_wait, 

515 max_wait_seconds=max_wait, 

516 last_updated=time.time(), 

517 total_attempts=len(attempts), 

518 success_rate=success_rate, 

519 ) 

520 session_inner.add(estimate) 

521 

522 except Exception: 

523 logger.warning("Failed to persist rate limit estimate") 

524 else: 

525 logger.debug( 

526 "Skipping rate limit estimate save - no user context" 

527 ) 

528 

529 logger.info( 

530 f"Updated rate limit for {engine_type}: {new_base:.2f}s " 

531 f"(success rate: {success_rate:.1%})" 

532 ) 

533 

534 def _get_in_memory_stats( 

535 self, engine_type: Optional[str] = None 

536 ) -> List[Tuple[str, float, float, float, float, int, float]]: 

537 """Return stats from in-memory caches (no DB access).""" 

538 with self._cache_lock: 

539 stats = [] 

540 engines_to_check = ( 

541 [engine_type] 

542 if engine_type 

543 else list(self.current_estimates.keys()) 

544 ) 

545 for engine in engines_to_check: 

546 if engine in self.current_estimates: 

547 est = self.current_estimates[engine] 

548 attempts = self.recent_attempts.get(engine) 

549 attempt_count = len(attempts) if attempts else 0 

550 stats.append( 

551 ( 

552 engine, 

553 est["base"], 

554 est["min"], 

555 est["max"], 

556 time.time(), 

557 attempt_count, 

558 est.get("confidence", 0.0), 

559 ) 

560 ) 

561 return stats 

562 

563 def get_stats( 

564 self, engine_type: Optional[str] = None 

565 ) -> List[Tuple[str, float, float, float, float, int, float]]: 

566 """ 

567 Get statistics for monitoring. 

568 

569 Args: 

570 engine_type: Specific engine to get stats for, or None for all 

571 

572 Returns: 

573 List of tuples with engine statistics 

574 """ 

575 # Skip database operations in CI mode 

576 if is_ci_environment(): 

577 logger.debug("Skipping database stats in CI mode") 

578 return self._get_in_memory_stats(engine_type) 

579 

580 # Skip database operations in programmatic mode 

581 if self.programmatic_mode: 

582 return self._get_in_memory_stats(engine_type) 

583 

584 try: 

585 context = get_search_context() 

586 if not context: 

587 return self._get_in_memory_stats(engine_type) 

588 

589 username = context.get("username") 

590 password = context.get("user_password") 

591 if not username or not password: 591 ↛ 592line 591 didn't jump to line 592 because the condition on line 591 was never true

592 return self._get_in_memory_stats(engine_type) 

593 

594 db_imports = _get_db_imports() 

595 RateLimitEstimate = db_imports.get("RateLimitEstimate") 

596 

597 from ...database.thread_metrics import metrics_writer 

598 

599 metrics_writer.set_user_password(username, password) 

600 

601 session_est: "Session" 

602 with metrics_writer.get_session(username) as session_est: 

603 if engine_type: 

604 estimates: list[Any] = ( 

605 session_est.query(RateLimitEstimate) 

606 .filter_by(engine_type=engine_type) 

607 .all() 

608 ) 

609 else: 

610 estimates = ( 

611 session_est.query(RateLimitEstimate) 

612 .order_by(RateLimitEstimate.engine_type) 

613 .all() 

614 ) 

615 

616 return [ 

617 ( 

618 est.engine_type, 

619 est.base_wait_seconds, 

620 est.min_wait_seconds, 

621 est.max_wait_seconds, 

622 est.last_updated, 

623 est.total_attempts, 

624 est.success_rate, 

625 ) 

626 for est in estimates 

627 ] 

628 except Exception: 

629 logger.warning("Failed to get rate limit stats from DB") 

630 # Return in-memory stats as fallback 

631 return self._get_in_memory_stats(engine_type) 

632 

633 def reset_engine(self, engine_type: str) -> None: 

634 """ 

635 Reset learned values for a specific engine. 

636 

637 Args: 

638 engine_type: Engine to reset 

639 """ 

640 # Always clear from memory first 

641 with self._cache_lock: 

642 if engine_type in self.recent_attempts: 

643 del self.recent_attempts[engine_type] 

644 if engine_type in self.current_estimates: 

645 del self.current_estimates[engine_type] 

646 

647 # Skip database operations in programmatic mode 

648 if self.programmatic_mode: 

649 logger.debug( 

650 f"Reset rate limit data for {engine_type} (memory only in programmatic mode)" 

651 ) 

652 return 

653 

654 # Skip database operations in CI mode 

655 if is_ci_environment(): 

656 logger.debug( 

657 f"Reset rate limit data for {engine_type} (memory only in CI mode)" 

658 ) 

659 return 

660 

661 try: 

662 context = get_search_context() 

663 if not context: 663 ↛ 664line 663 didn't jump to line 664 because the condition on line 663 was never true

664 logger.debug( 

665 f"Reset rate limit data for {engine_type} (memory only - no user context)" 

666 ) 

667 return 

668 

669 username = context.get("username") 

670 password = context.get("user_password") 

671 if not username or not password: 671 ↛ 672line 671 didn't jump to line 672 because the condition on line 671 was never true

672 logger.debug( 

673 f"Reset rate limit data for {engine_type} (memory only - no credentials)" 

674 ) 

675 return 

676 

677 db_imports = _get_db_imports() 

678 RateLimitAttempt = db_imports.get("RateLimitAttempt") 

679 RateLimitEstimate = db_imports.get("RateLimitEstimate") 

680 

681 from ...database.thread_metrics import metrics_writer 

682 

683 metrics_writer.set_user_password(username, password) 

684 

685 session_reset: "Session" 

686 with metrics_writer.get_session(username) as session_reset: 

687 # Delete historical attempts 

688 session_reset.query(RateLimitAttempt).filter_by( 

689 engine_type=engine_type 

690 ).delete() 

691 

692 # Delete estimates 

693 session_reset.query(RateLimitEstimate).filter_by( 

694 engine_type=engine_type 

695 ).delete() 

696 

697 session_reset.commit() 

698 

699 logger.info(f"Reset rate limit data for {engine_type}") 

700 

701 except Exception: 

702 logger.warning( 

703 f"Failed to reset rate limit data in database for {engine_type}" 

704 "In-memory data was cleared successfully." 

705 ) 

706 # Don't re-raise in test contexts - the memory cleanup is sufficient 

707 

708 def get_search_quality_stats( 

709 self, engine_type: Optional[str] = None 

710 ) -> List[Dict]: 

711 """ 

712 Get basic search quality statistics for monitoring. 

713 

714 Note: no HTTP route consumes this method any more — 

715 ``/benchmark/api/search-quality`` now reports success_rate from the 

716 persisted ``RateLimitEstimate`` table instead of this in-memory, 

717 per-process view. It is retained as a tracker diagnostic capability 

718 (with its own test coverage) for CLI / programmatic use; the 

719 ``recent_avg_results`` / ``sample_size`` shape it returns is no longer 

720 exposed over the API. 

721 

722 Args: 

723 engine_type: Specific engine to get stats for, or None for all 

724 

725 Returns: 

726 List of dictionaries with search quality metrics 

727 """ 

728 stats = [] 

729 

730 with self._cache_lock: 

731 engines_to_check = ( 

732 [engine_type] 

733 if engine_type 

734 else list(self.recent_attempts.keys()) 

735 ) 

736 # Snapshot the data under lock, then process outside 

737 engine_attempts = {} 

738 for engine in engines_to_check: 

739 if engine in self.recent_attempts: 

740 engine_attempts[engine] = list(self.recent_attempts[engine]) 

741 

742 for engine, recent in engine_attempts.items(): 

743 search_counts = [ 

744 attempt.get("search_result_count", 0) 

745 for attempt in recent 

746 if attempt.get("search_result_count") is not None 

747 ] 

748 

749 if not search_counts: 

750 continue 

751 

752 recent_avg = sum(search_counts) / len(search_counts) 

753 

754 stats.append( 

755 { 

756 "engine_type": engine, 

757 "recent_avg_results": recent_avg, 

758 "min_recent_results": min(search_counts), 

759 "max_recent_results": max(search_counts), 

760 "sample_size": len(search_counts), 

761 "total_attempts": len(recent), 

762 "status": self._get_quality_status(recent_avg), 

763 } 

764 ) 

765 

766 return stats 

767 

768 def _get_quality_status(self, recent_avg: float) -> str: 

769 """Get quality status string based on average results.""" 

770 if recent_avg < 1: 

771 return "CRITICAL" 

772 if recent_avg < 3: 

773 return "WARNING" 

774 if recent_avg < 5: 

775 return "CAUTION" 

776 if recent_avg >= 10: 

777 return "EXCELLENT" 

778 return "GOOD" 

779 

780 def cleanup_old_data(self, days: int = 30) -> None: 

781 """ 

782 Remove old retry attempt data to prevent database bloat. 

783 

784 Args: 

785 days: Remove data older than this many days 

786 """ 

787 cutoff_time = time.time() - (days * 24 * 3600) 

788 

789 # Skip database operations in programmatic mode 

790 if self.programmatic_mode: 

791 logger.debug("Skipping database cleanup in programmatic mode") 

792 return 

793 

794 # Skip database operations in CI mode 

795 if is_ci_environment(): 

796 logger.debug("Skipping database cleanup in CI mode") 

797 return 

798 

799 try: 

800 context = get_search_context() 

801 if not context: 801 ↛ 802line 801 didn't jump to line 802 because the condition on line 801 was never true

802 logger.debug("Skipping database cleanup - no user context") 

803 return 

804 

805 username = context.get("username") 

806 password = context.get("user_password") 

807 if not username or not password: 807 ↛ 808line 807 didn't jump to line 808 because the condition on line 807 was never true

808 logger.debug("Skipping database cleanup - no credentials") 

809 return 

810 

811 db_imports = _get_db_imports() 

812 RateLimitAttempt = db_imports.get("RateLimitAttempt") 

813 

814 from ...database.thread_metrics import metrics_writer 

815 

816 metrics_writer.set_user_password(username, password) 

817 

818 session_clean: "Session" 

819 with metrics_writer.get_session(username) as session_clean: 

820 # Count and delete old attempts 

821 old_attempts: Any = session_clean.query( 

822 RateLimitAttempt 

823 ).filter(RateLimitAttempt.timestamp < cutoff_time) 

824 deleted_count = old_attempts.count() 

825 old_attempts.delete() 

826 

827 session_clean.commit() 

828 

829 if deleted_count > 0: 

830 logger.info(f"Cleaned up {deleted_count} old retry attempts") 

831 

832 except Exception: 

833 logger.warning("Failed to cleanup old rate limit data") 

834 

835 

836def get_tracker() -> AdaptiveRateLimitTracker: 

837 """Create a fresh rate limit tracker instance. 

838 

839 Returns a new instance each call so that per-user DB credentials 

840 are never cached across requests in a multi-user Flask app. 

841 """ 

842 return AdaptiveRateLimitTracker()