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

408 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 contextlib import AbstractContextManager, contextmanager, nullcontext 

10from typing import ( 

11 TYPE_CHECKING, 

12 Any, 

13 Dict, 

14 Iterator, 

15 List, 

16 Optional, 

17 Tuple, 

18) 

19from cachetools import LRUCache 

20 

21if TYPE_CHECKING: 

22 from sqlalchemy.orm import Session 

23from ...security.secure_logging import logger 

24 

25from ...config.thread_settings import ( 

26 NoSettingsContextError, 

27 get_setting_from_snapshot, 

28 get_settings_context, 

29) 

30from ...settings.env_registry import is_ci_environment 

31from ...utilities.thread_context import get_search_context 

32from ...utilities.metric_write_health import record_metric_write_drop 

33 

34# Lazy imports to avoid database initialization in programmatic mode 

35_db_imports = None 

36 

37 

38def _get_db_imports(): 

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

40 global _db_imports 

41 if _db_imports is None: 

42 try: 

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

44 from ...database.thread_local_session import thread_session_manager 

45 

46 _db_imports = { 

47 "RateLimitAttempt": RateLimitAttempt, 

48 "RateLimitEstimate": RateLimitEstimate, 

49 "thread_session_manager": thread_session_manager, 

50 } 

51 except (ImportError, RuntimeError): 

52 # Database not available - programmatic mode 

53 _db_imports = {} 

54 return _db_imports 

55 

56 

57class AdaptiveRateLimitTracker: 

58 """ 

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

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

61 """ 

62 

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

64 self.settings_snapshot = settings_snapshot or {} 

65 self.programmatic_mode = programmatic_mode 

66 

67 # Helper function to get settings with defaults 

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

69 try: 

70 value = get_setting_from_snapshot( 

71 key, 

72 settings_snapshot=self.settings_snapshot, 

73 ) 

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

75 if value is None: 

76 return default 

77 return type_fn(value) if type_fn else value 

78 except NoSettingsContextError: 

79 return default 

80 

81 # Get settings with explicit defaults 

82 self.memory_window = get_setting_or_default( 

83 "rate_limiting.memory_window", 100, int 

84 ) 

85 self.exploration_rate = get_setting_or_default( 

86 "rate_limiting.exploration_rate", 0.1, float 

87 ) 

88 self.learning_rate = get_setting_or_default( 

89 "rate_limiting.learning_rate", 0.3, float 

90 ) 

91 self.decay_per_day = get_setting_or_default( 

92 "rate_limiting.decay_per_day", 0.95, float 

93 ) 

94 

95 # In programmatic mode, default to disabled 

96 self.enabled = get_setting_or_default( 

97 "rate_limiting.enabled", 

98 not self.programmatic_mode, # Default based on mode 

99 bool, 

100 ) 

101 

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

103 

104 if self.programmatic_mode and self.enabled: 

105 logger.info( 

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

107 ) 

108 

109 # Apply rate limiting profile 

110 self._apply_profile(profile) 

111 

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

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

114 self._cache_lock = threading.Lock() 

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

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

117 

118 # Initialize the _estimates_loaded flag 

119 self._estimates_loaded = False 

120 

121 # Load estimates from database 

122 self._load_estimates() 

123 

124 logger.info( 

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

126 ) 

127 

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

129 """Apply rate limiting profile settings.""" 

130 if profile == "conservative": 

131 # More conservative: lower exploration, slower learning 

132 self.exploration_rate = min( 

133 self.exploration_rate * 0.5, 0.05 

134 ) # 5% max exploration 

135 self.learning_rate = min( 

136 self.learning_rate * 0.7, 0.2 

137 ) # Slower learning 

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

139 elif profile == "aggressive": 

140 # More aggressive: higher exploration, faster learning 

141 self.exploration_rate = min( 

142 self.exploration_rate * 1.5, 0.2 

143 ) # Up to 20% exploration 

144 self.learning_rate = min( 

145 self.learning_rate * 1.3, 0.5 

146 ) # Faster learning 

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

148 else: # balanced 

149 # Use settings as-is 

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

151 

152 def _load_estimates(self) -> None: 

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

154 # Skip database operations in programmatic mode 

155 if self.programmatic_mode: 

156 logger.debug( 

157 "Skipping rate limit estimate loading in programmatic mode" 

158 ) 

159 self._estimates_loaded = ( 

160 True # Mark as loaded to skip future attempts 

161 ) 

162 return 

163 

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

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

166 self._estimates_loaded = False 

167 logger.debug( 

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

169 ) 

170 

171 def _resolve_session( 

172 self, username: str, password: str 

173 ) -> AbstractContextManager["Session"]: 

174 """Unified session resolution helper for database operations. 

175 

176 Reuses the thread-local session when an enclosing scope is active 

177 (avoiding a second QueuePool connection and SQLite busy-timeout deadlock); 

178 otherwise falls back to a dedicated session that commits and closes on exit. 

179 Raises ``RuntimeError`` when the active scope holds no session for 

180 ``username``, for example because it belongs to another user. 

181 """ 

182 db_imports = _get_db_imports() or {} 

183 thread_session_manager = db_imports.get("thread_session_manager") 

184 if thread_session_manager is None: 

185 try: 

186 from ...database.thread_local_session import ( 

187 thread_session_manager, 

188 ) 

189 except (ImportError, RuntimeError): 

190 thread_session_manager = None 

191 

192 if ( 

193 thread_session_manager is not None 

194 and thread_session_manager.in_scope() 

195 ): 

196 session = thread_session_manager.get_scoped_session(username) 

197 if session is None: 

198 raise RuntimeError( 

199 "Rate telemetry requires a matching active owner session" 

200 ) 

201 # Borrow without revalidation, replacement, commit, or rollback. 

202 return nullcontext(session) 

203 

204 from ...database.thread_metrics import metrics_writer 

205 

206 metrics_writer.set_user_password(username, password) 

207 return metrics_writer.get_session(username) 

208 

209 @contextmanager 

210 def _isolated_begin_nested(self, session: "Session") -> Iterator[None]: 

211 """Open a SAVEPOINT without flushing pre-existing pending ORM state. 

212 

213 ``Session.begin_nested()`` flushes every pending change before it 

214 issues SAVEPOINT. If another connection holds SQLite's writer lock, 

215 that flush fails outside the savepoint and SQLAlchemy invalidates 

216 the caller's transaction. Detach already-pending instances first so 

217 tracker writes cannot flush them until after the isolation boundary 

218 exists; restore them after the savepoint settles. The savepoint 

219 joins the caller's open write transaction, or commits at once on 

220 its own when the scope has not written (``telemetry_savepoint``). 

221 

222 Pending rate-limit records skip this operation, because loading or 

223 deleting their persisted counterparts would prevent safe restoration. 

224 Other pending caller state retains the existing detach/restore path. 

225 

226 Both expunge and restore handle errors: cascade-expelled 

227 children (``cascade="all, delete-orphan"`` such as 

228 ``ResearchHistory.resources``) are skipped instead of raising 

229 ``InvalidRequestError``, and any failure during expunge or restore 

230 is contained so a half-failed tracker write cannot leave caller 

231 objects detached from the session. 

232 """ 

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

234 from ...database.session_context import telemetry_savepoint 

235 

236 # Detaching a pending metrics row lets the tracker load another 

237 # instance with the same identity, making restoration fail. A bulk 

238 # reset/delete can likewise invalidate a caller's staged metrics edit. 

239 # Keep those rows attached and skip this best-effort operation. 

240 pending = (*session.new, *session.dirty, *session.deleted) 

241 if any( 

242 isinstance(row, (RateLimitAttempt, RateLimitEstimate)) 

243 for row in pending 

244 ): 

245 raise RuntimeError( 

246 "Rate telemetry cannot isolate pending rate-limit records" 

247 ) 

248 

249 pending_new = list(getattr(session, "new", ()) or ()) 

250 pending_deleted = list(getattr(session, "deleted", ()) or ()) 

251 skip_ids = {id(obj) for obj in pending_new} 

252 skip_ids.update(id(obj) for obj in pending_deleted) 

253 pending_dirty = [ 

254 obj 

255 for obj in list(getattr(session, "dirty", ()) or ()) 

256 if id(obj) not in skip_ids 

257 ] 

258 

259 # Dedupe by identity and skip objects already detached (e.g. by an 

260 # earlier cascade expunge of a parent). ``expunge`` cascades along 

261 # ``cascade="all, delete-orphan"`` relationships, so a parent + 

262 # child both pending must not be expunged twice. 

263 ordered: list[Any] = [] 

264 seen: set[int] = set() 

265 for obj in (*pending_new, *pending_dirty, *pending_deleted): 

266 if id(obj) in seen: 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true

267 continue 

268 seen.add(id(obj)) 

269 ordered.append(obj) 

270 

271 for obj in ordered: 

272 if obj not in session: 

273 # Cascade already detached this object when its parent was 

274 # expunged; it remains in pending_new/dirty/deleted by 

275 # Python identity, but not by session membership. 

276 continue 

277 try: 

278 session.expunge(obj) 

279 except Exception: 

280 # ``expunge`` can raise for already-detached instances or 

281 # for objects the session has lost track of. Swallow and 

282 # continue so any later objects can still be detached and 

283 # later we still attempt full restoration below. 

284 logger.debug( 

285 "Tracker isolation: expunge failed during pre-flush detach", 

286 exc_info=True, 

287 ) 

288 

289 try: 

290 with telemetry_savepoint(session): 

291 yield 

292 finally: 

293 self._restore_pending_state( 

294 session, pending_new, pending_dirty, pending_deleted 

295 ) 

296 

297 def _restore_pending_state( 

298 self, 

299 session: "Session", 

300 pending_new: list[Any], 

301 pending_dirty: list[Any], 

302 pending_deleted: list[Any], 

303 ) -> None: 

304 """Best-effort restoration of expunged caller objects. 

305 

306 Adds new/dirty objects back to the session and re-stages deleted 

307 objects. ``add(parent)`` cascades to children for relationships 

308 declared with ``cascade="all, delete-orphan"``, so we skip any 

309 child already re-attached by cascade to avoid double-add errors. 

310 All restoration steps are individually exception-safe: a failure 

311 here must never propagate out and detach caller state. 

312 """ 

313 for obj in (*pending_new, *pending_dirty): 

314 try: 

315 if obj not in session: 

316 session.add(obj) 

317 except Exception: 

318 logger.debug( 

319 "Tracker isolation: failed to re-attach caller object", 

320 exc_info=True, 

321 ) 

322 for obj in pending_deleted: 322 ↛ 323line 322 didn't jump to line 323 because the loop on line 322 never started

323 try: 

324 if obj not in session: 

325 session.add(obj) 

326 except Exception: 

327 logger.debug( 

328 "Tracker isolation: failed to re-add deleted caller object", 

329 exc_info=True, 

330 ) 

331 continue 

332 try: 

333 session.delete(obj) 

334 except Exception: 

335 logger.debug( 

336 "Tracker isolation: failed to re-stage deleted caller object", 

337 exc_info=True, 

338 ) 

339 

340 def _ensure_estimates_loaded(self) -> None: 

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

342 # Early return if already loaded or should skip 

343 if self._estimates_loaded or self.programmatic_mode: 

344 if not self._estimates_loaded: 

345 self._estimates_loaded = True 

346 return 

347 

348 # Get database imports 

349 db_imports = _get_db_imports() 

350 RateLimitEstimate = ( 

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

352 ) 

353 

354 if not db_imports or not RateLimitEstimate: 

355 # Database not available 

356 self._estimates_loaded = True 

357 return 

358 

359 # Try to get research context from search tracker 

360 

361 context = get_search_context() 

362 if not context: 

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

364 self._estimates_loaded = True 

365 return 

366 

367 username = context.get("username") 

368 password = context.get("user_password") 

369 

370 if username and password: 

371 try: 

372 session: "Session" 

373 with self._resolve_session(username, password) as session: 

374 # The production sessionmaker defaults to 

375 # ``autoflush=True``, so a bare ``query()`` could flush 

376 # the caller's pending ORM writes before the SELECT 

377 # runs. Under a competing SQLite writer that flush 

378 # fails and SQLAlchemy invalidates the outer 

379 # transaction; ``no_autoflush`` keeps the read from 

380 # touching unrelated caller state. 

381 with session.no_autoflush: 

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

383 RateLimitEstimate 

384 ).all() 

385 

386 for estimate in estimates: 

387 # Apply decay for old estimates 

388 age_hours = ( 

389 time.time() - estimate.last_updated 

390 ) / 3600 

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

392 

393 with self._cache_lock: 

394 self.current_estimates[estimate.engine_type] = { 

395 "base": estimate.base_wait_seconds, 

396 "min": estimate.min_wait_seconds, 

397 "max": estimate.max_wait_seconds, 

398 "confidence": decay, 

399 } 

400 

401 logger.debug( 

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

403 ) 

404 

405 self._estimates_loaded = True 

406 logger.info( 

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

408 ) 

409 

410 except Exception: 

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

412 # Mark as loaded anyway to avoid repeated attempts 

413 self._estimates_loaded = True 

414 

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

416 """ 

417 Get adaptive wait time for a search engine. 

418 Includes exploration to discover better rates. 

419 

420 Args: 

421 engine_type: Name of the search engine 

422 

423 Returns: 

424 Wait time in seconds 

425 """ 

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

427 if not self.enabled: 

428 return 0.1 

429 

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

431 context = get_search_context() 

432 if not context and not self.programmatic_mode: 

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

434 logger.warning( 

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

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

437 "This may indicate a configuration issue." 

438 ) 

439 return 0.0 

440 

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

442 

443 # Ensure estimates are loaded from database 

444 self._ensure_estimates_loaded() 

445 

446 with self._cache_lock: 

447 if engine_type not in self.current_estimates: 

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

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

450 optimistic_defaults = { 

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

452 } 

453 

454 wait_time = optimistic_defaults.get( 

455 engine_type, 0.1 

456 ) # Default optimistic for others 

457 logger.info( 

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

459 ) 

460 return wait_time 

461 

462 estimate = self.current_estimates[engine_type] 

463 base_wait = estimate["base"] 

464 

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

466 # Exploration vs exploitation 

467 # nosemgrep: semgrep.rules.weak-random-generation, reason: Non-security scheduling or rate-limit jitter 

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

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

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

471 logger.debug( 

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

473 ) 

474 else: 

475 # Exploit: use learned estimate with jitter 

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

477 

478 # Enforce bounds 

479 wait_time = max( 

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

481 ) 

482 return float(wait_time) 

483 

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

485 """ 

486 Apply rate limiting for the given engine type. 

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

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

489 

490 Args: 

491 engine_type: The type of search engine 

492 

493 Returns: 

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

495 """ 

496 if not self.enabled: 

497 return 0.0 

498 

499 wait_time = self.get_wait_time(engine_type) 

500 if wait_time > 0: 

501 logger.debug( 

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

503 ) 

504 time.sleep(wait_time) 

505 return wait_time 

506 

507 def record_outcome( 

508 self, 

509 engine_type: str, 

510 wait_time: float, 

511 success: bool, 

512 retry_count: int, 

513 error_type: Optional[str] = None, 

514 search_result_count: Optional[int] = None, 

515 ) -> None: 

516 """ 

517 Record the outcome of a retry attempt. 

518 

519 Args: 

520 engine_type: Name of the search engine 

521 wait_time: How long we waited before this attempt 

522 success: Whether the attempt succeeded 

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

524 error_type: Type of error if failed 

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

526 """ 

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

528 if not self.enabled: 

529 logger.info( 

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

531 ) 

532 return 

533 

534 logger.debug( 

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

536 ) 

537 timestamp = time.time() 

538 

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

540 # database locking issues under heavy parallel search load. 

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

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

543 

544 # Update in-memory tracking 

545 with self._cache_lock: 

546 if engine_type not in self.recent_attempts: 

547 # Get current memory window setting from thread context 

548 settings_context = get_settings_context() 

549 if settings_context: 

550 current_memory_window = int( 

551 settings_context.get_setting( 

552 "rate_limiting.memory_window", self.memory_window 

553 ) 

554 ) 

555 else: 

556 current_memory_window = self.memory_window 

557 

558 self.recent_attempts[engine_type] = deque( 

559 maxlen=current_memory_window 

560 ) 

561 

562 self.recent_attempts[engine_type].append( 

563 { 

564 "wait_time": wait_time, 

565 "success": success, 

566 "timestamp": timestamp, 

567 "retry_count": retry_count, 

568 "search_result_count": search_result_count, 

569 } 

570 ) 

571 

572 # Update estimates 

573 self._update_estimate(engine_type) 

574 

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

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

577 with self._cache_lock: 

578 if ( 

579 engine_type not in self.recent_attempts 

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

581 ): 

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

583 logger.info( 

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

585 ) 

586 return 

587 

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

589 old_estimate = self.current_estimates.get(engine_type) 

590 

591 # Calculate success rate and optimal wait time 

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

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

594 

595 if not successful_waits: 

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

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

598 # Cap the base wait time to prevent runaway growth 

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

600 else: 

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

602 # This provides a balanced approach between speed and reliability 

603 successful_waits.sort() 

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

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

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

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

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

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

610 new_base = successful_waits[median_index] 

611 

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

613 if old_estimate is not None: 

614 old_base = old_estimate["base"] 

615 # Get current learning rate from settings context 

616 settings_context = get_settings_context() 

617 if settings_context: 

618 current_learning_rate = float( 

619 settings_context.get_setting( 

620 "rate_limiting.learning_rate", self.learning_rate 

621 ) 

622 ) 

623 else: 

624 current_learning_rate = self.learning_rate 

625 

626 new_base = ( 

627 1 - current_learning_rate 

628 ) * old_base + current_learning_rate * new_base 

629 

630 # Apply absolute cap to prevent extreme wait times 

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

632 

633 # Calculate bounds with more reasonable limits 

634 min_wait = max(0.01, new_base * 0.5) 

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

636 

637 # Update in memory 

638 with self._cache_lock: 

639 self.current_estimates[engine_type] = { 

640 "base": new_base, 

641 "min": min_wait, 

642 "max": max_wait, 

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

644 } 

645 

646 # Persist to database 

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

648 

649 # Skip database operations in programmatic mode 

650 if self.programmatic_mode: 

651 logger.debug( 

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

653 ) 

654 else: 

655 # Try to get research context from search tracker 

656 

657 context = get_search_context() 

658 username = None 

659 password = None 

660 if context is not None: 

661 username = context.get("username") 

662 password = context.get("user_password") 

663 

664 if username and password: 

665 try: 

666 from ...database.sqlcipher_utils import retry_on_db_lock 

667 

668 def _persist_estimate() -> None: 

669 db_imports = _get_db_imports() 

670 RateLimitEstimate = ( 

671 db_imports.get("RateLimitEstimate") 

672 if db_imports 

673 else None 

674 ) 

675 if not RateLimitEstimate: 

676 record_metric_write_drop( 

677 username, "rate_limits", "database_unavailable" 

678 ) 

679 return 

680 

681 session_inner: "Session" 

682 with self._resolve_session( 

683 username, password 

684 ) as session_inner: 

685 with self._isolated_begin_nested(session_inner): 

686 # Check if estimate exists 

687 estimate: Any = ( 

688 session_inner.query(RateLimitEstimate) 

689 .filter_by(engine_type=engine_type) 

690 .first() 

691 ) 

692 

693 if estimate: 

694 # Update existing estimate 

695 estimate.base_wait_seconds = new_base 

696 estimate.min_wait_seconds = min_wait 

697 estimate.max_wait_seconds = max_wait 

698 estimate.last_updated = time.time() 

699 estimate.total_attempts = len(attempts) 

700 estimate.success_rate = success_rate 

701 else: 

702 # Create new estimate 

703 estimate = RateLimitEstimate( 

704 engine_type=engine_type, 

705 base_wait_seconds=new_base, 

706 min_wait_seconds=min_wait, 

707 max_wait_seconds=max_wait, 

708 last_updated=time.time(), 

709 total_attempts=len(attempts), 

710 success_rate=success_rate, 

711 ) 

712 session_inner.add(estimate) 

713 

714 # Retry transient SQLITE_BUSY ("database is locked") 

715 # with backoff so a short overlap with another 

716 # writer does not surface as a permanent failure 

717 # outside a caller write-scope. Non-lock errors 

718 # propagate immediately. Each attempt re-resolves 

719 # its session; outside a caller scope that is a 

720 # fresh session per attempt (the common case for 

721 # engine threads), while inside a scope the shared 

722 # session is reused. A scope that has not written 

723 # gives each attempt its own short transaction (a 

724 # failed one is rolled back). Inside the caller's 

725 # write transaction its open snapshot is not rolled 

726 # back, so retries may fail fast and any poisoned 

727 # state surfaces (with its class logged below) 

728 # instead of silently failing. 

729 retry_on_db_lock(_persist_estimate) 

730 except Exception as exc: 

731 record_metric_write_drop( 

732 username, "rate_limits", "write_error" 

733 ) 

734 # Class only, never message/traceback: the DB 

735 # password is a live local in the session frames 

736 # below, and rendering them would leak it 

737 # (TRUST.md §5). The class name alone 

738 # distinguishes lock timeouts from poisoned 

739 # sessions, open failures, and constraint errors. 

740 logger.warning( 

741 "Failed to persist rate limit estimate " 

742 f"(exc_class={type(exc).__name__})" 

743 ) 

744 else: 

745 if username: 

746 record_metric_write_drop( 

747 username, "rate_limits", "missing_credentials" 

748 ) 

749 logger.debug( 

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

751 ) 

752 

753 logger.info( 

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

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

756 ) 

757 

758 def _get_in_memory_stats( 

759 self, engine_type: Optional[str] = None 

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

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

762 with self._cache_lock: 

763 stats = [] 

764 engines_to_check = ( 

765 [engine_type] 

766 if engine_type 

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

768 ) 

769 for engine in engines_to_check: 

770 if engine in self.current_estimates: 

771 est = self.current_estimates[engine] 

772 attempts = self.recent_attempts.get(engine) 

773 attempt_count = len(attempts) if attempts else 0 

774 stats.append( 

775 ( 

776 engine, 

777 est["base"], 

778 est["min"], 

779 est["max"], 

780 time.time(), 

781 attempt_count, 

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

783 ) 

784 ) 

785 return stats 

786 

787 def get_stats( 

788 self, engine_type: Optional[str] = None 

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

790 """ 

791 Get statistics for monitoring. 

792 

793 Args: 

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

795 

796 Returns: 

797 List of tuples with engine statistics 

798 """ 

799 # Skip database operations in CI mode 

800 if is_ci_environment(): 

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

802 return self._get_in_memory_stats(engine_type) 

803 

804 # Skip database operations in programmatic mode 

805 if self.programmatic_mode: 

806 return self._get_in_memory_stats(engine_type) 

807 

808 try: 

809 context = get_search_context() 

810 if not context: 

811 return self._get_in_memory_stats(engine_type) 

812 

813 username = context.get("username") 

814 password = context.get("user_password") 

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

816 return self._get_in_memory_stats(engine_type) 

817 

818 db_imports = _get_db_imports() 

819 RateLimitEstimate = db_imports.get("RateLimitEstimate") 

820 

821 session_est: "Session" 

822 with self._resolve_session(username, password) as session_est: 

823 # See ``_ensure_estimates_loaded``: the production 

824 # sessionmaker defaults to ``autoflush=True`` and a cold 

825 # ``get_stats`` would otherwise flush caller's pending 

826 # writes before the SELECT, which under a competing 

827 # SQLite writer fails and invalidates the outer 

828 # transaction. 

829 with session_est.no_autoflush: 

830 if engine_type: 

831 estimates: list[Any] = ( 

832 session_est.query(RateLimitEstimate) 

833 .filter_by(engine_type=engine_type) 

834 .all() 

835 ) 

836 else: 

837 estimates = ( 

838 session_est.query(RateLimitEstimate) 

839 .order_by(RateLimitEstimate.engine_type) 

840 .all() 

841 ) 

842 

843 return [ 

844 ( 

845 est.engine_type, 

846 est.base_wait_seconds, 

847 est.min_wait_seconds, 

848 est.max_wait_seconds, 

849 est.last_updated, 

850 est.total_attempts, 

851 est.success_rate, 

852 ) 

853 for est in estimates 

854 ] 

855 except Exception: 

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

857 # Return in-memory stats as fallback 

858 return self._get_in_memory_stats(engine_type) 

859 

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

861 """ 

862 Reset learned values for a specific engine. 

863 

864 Args: 

865 engine_type: Engine to reset 

866 """ 

867 # Always clear from memory first 

868 with self._cache_lock: 

869 if engine_type in self.recent_attempts: 

870 del self.recent_attempts[engine_type] 

871 if engine_type in self.current_estimates: 

872 del self.current_estimates[engine_type] 

873 

874 # Skip database operations in programmatic mode 

875 if self.programmatic_mode: 

876 logger.debug( 

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

878 ) 

879 return 

880 

881 # Skip database operations in CI mode 

882 if is_ci_environment(): 

883 logger.debug( 

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

885 ) 

886 return 

887 

888 try: 

889 context = get_search_context() 

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

891 logger.debug( 

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

893 ) 

894 return 

895 

896 username = context.get("username") 

897 password = context.get("user_password") 

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

899 logger.debug( 

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

901 ) 

902 return 

903 

904 db_imports = _get_db_imports() 

905 RateLimitAttempt = ( 

906 db_imports.get("RateLimitAttempt") if db_imports else None 

907 ) 

908 RateLimitEstimate = ( 

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

910 ) 

911 if not RateLimitAttempt or not RateLimitEstimate: 911 ↛ 912line 911 didn't jump to line 912 because the condition on line 911 was never true

912 return 

913 

914 from ...database.sqlcipher_utils import retry_on_db_lock 

915 

916 # Resolve again per attempt: owned sessions close before backoff; 

917 # borrowed sessions keep main's savepoint and caller-state isolation. 

918 def _reset_db(): 

919 session_reset: "Session" 

920 with self._resolve_session(username, password) as session_reset: 

921 with self._isolated_begin_nested(session_reset): 

922 # Delete historical attempts 

923 session_reset.query(RateLimitAttempt).filter_by( 

924 engine_type=engine_type 

925 ).delete() 

926 

927 # Delete estimates 

928 session_reset.query(RateLimitEstimate).filter_by( 

929 engine_type=engine_type 

930 ).delete() 

931 

932 retry_on_db_lock(_reset_db) 

933 

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

935 

936 except Exception: 

937 logger.warning( 

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

939 "In-memory data was cleared successfully." 

940 ) 

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

942 

943 def get_search_quality_stats( 

944 self, engine_type: Optional[str] = None 

945 ) -> List[Dict]: 

946 """ 

947 Get basic search quality statistics for monitoring. 

948 

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

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

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

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

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

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

955 exposed over the API. 

956 

957 Args: 

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

959 

960 Returns: 

961 List of dictionaries with search quality metrics 

962 """ 

963 stats = [] 

964 

965 with self._cache_lock: 

966 engines_to_check = ( 

967 [engine_type] 

968 if engine_type 

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

970 ) 

971 # Snapshot the data under lock, then process outside 

972 engine_attempts = {} 

973 for engine in engines_to_check: 

974 if engine in self.recent_attempts: 

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

976 

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

978 search_counts = [ 

979 attempt.get("search_result_count", 0) 

980 for attempt in recent 

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

982 ] 

983 

984 if not search_counts: 

985 continue 

986 

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

988 

989 stats.append( 

990 { 

991 "engine_type": engine, 

992 "recent_avg_results": recent_avg, 

993 "min_recent_results": min(search_counts), 

994 "max_recent_results": max(search_counts), 

995 "sample_size": len(search_counts), 

996 "total_attempts": len(recent), 

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

998 } 

999 ) 

1000 

1001 return stats 

1002 

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

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

1005 if recent_avg < 1: 

1006 return "CRITICAL" 

1007 if recent_avg < 3: 

1008 return "WARNING" 

1009 if recent_avg < 5: 

1010 return "CAUTION" 

1011 if recent_avg >= 10: 

1012 return "EXCELLENT" 

1013 return "GOOD" 

1014 

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

1016 """ 

1017 Remove old retry attempt data to prevent database bloat. 

1018 

1019 Args: 

1020 days: Remove data older than this many days 

1021 """ 

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

1023 

1024 # Skip database operations in programmatic mode 

1025 if self.programmatic_mode: 

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

1027 return 

1028 

1029 # Skip database operations in CI mode 

1030 if is_ci_environment(): 

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

1032 return 

1033 

1034 try: 

1035 context = get_search_context() 

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

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

1038 return 

1039 

1040 username = context.get("username") 

1041 password = context.get("user_password") 

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

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

1044 return 

1045 

1046 db_imports = _get_db_imports() 

1047 RateLimitAttempt = ( 

1048 db_imports.get("RateLimitAttempt") if db_imports else None 

1049 ) 

1050 if not RateLimitAttempt: 1050 ↛ 1051line 1050 didn't jump to line 1051 because the condition on line 1050 was never true

1051 return 

1052 

1053 from ...database.sqlcipher_utils import retry_on_db_lock 

1054 

1055 def _cleanup_db(): 

1056 session_clean: "Session" 

1057 with self._resolve_session(username, password) as session_clean: 

1058 with self._isolated_begin_nested(session_clean): 

1059 old_attempts: Any = session_clean.query( 

1060 RateLimitAttempt 

1061 ).filter(RateLimitAttempt.timestamp < cutoff_time) 

1062 deleted_count = old_attempts.count() 

1063 old_attempts.delete() 

1064 return deleted_count 

1065 

1066 deleted_count = retry_on_db_lock(_cleanup_db) 

1067 

1068 if deleted_count > 0: 

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

1070 

1071 except Exception: 

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

1073 

1074 

1075def get_tracker() -> AdaptiveRateLimitTracker: 

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

1077 

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

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

1080 """ 

1081 return AdaptiveRateLimitTracker()