Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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(
Expand Down
46 changes: 46 additions & 0 deletions tests/test_telegram_polling_progress_ptb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"