Skip to content

Switch from requests to httpx #10

@nmlorg

Description

@nmlorg

Splitting this out from #9.

I haven't done a lot of testing, but 0045680 seems to have intermittently but significantly reduced response time, presumably by eliminating both the TCP handshake (to the Netherlands) and TLS negotiation for some requests.

Right now, each thread gets its own requests.Session, so each bot's long-poll Session stays active pretty much constantly but the main thread's Session (and any connections it maintains) can go idle potentially for hours at a time (and hence time out and need to be reestablished before a response can be delivered).

encode/httpx#1633 (comment)

Yes. HTTPX is intended to be thread-safe, and yes, a single client-instance across all threads will do better in terms of connection pooling, than using an instance-per-thread.

However, changing requests.py to:

_SESSION = httpx.Client(http2=True)
⋮
def post(*args, **kwargs):
    return _SESSION.post(*args, **kwargs)

consistently gives:

2024-08-23 14:59:28,013 INFO Thread-1 (_poll_bot) _client.py:1026] HTTP Request: POST https://api.telegram.org/bot44…/getupdates "HTTP/2 200 OK"
2024-08-23 15:00:01,741 INFO MainThread reminders.py:42] Running periodic.
2024-08-23 15:00:08,887 INFO MainThread reminders.py:192] Editing reminder -40…/8197.
2024-08-23 15:00:18,220 INFO Thread-1 (_poll_bot) _client.py:1026] HTTP Request: POST https://api.telegram.org/bot44…/getupdates "HTTP/2 200 OK"
2024-08-23 15:00:18,221 INFO MainThread _client.py:1026] HTTP Request: POST https://api.telegram.org/bot44…/editmessagecaption "HTTP/2 200 OK"

which suggests that requests to the same host are serialized (the editMessageCaption didn't complete until 1 ms after a previous getUpdates completed). A quick test seems to confirm this is an actual effect, not just a discrepancy in the logging:

>>> import ntelebot
>>> import threading
>>> import time
>>> bot = ntelebot.bot.Bot('18…')
>>> print(time.time(), bot.getme(), time.time())
1724451017.3645363 {'id': 18…, 'is_bot': True, 'first_name': 'ntelebot', 'username': 'ntelebot', 'can_join_groups': False, 'can_read_all_group_messages': False, 'supports_inline_queries': False, 'can_connect_to_business': False, 'has_main_web_app': False} 1724451017.9219635
>>> t = threading.Thread(target=lambda: print(time.time(), bot.getupdates(timeout=20), time.time())); t.daemon = True; t.start()
>>> print(time.time(), bot.getme(), time.time())
1724451058.9599593 [] 1724451079.5572999
1724451061.5596561 {'id': 18…, 'is_bot': True, 'first_name': 'ntelebot', 'username': 'ntelebot', 'can_join_groups': False, 'can_read_all_group_messages': False, 'supports_inline_queries': False, 'can_connect_to_business': False, 'has_main_web_app': False} 1724451079.5575902

The exact timing (getUpdates returning at 1724451079.5572999 and getMe returning at 1724451079.5575902, 290 µs — not ms — later) suggests the requests did actually go out as requested, but something caused the responses to stack up. I haven't dug deeply enough to figure out if this is actually happening in the HTTP/2 stream or if this is happening somewhere inside httpx.


Even more worryingly:

>>> t = threading.Thread(target=lambda: print(bot.getupdates())); t.daemon = True; t.start(); print(bot.getme())
Exception in thread Thread-1 (<lambda>):
Traceback (most recent call last):
  File "lib/python3.10/site-packages/httpx/_transports/default.py", line 69, in map_httpcore_exceptions
    yield
  File "lib/python3.10/site-packages/httpx/_transports/default.py", line 233, in handle_request
    resp = self._pool.handle_request(req)
  File "lib/python3.10/site-packages/httpcore/_sync/connection_pool.py", line 216, in handle_request
    raise exc from None
  File "lib/python3.10/site-packages/httpcore/_sync/connection_pool.py", line 196, in handle_request
    response = connection.handle_request(
  File "lib/python3.10/site-packages/httpcore/_sync/connection.py", line 101, in handle_request
    return self._connection.handle_request(request)
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 185, in handle_request
    raise exc
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 148, in handle_request
    status, headers = self._receive_response(
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 292, in _receive_response
    event = self._receive_stream_event(request, stream_id)
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 333, in _receive_stream_event
    self._receive_events(request, stream_id)
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 361, in _receive_events
    events = self._read_incoming_data(request)
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 452, in _read_incoming_data
    raise exc
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 440, in _read_incoming_data
    raise RemoteProtocolError("Server disconnected")
httpcore.RemoteProtocolError: Server disconnected

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/usr/lib/python3.10/threading.py", line 1016, in _bootstrap_inner
    self.run()
  File "/usr/lib/python3.10/threading.py", line 953, in run
    self._target(*self._args, **self._kwargs)
  File "<stdin>", line 1, in <lambda>
Traceback (most recent call last):
  File "lib/python3.10/site-packages/httpx/_transports/default.py", line 69, in map_httpcore_exceptions
    yield
  File "lib/python3.10/site-packages/httpx/_transports/default.py", line 233, in handle_request
    resp = self._pool.handle_request(req)
  File "lib/python3.10/site-packages/httpcore/_sync/connection_pool.py", line 216, in handle_request
    raise exc from None
  File "lib/python3.10/site-packages/httpcore/_sync/connection_pool.py", line 196, in handle_request
    response = connection.handle_request(
  File "lib/python3.10/site-packages/httpcore/_sync/connection.py", line 101, in handle_request
    return self._connection.handle_request(request)
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 185, in handle_request
    raise exc
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 148, in handle_request
    status, headers = self._receive_response(
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 292, in _receive_response
    event = self._receive_stream_event(request, stream_id)
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 333, in _receive_stream_event
    self._receive_events(request, stream_id)
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 361, in _receive_events
    events = self._read_incoming_data(request)
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 435, in _read_incoming_data
    raise self._read_exception  # pragma: nocover
  File "lib/python3.10/site-packages/httpx/_transports/default.py", line 69, in map_httpcore_exceptions
    yield
  File "lib/python3.10/site-packages/httpx/_transports/default.py", line 233, in handle_request
    resp = self._pool.handle_request(req)
  File "lib/python3.10/site-packages/httpcore/_sync/connection_pool.py", line 216, in handle_request
    raise exc from None
  File "lib/python3.10/site-packages/httpcore/_sync/connection_pool.py", line 196, in handle_request
    response = connection.handle_request(
  File "lib/python3.10/site-packages/httpcore/_sync/connection.py", line 101, in handle_request
    return self._connection.handle_request(request)
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 185, in handle_request
    raise exc
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 148, in handle_request
    status, headers = self._receive_response(
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 292, in _receive_response
    event = self._receive_stream_event(request, stream_id)
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 333, in _receive_stream_event
    self._receive_events(request, stream_id)
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 361, in _receive_events
    events = self._read_incoming_data(request)
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 452, in _read_incoming_data
    raise exc
  File "lib/python3.10/site-packages/httpcore/_sync/http2.py", line 440, in _read_incoming_data
    raise RemoteProtocolError("Server disconnected")
httpcore.RemoteProtocolError: Server disconnected

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "ntelebot/ntelebot/bot.py", line 57, in __call__
    data = ntelebot.requests.post(self.url, timeout=self.timeout, **_prepare(params)).json()
  File "ntelebot/ntelebot/requests.py", line 29, in post
    return _SESSION.post(*args, **kwargs)
  File "lib/python3.10/site-packages/httpx/_client.py", line 1145, in post
    return self.request(
  File "lib/python3.10/site-packages/httpx/_client.py", line 827, in request
    return self.send(request, auth=auth, follow_redirects=follow_redirects)
  File "lib/python3.10/site-packages/httpx/_client.py", line 914, in send
    response = self._send_handling_auth(
  File "lib/python3.10/site-packages/httpx/_client.py", line 942, in _send_handling_auth
    response = self._send_handling_redirects(
  File "lib/python3.10/site-packages/httpx/_client.py", line 979, in _send_handling_redirects
    response = self._send_single_request(request)
  File "lib/python3.10/site-packages/httpx/_client.py", line 1015, in _send_single_request
    response = transport.handle_request(request)
  File "lib/python3.10/site-packages/httpx/_transports/default.py", line 232, in handle_request
    with map_httpcore_exceptions():
  File "/usr/lib/python3.10/contextlib.py", line 153, in __exit__
    self.gen.throw(typ, value, traceback)
  File "lib/python3.10/site-packages/httpx/_transports/default.py", line 86, in map_httpcore_exceptions
    raise mapped_exc(message) from exc
httpx.RemoteProtocolError: Server disconnected
  File "ntelebot/ntelebot/bot.py", line 57, in __call__
    data = ntelebot.requests.post(self.url, timeout=self.timeout, **_prepare(params)).json()
>>>   File "ntelebot/ntelebot/requests.py", line 29, in post
    return _SESSION.post(*args, **kwargs)
  File "lib/python3.10/site-packages/httpx/_client.py", line 1145, in post
    return self.request(
  File "lib/python3.10/site-packages/httpx/_client.py", line 827, in request
    return self.send(request, auth=auth, follow_redirects=follow_redirects)
  File "lib/python3.10/site-packages/httpx/_client.py", line 914, in send
    response = self._send_handling_auth(
  File "lib/python3.10/site-packages/httpx/_client.py", line 942, in _send_handling_auth
    response = self._send_handling_redirects(
  File "lib/python3.10/site-packages/httpx/_client.py", line 979, in _send_handling_redirects
    response = self._send_single_request(request)
  File "lib/python3.10/site-packages/httpx/_client.py", line 1015, in _send_single_request
    response = transport.handle_request(request)
  File "lib/python3.10/site-packages/httpx/_transports/default.py", line 232, in handle_request
    with map_httpcore_exceptions():
  File "/usr/lib/python3.10/contextlib.py", line 153, in __exit__
    self.gen.throw(typ, value, traceback)
  File "lib/python3.10/site-packages/httpx/_transports/default.py", line 86, in map_httpcore_exceptions
    raise mapped_exc(message) from exc
httpx.RemoteProtocolError: Server disconnected

but I have not been able to reliably reproduce this, so I don't know if this is something like a really tight race in httpx or if TBA just decided to dump a connection at the perfect wrong time. Either way, though, I think this is showing that both requests were canceled, i.e. if TBA dumped the connection during getUpdates it caused the subsequent getMe to fail too, rather than trigger a reconnect 🙁.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions