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]