88 lines
2.8 KiB
Python
88 lines
2.8 KiB
Python
"""Unit tests for TMDbProvider's 429 (rate-limited) handling -- added
|
|||
|
|
before running a full-catalog bulk import (app/scripts/bulk_import.py),
|
||
|
|
where sustained high request volume makes 429s an expected, recoverable
|
||
|
|
condition rather than a rare edge case. Uses httpx.MockTransport, and
|
||
|
|
Retry-After: 0 in the mocked responses so retries don't actually slow
|
||
|
|
these tests down."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from unittest.mock import Mock
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from app.providers.base import ProviderRateLimitedError
|
||
|
|
from app.providers.tmdb import TMDbProvider, _tmdb_wait
|
||
|
|
|
||
|
|
|
||
|
|
def _client_for(handler) -> httpx.AsyncClient:
|
||
|
|
return httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url="https://api.themoviedb.org/3")
|
||
|
|
|
||
|
|
|
||
|
|
def _provider(handler) -> TMDbProvider:
|
||
|
|
return TMDbProvider(api_key="test-key", client=_client_for(handler))
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_retries_after_a_single_429_and_succeeds():
|
||
|
|
calls: list[int] = []
|
||
|
|
|
||
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
||
|
|
calls.append(1)
|
||
|
|
if len(calls) == 1:
|
||
|
|
return httpx.Response(429, headers={"Retry-After": "0"})
|
||
|
|
return httpx.Response(200, json={"id": 603, "title": "The Matrix"})
|
||
|
|
|
||
|
|
provider = _provider(handler)
|
||
|
|
data = await provider.get_movie("603")
|
||
|
|
|
||
|
|
assert data == {"id": 603, "title": "The Matrix"}
|
||
|
|
assert len(calls) == 2
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_reraises_provider_rate_limited_error_after_exhausting_retries():
|
||
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
||
|
|
return httpx.Response(429, headers={"Retry-After": "0"})
|
||
|
|
|
||
|
|
provider = _provider(handler)
|
||
|
|
|
||
|
|
with pytest.raises(ProviderRateLimitedError):
|
||
|
|
await provider.get_movie("603")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_defaults_retry_after_when_header_is_not_a_number():
|
||
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
||
|
|
return httpx.Response(429, headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"})
|
||
|
|
|
||
|
|
provider = _provider(handler)
|
||
|
|
|
||
|
|
with pytest.raises(ProviderRateLimitedError) as exc_info:
|
||
|
|
await provider.get_movie("603")
|
||
|
|
|
||
|
|
assert exc_info.value.retry_after == 1.0
|
||
|
|
|
||
|
|
|
||
|
|
def _fake_retry_state(exception: Exception | None) -> Mock:
|
||
|
|
state = Mock()
|
||
|
|
if exception is None:
|
||
|
|
state.outcome = None
|
||
|
|
else:
|
||
|
|
state.outcome.exception.return_value = exception
|
||
|
|
return state
|
||
|
|
|
||
|
|
|
||
|
|
def test_tmdb_wait_uses_retry_after_for_rate_limit_errors():
|
||
|
|
state = _fake_retry_state(ProviderRateLimitedError("rate limited", retry_after=12.5))
|
||
|
|
assert _tmdb_wait(state) == 12.5
|
||
|
|
|
||
|
|
|
||
|
|
def test_tmdb_wait_falls_back_to_exponential_backoff_for_other_errors():
|
||
|
|
state = _fake_retry_state(httpx.TransportError("boom"))
|
||
|
|
state.attempt_number = 1
|
||
|
|
waited = _tmdb_wait(state)
|
||
|
|
assert isinstance(waited, float)
|
||
|
|
assert waited >= 0
|