wrongmove/crawler/tests/unit/test_throttle_detection.py

334 lines
12 KiB
Python

"""Unit tests for throttle detection and circuit breaker."""
import pytest
from unittest.mock import MagicMock, AsyncMock
import time
from rec.exceptions import (
RightmoveAPIError,
ThrottlingError,
RateLimitError,
ServiceUnavailableError,
IPBlockedError,
SlowResponseError,
UnexpectedEmptyResponseError,
InvalidResponseError,
CircuitBreakerOpenError,
)
from rec.throttle_detector import (
ThrottleMetrics,
validate_response,
get_throttle_metrics,
reset_throttle_metrics,
)
from rec.circuit_breaker import CircuitBreaker, CircuitState
class TestExceptionHierarchy:
"""Test custom exception hierarchy."""
def test_rightmove_api_error_is_exception(self) -> None:
assert issubclass(RightmoveAPIError, Exception)
def test_throttling_error_is_rightmove_api_error(self) -> None:
assert issubclass(ThrottlingError, RightmoveAPIError)
def test_rate_limit_error_is_throttling_error(self) -> None:
assert issubclass(RateLimitError, ThrottlingError)
def test_service_unavailable_error_is_throttling_error(self) -> None:
assert issubclass(ServiceUnavailableError, ThrottlingError)
def test_ip_blocked_error_is_throttling_error(self) -> None:
assert issubclass(IPBlockedError, ThrottlingError)
def test_slow_response_error_is_throttling_error(self) -> None:
assert issubclass(SlowResponseError, ThrottlingError)
def test_unexpected_empty_response_error_is_rightmove_api_error(self) -> None:
assert issubclass(UnexpectedEmptyResponseError, RightmoveAPIError)
assert not issubclass(UnexpectedEmptyResponseError, ThrottlingError)
def test_invalid_response_error_is_rightmove_api_error(self) -> None:
assert issubclass(InvalidResponseError, RightmoveAPIError)
assert not issubclass(InvalidResponseError, ThrottlingError)
def test_circuit_breaker_open_error_is_rightmove_api_error(self) -> None:
assert issubclass(CircuitBreakerOpenError, RightmoveAPIError)
def test_exception_messages(self) -> None:
error = RateLimitError("Too many requests")
assert str(error) == "Too many requests"
class TestThrottleMetrics:
"""Test ThrottleMetrics class."""
def test_initial_state(self) -> None:
metrics = ThrottleMetrics()
assert metrics.rate_limit_count == 0
assert metrics.service_unavailable_count == 0
assert metrics.ip_blocked_count == 0
assert metrics.slow_response_count == 0
assert metrics.empty_response_count == 0
assert metrics.invalid_response_count == 0
assert metrics.total_requests == 0
assert metrics.total_response_time == 0.0
def test_record_rate_limit(self) -> None:
metrics = ThrottleMetrics()
metrics.record_rate_limit()
assert metrics.rate_limit_count == 1
metrics.record_rate_limit()
assert metrics.rate_limit_count == 2
def test_record_service_unavailable(self) -> None:
metrics = ThrottleMetrics()
metrics.record_service_unavailable()
assert metrics.service_unavailable_count == 1
def test_record_ip_blocked(self) -> None:
metrics = ThrottleMetrics()
metrics.record_ip_blocked()
assert metrics.ip_blocked_count == 1
def test_record_slow_response(self) -> None:
metrics = ThrottleMetrics()
metrics.record_slow_response(15.0)
assert metrics.slow_response_count == 1
assert metrics.total_response_time == 15.0
assert metrics.total_requests == 1
def test_record_empty_response(self) -> None:
metrics = ThrottleMetrics()
metrics.record_empty_response()
assert metrics.empty_response_count == 1
def test_record_invalid_response(self) -> None:
metrics = ThrottleMetrics()
metrics.record_invalid_response()
assert metrics.invalid_response_count == 1
def test_record_request(self) -> None:
metrics = ThrottleMetrics()
metrics.record_request(0.5)
assert metrics.total_requests == 1
assert metrics.total_response_time == 0.5
def test_average_response_time(self) -> None:
metrics = ThrottleMetrics()
metrics.record_request(1.0)
metrics.record_request(2.0)
metrics.record_request(3.0)
assert metrics.average_response_time == 2.0
def test_average_response_time_zero_requests(self) -> None:
metrics = ThrottleMetrics()
assert metrics.average_response_time == 0.0
def test_total_throttling_events(self) -> None:
metrics = ThrottleMetrics()
metrics.record_rate_limit()
metrics.record_service_unavailable()
metrics.record_ip_blocked()
metrics.record_slow_response(15.0)
assert metrics.total_throttling_events == 4
def test_throttle_rate(self) -> None:
metrics = ThrottleMetrics()
metrics.record_request(0.5) # 1 normal request
metrics.record_request(0.5) # 2 normal requests
metrics.record_rate_limit()
metrics.record_request(0.5) # 3 normal requests (rate limit doesn't count as request)
# 1 throttling event, 3 requests = 33.33%
assert metrics.throttle_rate == pytest.approx(33.33, rel=0.01)
def test_throttle_rate_zero_requests(self) -> None:
metrics = ThrottleMetrics()
assert metrics.throttle_rate == 0.0
def test_elapsed_time(self) -> None:
metrics = ThrottleMetrics()
time.sleep(0.1)
assert metrics.elapsed_time >= 0.1
def test_summary(self) -> None:
metrics = ThrottleMetrics()
metrics.record_request(1.0)
metrics.record_rate_limit()
summary = metrics.summary()
assert "Total Requests:" in summary
assert "Rate Limit (429):" in summary
assert "1" in summary
class TestGlobalMetrics:
"""Test global metrics accessor."""
def test_get_throttle_metrics_singleton(self) -> None:
reset_throttle_metrics()
m1 = get_throttle_metrics()
m2 = get_throttle_metrics()
assert m1 is m2
def test_reset_throttle_metrics(self) -> None:
reset_throttle_metrics()
metrics = get_throttle_metrics()
metrics.record_rate_limit()
assert metrics.rate_limit_count == 1
reset_throttle_metrics()
new_metrics = get_throttle_metrics()
assert new_metrics.rate_limit_count == 0
class TestValidateResponse:
"""Test validate_response function."""
def setup_method(self) -> None:
reset_throttle_metrics()
def create_mock_response(self, status: int) -> MagicMock:
response = MagicMock()
response.status = status
return response
def test_rate_limit_error(self) -> None:
response = self.create_mock_response(429)
with pytest.raises(RateLimitError):
validate_response(response, 0.5, None, 10.0)
assert get_throttle_metrics().rate_limit_count == 1
def test_service_unavailable_error(self) -> None:
response = self.create_mock_response(503)
with pytest.raises(ServiceUnavailableError):
validate_response(response, 0.5, None, 10.0)
assert get_throttle_metrics().service_unavailable_count == 1
def test_ip_blocked_error(self) -> None:
response = self.create_mock_response(403)
with pytest.raises(IPBlockedError):
validate_response(response, 0.5, None, 10.0)
assert get_throttle_metrics().ip_blocked_count == 1
def test_slow_response_error(self) -> None:
response = self.create_mock_response(200)
body = {"totalAvailableResults": 0, "properties": []}
with pytest.raises(SlowResponseError):
validate_response(response, 15.0, body, 10.0)
assert get_throttle_metrics().slow_response_count == 1
def test_slow_response_just_under_threshold(self) -> None:
response = self.create_mock_response(200)
body = {"totalAvailableResults": 0, "properties": []}
# Should not raise
validate_response(response, 9.9, body, 10.0)
assert get_throttle_metrics().slow_response_count == 0
def test_error_in_response_body(self) -> None:
response = self.create_mock_response(200)
body = {"error": "Something went wrong"}
with pytest.raises(InvalidResponseError):
validate_response(response, 0.5, body, 10.0)
assert get_throttle_metrics().invalid_response_count == 1
def test_generic_error_in_body(self) -> None:
response = self.create_mock_response(200)
body = {"message": "GENERIC_ERROR occurred"}
with pytest.raises(InvalidResponseError):
validate_response(response, 0.5, body, 10.0)
def test_unexpected_empty_response(self) -> None:
response = self.create_mock_response(200)
body = {"totalAvailableResults": 100, "properties": []}
with pytest.raises(UnexpectedEmptyResponseError):
validate_response(response, 0.5, body, 10.0, expect_data=True)
assert get_throttle_metrics().empty_response_count == 1
def test_empty_response_when_not_expecting_data(self) -> None:
response = self.create_mock_response(200)
body = {"totalAvailableResults": 100, "properties": []}
# Should not raise when expect_data=False
validate_response(response, 0.5, body, 10.0, expect_data=False)
assert get_throttle_metrics().empty_response_count == 0
def test_valid_response(self) -> None:
response = self.create_mock_response(200)
body = {
"totalAvailableResults": 10,
"properties": [{"id": 1}, {"id": 2}],
}
validate_response(response, 0.5, body, 10.0, expect_data=True)
assert get_throttle_metrics().total_requests == 1
assert get_throttle_metrics().total_throttling_events == 0
class TestCircuitBreaker:
"""Test CircuitBreaker class."""
def test_initial_state_is_closed(self) -> None:
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=10.0)
assert cb.state == CircuitState.CLOSED
assert cb.is_closed
assert not cb.is_open
assert not cb.is_half_open
def test_allows_requests_when_closed(self) -> None:
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=10.0)
# Should not raise
cb.call()
def test_opens_after_threshold_failures(self) -> None:
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=10.0)
cb.record_failure()
cb.record_failure()
assert cb.is_closed
cb.record_failure()
assert cb.is_open
def test_blocks_requests_when_open(self) -> None:
cb = CircuitBreaker(failure_threshold=1, recovery_timeout=60.0)
cb.record_failure()
assert cb.is_open
with pytest.raises(CircuitBreakerOpenError):
cb.call()
def test_success_resets_failure_count(self) -> None:
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=10.0)
cb.record_failure()
cb.record_failure()
assert cb.failure_count == 2
cb.record_success()
assert cb.failure_count == 0
def test_transitions_to_half_open_after_timeout(self) -> None:
cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1)
cb.record_failure()
assert cb.is_open
time.sleep(0.15)
cb.call() # Should transition to half-open
assert cb.is_half_open
def test_half_open_success_closes_circuit(self) -> None:
cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1)
cb.record_failure()
time.sleep(0.15)
cb.call() # Transition to half-open
assert cb.is_half_open
cb.record_success()
assert cb.is_closed
def test_half_open_failure_reopens_circuit(self) -> None:
cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1)
cb.record_failure()
time.sleep(0.15)
cb.call() # Transition to half-open
assert cb.is_half_open
cb.record_failure()
assert cb.is_open
def test_reset(self) -> None:
cb = CircuitBreaker(failure_threshold=1, recovery_timeout=60.0)
cb.record_failure()
assert cb.is_open
cb.reset()
assert cb.is_closed
assert cb.failure_count == 0