Repository: encode/httpx

Issue:
HTTP/2 download speeds / flow control settings
Which having an initial look at HTTP/3, @jlaine dug out that we seem to have signiifcantly slow uploading right now.

First thing to do would be to investigate and replicate, by eg. compare and contrast a simple upload from `httpx` vs `requests/urllib3` - does it replicate trivially and reliably? This really *shouldn't* be an issue for us so will need some looking at.

Initial thoughts on where issues could be:

* We're sending too-small chunks, and always waiting for the network to fully drain on each chunk is negatively impacting performance.
* We're sending too-small chunks, and ending up with largish computation overhead relative to data transfer as a result.
* We've got some big unseen overhead in the sync-to-async marshalling.
* Something else?


Discussion:
Actually the issue I reported was *download* speeds, and it does seem to be linked to HTTP/2.

Here is a script to reproduce the issue:

```python
import httpx
import time
import requests

url = "https://quic.aiortc.org/50000000"

start = time.time()
r = requests.get(url)
print("requests %.1f s" % (time.time() - start))

for http_version in ["HTTP/1.1", "HTTP/2"]:
    client = httpx.Client(http_versions=http_version)

    start = time.time()
    r = client.get(url)
    print("httpx %s %.1f s" % (http_version, time.time() - start))
```

The results (the first two results vary somewhat, but are always close to each other):

```
requests 5.6 s
httpx HTTP/1.1 5.8 s
httpx HTTP/2 45.7 s
```
Okay, at this point it's a bit more complicated, since we've also got different performance on the server side too.

A sensible first thing to do might be to try to replicate against a non-python HTTP/1.1 and HTTP/2 server in order to try to help isolate if we've for a client issue or a server issue here (or both)?
Tried this against `httpbin`, because that'll be implemented as a WSGI HTTP/1.1 - I was expecting it to be fronted by something that supported both HTTP/1.1 and HTTP/2 (I think it's on heroku?)

That didn't actually progress things tho, since it turns out whatever it's hosted on is HTTP/1.1 only...

```python
import httpx
import time
import requests

url = "https://httpbin.org/stream-bytes/50000000"

start = time.time()
r = requests.get(url)
print("requests %.1f s" % (time.time() - start))

for http_version in ["HTTP/1.1", "HTTP/2"]:
    client = httpx.Client(http_versions=http_version)

    start = time.time()
    r = client.get(url)
    print("httpx %s %.1f s" % (http_version, time.time() - start))
    print(r.http_version)
```

```shell
$ python ./example.py 
requests 0.8 s
httpx HTTP/1.1 0.8 s
HTTP/1.1
httpx HTTP/2 0.8 s
HTTP/1.1
```

Might want to try something similar but against some cloudflare content or something - would at least help us isolate things a bit more clearly.
The server mentioned (quic.aiortc.org) above is serving HTTP/1.1 and HTTP/2 using nginx talking (over HTTP/1.1) to uvicorn.

Using cURL over HTTP/1.1 or HTTP/2 against the same server as above both give me times which are consistent with `requests` and `httpx` over HTTP/1.1 (i.e. around 6s). `httpx` over HTTP/2 really seems to be the outlier.

curl --http1.1 https://quic.aiortc.org/50000000
curl --http2 https://quic.aiortc.org/50000000

The httpbin test you gave above isn't super exciting, it only transfers ~100kB of data vs 50MB in mine :)
Okay, that's really helpful, thanks.

Something that'd be useful for us to do at some point would be crack open a tool like wireshark in cases like this, and verify if our HTTP messaging matches up properly with curl's implementation or not. (Ie. are we doing something differently in terms of the messaging that's causing it to be slower)
We've got a much cleaner HTTP/2 implementation now, and this still replicates.

I *strongly* suspect that it's a flow control issue. Perhaps something like our allowable window sizes being set too low by default, and having to send too many window updates.

In any case, we really need someone to try to dig into a proper comparision against some other tool (curl or a browser implementation) too see how our messaging differs.

I *guess* it's also possible that the messaging is correct, but there's a networking implementation issue (eg. perhaps we *really* need the writes to be on a different flow of control here.) I don't *particularly* expect that's the case, but it might be interesting to see if the issue persists on asyncio if we *stop* calling `.drain()` after every write?

Resolving patch:
diff --git a/httpx/dispatch/http2.py b/httpx/dispatch/http2.py
--- a/httpx/dispatch/http2.py
+++ b/httpx/dispatch/http2.py
@@ -96,6 +96,7 @@ async def send_connection_init(self, timeout: Timeout) -> None:
         del self.state.local_settings[h2.settings.SettingCodes.ENABLE_CONNECT_PROTOCOL]
 
         self.state.initiate_connection()
+        self.state.increment_flow_control_window(2 ** 24)
         data_to_send = self.state.data_to_send()
         await self.socket.write(data_to_send, timeout)
 
@@ -165,6 +166,7 @@ async def send_headers(
         timeout: Timeout,
     ) -> None:
         self.state.send_headers(stream_id, headers)
+        self.state.increment_flow_control_window(2 ** 24, stream_id=stream_id)
         data_to_send = self.state.data_to_send()
         await self.socket.write(data_to_send, timeout)
 


Test patch:
diff --git a/tests/dispatch/utils.py b/tests/dispatch/utils.py
--- a/tests/dispatch/utils.py
+++ b/tests/dispatch/utils.py
@@ -41,7 +41,6 @@ def __init__(self, app, backend):
         self.requests = {}
         self.close_connection = False
         self.return_data = {}
-        self.returning = {}
         self.settings_changed = []
 
     # Socket stream interface
@@ -75,14 +74,6 @@ def write_no_block(self, data: bytes) -> None:
                     self.buffer += self.conn.data_to_send()
             elif isinstance(event, h2.events.StreamEnded):
                 self.stream_complete(event.stream_id)
-            elif isinstance(event, h2.events.WindowUpdated):
-                if event.stream_id == 0:
-                    for key, value in self.returning.items():
-                        if value:
-                            self.send_return_data(key)
-                # This will throw an error if the event is for a not-yet created stream
-                elif self.returning[event.stream_id]:
-                    self.send_return_data(event.stream_id)
             elif isinstance(event, h2.events.RemoteSettingsChanged):
                 self.settings_changed.append(event)
 
@@ -141,7 +132,6 @@ def stream_complete(self, stream_id):
         self.conn.send_headers(stream_id, response_headers)
         self.buffer += self.conn.data_to_send()
         self.return_data[stream_id] = response.content
-        self.returning[stream_id] = True
         self.send_return_data(stream_id)
 
     def send_return_data(self, stream_id):
@@ -161,7 +151,6 @@ def send_return_data(self, stream_id):
                 )
                 self.conn.send_data(stream_id, chunk)
                 self.buffer += self.conn.data_to_send()
-        self.returning[stream_id] = False
         self.conn.end_stream(stream_id)
         self.buffer += self.conn.data_to_send()