7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
701 lines
24 KiB
Python
701 lines
24 KiB
Python
"""
|
|
Tests for the adaptive rate limiting system.
|
|
"""
|
|
|
|
import os
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.web_search_engines.rate_limiting import (
|
|
AdaptiveRateLimitTracker,
|
|
RateLimitError,
|
|
)
|
|
|
|
|
|
class TestAdaptiveRateLimitTracker(unittest.TestCase):
|
|
"""Test the AdaptiveRateLimitTracker class."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
"""Set up class-level fixtures."""
|
|
# Tables are created automatically via SQLAlchemy models
|
|
pass
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
# Note: Using the main database - in a real test environment
|
|
# you'd want to mock the database session
|
|
|
|
# Force settings to use default values
|
|
# The deprecated function has been removed
|
|
# Create settings snapshot with rate limiting enabled for tests
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": True, "ui_element": "checkbox"}
|
|
}
|
|
|
|
# Use programmatic_mode=True for tests to avoid needing user context
|
|
self.tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
# Skip database cleanup in CI to avoid timeouts
|
|
if os.environ.get("CI") != "true":
|
|
# Clean up any existing test data before each test
|
|
test_engines = [
|
|
"TestEngine",
|
|
"TestEngine_GetStats",
|
|
"TestEngine_Reset",
|
|
"SearXNGSearchEngine",
|
|
]
|
|
for engine in test_engines:
|
|
try:
|
|
self.tracker.reset_engine(engine)
|
|
except Exception:
|
|
pass
|
|
|
|
def tearDown(self):
|
|
"""Clean up test fixtures."""
|
|
# Skip database cleanup in CI to avoid timeouts
|
|
if os.environ.get("CI") != "true":
|
|
# Clean up any test data
|
|
test_engines = [
|
|
"TestEngine",
|
|
"TestEngine_GetStats",
|
|
"TestEngine_Reset",
|
|
"SearXNGSearchEngine",
|
|
]
|
|
for engine in test_engines:
|
|
try:
|
|
self.tracker.reset_engine(engine)
|
|
except Exception:
|
|
pass
|
|
|
|
@pytest.mark.timeout(30)
|
|
def test_get_wait_time_new_engine(self):
|
|
"""Test getting wait time for a new engine.
|
|
|
|
Uses timeout marker instead of skipping to fail fast if DB operations hang.
|
|
"""
|
|
# Reset any existing data for test engines
|
|
try:
|
|
self.tracker.reset_engine("TestEngine")
|
|
self.tracker.reset_engine("SearXNGSearchEngine")
|
|
except Exception:
|
|
pass
|
|
|
|
# Test default engine (unknown)
|
|
wait_time = self.tracker.get_wait_time("TestEngine")
|
|
self.assertEqual(wait_time, 0.1) # Default optimistic
|
|
|
|
# Test SearXNG (self-hosted default)
|
|
# Clear from current estimates to force default
|
|
if "SearXNGSearchEngine" in self.tracker.current_estimates:
|
|
del self.tracker.current_estimates["SearXNGSearchEngine"]
|
|
searxng_wait = self.tracker.get_wait_time("SearXNGSearchEngine")
|
|
self.assertEqual(searxng_wait, 0.1) # Very optimistic for self-hosted
|
|
|
|
@pytest.mark.timeout(30)
|
|
def test_record_outcome_and_learning(self):
|
|
"""Test recording outcomes and learning from them.
|
|
|
|
Uses timeout marker instead of skipping to fail fast if DB operations hang.
|
|
"""
|
|
engine_type = "TestEngine"
|
|
|
|
# Record several successful attempts with different wait times
|
|
successful_waits = [2.0, 2.5, 3.0, 2.2, 2.8]
|
|
for i, wait_time in enumerate(successful_waits):
|
|
self.tracker.record_outcome(
|
|
engine_type=engine_type,
|
|
wait_time=wait_time,
|
|
success=True,
|
|
retry_count=1,
|
|
)
|
|
|
|
# The tracker should have learned from these attempts
|
|
self.assertIn(engine_type, self.tracker.current_estimates)
|
|
|
|
# Get new wait time - should be influenced by successful attempts
|
|
new_wait_time = self.tracker.get_wait_time(engine_type)
|
|
self.assertGreater(new_wait_time, 0)
|
|
|
|
def test_record_failure_increases_wait_time(self):
|
|
"""Test that failures increase the wait time when all attempts fail."""
|
|
engine_type = "TestEngine"
|
|
|
|
# Record some initial successful attempts (need at least 3 for estimate creation)
|
|
for wait_time in [2.0, 2.5, 3.0]:
|
|
self.tracker.record_outcome(
|
|
engine_type=engine_type,
|
|
wait_time=wait_time,
|
|
success=True,
|
|
retry_count=1,
|
|
)
|
|
|
|
initial_estimate = self.tracker.current_estimates[engine_type]["base"]
|
|
|
|
# Reset and record only failures to test failure handling
|
|
self.tracker.recent_attempts[engine_type] = (
|
|
self.tracker.recent_attempts[engine_type].__class__(
|
|
maxlen=self.tracker.memory_window
|
|
)
|
|
)
|
|
|
|
# Record only failures (this should increase wait time)
|
|
for wait_time in [2.0, 2.5, 3.0]:
|
|
self.tracker.record_outcome(
|
|
engine_type=engine_type,
|
|
wait_time=wait_time,
|
|
success=False,
|
|
retry_count=2,
|
|
error_type="RateLimitError",
|
|
)
|
|
|
|
# Base wait time should increase after all failures
|
|
new_estimate = self.tracker.current_estimates[engine_type]["base"]
|
|
self.assertGreater(new_estimate, initial_estimate)
|
|
|
|
@pytest.mark.skip(
|
|
reason="Skip database persistence test - requires user context which is not available in test environment",
|
|
)
|
|
def test_persistence(self):
|
|
"""Test that estimates are persisted across instances."""
|
|
engine_type = "TestEngine2" # Use different name to avoid conflicts
|
|
|
|
# Record enough data to create an estimate (need at least 3 attempts)
|
|
for wait_time in [4.0, 5.0, 6.0]:
|
|
self.tracker.record_outcome(
|
|
engine_type=engine_type,
|
|
wait_time=wait_time,
|
|
success=True,
|
|
retry_count=1,
|
|
)
|
|
|
|
original_base = self.tracker.current_estimates[engine_type]["base"]
|
|
|
|
# Create a new tracker instance (uses same database)
|
|
new_tracker = AdaptiveRateLimitTracker()
|
|
|
|
# Should load the previous estimate
|
|
self.assertIn(engine_type, new_tracker.current_estimates)
|
|
loaded_base = new_tracker.current_estimates[engine_type]["base"]
|
|
|
|
# Should be close to the original (allowing for decay)
|
|
self.assertAlmostEqual(loaded_base, original_base, delta=1.0)
|
|
|
|
# Clean up
|
|
new_tracker.reset_engine(engine_type)
|
|
|
|
def test_get_stats(self):
|
|
"""Test getting statistics."""
|
|
# Use unique engine name for this test to avoid conflicts
|
|
import uuid
|
|
|
|
engine_type = f"TestEngine_GetStats_{uuid.uuid4().hex[:8]}"
|
|
|
|
# Create a fresh tracker for this test with programmatic_mode and rate limiting enabled
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": True, "ui_element": "checkbox"}
|
|
}
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
# Make sure we start clean
|
|
tracker.reset_engine(engine_type)
|
|
|
|
# Record enough data to create an estimate (need at least 3 attempts)
|
|
for wait_time in [3.0, 3.5, 4.0]:
|
|
tracker.record_outcome(
|
|
engine_type=engine_type,
|
|
wait_time=wait_time,
|
|
success=True,
|
|
retry_count=1,
|
|
)
|
|
|
|
# For get_stats, we'll check the in-memory estimates
|
|
self.assertIn(engine_type, tracker.current_estimates)
|
|
|
|
# Clean up
|
|
tracker.reset_engine(engine_type)
|
|
|
|
@pytest.mark.timeout(30)
|
|
def test_reset_engine(self):
|
|
"""Test resetting an engine's data.
|
|
|
|
Uses timeout marker instead of skipping to fail fast if DB operations hang.
|
|
"""
|
|
# Use unique engine name for this test
|
|
import uuid
|
|
|
|
engine_type = f"TestEngine_Reset_{uuid.uuid4().hex[:8]}"
|
|
|
|
# Use the test fixture's tracker instead of creating a new one
|
|
# This ensures we're using the same database session
|
|
tracker = self.tracker
|
|
|
|
# Make sure we start clean
|
|
tracker.reset_engine(engine_type)
|
|
|
|
# Record enough data to create an estimate (need at least 3 attempts)
|
|
for wait_time in [3.0, 3.5, 4.0]:
|
|
tracker.record_outcome(
|
|
engine_type=engine_type,
|
|
wait_time=wait_time,
|
|
success=True,
|
|
retry_count=1,
|
|
)
|
|
|
|
# Verify data exists
|
|
self.assertIn(engine_type, tracker.current_estimates)
|
|
|
|
# Get the wait time before reset - take multiple samples to account for randomness
|
|
wait_times_before = [
|
|
tracker.get_wait_time(engine_type) for _ in range(10)
|
|
]
|
|
avg_wait_time_before = sum(wait_times_before) / len(wait_times_before)
|
|
self.assertGreaterEqual(
|
|
avg_wait_time_before, 2.5
|
|
) # Should be around recorded values
|
|
|
|
# Reset the engine
|
|
try:
|
|
tracker.reset_engine(engine_type)
|
|
except Exception as e:
|
|
print(f"DEBUG: Exception during reset: {e}")
|
|
# Even if database reset fails, memory should be cleared
|
|
|
|
# Check immediately after reset, before calling get_wait_time
|
|
# The engine should not be in estimates after reset
|
|
self.assertNotIn(engine_type, tracker.current_estimates)
|
|
|
|
# After reset, wait time should be much lower
|
|
# Take multiple samples to account for randomness
|
|
wait_times_after = [
|
|
tracker.get_wait_time(engine_type) for _ in range(10)
|
|
]
|
|
avg_wait_time_after = sum(wait_times_after) / len(wait_times_after)
|
|
|
|
# After reset, should get default wait time (0.5s for unknown engines)
|
|
# With some tolerance for CI environment variations
|
|
self.assertLess(
|
|
avg_wait_time_after, 1.0
|
|
) # Should be close to default 0.5s
|
|
self.assertLess(
|
|
avg_wait_time_after, avg_wait_time_before * 0.5
|
|
) # Should be significantly lower
|
|
|
|
@pytest.mark.timeout(30)
|
|
def test_reset_engine_simple(self):
|
|
"""Simple test for reset functionality.
|
|
|
|
Uses timeout marker instead of skipping to fail fast if DB operations hang.
|
|
"""
|
|
engine_type = "TestEngine_Simple_Reset"
|
|
|
|
# Just test in-memory operations without database
|
|
self.tracker.current_estimates[engine_type] = {
|
|
"base": 5.0,
|
|
"min": 2.0,
|
|
"max": 10.0,
|
|
"confidence": 0.8,
|
|
}
|
|
from collections import deque
|
|
|
|
self.tracker.recent_attempts[engine_type] = deque(maxlen=100)
|
|
|
|
# Verify data exists
|
|
self.assertIn(engine_type, self.tracker.current_estimates)
|
|
|
|
# Clear in-memory data directly (bypass database)
|
|
if engine_type in self.tracker.current_estimates:
|
|
del self.tracker.current_estimates[engine_type]
|
|
if engine_type in self.tracker.recent_attempts:
|
|
del self.tracker.recent_attempts[engine_type]
|
|
|
|
# Verify data is cleared
|
|
self.assertNotIn(engine_type, self.tracker.current_estimates)
|
|
self.assertNotIn(engine_type, self.tracker.recent_attempts)
|
|
|
|
def test_exploration_vs_exploitation(self):
|
|
"""Test that exploration sometimes returns different wait times."""
|
|
engine_type = "TestEngine"
|
|
|
|
# Set up a known estimate
|
|
self.tracker.current_estimates[engine_type] = {
|
|
"base": 10.0,
|
|
"min": 5.0,
|
|
"max": 20.0,
|
|
"confidence": 0.8,
|
|
}
|
|
|
|
# Get multiple wait times
|
|
wait_times = [
|
|
self.tracker.get_wait_time(engine_type) for _ in range(50)
|
|
]
|
|
|
|
# Should have some variation due to exploration and jitter
|
|
unique_times = set(wait_times)
|
|
self.assertGreater(len(unique_times), 5) # Should have some variation
|
|
|
|
# All should be within bounds
|
|
for wait_time in wait_times:
|
|
self.assertGreaterEqual(wait_time, 5.0)
|
|
self.assertLessEqual(wait_time, 20.0)
|
|
|
|
|
|
class TestRateLimitIntegration(unittest.TestCase):
|
|
"""Test rate limiting integration with search engines."""
|
|
|
|
def test_rate_limit_error_exception(self):
|
|
"""Test that RateLimitError can be raised and caught."""
|
|
with self.assertRaises(RateLimitError):
|
|
raise RateLimitError("Test rate limit")
|
|
|
|
@patch(
|
|
"local_deep_research.web_search_engines.rate_limiting.tracker.AdaptiveRateLimitTracker"
|
|
)
|
|
def test_base_search_engine_integration(self, mock_tracker_class):
|
|
"""Test integration with BaseSearchEngine."""
|
|
# This would require more complex mocking of the search engine
|
|
# For now, just verify the import works
|
|
from local_deep_research.web_search_engines.search_engine_base import (
|
|
BaseSearchEngine,
|
|
)
|
|
|
|
# Create a mock engine to verify rate_tracker is set during init
|
|
# We need to provide required abstract methods
|
|
class MockSearchEngine(BaseSearchEngine):
|
|
def _get_previews(self, query):
|
|
return []
|
|
|
|
def _get_full_content(self, relevant_items):
|
|
return []
|
|
|
|
# Create instance and verify rate_tracker is set
|
|
mock_engine = MockSearchEngine()
|
|
self.assertTrue(hasattr(mock_engine, "rate_tracker"))
|
|
self.assertIsNotNone(mock_engine.rate_tracker)
|
|
|
|
|
|
class TestAdaptiveRateLimitTrackerProfiles(unittest.TestCase):
|
|
"""Test rate limiting profile configurations."""
|
|
|
|
def test_conservative_profile(self):
|
|
"""Test conservative profile applies lower rates."""
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": True, "ui_element": "checkbox"},
|
|
"rate_limiting.profile": {
|
|
"value": "conservative",
|
|
"ui_element": "select",
|
|
},
|
|
"rate_limiting.exploration_rate": {
|
|
"value": 0.1,
|
|
"ui_element": "slider",
|
|
},
|
|
"rate_limiting.learning_rate": {
|
|
"value": 0.3,
|
|
"ui_element": "slider",
|
|
},
|
|
}
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
# Conservative profile should reduce exploration rate
|
|
self.assertLessEqual(tracker.exploration_rate, 0.05)
|
|
# Conservative profile should reduce learning rate
|
|
self.assertLessEqual(tracker.learning_rate, 0.21)
|
|
|
|
def test_aggressive_profile(self):
|
|
"""Test aggressive profile applies higher rates."""
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": True, "ui_element": "checkbox"},
|
|
"rate_limiting.profile": {
|
|
"value": "aggressive",
|
|
"ui_element": "select",
|
|
},
|
|
"rate_limiting.exploration_rate": {
|
|
"value": 0.1,
|
|
"ui_element": "slider",
|
|
},
|
|
"rate_limiting.learning_rate": {
|
|
"value": 0.3,
|
|
"ui_element": "slider",
|
|
},
|
|
}
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
# Aggressive profile should increase exploration rate
|
|
self.assertGreaterEqual(tracker.exploration_rate, 0.1)
|
|
# Aggressive profile should increase learning rate
|
|
self.assertGreaterEqual(tracker.learning_rate, 0.3)
|
|
|
|
def test_balanced_profile(self):
|
|
"""Test balanced profile keeps default rates."""
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": True, "ui_element": "checkbox"},
|
|
"rate_limiting.profile": {
|
|
"value": "balanced",
|
|
"ui_element": "select",
|
|
},
|
|
"rate_limiting.exploration_rate": {
|
|
"value": 0.1,
|
|
"ui_element": "slider",
|
|
},
|
|
"rate_limiting.learning_rate": {
|
|
"value": 0.3,
|
|
"ui_element": "slider",
|
|
},
|
|
}
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
# Balanced profile should use configured rates
|
|
self.assertEqual(tracker.exploration_rate, 0.1)
|
|
self.assertEqual(tracker.learning_rate, 0.3)
|
|
|
|
|
|
class TestApplyRateLimit(unittest.TestCase):
|
|
"""Test the apply_rate_limit method."""
|
|
|
|
def test_apply_rate_limit_disabled(self):
|
|
"""Test apply_rate_limit returns 0 when disabled."""
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": False, "ui_element": "checkbox"},
|
|
}
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
wait_time = tracker.apply_rate_limit("TestEngine")
|
|
self.assertEqual(wait_time, 0.0)
|
|
|
|
def test_apply_rate_limit_enabled(self):
|
|
"""Test apply_rate_limit returns and applies wait time when enabled."""
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": True, "ui_element": "checkbox"},
|
|
}
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
# For unknown engines, returns default optimistic wait time
|
|
wait_time = tracker.apply_rate_limit("TestEngine")
|
|
self.assertGreaterEqual(wait_time, 0.0)
|
|
|
|
|
|
class TestUpdateEstimate(unittest.TestCase):
|
|
"""Test the _update_estimate method."""
|
|
|
|
def test_update_estimate_needs_minimum_attempts(self):
|
|
"""Test that update_estimate requires at least 3 attempts."""
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": True, "ui_element": "checkbox"},
|
|
}
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
engine_type = "TestEngine_MinAttempts"
|
|
|
|
# Record only 2 attempts
|
|
tracker.record_outcome(
|
|
engine_type=engine_type,
|
|
wait_time=1.0,
|
|
success=True,
|
|
retry_count=1,
|
|
)
|
|
tracker.record_outcome(
|
|
engine_type=engine_type,
|
|
wait_time=1.5,
|
|
success=True,
|
|
retry_count=1,
|
|
)
|
|
|
|
# Should not have an estimate yet
|
|
self.assertNotIn(engine_type, tracker.current_estimates)
|
|
|
|
# Third attempt should create estimate
|
|
tracker.record_outcome(
|
|
engine_type=engine_type,
|
|
wait_time=2.0,
|
|
success=True,
|
|
retry_count=1,
|
|
)
|
|
|
|
self.assertIn(engine_type, tracker.current_estimates)
|
|
|
|
def test_update_estimate_all_failures(self):
|
|
"""Test that all failures increase wait time."""
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": True, "ui_element": "checkbox"},
|
|
}
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
engine_type = "TestEngine_AllFail"
|
|
|
|
# Record 3 failed attempts
|
|
for wait_time in [1.0, 1.5, 2.0]:
|
|
tracker.record_outcome(
|
|
engine_type=engine_type,
|
|
wait_time=wait_time,
|
|
success=False,
|
|
retry_count=1,
|
|
)
|
|
|
|
# Should have an estimate with increased base time
|
|
self.assertIn(engine_type, tracker.current_estimates)
|
|
# Base should be higher than max failed wait * 1.5, capped at 10
|
|
self.assertGreater(tracker.current_estimates[engine_type]["base"], 2.0)
|
|
|
|
|
|
class TestGetStats(unittest.TestCase):
|
|
"""Test the get_stats method."""
|
|
|
|
def test_get_stats_in_ci_mode(self):
|
|
"""Test get_stats returns in-memory stats in CI mode."""
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": True, "ui_element": "checkbox"},
|
|
}
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
engine_type = "TestEngine_Stats"
|
|
|
|
# Record some attempts
|
|
for wait_time in [1.0, 1.5, 2.0]:
|
|
tracker.record_outcome(
|
|
engine_type=engine_type,
|
|
wait_time=wait_time,
|
|
success=True,
|
|
retry_count=1,
|
|
)
|
|
|
|
# Mock is_ci_environment to return True, ensuring we take the in-memory path
|
|
with patch(
|
|
"local_deep_research.web_search_engines.rate_limiting.tracker.is_ci_environment",
|
|
return_value=True,
|
|
):
|
|
# Get stats
|
|
stats = tracker.get_stats(engine_type)
|
|
|
|
# Should return stats from in-memory estimates
|
|
self.assertIsInstance(stats, list)
|
|
self.assertEqual(len(stats), 1)
|
|
|
|
def test_get_stats_all_engines_in_ci_mode(self):
|
|
"""Test get_stats with no engine type returns all in CI mode."""
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": True, "ui_element": "checkbox"},
|
|
}
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
# Record attempts for multiple engines
|
|
for engine in ["Engine1", "Engine2"]:
|
|
for wait_time in [1.0, 1.5, 2.0]:
|
|
tracker.record_outcome(
|
|
engine_type=engine,
|
|
wait_time=wait_time,
|
|
success=True,
|
|
retry_count=1,
|
|
)
|
|
|
|
# Mock is_ci_environment to return True
|
|
with patch(
|
|
"local_deep_research.web_search_engines.rate_limiting.tracker.is_ci_environment",
|
|
return_value=True,
|
|
):
|
|
# Get all stats
|
|
stats = tracker.get_stats()
|
|
|
|
# Should return stats for both engines
|
|
self.assertGreaterEqual(len(stats), 2)
|
|
|
|
|
|
class TestRateLimitingDisabled(unittest.TestCase):
|
|
"""Test behavior when rate limiting is disabled."""
|
|
|
|
def test_disabled_returns_minimal_wait(self):
|
|
"""Test disabled rate limiting returns minimal wait time."""
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": False, "ui_element": "checkbox"},
|
|
}
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
wait_time = tracker.get_wait_time("AnyEngine")
|
|
self.assertEqual(wait_time, 0.1)
|
|
|
|
def test_disabled_does_not_record_outcomes(self):
|
|
"""Test disabled rate limiting doesn't record outcomes."""
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": False, "ui_element": "checkbox"},
|
|
}
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
engine_type = "TestEngine_Disabled"
|
|
|
|
# Try to record outcomes
|
|
tracker.record_outcome(
|
|
engine_type=engine_type,
|
|
wait_time=1.0,
|
|
success=True,
|
|
retry_count=1,
|
|
)
|
|
|
|
# Should not create any tracking data
|
|
self.assertNotIn(engine_type, tracker.current_estimates)
|
|
self.assertNotIn(engine_type, tracker.recent_attempts)
|
|
|
|
|
|
class TestProgrammaticMode(unittest.TestCase):
|
|
"""Test programmatic mode behavior."""
|
|
|
|
def test_programmatic_mode_skips_database(self):
|
|
"""Test programmatic mode skips database operations."""
|
|
settings_snapshot = {
|
|
"rate_limiting.enabled": {"value": True, "ui_element": "checkbox"},
|
|
}
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings_snapshot, programmatic_mode=True
|
|
)
|
|
|
|
# Should work without database errors
|
|
engine_type = "TestEngine_Programmatic"
|
|
|
|
for wait_time in [1.0, 1.5, 2.0]:
|
|
tracker.record_outcome(
|
|
engine_type=engine_type,
|
|
wait_time=wait_time,
|
|
success=True,
|
|
retry_count=1,
|
|
)
|
|
|
|
# Should have in-memory estimate
|
|
self.assertIn(engine_type, tracker.current_estimates)
|
|
|
|
def test_programmatic_mode_default_disabled(self):
|
|
"""Test programmatic mode defaults to disabled rate limiting."""
|
|
tracker = AdaptiveRateLimitTracker(programmatic_mode=True)
|
|
|
|
# Without explicit settings, programmatic mode disables rate limiting
|
|
self.assertFalse(tracker.enabled)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|