Repository navigation
Client.close() can hang. (asyncio only). #634
Copy link
Copy link
Closed
Labels
bugSomething isn't workingSomething isn't workinghttp/1.1Issues and PRs related to HTTP/1.1Issues and PRs related to HTTP/1.1interopIssues interoperating with existing websites and servicesIssues interoperating with existing websites and services
Description
I've been doing some async requests with HTTPX for a while now without any issue.
Summary
However, I've stumbled upon today a case I cannot explain. While doing a request to Microsoft Graph OAuth2 access token endpoint, I correctly receive a response but HTTPX client seems to hang and never return. I didn't find any other API with the same behaviour.
Reproduction
import asyncio
import httpx
async def main():
async with httpx.Client() as client:
response = await client.post(
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
# You can keep those parameters, the issue happens also for 4XX responses
data={
"grant_type": "authorization_code",
"code": "code",
"redirect_uri": "http://localhost:8000/redirect",
"client_id": "CLIENT_ID",
"client_secret": "CLIENT_SECRET",
},
)
print(response.http_version) # HTTP/1.1
print(len(response.content)) # 485 (same as in Content-Length header)
print(response.headers)
# Never happens
print("Done")
return response
asyncio.run(main())Response headers
Headers([('cache-control', 'no-cache, no-store'), ('pragma', 'no-cache'), ('content-type', 'application/json; charset=utf-8'), ('expires', '-1'), ('strict-transport-security', 'max-age=31536000; includeSubDomains'), ('x-content-type-options', 'nosniff'), ('x-ms-request-id', 'dd57f328-14dd-4a99-8ed4-c81566f10800'), ('x-ms-ests-server', '2.1.9707.19 - AMS2 ProdSlices'), ('p3p', 'CP="DSP CUR OTPi IND OTRi ONL FIN"'), ('set-cookie', 'fpc=AhPq7wxRjzlCr42-XJpjplo; expires=Sun, 12-Jan-2020 06:43:04 GMT; path=/; secure; HttpOnly; SameSite=None'), ('set-cookie', 'x-ms-gateway-slice=prod; path=/; SameSite=None; secure; HttpOnly'), ('set-cookie', 'stsservicecookie=ests; path=/; SameSite=None; secure; HttpOnly'), ('date', 'Fri, 13 Dec 2019 06:43:04 GMT'), ('content-length', '485')])
After waiting a while, a `ConnectionResetError` is raised. Here is the stacktrace
File "/Users/fvoron/.local/share/virtualenvs/httpx-oauth-5xgbpDkq/src/httpx/httpx/client.py", line 884, in __aexit__
await self.close()
File "/Users/fvoron/.local/share/virtualenvs/httpx-oauth-5xgbpDkq/src/httpx/httpx/client.py", line 873, in close
await self.dispatch.close()
File "/Users/fvoron/.local/share/virtualenvs/httpx-oauth-5xgbpDkq/src/httpx/httpx/dispatch/connection_pool.py", line 212, in close
await connection.close()
File "/Users/fvoron/.local/share/virtualenvs/httpx-oauth-5xgbpDkq/src/httpx/httpx/dispatch/connection.py", line 171, in close
await self.open_connection.close()
File "/Users/fvoron/.local/share/virtualenvs/httpx-oauth-5xgbpDkq/src/httpx/httpx/dispatch/http11.py", line 72, in close
await self.socket.close()
File "/Users/fvoron/.local/share/virtualenvs/httpx-oauth-5xgbpDkq/src/httpx/httpx/concurrency/asyncio.py", line 167, in close
await self.stream_writer.wait_closed()
File "/Users/fvoron/.pyenv/versions/3.7.5/lib/python3.7/asyncio/streams.py", line 323, in wait_closed
await self._protocol._closed
File "/Users/fvoron/.pyenv/versions/3.7.5/lib/python3.7/asyncio/selector_events.py", line 804, in _read_ready__data_received
data = self._sock.recv(self.max_size)
ConnectionResetError: [Errno 54] Connection reset by peer
Versions
- Python 3.7
- HTTPX 0.9.3
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't workinghttp/1.1Issues and PRs related to HTTP/1.1Issues and PRs related to HTTP/1.1interopIssues interoperating with existing websites and servicesIssues interoperating with existing websites and services
Activity
florimondmanca commentedon Dec 13, 2019
From the code sample and stack traces, the issue seems to be that we fail to close the socket with the same server - the ConnectionResetError traceback shows that very explicitly: stream.wait_closed() hangs.
To me this could seem like an asyncio bug (although very unlikely) - perhaps one way to start the investigation would be to connect to the endpoint via a raw asyncio stream (without HTTPX), then close it immediately and see if wait_closed() hangs as well?
frankie567 commentedon Dec 13, 2019
Thank you for your feedback @florimondmanca :) I've tried what you suggest (although I have to admit my knowledge on the subject is quite limited):
The stream is closing properly in this case.
lovelydinosaur commentedon Dec 13, 2019
It seems fairly likely this might have been resolved by 0.9.4, just released.
Some things to test out here...
frankie567 commentedon Dec 13, 2019
Thank you @tomchristie!
So, should we suspect a bug in
asyncio? Clearly there must be something strange with the Microsoft server to trigger such an issue.lovelydinosaur commentedon Dec 13, 2019
Okay, thanks, that's super helpful - what's the absolute simplest case you can reduce this to?
[-]Some HTTP/1.1 requests hang[/-][+]Client.close() can hang. (asyncio only).[/+]lovelydinosaur commentedon Dec 13, 2019
I'm assuming the you wouldn't see an issue if using
client = httpx.Client(), since it appears that the point at which the issue occurs is theawait client.close()that happens automatically on leaving the client context block.frankie567 commentedon Dec 13, 2019
This:
You're right. In this case , the issue happens when i call
await client.close():florimondmanca commentedon Dec 14, 2019
@frankie567 Can you share the debug output from running your sample program?
Also, is this specific to 3.7, ie can you reproduce (if able to) on 3.6 or 3.8?
Shivelight commentedon Dec 14, 2019
I can reproduce this on 3.8 but not on 3.6
frankie567 commentedon Dec 14, 2019
@florimondmanca Here is the debug output ; unfortunately, nothing really interesting 🙁
I've also tested on other Python versions and indeed, as @Shivelight stated, the problem does not occur on 3.6 but occurs on 3.8.
frankie567 commentedon Dec 14, 2019
If of any use, I've created a test script with Tox to help debug on different versions: https://github.com/frankie567/httpx-issue-634
florimondmanca commentedon Dec 14, 2019
@frankie567 Ah sorry, meant to point you at the
TRACEoutput, which gives a lot more details about what's going on in the internals:HTTPX_LOG_LEVEL=trace ...florimondmanca commentedon Dec 14, 2019
Thanks a lot for the reproduction repo @frankie567, super helpful!
Here's what
HTTPX_LOG_LEVEL=trace pytest -soutputs for me there. After reaching the last trace log line, the program hangs for a while then theConnectionResetErrortraceback shows up. Nothing strange to me in there.florimondmanca commentedon Dec 14, 2019
So, turns out that if we remove the
.wait_closed()call added by #494, then the request doesn't hang anymore…I pushed the change in #640. @frankie567 Can you verify this solves the issue on your side?
gdhameeja commentedon Dec 14, 2019
@florimondmanca yes, I was debugging exactly that, it gets stuck on
wait_closed()since inside therun_forevermethod doesn't break because in case we hit MS, asself._stoppingnever gets set toTrue.In case of hitting other websites however,
self._stoppinggets set toTrueafter about 3-4 iterations of loop insidedef run_foreverwhich internally calls_runAlso, would we be okay with removing
wait_closed()since the docs suggest calling it?https://docs.python.org/3/library/asyncio-stream.html#asyncio.StreamWriter.close
debug trace post for reference ->
frankie567 commentedon Dec 15, 2019
@florimondmanca Yes, it solves the issue 😃
I would be curious though to understand what's so particular in the MS endpoint that prevents the stream to close properly 🤔
lovelydinosaur commentedon Dec 16, 2019
Closed via #640.
Even so, it would be really useful if anyone's able to dig into this further.
(Perhaps try simplifying things right down, eg. just open the connection using asyncio, then issue a
closeandwait_closedand determine if the issue still presents.)frankie567 commentedon Dec 16, 2019
@tomchristie I'll try things and keep you updated ; I'm really curious about this issue.
frankie567 commentedon Dec 18, 2019
So, I tried things but unfortunately I wasn't able to go much further in our understanding of the issue. I made a replication script using the
asyncio.open_connectionAPI: https://github.com/frankie567/httpx-issue-634/blob/master/debug.pyI found that the issue happens only with SSL. By reading the debug logs, it looks like MS never send an EOF after the client has initiated a shutdown. Hence, we get stuck at
self._sock.recv(self.max_size).Debug log with login.microsoftonline.com ❌
Debug log with www.google.fr ✅
I've also made another attempt by using the low-level socket/ssl API (which is synchronous): https://github.com/frankie567/httpx-issue-634/blob/master/debug_ssl.py
Basically, it just opens the socket, unwrap and close. With
login.microsoftonline.com, it gets stuck and raisesConnectionResetError:Other servers either immediately return or raise
OSError: [Errno 0] Error(?).florimondmanca commentedon Dec 18, 2019
Thanks for going further in the investigation @frankie567!
Not sure if this was asked before — does this behavior replicate with Requests or urllib3? Surely a server not sending an EOF on connection close is an issue these libraries have encountered before, might be worth looking at whether/how they’re handling it.
frankie567 commentedon Dec 19, 2019
Ok, so now things are becoming interesting 😅 I've dug a bit into
urllib3and found out that ultimately, theclosemethod is handled by the standard library HTTPConnection.close method. In it,closemethod is called on the socket and we're done.On the other hand, in the
asynciocode, great efforts are made to firstunwrapthe socket.And here is where the problem arises for MS server. Doing this with
urllib3reproduces the exact same problem:So... I don't know. My (bold) theory would be that
asynciodevs wanted to do things right by doing theunwrapprocess ; which unexpectedly raise this kind of issues. But actually, it was never implemented like this in thehttplib.lovelydinosaur commentedon Dec 19, 2019
Okay, that's some good investigation, thanks.
My short on this would be that on asyncio we're now doing the right thing just calling
.close()and not following up withwait_closed(), because it gives us more robust behaviour.It's interesting that
trioalso does not present this issue. I've no idea about what differences they have wrt. scoket unwrapping. (I don't have a much experience with low level SSL implementation in any case.)It's possible? that there's a "technically correct, but real-world problematic" bug that's worth filing against asyncio here, but I don't have enough background to take a call on that.
frankie567 commentedon Dec 19, 2019
Following your comment, I had a look at
trio. I should have done it way earlier: it seems that they encountered the very same problem, and even added a very long comment about it in the code: https://github.com/python-trio/trio/blob/master/trio/_ssl.py#L779-L829They purposely have an
ignore_want_readflag, so that they don't block on read after unwrapping.Maybe it's worth to file a case at
asyncio, telling howtriotackle this and see what they think about it?lovelydinosaur commentedon Jan 15, 2020
I think we can reassess that "very unlikely". 😃
lovelydinosaur commentedon Feb 26, 2020
Raised https://bugs.python.org/issue39758