backend/tests/test_http_session.py
Rama master · réplica de lectura del repositorio original.
"""Tests for providers/http_session module.
Covers: create_session, RetryingSession (default timeout, rate limit handling,
429 response, auth errors, X-RateLimit headers).
"""
import logging
import time
from unittest.mock import MagicMock, patch
import pytest
import requests
from providers.http_session import RetryingSession, create_session
# ---------------------------------------------------------------------------
# create_session
# ---------------------------------------------------------------------------
class TestCreateSession:
def test_returns_retrying_session(self):
session = create_session()
assert isinstance(session, RetryingSession)
def test_default_user_agent(self):
session = create_session()
assert session.headers["User-Agent"] == "Sublarr/1.0"
def test_custom_user_agent(self):
session = create_session(user_agent="TestAgent/2.0")
assert session.headers["User-Agent"] == "TestAgent/2.0"
def test_default_timeout(self):
session = create_session()
assert session.default_timeout == 15
def test_custom_timeout(self):
session = create_session(timeout=30)
assert session.default_timeout == 30
def test_https_adapter_mounted(self):
session = create_session()
assert "https://" in session.adapters
def test_http_adapter_mounted(self):
session = create_session()
assert "http://" in session.adapters
# ---------------------------------------------------------------------------
# RetryingSession — basics
# ---------------------------------------------------------------------------
class TestRetryingSessionBasics:
def test_default_timeout_applied(self):
session = RetryingSession(timeout=10)
assert session.default_timeout == 10
def test_rate_limit_remaining_no_throttle(self):
session = RetryingSession()
assert session.rate_limit_remaining_seconds == 0.0
def test_rate_limit_remaining_when_throttled(self):
session = RetryingSession()
session._rate_limit_until = time.time() + 30
remaining = session.rate_limit_remaining_seconds
assert 29 < remaining <= 30
def test_rate_limit_remaining_expired(self):
session = RetryingSession()
session._rate_limit_until = time.time() - 10
assert session.rate_limit_remaining_seconds == 0.0
# ---------------------------------------------------------------------------
# RetryingSession.request — timeout handling
# ---------------------------------------------------------------------------
class TestRetryingSessionTimeout:
@patch.object(requests.Session, "request")
def test_default_timeout_added(self, mock_request):
mock_request.return_value = MagicMock(status_code=200, headers={})
session = RetryingSession(timeout=20)
session.request("GET", "https://example.com")
_, kwargs = mock_request.call_args
assert kwargs["timeout"] == 20
@patch.object(requests.Session, "request")
def test_explicit_timeout_preserved(self, mock_request):
mock_request.return_value = MagicMock(status_code=200, headers={})
session = RetryingSession(timeout=20)
session.request("GET", "https://example.com", timeout=5)
_, kwargs = mock_request.call_args
assert kwargs["timeout"] == 5
# ---------------------------------------------------------------------------
# RetryingSession.request — error handling
# ---------------------------------------------------------------------------
class TestRetryingSessionErrors:
@patch.object(requests.Session, "request")
def test_connection_error_raised(self, mock_request):
mock_request.side_effect = requests.ConnectionError("conn failed")
session = RetryingSession()
with pytest.raises(requests.ConnectionError):
session.request("GET", "https://example.com")
@patch.object(requests.Session, "request")
def test_timeout_error_raised(self, mock_request):
mock_request.side_effect = requests.Timeout("timed out")
session = RetryingSession()
with pytest.raises(requests.Timeout):
session.request("GET", "https://example.com")
# ---------------------------------------------------------------------------
# RetryingSession.request — 429 rate limiting
# ---------------------------------------------------------------------------
class TestRetryingSession429:
@patch.object(requests.Session, "request")
def test_429_with_retry_after_header(self, mock_request):
from providers.base import ProviderRateLimitError
resp = MagicMock(status_code=429, headers={"Retry-After": "120"})
mock_request.return_value = resp
session = RetryingSession()
with pytest.raises(ProviderRateLimitError, match="retry after 120s"):
session.request("GET", "https://example.com")
assert session._rate_limit_until is not None
assert session._rate_limit_until > time.time()
@patch.object(requests.Session, "request")
def test_429_without_retry_after_defaults_60s(self, mock_request):
from providers.base import ProviderRateLimitError
resp = MagicMock(status_code=429, headers={})
mock_request.return_value = resp
session = RetryingSession()
with pytest.raises(ProviderRateLimitError):
session.request("GET", "https://example.com")
# Default wait is 60 seconds
assert session._rate_limit_until >= time.time() + 59
@patch.object(requests.Session, "request")
def test_429_non_integer_retry_after(self, mock_request):
from providers.base import ProviderRateLimitError
resp = MagicMock(status_code=429, headers={"Retry-After": "not-a-number"})
mock_request.return_value = resp
session = RetryingSession()
with pytest.raises(ProviderRateLimitError):
session.request("GET", "https://example.com")
# Falls back to 60 seconds
assert session._rate_limit_until >= time.time() + 59
# ---------------------------------------------------------------------------
# RetryingSession.request — auth errors
# ---------------------------------------------------------------------------
class TestRetryingSessionAuth:
@patch.object(requests.Session, "request")
def test_401_raises_auth_error(self, mock_request):
from providers.base import ProviderAuthError
resp = MagicMock(status_code=401, headers={})
mock_request.return_value = resp
session = RetryingSession()
with pytest.raises(ProviderAuthError, match="Authentication failed"):
session.request("GET", "https://example.com")
@patch.object(requests.Session, "request")
def test_403_raises_auth_error(self, mock_request):
from providers.base import ProviderAuthError
resp = MagicMock(status_code=403, headers={})
mock_request.return_value = resp
session = RetryingSession()
with pytest.raises(ProviderAuthError, match="HTTP 403"):
session.request("GET", "https://example.com")
@pytest.mark.parametrize("status", [401, 403])
@patch.object(requests.Session, "request")
def test_the_status_travels_on_the_error(self, mock_request, status):
"""Callers recover differently from 401 and 403, and need to tell them apart.
OpenSubtitles re-logs-in and retries once on 401, because its download
token expires after 24h while search keeps working on the API key alone.
On 403 it must not: the account simply may not have that file, and a
second login only spends a request to be refused again.
This assertion is the contract that makes that possible. Without the
status on the exception the only discriminator is the message text, so
rewording the line below would silently disable the recovery — and the
provider's own tests, which build the exception themselves, would not
notice.
"""
from providers.base import ProviderAuthError
mock_request.return_value = MagicMock(status_code=status, headers={})
session = RetryingSession()
with pytest.raises(ProviderAuthError) as excinfo:
session.request("GET", "https://example.com")
assert excinfo.value.status_code == status
# ---------------------------------------------------------------------------
# RetryingSession.request — X-RateLimit headers
# ---------------------------------------------------------------------------
class TestRetryingSessionRateLimitHeaders:
@patch.object(requests.Session, "request")
def test_low_remaining_sets_throttle(self, mock_request):
resp = MagicMock(
status_code=200,
headers={
"X-RateLimit-Remaining": "1",
"X-RateLimit-Reset": "5", # relative seconds
},
)
mock_request.return_value = resp
session = RetryingSession()
session.request("GET", "https://example.com")
assert session._rate_limit_until is not None
assert session._rate_limit_until > time.time()
@patch.object(requests.Session, "request")
def test_remaining_above_1_no_throttle(self, mock_request):
resp = MagicMock(
status_code=200,
headers={"X-RateLimit-Remaining": "10"},
)
mock_request.return_value = resp
session = RetryingSession()
session.request("GET", "https://example.com")
assert session._rate_limit_until is None
@patch.object(requests.Session, "request")
def test_remaining_0_throttles(self, mock_request):
resp = MagicMock(
status_code=200,
headers={"X-RateLimit-Remaining": "0"},
)
mock_request.return_value = resp
session = RetryingSession()
session.request("GET", "https://example.com")
# Should set throttle with 5s default (no Reset header)
assert session._rate_limit_until is not None
@patch.object(requests.Session, "request")
def test_unix_timestamp_reset(self, mock_request):
"""X-RateLimit-Reset as Unix timestamp (>1e9) -> absolute time."""
future = time.time() + 60
resp = MagicMock(
status_code=200,
headers={
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(future),
},
)
mock_request.return_value = resp
session = RetryingSession()
session.request("GET", "https://example.com")
assert session._rate_limit_until is not None
# Should be close to the future timestamp
assert abs(session._rate_limit_until - future) < 2
@patch.object(requests.Session, "request")
def test_millisecond_timestamp_reset(self, mock_request):
"""X-RateLimit-Reset as millisecond timestamp (>1e12) -> converted."""
future_ms = (time.time() + 60) * 1000
resp = MagicMock(
status_code=200,
headers={
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(future_ms),
},
)
mock_request.return_value = resp
session = RetryingSession()
session.request("GET", "https://example.com")
assert session._rate_limit_until is not None
@patch.object(requests.Session, "request")
def test_lowercase_header_names(self, mock_request):
"""Handles lowercase x-ratelimit-remaining header."""
resp = MagicMock(
status_code=200,
headers={"x-ratelimit-remaining": "0", "x-ratelimit-reset": "10"},
)
mock_request.return_value = resp
session = RetryingSession()
session.request("GET", "https://example.com")
assert session._rate_limit_until is not None
@patch.object(requests.Session, "request")
def test_non_numeric_remaining_ignored(self, mock_request):
"""Non-numeric X-RateLimit-Remaining is silently ignored."""
resp = MagicMock(
status_code=200,
headers={"X-RateLimit-Remaining": "abc"},
)
mock_request.return_value = resp
session = RetryingSession()
session.request("GET", "https://example.com")
# No exception, no throttle set
assert session._rate_limit_until is None
@patch.object(requests.Session, "request")
def test_200_without_ratelimit_headers(self, mock_request):
"""Normal 200 without rate limit headers -> no throttle."""
resp = MagicMock(status_code=200, headers={})
mock_request.return_value = resp
session = RetryingSession()
session.request("GET", "https://example.com")
assert session._rate_limit_until is None
# ---------------------------------------------------------------------------
# Secret redaction (#212: the SubDL key reached sublarr.log via these lines)
# ---------------------------------------------------------------------------
_SECRET = "SECRETKEY1234567890"
class TestSecretRedaction:
@pytest.mark.parametrize(
"text",
[
f"https://api.subdl.com/api/v1/subtitles?api_key={_SECRET}&x=1",
f"url: /api/v1/subtitles?subs_per_page=30&apikey={_SECRET}",
f"https://h/p?token={_SECRET}",
f"https://h/p?a=1&API_KEY={_SECRET}",
f"https://h/p?access_token={_SECRET}&b=2",
],
)
def test_redact_url_secrets(self, text):
from providers.http_session import redact_url_secrets
redacted = redact_url_secrets(text)
assert _SECRET not in redacted
assert "***" in redacted
def test_redact_keeps_other_params(self):
from providers.http_session import redact_url_secrets
assert redact_url_secrets("https://h/p?api_key=abc&film_name=x") == (
"https://h/p?api_key=***&film_name=x"
)
@patch.object(requests.Session, "request")
def test_connection_error_log_is_redacted(self, mock_request, caplog):
mock_request.side_effect = requests.ConnectionError(
f"Max retries exceeded with url: /api/v1/subtitles?api_key={_SECRET}&subs_per_page=30"
)
session = RetryingSession()
caplog.set_level(logging.DEBUG)
with pytest.raises(requests.ConnectionError):
session.request("GET", f"https://api.subdl.com/x?api_key={_SECRET}")
assert "Connection error" in caplog.text
assert _SECRET not in caplog.text
@patch.object(requests.Session, "request")
def test_timeout_log_is_redacted(self, mock_request, caplog):
mock_request.side_effect = requests.Timeout("timed out")
session = RetryingSession()
caplog.set_level(logging.DEBUG)
with pytest.raises(requests.Timeout):
session.request("GET", f"https://api.subdl.com/x?api_key={_SECRET}")
assert _SECRET not in caplog.text
@patch.object(requests.Session, "request")
def test_rate_limit_message_is_redacted(self, mock_request, caplog):
from providers.base import ProviderRateLimitError
mock_request.return_value = MagicMock(status_code=429, headers={})
session = RetryingSession()
caplog.set_level(logging.DEBUG)
with pytest.raises(ProviderRateLimitError) as excinfo:
session.request("GET", f"https://api.subdl.com/x?api_key={_SECRET}")
assert _SECRET not in str(excinfo.value)
assert _SECRET not in caplog.text
@patch.object(requests.Session, "request")
def test_auth_error_message_is_redacted(self, mock_request):
from providers.base import ProviderAuthError
mock_request.return_value = MagicMock(status_code=401, headers={})
session = RetryingSession()
with pytest.raises(ProviderAuthError) as excinfo:
session.request("GET", f"https://api.subdl.com/x?api_key={_SECRET}")
assert _SECRET not in str(excinfo.value)
def test_urllib3_retry_warning_is_redacted(self, caplog):
import providers.http_session # noqa: F401 — installs the filter
caplog.set_level(logging.WARNING)
logging.getLogger("urllib3.connectionpool").warning(
"Retrying (%r) after connection broken by '%r': %s",
"Retry(total=1)",
"NewConnectionError",
f"/api/v1/subtitles?api_key={_SECRET}&subs_per_page=30",
)
assert "Retrying" in caplog.text
assert _SECRET not in caplog.text