Files
booksync/libs/mangacollec/tests/test_mangacollec.py
T
2026-10-07 16:50:30 +02:00

202 lines
7.5 KiB
Python

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