Conversation
… at ~39s api.telegram.org closes a pooled connection roughly 39 seconds after it is opened. PTB's long poll runs back-to-back (poll_interval defaults to 0), so the socket is never idle and keepalive_expiry -- which measures *idle* time -- never fires. httpx therefore hands the next getUpdates a socket the server has already closed and raises a bare `httpx.ReadError`. The polling error callback reads that as a network fault and answers with a 5s reconnect, so a healthy bot on a healthy network reconnects every ~44s forever, losing a 5s window of updates each time. Left running it is also the "sustained reconnect storm" the send path already warns about, where PTB's pool can report SendResult(success=True) for sends that never transmit. Measured against the live endpoint with plain GETs on a reused pool (no bot token involved): the connection died at 38.7s and 38.9s, matching the adapter's observed 39.3s reconnect period. With max_keepalive_connections=0 the same probe ran 100s with zero errors. Give the getUpdates pool limits that never keep a connection alive. Only that pool opts out; ordinary Bot API calls keep the shared tuned keepalive, where reuse is a win and the ~39s lifetime is invisible because those requests are short and sporadic. Cost is one TLS handshake per poll (~35ms to the IPv6 endpoint) against a 5s blind window every 44s. The limits must reach whichever object owns the pool. When a custom transport is supplied, httpx builds no transport of its own and the client-level `limits` are silently ignored, so the fallback-IP branch passes them into TelegramFallbackTransport, which forwards **transport_kwargs to the httpx.AsyncHTTPTransport it creates per IP. Wiring them only into the client looks correct and changes nothing on that branch. keepalive_expiry is carried from the shared tuned limits even though it is moot at max_keepalive_connections=0, so NousResearch#31599's "no pool on httpx's 5.0 default" invariant still holds if the keepalive count is ever raised. test_telegram_closewait_limits_31599 now asserts the two pools separately: the general pool keeps tight keepalive (1..50 connections), the getUpdates pool must have none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(telegram): stop reusing the getUpdates connection Telegram closes at ~39s
|
|
Heads-up: PR #99691 (just opened for #87057, salvaging #87111 + #87265) sets |
What does this PR do?
A healthy Telegram bot on a healthy network reconnects every ~44 seconds, forever. In one 20-minute sample: 133 disconnects, 134 recoveries. It never looks broken — the attempt counter resets to 1 each time, so the reconnect ladder never escalates and the bot keeps working — but ~11% of wall-clock time is spent not polling, and each gap adds up to 5s of latency.
The cause is not a flaky network. It is a metronome:
Root cause:
api.telegram.orgcloses a pooled connection roughly 39 seconds after it is opened. PTB's long poll runs back-to-back (Updater.start_polling()defaults topoll_interval=0), so the socket is never idle.keepalive_expirymeasures idle time, so it never fires and the same TCP connection is reused indefinitely. At ~39s the server closes it, httpx hands the nextgetUpdatesthat dead socket and raises a barehttpx.ReadError(empty message: the connection went away mid-read)._handle_polling_network_errorclassifies that as a network fault and answers with a 5s backoff +start_polling(). Hence the ~44s cycle: 39s of polling, 5s blind.The fix: give the
getUpdatespool limits that never keep a connection alive, so every long poll starts on a fresh socket that cannot already be dead.Only that pool opts out. Ordinary Bot API calls keep the shared tuned keepalive from
platform_httpx_limits()— they are short and sporadic, reuse is a win there, and the ~39s lifetime is invisible to them.Cost is one TLS handshake per poll: ~35ms to the IPv6 endpoint, ~150ms to IPv4, once per
timeoutseconds. Against a 5s blind window every 44s, that trade is worth making.The part that is easy to get wrong
The limits must reach whichever object owns the connection pool.
When a custom
transportis passed tohttpx.AsyncClient, httpx builds no transport of its own and the client-levellimitsare silently ignored — they exist only to construct the default transport. On the fallback-IP branchTelegramFallbackTransportowns the pool, so the limits go into it; it forwards**transport_kwargsto thehttpx.AsyncHTTPTransportit creates per IP. This matches the rule the surrounding code already documents for the general pool.Wiring them only into the client passes review, changes nothing on that branch, and leaves the bug fully intact. That was the first attempt at this fix and it looked correct in the diff.
keepalive_expiryCarried from the shared tuned limits even though it is moot while
max_keepalive_connections=0— nothing is kept alive to expire. #31599's invariant is that no pool is left on httpx's5.0default, and the value must already be right if the keepalive count is ever raised.Related Issue
No open issue tracks this specific symptom. The closest is #31599 (CLOSE_WAIT fd leak in the general pool), which is closed and was fixed by #51541 — this PR does not re-fix it. The code and tests here reference #31599 because they extend the invariant it established to the
getUpdatespool.Fixes #
Type of Change
Changes Made
plugins/platforms/telegram/adapter.py_updates_limits()— buildshttpx.Limits(max_keepalive_connections=0, ...)for thegetUpdatespool, preserving PTB'sconnection_pool_sizeasmax_connectionsand carryingkeepalive_expiryfrom the shared tuned limits._with_updates_limits()— injects those limits at the client level on the proxy and direct branches, where httpx honours the client-levellimitskwarg._transport_kwargsand overrideslimitswith_updates_limits(), so they reachTelegramFallbackTransportitself. The client-level kwarg is deliberately not set here; httpx would discard it next to a custom transport.tests/gateway/test_telegram_closewait_limits_31599.py_assert_updates_pool_never_reuses()— new helper asserting thegetUpdatespool contract.TelegramFallbackTransport._transport_kwargs["limits"]rather than through the client-level helpers, because that branch deliberately does not set client-level limits.How to Test
Reproduce without a bot token (does not disturb a running gateway — plain
GETs, nogetUpdates, no 409 conflict). Save the script below asrepro_telegram_conn_lifetime.py:Observed:
38.7s and 38.9s — matching the adapter's 39.3s. With
--fixedthe same probe runs 100s with zero errors.repro_telegram_conn_lifetime.py(no bot token needed)Unit tests:
On a live gateway — same bot, same network, same machine. Before: 133 disconnects in 20 minutes, one every ~44s without exception. After: 25 minutes 34 seconds of uptime, 0 disconnects, 0
ReadError. The gateway is live throughout (12.1s CPU accumulated, dispatcher and housekeeping both ticking) — the silence is an absence of errors, not an absence of work. At the old rate that window would have contained roughly 34 reconnects.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passOn the two boxes above, precisely:
pytest tests/ -qgreen, so I have not ticked it.pytest-asynciois not in the dev deps, so every@pytest.mark.asynciotest errors out in my environment regardless of this patch. What I did instead is a before/after comparison on the same interpreter and selection:pytest tests/gateway -k telegram --ignore=tests/gateway/relaygives 221 failed / 367 passed on untouchedmainand 221 failed / 367 passed on this branch, with identical failure sets — no test fails here that does not already fail without the patch. Happy to re-run anything specific.test_telegram_closewait_limits_31599.pyalready drive both branches ofconnect(), so the newgetUpdatescontract is asserted there via a new helper rather than by adding a third test that would duplicate the setup.Documentation & Housekeeping
docs/, docstrings) — or N/A — N/A; the reasoning lives in docstrings and comments on the new helpers, where the existing pool-limit rationale already sits.cli-config.yaml.exampleif I added/changed config keys — or N/A — N/A; no new config keys or env vars. This is a behaviour fix, not a knob.CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/A — N/AScreenshots / Logs
Before — the loop, roughly every 44 seconds, forever:
After — the last
httpx.ReadErrorin the log is timestamped 23:37:09, twenty-one seconds before the patched gateway finished starting at 23:37:30. Nothing after it.Related work: #29326 (open) proposed isolating the
getUpdatestransport in May and deserves the credit for raising it first; it targetsgateway/platforms/telegram.py, which no longer exists after the adapter moved toplugins/platforms/telegram/, and it also adds polling-timing env vars. This PR is rebased on currentmain, limited to the pool-limits fix, and additionally covers the fallback-IP transport branch. Maintainers may well prefer to revive that one instead — happy either way.