diff --git a/src/telegram/error.py b/src/telegram/error.py index 014bf8631b3..548f7231778 100644 --- a/src/telegram/error.py +++ b/src/telegram/error.py @@ -37,6 +37,7 @@ "InvalidToken", "NetworkError", "PassportDecryptionError", + "PoolTimeout", "RetryAfter", "TelegramError", "TimedOut", @@ -179,6 +180,24 @@ def __init__(self, message: str | None = None) -> None: super().__init__(message or "Timed out") +class PoolTimeout(TimedOut): + """Raised when a request could not acquire a connection from the connection pool in time. + + This is a subclass of :class:`TimedOut` to preserve compatibility with code handling all + request timeouts. + + .. versionadded:: NEXT.VERSION + + Args: + message (:obj:`str`, optional): Any additional information about the exception. + """ + + __slots__ = () + + def __init__(self, message: str | None = None) -> None: + super().__init__(message or "Pool timed out") + + class ChatMigrated(TelegramError): """ Raised when the requested group chat migrated to supergroup and has a new chat id. diff --git a/src/telegram/ext/_utils/networkloop.py b/src/telegram/ext/_utils/networkloop.py index f3696b589aa..486b3505f4f 100644 --- a/src/telegram/ext/_utils/networkloop.py +++ b/src/telegram/ext/_utils/networkloop.py @@ -36,7 +36,7 @@ from collections.abc import Callable, Coroutine from telegram._utils.logging import get_logger -from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut +from telegram.error import InvalidToken, PoolTimeout, RetryAfter, TelegramError, TimedOut _LOGGER = get_logger(__name__) @@ -169,6 +169,17 @@ async def do_action() -> None: exception_info = f"{exc}. Adding {slack_time} seconds to the specified time." # Check max_retries for RetryAfter as well + if check_max_retries_and_log(retries, exception_info): + raise + except PoolTimeout as pool_timeout: + # Unlike regular request timeouts, pool exhaustion should be observable by callers. + if on_err_cb: + on_err_cb(pool_timeout) + + # If failure is due to timeout, we should retry asap. + cur_interval = 0 + exception_info = f"Pool timed out: {pool_timeout}." + if check_max_retries_and_log(retries, exception_info): raise except TimedOut as toe: diff --git a/src/telegram/request/_httpxrequest.py b/src/telegram/request/_httpxrequest.py index 080fd3d0735..b751a57d277 100644 --- a/src/telegram/request/_httpxrequest.py +++ b/src/telegram/request/_httpxrequest.py @@ -26,7 +26,7 @@ from telegram._utils.defaultvalue import DefaultValue from telegram._utils.logging import get_logger from telegram._utils.types import HTTPVersion, ODVInput, SocketOpt -from telegram.error import NetworkError, TimedOut +from telegram.error import NetworkError, PoolTimeout, TimedOut from telegram.request._baserequest import BaseRequest from telegram.request._requestdata import RequestData @@ -79,9 +79,13 @@ class HTTPXRequest(BaseRequest): Defaults to ``1``. Warning: - With a finite pool timeout, you must expect :exc:`telegram.error.TimedOut` + With a finite pool timeout, you must expect :exc:`telegram.error.PoolTimeout` exceptions to be thrown when more requests are made simultaneously than there are connections in the connection pool! + + .. versionchanged:: NEXT.VERSION + Raises :exc:`telegram.error.PoolTimeout`, a subclass of + :exc:`telegram.error.TimedOut`, instead of :exc:`telegram.error.TimedOut`. http_version (:obj:`str`, optional): If ``"2"`` or ``"2.0"``, HTTP/2 will be used instead of HTTP/1.1. Defaults to ``"1.1"``. @@ -286,7 +290,7 @@ async def do_request( ) except httpx.TimeoutException as err: if isinstance(err, httpx.PoolTimeout): - raise TimedOut( + raise PoolTimeout( message=( "Pool timeout: All connections in the connection pool are occupied. " "Request was *not* sent to Telegram. Consider adjusting the connection " diff --git a/tests/ext/_utils/test_networkloop.py b/tests/ext/_utils/test_networkloop.py index ba3f64a3e10..548674bef2a 100644 --- a/tests/ext/_utils/test_networkloop.py +++ b/tests/ext/_utils/test_networkloop.py @@ -26,7 +26,7 @@ import pytest -from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut +from telegram.error import InvalidToken, PoolTimeout, RetryAfter, TelegramError, TimedOut from telegram.ext._utils.networkloop import network_retry_loop @@ -194,6 +194,30 @@ async def action_with_telegram_error(): assert error_callback_count == 3 assert isinstance(caught_exception, TelegramError) + async def test_error_callback_called_for_pool_timeout(self): + """Test that pool timeouts are observable while regular timeouts remain silent.""" + pool_timeout = PoolTimeout("Test pool timeout") + error_callback_count = 0 + + def error_callback(exc): + nonlocal error_callback_count + error_callback_count += 1 + assert exc is pool_timeout + + async def action_with_pool_timeout(): + raise pool_timeout + + with pytest.raises(PoolTimeout): + await network_retry_loop( + action_cb=action_with_pool_timeout, + on_err_cb=error_callback, + description="Test PoolTimeout callback", + interval=0, + max_retries=2, + ) + + assert error_callback_count == 3 + async def test_success_after_retries(self): """Test that action succeeds after some retries.""" call_count = 0 diff --git a/tests/ext/test_updater.py b/tests/ext/test_updater.py index bbe1d3dc320..b268b0877c5 100644 --- a/tests/ext/test_updater.py +++ b/tests/ext/test_updater.py @@ -28,7 +28,7 @@ import pytest from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup, Update -from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut +from telegram.error import InvalidToken, PoolTimeout, RetryAfter, TelegramError, TimedOut from telegram.ext import ExtBot, InvalidCallbackData, Updater from tests.auxil.build_messages import make_message, make_message_update from tests.auxil.envvars import TEST_WITH_OPT_DEPS @@ -492,10 +492,11 @@ async def delete_webhook(*args, **kwargs): ("error", "callback_should_be_called"), argvalues=[ (TelegramError("TestMessage"), True), + (PoolTimeout("TestMessage"), True), (RetryAfter(1), False), (TimedOut("TestMessage"), False), ], - ids=("TelegramError", "RetryAfter", "TimedOut"), + ids=("TelegramError", "PoolTimeout", "RetryAfter", "TimedOut"), ) @pytest.mark.parametrize("custom_error_callback", [True, False]) async def test_start_polling_exceptions_and_error_callback( diff --git a/tests/request/test_request.py b/tests/request/test_request.py index a0d71544aa3..dd73a73c1d2 100644 --- a/tests/request/test_request.py +++ b/tests/request/test_request.py @@ -43,6 +43,7 @@ Forbidden, InvalidToken, NetworkError, + PoolTimeout, RetryAfter, TelegramError, TimedOut, @@ -658,13 +659,14 @@ async def request(_, **kwargs): monkeypatch.setattr(httpx.AsyncClient, "request", request) async with HTTPXRequest(pool_timeout=0.02) as httpx_request: - with pytest.raises(TimedOut, match="Pool timeout") as exc_info: + with pytest.raises(PoolTimeout, match="Pool timeout") as exc_info: await asyncio.gather( httpx_request.do_request(method="GET", url="URL"), httpx_request.do_request(method="GET", url="URL"), ) assert exc_info.value.__cause__ is pool_timeout + assert isinstance(exc_info.value, TimedOut) @pytest.mark.parametrize("media", [True, False]) async def test_do_request_write_timeout( diff --git a/tests/test_error.py b/tests/test_error.py index a6eadc0e2f1..594cc555de8 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -31,6 +31,7 @@ InvalidToken, NetworkError, PassportDecryptionError, + PoolTimeout, RetryAfter, TelegramError, TimedOut, @@ -89,6 +90,10 @@ def test_timed_out(self): with pytest.raises(TimedOut, match=r"^Timed out$"): raise TimedOut + def test_pool_timeout(self): + with pytest.raises(PoolTimeout, match=r"^Pool timed out$"): + raise PoolTimeout + def test_chat_migrated(self): with pytest.raises(ChatMigrated, match="New chat id: 1234") as e: raise ChatMigrated(1234) @@ -130,6 +135,7 @@ def test_conflict(self): (NetworkError("test message"), ["message"]), (BadRequest("test message"), ["message"]), (TimedOut(), ["message"]), + (PoolTimeout(), ["message"]), (ChatMigrated(1234), ["message", "new_chat_id"]), (RetryAfter(12), ["message", "retry_after"]), (RetryAfter(dtm.timedelta(seconds=12)), ["message", "retry_after"]), @@ -157,6 +163,7 @@ def test_errors_pickling(self, exception, attributes): (NetworkError("test message")), (BadRequest("test message")), (TimedOut()), + (PoolTimeout()), (ChatMigrated(1234)), (RetryAfter(dtm.timedelta(seconds=12))), (Conflict("test message")), @@ -198,6 +205,7 @@ def make_assertion(cls): EndPointNotFound, }, NetworkError: {BadRequest, TimedOut}, + TimedOut: {PoolTimeout}, } )