Files
telegram-sticker-downloader/tests/test_retry.py
T
2026-07-27 15:16:01 +02:00

139 lines
3.9 KiB
Python

from __future__ import annotations
import asyncio
from datetime import timedelta
import aiohttp
import pytest
from telegram.error import BadRequest, InvalidToken, NetworkError, RetryAfter, TimedOut
from sticker_downloader.errors import FetchError
from sticker_downloader.retry import is_retriable, retry_delay, with_retries
from tests.conftest import no_sleep
async def test_returns_first_success() -> None:
calls = 0
async def operation() -> str:
nonlocal calls
calls += 1
return "ok"
assert await with_retries(operation, sleep=no_sleep) == "ok"
assert calls == 1
async def test_retries_transient_failure_then_succeeds() -> None:
attempts = 0
async def operation() -> str:
nonlocal attempts
attempts += 1
if attempts < 3:
raise TimedOut
return "ok"
assert await with_retries(operation, attempts=3, sleep=no_sleep) == "ok"
assert attempts == 3
async def test_raises_after_exhausting_attempts() -> None:
attempts = 0
async def operation() -> str:
nonlocal attempts
attempts += 1
raise NetworkError("down")
with pytest.raises(NetworkError):
await with_retries(operation, attempts=2, sleep=no_sleep)
assert attempts == 2
async def test_does_not_retry_bad_request() -> None:
"""BadRequest subclasses NetworkError in python-telegram-bot: it must not retry."""
attempts = 0
async def operation() -> str:
nonlocal attempts
attempts += 1
raise BadRequest("Stickerset_invalid")
with pytest.raises(BadRequest):
await with_retries(operation, attempts=5, sleep=no_sleep)
assert attempts == 1
async def test_reports_each_retry() -> None:
seen: list[tuple[str, int, float]] = []
async def operation() -> str:
raise TimedOut
def on_retry(error: BaseException, attempt: int, delay: float) -> None:
seen.append((type(error).__name__, attempt, delay))
with pytest.raises(TimedOut):
await with_retries(
operation, attempts=3, base_delay=1.0, on_retry=on_retry, sleep=no_sleep
)
assert seen == [("TimedOut", 1, 1.0), ("TimedOut", 2, 2.0)]
async def test_awaits_the_requested_delays() -> None:
slept: list[float] = []
async def record(delay: float) -> None:
slept.append(delay)
async def operation() -> str:
raise TimedOut
with pytest.raises(TimedOut):
await with_retries(operation, attempts=3, base_delay=0.5, sleep=record)
assert slept == [0.5, 1.0]
@pytest.mark.parametrize(
("error", "expected"),
[
(TimedOut(), True),
(NetworkError("x"), True),
(aiohttp.ClientError(), True),
(asyncio.TimeoutError(), True),
(RetryAfter(3), True),
(FetchError("u", 500), True),
(FetchError("u", 429), True),
(FetchError("u", 404), False),
(FetchError("u", 403), False),
(BadRequest("nope"), False),
(InvalidToken(), False),
(ValueError("nope"), False),
],
)
def test_retriability_classification(error: BaseException, expected: bool) -> None:
assert is_retriable(error) is expected
def test_retry_after_delay_uses_telegram_hint() -> None:
assert retry_delay(RetryAfter(7), attempt=1, base_delay=0.5) == pytest.approx(7.0)
def test_retry_after_delay_accepts_a_timedelta() -> None:
"""python-telegram-bot will switch ``retry_after`` to a timedelta in a future
major version; handle both shapes."""
class FutureRetryAfter(RetryAfter):
@property
def retry_after(self) -> timedelta: # type: ignore[override]
return timedelta(seconds=12)
error = FutureRetryAfter(1)
assert retry_delay(error, attempt=1, base_delay=0.5) == pytest.approx(12.0)
def test_backoff_is_exponential() -> None:
delays = [retry_delay(TimedOut(), attempt=n, base_delay=0.5) for n in (1, 2, 3)]
assert delays == [0.5, 1.0, 2.0]