update mangacollec
This commit is contained in:
1 parent
e4865224cd
commit
4f564e2962
64 files changed
+531
-174
No files matched your search
@@ -0,0 +1,69 @@
|
||||
"""Fixtures for MangaCollec requests without network access or real waiting."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mangacollec.mangacollec import Mangacollec
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def responses() -> list[httpx.Response | httpx.RequestError]:
|
||||
"""Return the sequence of HTTP responses and transport errors to simulate."""
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sent_requests() -> list[httpx.Request]:
|
||||
"""Record requests made through the mock transport."""
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(
|
||||
responses: list[httpx.Response | httpx.RequestError],
|
||||
sent_requests: list[httpx.Request],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Iterator[Mangacollec]:
|
||||
"""Provide an authenticated client with mocked resource and OAuth transports."""
|
||||
monkeypatch.setenv("BOOKSYNC_REQUEST_TIMEOUT", "30")
|
||||
client = Mangacollec("test-client", "test-secret")
|
||||
asyncio.run(client.close())
|
||||
|
||||
def handle_request(request: httpx.Request) -> httpx.Response:
|
||||
"""Consume the next simulated response and record its request."""
|
||||
sent_requests.append(request)
|
||||
response = responses.pop(0)
|
||||
if isinstance(response, httpx.RequestError):
|
||||
raise response
|
||||
return response
|
||||
|
||||
transport = httpx.MockTransport(handle_request)
|
||||
client._client = httpx.AsyncClient(
|
||||
base_url="https://api.mangacollec.com/", transport=transport, trust_env=False,
|
||||
)
|
||||
client._oauth_client = httpx.AsyncClient(
|
||||
base_url="https://api.mangacollec.com/", transport=transport, trust_env=False,
|
||||
)
|
||||
client._set_token({
|
||||
"access_token": "test-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 86400,
|
||||
"created_at": 0,
|
||||
})
|
||||
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
asyncio.run(client.close())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sleep_mock(mocker: MockerFixture) -> AsyncMock:
|
||||
"""Record asynchronous waits while completing them immediately."""
|
||||
sleep = AsyncMock(return_value=None)
|
||||
mocker.patch("mangacollec.mangacollec.asyncio.sleep", new=sleep)
|
||||
return sleep
|
||||
@@ -0,0 +1,201 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for server-directed waits and exponential fallback delays."""
|
||||
|
||||
import pytest
|
||||
from mangacollec.retry_policy import RetryPolicy
|
||||
|
||||
|
||||
class TestDelayForRetry:
|
||||
"""Verify the delay policy independently of HTTP requests."""
|
||||
|
||||
@pytest.mark.parametrize("retry_count", [1, 5])
|
||||
@pytest.mark.parametrize("retry_after, expected", [
|
||||
("120", 126.0),
|
||||
("8", 8.4),
|
||||
("1", 1.05),
|
||||
("0", 0.0),
|
||||
(" 120 ", 126.0),
|
||||
("00120", 126.0),
|
||||
])
|
||||
def test_server_delay(self, retry_count: int, retry_after: str, expected: float) -> None:
|
||||
"""Use server seconds plus five percent, without the fallback's cap."""
|
||||
delay = RetryPolicy.delay_for_retry(retry_count, retry_after=retry_after)
|
||||
|
||||
assert delay == pytest.approx(expected)
|
||||
|
||||
@pytest.mark.parametrize("retry_after", [
|
||||
None, "", " ", "-1", "+1", "1.5", "1e2", "invalid", "NaN", "Infinity",
|
||||
"١٢٠", "120, 60", "Fri, 31 Dec 1999 23:59:59 GMT",
|
||||
])
|
||||
def test_invalid_header(self, retry_after: str | None) -> None:
|
||||
"""Fall back for missing headers and values outside the seconds format."""
|
||||
assert RetryPolicy.delay_for_retry(1, retry_after=retry_after) == 0.5
|
||||
assert RetryPolicy.delay_for_retry(5, retry_after=retry_after) == 8.0
|
||||
|
||||
@pytest.mark.parametrize("retry_after", [
|
||||
pytest.param("9" * 400, id="non-finite-seconds"),
|
||||
pytest.param("179" + "0" * 306, id="non-finite-margin"),
|
||||
pytest.param("9" * 5000, id="oversized-number"),
|
||||
])
|
||||
def test_non_finite_delay(self, retry_after: str) -> None:
|
||||
"""Avoid passing infinite delays to the asynchronous wait."""
|
||||
assert RetryPolicy.delay_for_retry(1, retry_after=retry_after) == 0.5
|
||||
|
||||
@pytest.mark.parametrize("retry_count, expected", [
|
||||
(1, 0.5), (2, 1.0), (3, 2.0), (4, 4.0), (5, 8.0), (6, 8.0),
|
||||
])
|
||||
def test_exponential_fallback(self, retry_count: int, expected: float) -> None:
|
||||
"""Keep the existing exponential delays and maximum intact."""
|
||||
assert RetryPolicy.delay_for_retry(retry_count) == expected
|
||||
|
||||
@pytest.mark.parametrize("retry_count", [0, -1])
|
||||
@pytest.mark.parametrize("retry_after", [None, "120"])
|
||||
def test_invalid_retry_count(self, retry_count: int, retry_after: str | None) -> None:
|
||||
"""Validate the retry number even when the server supplies a delay."""
|
||||
with pytest.raises(ValueError):
|
||||
RetryPolicy.delay_for_retry(retry_count, retry_after=retry_after)
|
||||
Reference in new issue
Block a user