Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

933 lines
34 KiB
Python

"""
Behavioral tests for the rate limiting subsystem.
Covers untested logic in:
- cli.py: format_stats_table() pure formatting function
- detection.py: provider-specific detection paths, extract_retry_after edge cases
- wrapper.py: _check_if_local_model URL detection, _get_rate_limit_key edge cases,
_do_invoke rate limit wrapping, string representations
- exceptions.py: exception hierarchy
"""
from unittest.mock import Mock
import pytest
# ---------------------------------------------------------------------------
# cli.py — format_stats_table()
# ---------------------------------------------------------------------------
class TestFormatStatsTableEmptyInput:
"""Tests for format_stats_table with empty/no data."""
def test_empty_list_returns_no_data_message(self):
"""Empty stats list → specific 'no data' message."""
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
assert format_stats_table([]) == "No rate limit data available."
def test_none_like_empty_returns_no_data_message(self):
"""Any falsy input returns the no-data message."""
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
assert format_stats_table([]) == "No rate limit data available."
class TestFormatStatsTableStructure:
"""Tests for format_stats_table output structure."""
@pytest.fixture
def single_row(self):
"""A single stats row tuple: (engine, base_wait, min, max, timestamp, attempts, rate)."""
return [("DuckDuckGo", 2.50, 1.0, 5.0, 1700000000.0, 42, 0.95)]
def test_header_line_present(self, single_row):
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
output = format_stats_table(single_row)
assert "Rate Limit Statistics:" in output
def test_separator_lines_present(self, single_row):
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
output = format_stats_table(single_row)
assert "-" * 80 in output
def test_column_headers_present(self, single_row):
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
output = format_stats_table(single_row)
for header in [
"Engine",
"Base Wait",
"Range",
"Success",
"Attempts",
"Updated",
]:
assert header in output
def test_engine_name_appears_in_output(self, single_row):
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
output = format_stats_table(single_row)
assert "DuckDuckGo" in output
class TestFormatStatsTableFormatting:
"""Tests for format_stats_table value formatting rules."""
def test_base_wait_two_decimal_places(self):
"""base_wait uses .2f format: 2.5 → '2.50'."""
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
row = [("Engine", 2.5, 1.0, 5.0, 1700000000.0, 10, 0.9)]
output = format_stats_table(row)
assert "2.50" in output
def test_range_formatted_with_units(self):
"""Range shows 'min - max' with one decimal and 's' suffix."""
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 0.9)]
output = format_stats_table(row)
assert "1.0s - 5.0s" in output
def test_success_rate_formatted_as_percentage(self):
"""Success rate 0.95 → '95.0%'."""
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 0.95)]
output = format_stats_table(row)
assert "95.0%" in output
def test_zero_success_rate_formatted(self):
"""Success rate 0.0 → '0.0%'."""
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 0.0)]
output = format_stats_table(row)
assert "0.0%" in output
def test_full_success_rate_formatted(self):
"""Success rate 1.0 → '100.0%'."""
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 1.0)]
output = format_stats_table(row)
assert "100.0%" in output
def test_timestamp_formatted_as_date_time(self):
"""Timestamp uses MM-DD HH:MM format."""
from datetime import datetime
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
ts = 1700000000.0
expected = datetime.fromtimestamp(ts).strftime("%m-%d %H:%M")
row = [("Engine", 2.0, 1.0, 5.0, ts, 10, 0.9)]
output = format_stats_table(row)
assert expected in output
def test_attempts_count_appears(self):
"""Attempts count appears in output as-is."""
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 12345, 0.9)]
output = format_stats_table(row)
assert "12345" in output
def test_multiple_rows_all_appear(self):
"""Multiple data rows all appear in output."""
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
rows = [
("EngineA", 1.0, 0.5, 2.0, 1700000000.0, 10, 0.8),
("EngineB", 3.0, 1.5, 6.0, 1700000000.0, 20, 0.9),
("EngineC", 5.0, 2.5, 10.0, 1700000000.0, 30, 0.7),
]
output = format_stats_table(rows)
assert "EngineA" in output
assert "EngineB" in output
assert "EngineC" in output
def test_output_is_newline_joined(self):
"""Output is lines joined by newlines (not a list)."""
from local_deep_research.web_search_engines.rate_limiting.cli import (
format_stats_table,
)
row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 0.9)]
output = format_stats_table(row)
lines = output.split("\n")
# Header + separator + column headers + separator + data row = 5 lines
assert len(lines) == 5
# ---------------------------------------------------------------------------
# detection.py — provider-specific detection paths
# ---------------------------------------------------------------------------
class TestDetectionProviderSpecificPaths:
"""Tests for provider-specific error detection paths in is_llm_rate_limit_error.
The function checks (in order): HTTP 429, message patterns, error type name,
OpenAI module, Anthropic module. The Anthropic path is reachable when the
message contains 'too many' without 'requests'.
"""
def test_error_type_ratelimiterror_detected(self):
"""Error class named 'RateLimitError' detected via type-name check."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
is_llm_rate_limit_error,
)
class RateLimitError(Exception):
pass
error = RateLimitError("Some message without keywords")
# type name check: "ratelimiterror" in error_type
assert is_llm_rate_limit_error(error) is True
def test_error_type_quotaexceeded_detected(self):
"""Error class named 'QuotaExceeded' detected via type-name check."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
is_llm_rate_limit_error,
)
class QuotaExceeded(Exception):
pass
error = QuotaExceeded("Generic error")
assert is_llm_rate_limit_error(error) is True
def test_anthropic_too_many_without_requests_detected(self):
"""Anthropic module error with 'too many' (no 'requests') uses Anthropic-specific path.
The generic check looks for 'too many requests' but Anthropic check
looks for just 'too many' — so this is only caught by the Anthropic path.
"""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
is_llm_rate_limit_error,
)
# Create error class whose module contains "anthropic"
AnthropicError = type(
"SomeError", (Exception,), {"__module__": "anthropic.errors"}
)
error = AnthropicError("too many connections")
assert is_llm_rate_limit_error(error) is True
def test_anthropic_module_clean_message_not_detected(self):
"""Anthropic module error without any rate-limit indicator → False."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
is_llm_rate_limit_error,
)
AnthropicAuthError = type(
"AuthError", (Exception,), {"__module__": "anthropic.errors"}
)
error = AnthropicAuthError("Invalid API key provided")
assert is_llm_rate_limit_error(error) is False
def test_error_without_response_attribute_uses_message(self):
"""Plain Exception without .response → falls through to message check."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
is_llm_rate_limit_error,
)
error = Exception("rate limit exceeded")
assert not hasattr(error, "response")
assert is_llm_rate_limit_error(error) is True
def test_error_with_non_429_status_code_uses_message(self):
"""Error with response.status_code != 429 falls through to message check."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
is_llm_rate_limit_error,
)
error = Mock()
error.response = Mock()
error.response.status_code = 500
error.__str__ = lambda self: "Internal server error"
error.__class__.__name__ = "HTTPError"
error.__class__.__module__ = "requests"
assert is_llm_rate_limit_error(error) is False
def test_completely_clean_error_returns_false(self):
"""Error with no rate-limit signals at all → False."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
is_llm_rate_limit_error,
)
error = Exception("Connection timed out after 30 seconds")
assert is_llm_rate_limit_error(error) is False
def test_slow_down_message_detected(self):
"""'slow down' message pattern is detected."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
is_llm_rate_limit_error,
)
error = Exception("Please slow down your requests")
assert is_llm_rate_limit_error(error) is True
def test_threshold_message_detected(self):
"""'threshold' message pattern is detected."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
is_llm_rate_limit_error,
)
error = Exception("Request threshold reached")
assert is_llm_rate_limit_error(error) is True
def test_case_insensitive_detection(self):
"""Detection is case-insensitive (message lowercased before check)."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
is_llm_rate_limit_error,
)
error = Exception("RATE LIMIT EXCEEDED")
assert is_llm_rate_limit_error(error) is True
# ---------------------------------------------------------------------------
# detection.py — extract_retry_after edge cases
# ---------------------------------------------------------------------------
class TestExtractRetryAfterEdgeCases:
"""Edge case tests for extract_retry_after."""
def test_decimal_retry_after_header(self):
"""Retry-After header with decimal value: '30.5' → 30.5."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
extract_retry_after,
)
error = Mock()
error.response = Mock()
error.response.headers = {"Retry-After": "30.5"}
error.__str__ = lambda self: "rate limit"
assert extract_retry_after(error) == 30.5
def test_non_numeric_retry_after_header_falls_through(self):
"""Non-numeric Retry-After header → falls through to message parsing."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
extract_retry_after,
)
error = Mock()
error.response = Mock()
error.response.headers = {
"Retry-After": "Wed, 21 Oct 2023 07:28:00 GMT"
}
error.__str__ = lambda self: "Please try again in 60 seconds"
assert extract_retry_after(error) == 60.0
def test_no_response_attribute_uses_message(self):
"""Error without .response → falls through to message regex."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
extract_retry_after,
)
error = Exception("Retry after 15 seconds")
assert extract_retry_after(error) == 15.0
def test_retry_after_pattern_in_message(self):
"""'retry after N seconds' pattern matched (case insensitive)."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
extract_retry_after,
)
error = Exception("Retry after 25 seconds please")
assert extract_retry_after(error) == 25.0
def test_wait_pattern_in_message(self):
"""'wait N seconds' pattern matched."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
extract_retry_after,
)
error = Exception("Please wait 10 seconds before retrying")
assert extract_retry_after(error) == 10.0
def test_decimal_seconds_in_message(self):
"""Decimal seconds in message: 'try again in 1.5 seconds'."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
extract_retry_after,
)
error = Exception("Please try again in 1.5 seconds")
assert extract_retry_after(error) == 1.5
def test_no_time_info_returns_zero(self):
"""Error message with no time information → 0."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
extract_retry_after,
)
error = Exception("Rate limit exceeded, come back later")
assert extract_retry_after(error) == 0
def test_response_without_headers_attribute(self):
"""Error with response but no headers attribute → 0 if no message match."""
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
extract_retry_after,
)
error = Mock()
error.response = Mock(spec=["status_code"]) # No .headers
error.__str__ = lambda self: "Some error"
assert extract_retry_after(error) == 0
# ---------------------------------------------------------------------------
# wrapper.py — _check_if_local_model
# ---------------------------------------------------------------------------
class TestCheckIfLocalModel:
"""Tests for _check_if_local_model URL-based and provider-based detection."""
def _make_wrapper(self, provider=None, base_url=None):
"""Create a wrapper with given provider and base_url."""
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
create_rate_limited_llm_wrapper,
)
mock_llm = Mock()
if base_url is not None:
mock_llm.base_url = base_url
else:
# Remove base_url attribute
del mock_llm.base_url
mock_llm.model_name = "test-model"
return create_rate_limited_llm_wrapper(mock_llm, provider=provider)
def test_localhost_url_detected_as_local(self):
"""base_url containing 'localhost' → local model."""
wrapper = self._make_wrapper(
provider="custom", base_url="http://localhost:11434"
)
assert wrapper._check_if_local_model() is True
def test_127_0_0_1_url_detected_as_local(self):
"""base_url containing '127.0.0.1' → local model."""
wrapper = self._make_wrapper(
provider="custom", base_url="http://127.0.0.1:8080"
)
assert wrapper._check_if_local_model() is True
def test_0_0_0_0_url_detected_as_local(self):
"""base_url containing '0.0.0.0' → local model."""
wrapper = self._make_wrapper(
provider="custom", base_url="http://0.0.0.0:5000"
)
assert wrapper._check_if_local_model() is True
def test_remote_url_not_local(self):
"""Remote URL like api.openai.com → not local."""
wrapper = self._make_wrapper(
provider="custom", base_url="https://api.openai.com/v1"
)
assert wrapper._check_if_local_model() is False
def test_no_base_url_not_local(self):
"""LLM without base_url attribute → not local (by URL check)."""
wrapper = self._make_wrapper(provider="custom", base_url=None)
assert wrapper._check_if_local_model() is False
def test_ollama_provider_is_local(self):
"""'ollama' provider → local model."""
wrapper = self._make_wrapper(
provider="ollama", base_url="https://api.example.com"
)
assert wrapper._check_if_local_model() is True
def test_lmstudio_provider_is_local(self):
"""'lmstudio' provider → local model."""
wrapper = self._make_wrapper(provider="lmstudio")
assert wrapper._check_if_local_model() is True
def test_llamacpp_provider_is_local(self):
"""'llamacpp' provider → local model."""
wrapper = self._make_wrapper(provider="llamacpp")
assert wrapper._check_if_local_model() is True
def test_local_provider_is_local(self):
"""'local' provider → local model."""
wrapper = self._make_wrapper(provider="local")
assert wrapper._check_if_local_model() is True
def test_none_provider_string_is_local(self):
"""'none' provider → local model (in local_providers list)."""
wrapper = self._make_wrapper(provider="none")
assert wrapper._check_if_local_model() is True
def test_provider_case_insensitive(self):
"""Provider check is case-insensitive: 'OLLAMA' → local."""
wrapper = self._make_wrapper(provider="OLLAMA")
assert wrapper._check_if_local_model() is True
def test_none_provider_value_not_local(self):
"""None (Python None) provider → not local by provider check."""
wrapper = self._make_wrapper(provider=None)
assert wrapper._check_if_local_model() is False
def test_openai_provider_not_local(self):
"""'openai' provider with remote URL → not local."""
wrapper = self._make_wrapper(
provider="openai", base_url="https://api.openai.com"
)
assert wrapper._check_if_local_model() is False
# ---------------------------------------------------------------------------
# wrapper.py — _get_rate_limit_key edge cases
# ---------------------------------------------------------------------------
class TestGetRateLimitKey:
"""Tests for _get_rate_limit_key composite key building."""
def _make_wrapper(
self, provider=None, base_url=None, model_name=None, model=None
):
"""Create a wrapper with specified attributes on the mock LLM."""
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
create_rate_limited_llm_wrapper,
)
mock_llm = Mock(spec=[]) # Empty spec so we control attributes
if base_url is not None:
mock_llm.base_url = base_url
if model_name is not None:
mock_llm.model_name = model_name
if model is not None:
mock_llm.model = model
return create_rate_limited_llm_wrapper(mock_llm, provider=provider)
def test_basic_key_structure(self):
"""Key format: provider-url-model."""
wrapper = self._make_wrapper(
provider="openai",
base_url="https://api.openai.com/v1",
model_name="gpt-4",
)
key = wrapper._get_rate_limit_key()
assert key == "openai-api.openai.com-gpt-4"
def test_no_provider_uses_unknown(self):
"""None provider → 'unknown' in key."""
wrapper = self._make_wrapper(
provider=None,
base_url="https://api.example.com",
model_name="test",
)
key = wrapper._get_rate_limit_key()
assert key.startswith("unknown-")
def test_no_base_url_uses_unknown(self):
"""LLM without base_url or _client → 'unknown' for URL part."""
wrapper = self._make_wrapper(
provider="openai",
model_name="gpt-4",
)
key = wrapper._get_rate_limit_key()
assert "unknown" in key
def test_fallback_to_client_base_url(self):
"""Falls back to _client.base_url when base_url not present."""
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
create_rate_limited_llm_wrapper,
)
mock_llm = Mock(spec=[])
mock_llm._client = Mock()
mock_llm._client.base_url = "https://api.fallback.com/v1"
mock_llm.model_name = "test-model"
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
key = wrapper._get_rate_limit_key()
assert "api.fallback.com" in key
def test_model_name_slash_replaced(self):
"""'/' in model name replaced with '-'."""
wrapper = self._make_wrapper(
provider="openai",
base_url="https://api.example.com",
model_name="org/model-name",
)
key = wrapper._get_rate_limit_key()
assert "org-model-name" in key
assert "/" not in key
def test_model_name_colon_replaced(self):
"""':' in model name replaced with '-': 'llama3:8b' → 'llama3-8b'."""
wrapper = self._make_wrapper(
provider="ollama",
base_url="http://localhost:11434",
model_name="llama3:8b",
)
key = wrapper._get_rate_limit_key()
assert "llama3-8b" in key
assert "llama3:8b" not in key # Original colon form should not appear
def test_model_attribute_fallback(self):
"""Falls back to .model when .model_name not present."""
wrapper = self._make_wrapper(
provider="anthropic",
base_url="https://api.anthropic.com",
model="claude-3-opus",
)
key = wrapper._get_rate_limit_key()
assert "claude-3-opus" in key
def test_no_model_attribute_uses_unknown(self):
"""LLM without model_name or model → 'unknown' for model part."""
wrapper = self._make_wrapper(
provider="openai",
base_url="https://api.example.com",
)
key = wrapper._get_rate_limit_key()
assert key.endswith("-unknown")
def test_url_trailing_slashes_stripped(self):
"""Trailing slashes in URL are stripped."""
wrapper = self._make_wrapper(
provider="openai",
base_url="https://api.example.com/",
model_name="gpt-4",
)
key = wrapper._get_rate_limit_key()
assert not key.endswith("/")
assert "api.example.com-gpt-4" in key
def test_url_protocol_stripped(self):
"""URL protocol (https://) is stripped — only netloc kept."""
wrapper = self._make_wrapper(
provider="openai",
base_url="https://api.example.com/v1",
model_name="gpt-4",
)
key = wrapper._get_rate_limit_key()
assert "https://" not in key
assert "api.example.com" in key
# ---------------------------------------------------------------------------
# wrapper.py — string representations
# ---------------------------------------------------------------------------
class TestWrapperStringRepresentations:
"""Tests for __str__ and __repr__ of RateLimitedLLMWrapper."""
def test_str_format(self):
"""__str__ wraps base LLM string: 'RateLimited(...)'."""
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
create_rate_limited_llm_wrapper,
)
mock_llm = Mock()
mock_llm.__str__ = lambda self: "MockLLM(gpt-4)"
mock_llm.model_name = "gpt-4"
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
assert str(wrapper) == "RateLimited(MockLLM(gpt-4))"
def test_repr_format(self):
"""__repr__ wraps base LLM repr: 'RateLimitedLLMWrapper(...)'."""
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
create_rate_limited_llm_wrapper,
)
mock_llm = Mock()
mock_llm.__repr__ = lambda self: "MockLLM(model='gpt-4')"
mock_llm.model_name = "gpt-4"
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
assert repr(wrapper) == "RateLimitedLLMWrapper(MockLLM(model='gpt-4'))"
# ---------------------------------------------------------------------------
# wrapper.py — _do_invoke rate limit error wrapping
# ---------------------------------------------------------------------------
class TestDoInvokeRateLimitWrapping:
"""Tests for _do_invoke wrapping rate limit errors as RateLimitError."""
def test_rate_limit_error_wrapped(self):
"""Rate limit error from base LLM → wrapped as RateLimitError."""
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
RateLimitError,
)
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
create_rate_limited_llm_wrapper,
)
mock_llm = Mock()
mock_llm.model_name = "gpt-4"
mock_llm.invoke.side_effect = Exception("429 Too many requests")
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
with pytest.raises(RateLimitError, match="LLM rate limit"):
wrapper._do_invoke("test prompt")
def test_non_rate_limit_error_not_wrapped(self):
"""Non-rate-limit error from base LLM → re-raised as-is."""
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
create_rate_limited_llm_wrapper,
)
mock_llm = Mock()
mock_llm.model_name = "gpt-4"
mock_llm.invoke.side_effect = ValueError("Invalid input format")
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
with pytest.raises(ValueError, match="Invalid input format"):
wrapper._do_invoke("test prompt")
def test_successful_invoke_returns_result(self):
"""Successful invoke returns the base LLM result."""
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
create_rate_limited_llm_wrapper,
)
mock_llm = Mock()
mock_llm.model_name = "gpt-4"
mock_llm.invoke.return_value = "response text"
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
result = wrapper._do_invoke("test prompt")
assert result == "response text"
# ---------------------------------------------------------------------------
# wrapper.py — attribute passthrough via __getattr__
# ---------------------------------------------------------------------------
class TestWrapperAttributePassthrough:
"""Tests for __getattr__ passing attributes through to base LLM."""
def test_passes_through_custom_attributes(self):
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
create_rate_limited_llm_wrapper,
)
mock_llm = Mock()
mock_llm.model_name = "gpt-4"
mock_llm.temperature = 0.7
mock_llm.custom_setting = "value"
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
assert wrapper.temperature == 0.7
assert wrapper.custom_setting == "value"
def test_nonexistent_attribute_raises(self):
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
create_rate_limited_llm_wrapper,
)
mock_llm = Mock(spec=["invoke", "model_name"])
mock_llm.model_name = "gpt-4"
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
with pytest.raises(AttributeError):
_ = wrapper.nonexistent_attribute
# ---------------------------------------------------------------------------
# exceptions.py — exception hierarchy
# ---------------------------------------------------------------------------
class TestRateLimitExceptions:
"""Tests for rate limiting exception classes."""
def test_rate_limit_error_is_exception(self):
"""RateLimitError is a subclass of Exception."""
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
RateLimitError,
)
assert issubclass(RateLimitError, Exception)
def test_adaptive_retry_error_is_exception(self):
"""AdaptiveRetryError is a subclass of Exception."""
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
AdaptiveRetryError,
)
assert issubclass(AdaptiveRetryError, Exception)
def test_rate_limit_config_error_is_exception(self):
"""RateLimitConfigError is a subclass of Exception."""
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
RateLimitConfigError,
)
assert issubclass(RateLimitConfigError, Exception)
def test_rate_limit_error_carries_message(self):
"""RateLimitError preserves its message."""
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
RateLimitError,
)
error = RateLimitError("too many requests to API")
assert str(error) == "too many requests to API"
def test_exceptions_are_catchable_separately(self):
"""Each exception type is distinct and catchable independently."""
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
AdaptiveRetryError,
RateLimitConfigError,
RateLimitError,
)
with pytest.raises(RateLimitError):
raise RateLimitError("test")
with pytest.raises(AdaptiveRetryError):
raise AdaptiveRetryError("test")
with pytest.raises(RateLimitConfigError):
raise RateLimitConfigError("test")
def test_rate_limit_error_not_caught_as_config_error(self):
"""RateLimitError is not caught by RateLimitConfigError handler."""
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
RateLimitConfigError,
RateLimitError,
)
with pytest.raises(RateLimitError):
try:
raise RateLimitError("rate limited")
except RateLimitConfigError:
pytest.fail(
"RateLimitError incorrectly caught as RateLimitConfigError"
)
# ---------------------------------------------------------------------------
# wrapper.py — AdaptiveLLMWait strategy
# ---------------------------------------------------------------------------
class TestAdaptiveLLMWait:
"""Tests for AdaptiveLLMWait tenacity wait strategy."""
def test_uses_tracker_wait_time(self):
"""Wait time comes from tracker.get_wait_time."""
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
AdaptiveLLMWait,
)
mock_tracker = Mock()
mock_tracker.get_wait_time.return_value = 5.0
wait = AdaptiveLLMWait(mock_tracker, "test-engine")
retry_state = Mock()
retry_state.outcome = None
result = wait(retry_state)
assert result == 5.0
mock_tracker.get_wait_time.assert_called_once_with("test-engine")
def test_retry_after_overrides_tracker_when_larger(self):
"""retry-after from error overrides tracker wait when it's larger."""
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
AdaptiveLLMWait,
)
mock_tracker = Mock()
mock_tracker.get_wait_time.return_value = 2.0
wait = AdaptiveLLMWait(mock_tracker, "test-engine")
# Simulate a failed attempt with retry-after info
error = Exception("Please try again in 30 seconds")
retry_state = Mock()
retry_state.outcome = Mock()
retry_state.outcome.failed = True
retry_state.outcome.exception.return_value = error
result = wait(retry_state)
assert result == 30.0 # retry-after (30) > tracker (2)
def test_tracker_time_used_when_larger_than_retry_after(self):
"""Tracker wait time used when it's larger than retry-after."""
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
AdaptiveLLMWait,
)
mock_tracker = Mock()
mock_tracker.get_wait_time.return_value = 60.0
wait = AdaptiveLLMWait(mock_tracker, "test-engine")
error = Exception("Please try again in 5 seconds")
retry_state = Mock()
retry_state.outcome = Mock()
retry_state.outcome.failed = True
retry_state.outcome.exception.return_value = error
result = wait(retry_state)
assert result == 60.0 # tracker (60) > retry-after (5)
def test_no_failure_uses_tracker_time(self):
"""When outcome is not failed, only tracker time is used."""
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
AdaptiveLLMWait,
)
mock_tracker = Mock()
mock_tracker.get_wait_time.return_value = 3.0
wait = AdaptiveLLMWait(mock_tracker, "test-engine")
retry_state = Mock()
retry_state.outcome = Mock()
retry_state.outcome.failed = False
result = wait(retry_state)
assert result == 3.0