7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
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
439 lines
14 KiB
Python
439 lines
14 KiB
Python
"""Tests for rate limiting database models."""
|
|
|
|
import time
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from local_deep_research.database.models import (
|
|
Base,
|
|
RateLimitAttempt,
|
|
RateLimitEstimate,
|
|
)
|
|
|
|
|
|
class TestRateLimitingModels:
|
|
"""Test suite for rate limiting models."""
|
|
|
|
@pytest.fixture
|
|
def engine(self):
|
|
"""Create an in-memory SQLite database for testing."""
|
|
engine = create_engine("sqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
yield engine
|
|
engine.dispose()
|
|
|
|
@pytest.fixture
|
|
def session(self, engine):
|
|
"""Create a database session for testing."""
|
|
Session = sessionmaker(bind=engine)
|
|
session = Session()
|
|
yield session
|
|
session.close()
|
|
|
|
def test_rate_limit_attempt_creation(self, session):
|
|
"""Test creating rate limit attempt records."""
|
|
attempt = RateLimitAttempt(
|
|
engine_type="google",
|
|
timestamp=time.time(),
|
|
wait_time=2.5,
|
|
retry_count=1,
|
|
success=True,
|
|
error_type=None,
|
|
)
|
|
|
|
session.add(attempt)
|
|
session.commit()
|
|
|
|
# Verify attempt
|
|
saved = session.query(RateLimitAttempt).first()
|
|
assert saved is not None
|
|
assert saved.engine_type == "google"
|
|
assert saved.wait_time == 2.5
|
|
assert saved.retry_count == 1
|
|
assert saved.success is True
|
|
assert saved.error_type is None
|
|
|
|
def test_failed_rate_limit_attempts(self, session):
|
|
"""Test tracking failed rate limit attempts."""
|
|
current_time = time.time()
|
|
|
|
# Successful attempt
|
|
success = RateLimitAttempt(
|
|
engine_type="bing",
|
|
timestamp=current_time,
|
|
wait_time=1.0,
|
|
retry_count=0,
|
|
success=True,
|
|
)
|
|
|
|
# Failed attempt - too fast
|
|
too_fast = RateLimitAttempt(
|
|
engine_type="bing",
|
|
timestamp=current_time + 1,
|
|
wait_time=0.5,
|
|
retry_count=1,
|
|
success=False,
|
|
error_type="rate_limit",
|
|
)
|
|
|
|
# Failed attempt - other error
|
|
other_error = RateLimitAttempt(
|
|
engine_type="bing",
|
|
timestamp=current_time + 2,
|
|
wait_time=2.0,
|
|
retry_count=2,
|
|
success=False,
|
|
error_type="connection",
|
|
)
|
|
|
|
session.add_all([success, too_fast, other_error])
|
|
session.commit()
|
|
|
|
# Analyze attempts
|
|
all_attempts = (
|
|
session.query(RateLimitAttempt).filter_by(engine_type="bing").all()
|
|
)
|
|
assert len(all_attempts) == 3
|
|
|
|
failed = session.query(RateLimitAttempt).filter_by(success=False).all()
|
|
assert len(failed) == 2
|
|
|
|
# Check error types
|
|
rate_limit_errors = (
|
|
session.query(RateLimitAttempt)
|
|
.filter_by(error_type="rate_limit")
|
|
.count()
|
|
)
|
|
assert rate_limit_errors == 1
|
|
|
|
def test_rate_limit_estimate(self, session):
|
|
"""Test rate limit estimate storage and updates."""
|
|
estimate = RateLimitEstimate(
|
|
engine_type="duckduckgo",
|
|
base_wait_seconds=1.5,
|
|
min_wait_seconds=0.5,
|
|
max_wait_seconds=10.0,
|
|
last_updated=time.time(),
|
|
total_attempts=100,
|
|
success_rate=0.85,
|
|
)
|
|
|
|
session.add(estimate)
|
|
session.commit()
|
|
|
|
# Verify estimate
|
|
saved = (
|
|
session.query(RateLimitEstimate)
|
|
.filter_by(engine_type="duckduckgo")
|
|
.first()
|
|
)
|
|
assert saved is not None
|
|
assert saved.base_wait_seconds == 1.5
|
|
assert saved.success_rate == 0.85
|
|
assert saved.total_attempts == 100
|
|
|
|
def test_multiple_engine_tracking(self, session):
|
|
"""Test tracking rate limits for multiple search engines."""
|
|
engines = [
|
|
("google", 1.0, 0.5, 5.0, 0.9),
|
|
("bing", 0.8, 0.3, 3.0, 0.92),
|
|
("duckduckgo", 0.5, 0.1, 2.0, 0.95),
|
|
("searx", 2.0, 1.0, 10.0, 0.8),
|
|
]
|
|
|
|
current_time = time.time()
|
|
|
|
for engine, base, min_val, max_val, success_rate in engines:
|
|
estimate = RateLimitEstimate(
|
|
engine_type=engine,
|
|
base_wait_seconds=base,
|
|
min_wait_seconds=min_val,
|
|
max_wait_seconds=max_val,
|
|
last_updated=current_time,
|
|
total_attempts=50,
|
|
success_rate=success_rate,
|
|
)
|
|
session.add(estimate)
|
|
|
|
session.commit()
|
|
|
|
# Verify all engines
|
|
all_estimates = session.query(RateLimitEstimate).all()
|
|
assert len(all_estimates) == 4
|
|
|
|
# Find most reliable engine
|
|
most_reliable = (
|
|
session.query(RateLimitEstimate)
|
|
.order_by(RateLimitEstimate.success_rate.desc())
|
|
.first()
|
|
)
|
|
assert most_reliable.engine_type == "duckduckgo"
|
|
|
|
def test_adaptive_rate_learning(self, session):
|
|
"""Test updating rate limit estimates based on attempts."""
|
|
# Initial estimate
|
|
estimate = RateLimitEstimate(
|
|
engine_type="adaptive_test",
|
|
base_wait_seconds=1.0,
|
|
min_wait_seconds=0.5,
|
|
max_wait_seconds=5.0,
|
|
last_updated=time.time(),
|
|
total_attempts=10,
|
|
success_rate=0.8,
|
|
)
|
|
|
|
session.add(estimate)
|
|
session.commit()
|
|
|
|
# Simulate attempts
|
|
current_time = time.time()
|
|
attempts = [
|
|
(0.5, False), # Too fast
|
|
(0.8, False), # Still too fast
|
|
(1.2, True), # Success
|
|
(1.1, True), # Success
|
|
(1.0, True), # Success
|
|
(0.9, False), # Too fast again
|
|
(1.1, True), # Success
|
|
]
|
|
|
|
for i, (wait_time, success) in enumerate(attempts):
|
|
attempt = RateLimitAttempt(
|
|
engine_type="adaptive_test",
|
|
timestamp=current_time + i,
|
|
wait_time=wait_time,
|
|
retry_count=0 if success else 1,
|
|
success=success,
|
|
error_type=None if success else "rate_limit",
|
|
)
|
|
session.add(attempt)
|
|
|
|
session.commit()
|
|
|
|
# Update estimate based on attempts
|
|
successful_waits = (
|
|
session.query(RateLimitAttempt.wait_time)
|
|
.filter_by(engine_type="adaptive_test", success=True)
|
|
.all()
|
|
)
|
|
|
|
if successful_waits:
|
|
avg_successful_wait = sum(w[0] for w in successful_waits) / len(
|
|
successful_waits
|
|
)
|
|
estimate.base_wait_seconds = avg_successful_wait
|
|
estimate.total_attempts += len(attempts)
|
|
estimate.success_rate = len(successful_waits) / len(attempts)
|
|
estimate.last_updated = time.time()
|
|
session.commit()
|
|
|
|
# Verify updated estimate
|
|
updated = (
|
|
session.query(RateLimitEstimate)
|
|
.filter_by(engine_type="adaptive_test")
|
|
.first()
|
|
)
|
|
assert (
|
|
updated.base_wait_seconds > 1.0
|
|
) # Should increase based on attempts
|
|
assert updated.total_attempts == 17 # 10 + 7
|
|
assert updated.success_rate == 4 / 7 # 4 successes out of 7 attempts
|
|
|
|
def test_time_based_patterns(self, session):
|
|
"""Test identifying time-based rate limit patterns."""
|
|
# Simulate different success rates at different times
|
|
base_time = time.time()
|
|
|
|
# Morning hours - higher success rate
|
|
for i in range(10):
|
|
attempt = RateLimitAttempt(
|
|
engine_type="time_pattern",
|
|
timestamp=base_time + i * 60, # Every minute
|
|
wait_time=1.0,
|
|
retry_count=0,
|
|
success=i % 3 != 0, # 66% success
|
|
error_type=None if i % 3 != 0 else "rate_limit",
|
|
)
|
|
session.add(attempt)
|
|
|
|
# Afternoon - lower success rate
|
|
for i in range(10):
|
|
attempt = RateLimitAttempt(
|
|
engine_type="time_pattern",
|
|
timestamp=base_time + 3600 + i * 60, # 1 hour later
|
|
wait_time=1.0,
|
|
retry_count=0,
|
|
success=i % 2 == 0, # 50% success
|
|
error_type=None if i % 2 == 0 else "rate_limit",
|
|
)
|
|
session.add(attempt)
|
|
|
|
session.commit()
|
|
|
|
# Analyze patterns
|
|
morning_attempts = (
|
|
session.query(RateLimitAttempt)
|
|
.filter(
|
|
RateLimitAttempt.engine_type == "time_pattern",
|
|
RateLimitAttempt.timestamp < base_time + 3600,
|
|
)
|
|
.all()
|
|
)
|
|
|
|
afternoon_attempts = (
|
|
session.query(RateLimitAttempt)
|
|
.filter(
|
|
RateLimitAttempt.engine_type == "time_pattern",
|
|
RateLimitAttempt.timestamp >= base_time + 3600,
|
|
)
|
|
.all()
|
|
)
|
|
|
|
morning_success_rate = sum(
|
|
1 for a in morning_attempts if a.success
|
|
) / len(morning_attempts)
|
|
afternoon_success_rate = sum(
|
|
1 for a in afternoon_attempts if a.success
|
|
) / len(afternoon_attempts)
|
|
|
|
assert morning_success_rate > afternoon_success_rate
|
|
|
|
def test_estimate_updates(self, session):
|
|
"""Test updating rate limit estimates."""
|
|
# Create initial estimate
|
|
estimate = RateLimitEstimate(
|
|
engine_type="update_test",
|
|
base_wait_seconds=1.0,
|
|
min_wait_seconds=0.5,
|
|
max_wait_seconds=5.0,
|
|
last_updated=time.time() - 3600, # 1 hour ago
|
|
total_attempts=50,
|
|
success_rate=0.8,
|
|
)
|
|
|
|
session.add(estimate)
|
|
session.commit()
|
|
|
|
# Update estimate
|
|
estimate.base_wait_seconds = 1.5
|
|
estimate.success_rate = 0.85
|
|
estimate.total_attempts = 75
|
|
estimate.last_updated = time.time()
|
|
|
|
session.commit()
|
|
|
|
# Verify updates
|
|
updated = (
|
|
session.query(RateLimitEstimate)
|
|
.filter_by(engine_type="update_test")
|
|
.first()
|
|
)
|
|
assert updated.base_wait_seconds == 1.5
|
|
assert updated.success_rate == 0.85
|
|
assert updated.total_attempts == 75
|
|
assert updated.last_updated > time.time() - 60 # Updated recently
|
|
|
|
def test_cleanup_old_attempts(self, session):
|
|
"""Test cleaning up old rate limit attempts."""
|
|
current_time = time.time()
|
|
|
|
# Create attempts at different ages
|
|
for days_ago in range(10):
|
|
attempt = RateLimitAttempt(
|
|
engine_type="cleanup_test",
|
|
timestamp=current_time - (days_ago * 86400), # Days in seconds
|
|
wait_time=1.0,
|
|
retry_count=0,
|
|
success=True,
|
|
)
|
|
session.add(attempt)
|
|
|
|
session.commit()
|
|
|
|
# Count old attempts (older than 7 days)
|
|
seven_days_ago = current_time - (7 * 86400)
|
|
old_attempts = (
|
|
session.query(RateLimitAttempt)
|
|
.filter(RateLimitAttempt.timestamp < seven_days_ago)
|
|
.count()
|
|
)
|
|
|
|
# For days_ago in [7, 8, 9], which are all > 7 days ago
|
|
# But since timestamp is current_time - (days_ago * 86400)
|
|
# Only days 8 and 9 are actually older than 7 days
|
|
assert old_attempts == 2 # Days 8, 9
|
|
|
|
# Delete old attempts
|
|
session.query(RateLimitAttempt).filter(
|
|
RateLimitAttempt.timestamp < seven_days_ago
|
|
).delete()
|
|
session.commit()
|
|
|
|
# Verify cleanup
|
|
remaining = session.query(RateLimitAttempt).count()
|
|
assert remaining == 8 # 10 total - 2 deleted = 8
|
|
|
|
def test_rate_limit_metadata(self, session):
|
|
"""Test storing metadata with attempts."""
|
|
attempt = RateLimitAttempt(
|
|
engine_type="metadata_test",
|
|
timestamp=time.time(),
|
|
wait_time=2.0,
|
|
retry_count=1,
|
|
success=True,
|
|
error_type=None,
|
|
)
|
|
|
|
session.add(attempt)
|
|
session.commit()
|
|
|
|
# Verify
|
|
saved = session.query(RateLimitAttempt).first()
|
|
assert saved.engine_type == "metadata_test"
|
|
assert saved.created_at is not None # Auto-populated
|
|
|
|
def test_concurrent_engine_limits(self, session):
|
|
"""Test tracking concurrent rate limits for multiple engines."""
|
|
current_time = time.time()
|
|
|
|
# Create attempts for multiple engines at the same time
|
|
engines = ["google", "bing", "duckduckgo"]
|
|
|
|
for engine in engines:
|
|
for i in range(5):
|
|
attempt = RateLimitAttempt(
|
|
engine_type=engine,
|
|
timestamp=current_time + i,
|
|
wait_time=1.0 + i * 0.1,
|
|
retry_count=0,
|
|
success=True,
|
|
)
|
|
session.add(attempt)
|
|
|
|
session.commit()
|
|
|
|
# Verify each engine has its own attempts
|
|
for engine in engines:
|
|
count = (
|
|
session.query(RateLimitAttempt)
|
|
.filter_by(engine_type=engine)
|
|
.count()
|
|
)
|
|
assert count == 5
|
|
|
|
# Get latest attempt per engine
|
|
from sqlalchemy import func
|
|
|
|
latest_per_engine = (
|
|
session.query(
|
|
RateLimitAttempt.engine_type,
|
|
func.max(RateLimitAttempt.timestamp).label("latest"),
|
|
)
|
|
.group_by(RateLimitAttempt.engine_type)
|
|
.all()
|
|
)
|
|
|
|
assert len(latest_per_engine) == 3
|