"""Tests for HTTP retry behavior through simulated MangaCollec transports.""" import asyncio from unittest.mock import AsyncMock, call import httpx import pytest from mangacollec.mangacollec import Mangacollec class TestRequest: """Exercise HTTP retries, transport errors and retry eligibility.""" @pytest.mark.parametrize("status_code", [429, 500, 503, 599]) @pytest.mark.parametrize("retry_after, expected_delay", [ ("120", 126.0), ("0", 0.0), (None, 0.5), ("invalid", 0.5), ]) def test_retry_then_success( self, client: Mangacollec, responses: list[httpx.Response | httpx.RequestError], sent_requests: list[httpx.Request], sleep_mock: AsyncMock, status_code: int, retry_after: str | None, expected_delay: float, ) -> None: """Retry rate limits and server errors using the current response's delay.""" headers = {} if retry_after is None else {"retry-after": retry_after} responses.extend([ httpx.Response(status_code, headers=headers), httpx.Response(200, json={"series": []}), ]) payload = asyncio.run(client.get("series")) assert payload == {"series": []} assert len(sent_requests) == 2 sleep_mock.assert_awaited_once_with(expected_delay) @pytest.mark.parametrize("status_code", [429, 503]) @pytest.mark.parametrize("retry_after", [None, "120"]) def test_retry_limit( self, client: Mangacollec, responses: list[httpx.Response | httpx.RequestError], sent_requests: list[httpx.Request], sleep_mock: AsyncMock, status_code: int, retry_after: str | None, ) -> None: """Raise the final HTTP error after five waits and six attempts.""" headers = {} if retry_after is None else {"Retry-After": retry_after} responses.extend(httpx.Response(status_code, headers=headers) for _ in range(6)) final_response = responses[-1] with pytest.raises(httpx.HTTPStatusError) as error: asyncio.run(client.get("series")) assert error.value.response is final_response assert len(sent_requests) == 6 expected_delays = [0.5, 1.0, 2.0, 4.0, 8.0] if retry_after is None else [126.0] * 5 assert sleep_mock.await_args_list == [call(delay) for delay in expected_delays] @pytest.mark.parametrize("error_type", [ httpx.ReadTimeout, httpx.ConnectError, httpx.RemoteProtocolError, ]) def test_network_error_resets_header( self, client: Mangacollec, responses: list[httpx.Response | httpx.RequestError], sent_requests: list[httpx.Request], sleep_mock: AsyncMock, error_type: type[httpx.RequestError], ) -> None: """Use exponential fallback after a transport error rather than stale headers.""" responses.extend([ httpx.Response(429, headers={"Retry-After": "120"}), error_type("Simulated network error"), httpx.Response(200, json={}), ]) assert asyncio.run(client.get("series")) == {} assert len(sent_requests) == 3 assert sleep_mock.await_args_list == [call(126.0), call(1.0)] def test_network_error_limit( self, client: Mangacollec, responses: list[httpx.Response | httpx.RequestError], sent_requests: list[httpx.Request], sleep_mock: AsyncMock, ) -> None: """Propagate the final transport error without an additional wait.""" responses.extend(httpx.ConnectError("Simulated network error") for _ in range(6)) final_error = responses[-1] with pytest.raises(httpx.ConnectError) as error: asyncio.run(client.get("series")) assert error.value is final_error assert len(sent_requests) == 6 assert sleep_mock.await_args_list == [call(delay) for delay in [0.5, 1.0, 2.0, 4.0, 8.0]] @pytest.mark.parametrize("method", ["POST", "PUT", "DELETE"]) @pytest.mark.parametrize("status_code", [429, 503]) def test_mutation_not_retried( self, client: Mangacollec, responses: list[httpx.Response | httpx.RequestError], sent_requests: list[httpx.Request], sleep_mock: AsyncMock, method: str, status_code: int, ) -> None: """Preserve immediate errors for resource mutations despite Retry-After.""" responses.append(httpx.Response(status_code, headers={"Retry-After": "120"})) with pytest.raises(httpx.HTTPStatusError): asyncio.run(client._request(method, "possessions", data={})) assert len(sent_requests) == 1 sleep_mock.assert_not_awaited() @pytest.mark.parametrize("status_code", [400, 401, 403, 404]) def test_other_client_errors_not_retried( self, client: Mangacollec, responses: list[httpx.Response | httpx.RequestError], sent_requests: list[httpx.Request], sleep_mock: AsyncMock, status_code: int, ) -> None: """Do not expand retries to other client errors carrying the header.""" responses.append(httpx.Response(status_code, headers={"Retry-After": "120"})) with pytest.raises(httpx.HTTPStatusError): asyncio.run(client.get("series")) assert len(sent_requests) == 1 sleep_mock.assert_not_awaited() @pytest.mark.parametrize("status_code", [429, 503]) def test_oauth_retry( self, client: Mangacollec, responses: list[httpx.Response | httpx.RequestError], sent_requests: list[httpx.Request], sleep_mock: AsyncMock, status_code: int, ) -> None: """Apply the server delay to retriable OAuth POST requests.""" responses.extend([ httpx.Response(status_code, headers={"Retry-After": "120"}), httpx.Response(200, json={ "access_token": "new-test-token", "token_type": "Bearer", "expires_in": 86400, "created_at": 0, }), ]) asyncio.run(client.login()) assert client.authorization_header == "Bearer new-test-token" assert len(sent_requests) == 2 assert all(request.method == "POST" for request in sent_requests) assert all(request.url.path == "/oauth/token" for request in sent_requests) sleep_mock.assert_awaited_once_with(126.0) @pytest.mark.parametrize("status_code", [200, 204]) def test_success_does_not_wait( self, client: Mangacollec, responses: list[httpx.Response | httpx.RequestError], sleep_mock: AsyncMock, status_code: int, ) -> None: """Return successful responses immediately even if they have Retry-After.""" responses.append(httpx.Response(status_code, headers={"Retry-After": "120"}, json={})) assert asyncio.run(client.get("series")) == {} sleep_mock.assert_not_awaited() def test_logs_scheduled_wait( self, client: Mangacollec, responses: list[httpx.Response | httpx.RequestError], sleep_mock: AsyncMock, caplog: pytest.LogCaptureFixture, ) -> None: """Log the retry number and the actual wait duration.""" responses.extend([ httpx.Response(429, headers={"Retry-After": "120"}), httpx.Response(200, json={}), ]) asyncio.run(client.get("series")) assert any("reprise 1/5 dans 126.0 secondes" in message for message in caplog.messages) sleep_mock.assert_awaited_once_with(126.0)