"""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