From 36849102a3f29909a785cd11226f517eaf7077ab Mon Sep 17 00:00:00 2001 From: roosy12 <127028113+roosy12@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:22:39 +1000 Subject: [PATCH] fix(telegram): attach polling instrumentation without an instance attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTPXRequest declares __slots__ (python-telegram-bot >= 22), so _instrument_polling_request()'s `request.do_request = _do_request` raises AttributeError: 'HTTPXRequest' object attribute 'do_request' is read-only and Telegram startup aborts. The adapter cannot connect at all. Attach the wrapper by rebinding request.__class__ to a subclass that overrides do_request instead. A subclass with empty __slots__ has the same instance layout, so __class__ assignment is legal and creates no per-instance attribute. The wrapper must not simply be skipped when it fails to attach: the polling progress verifier consumes what _record_polling_progress() records, so a no-op wrapper makes healthy polling look stalled and drops the adapter into a reconnect loop ("getUpdates made no progress before verifier deadline"). Add a regression test against the real HTTPXRequest. The existing request doubles subclass BaseRequest without __slots__, so they acquire a __dict__ and accept the assignment — which is why the suite did not catch this. Fixes #64566 Fixes #64482 --- plugins/platforms/telegram/adapter.py | 36 +++++++++++++--- tests/test_telegram_polling_progress_ptb.py | 46 +++++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 32aa2481d3904..7f45caf728c44 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -1970,12 +1970,31 @@ def _record_polling_progress(self, generation: int) -> None: self._send_path_degraded = False def _instrument_polling_request(self, request): - """Wrap one dedicated PTB getUpdates request with progress tracking.""" - do_request = request.do_request + """Wrap one dedicated PTB getUpdates request with progress tracking. - async def _do_request(*args, **kwargs): + The wrapper is attached by rebinding ``request.__class__`` to a subclass + that overrides ``do_request``, rather than by assigning the attribute on + the instance. ``HTTPXRequest`` declares ``__slots__`` (python-telegram-bot + >= 22), so ``request.do_request = ...`` raises + + AttributeError: 'HTTPXRequest' object attribute 'do_request' is read-only + + and aborts Telegram startup. A subclass with empty ``__slots__`` has the + same instance layout, so ``__class__`` assignment is legal and adds no + per-instance attribute. + + The wrapper cannot simply be skipped when it fails to attach: the polling + progress verifier consumes what ``_record_polling_progress`` records, so a + no-op wrapper makes healthy polling look stalled and drops the adapter into + a reconnect loop. + """ + adapter = self + base = type(request) + base_do_request = base.do_request + + async def _do_request(request, *args, **kwargs): generation = _POLLING_GENERATION_CONTEXT.get() - result = await do_request(*args, **kwargs) + result = await base_do_request(request, *args, **kwargs) status_code, payload = result if generation is not None and 200 <= status_code < 300: try: @@ -1993,10 +2012,15 @@ async def _do_request(*args, **kwargs): and envelope.get("ok") is True and "result" in envelope ): - self._record_polling_progress(generation) + adapter._record_polling_progress(generation) return result - request.do_request = _do_request + instrumented = type( + f"_Instrumented{base.__name__}", + (base,), + {"__slots__": (), "do_request": _do_request}, + ) + request.__class__ = instrumented return request async def _start_polling_once( diff --git a/tests/test_telegram_polling_progress_ptb.py b/tests/test_telegram_polling_progress_ptb.py index c25c503358203..c895f44bc5584 100644 --- a/tests/test_telegram_polling_progress_ptb.py +++ b/tests/test_telegram_polling_progress_ptb.py @@ -291,3 +291,49 @@ def schedule_recovery(error): await app.updater.stop() await _cancel_task(adapter._polling_progress_verifier_task) await app.shutdown() + + +@pytest.mark.asyncio +async def test_instrument_polling_request_attaches_to_real_httpxrequest(monkeypatch): + """Instrumentation must attach to the real PTB request class. + + Regression for the Telegram startup failure + + AttributeError: 'HTTPXRequest' object attribute 'do_request' is read-only + + ``HTTPXRequest`` declares ``__slots__`` (python-telegram-bot >= 22), so the + instrumentation cannot be attached by assigning ``request.do_request``. The + other doubles in this suite subclass ``BaseRequest`` without ``__slots__``, + so they acquire a ``__dict__`` and accept the assignment — which is why the + suite did not catch this. Use the production class here. + + Attaching is not enough: progress must still be recorded. A wrapper that + silently fails to attach would make healthy polling look stalled to the + progress verifier and send the adapter into a reconnect loop. + """ + from telegram.request import HTTPXRequest + + async def _no_network(self, *args, **kwargs): + return (200, b'{"ok":true,"result":[]}') + + monkeypatch.setattr(HTTPXRequest, "do_request", _no_network) + + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="test-token")) + generation, progress = adapter._begin_polling_generation() + + request = HTTPXRequest() + instrumented = adapter._instrument_polling_request(request) + + assert instrumented is request + + generation_context = tg_adapter._POLLING_GENERATION_CONTEXT + token = generation_context.set(generation) + try: + result = await instrumented.do_request( + "https://api.telegram.org/bottest-token/getUpdates", "POST" + ) + finally: + generation_context.reset(token) + + assert result == (200, b'{"ok":true,"result":[]}') + assert progress.is_set(), "instrumentation attached but recorded no polling progress"