Repository navigation
Description
It is somewhat related to #502 as we started first observing the below issue in httpcore 0.14.5. Upgrading to 0.14.7 seemingly fixed the issue but we found out that there is still some underlying race condition that can poison the pool, making it unable to serve requests ever again. The issue came up using httpx==0.22.0 httpcore==0.14.7, it is not present using httpx==0.16.1 httpcore==0.12.3
Here is a script brute forcing the client and reliably triggering the issue on my machine. (The script is written for the sync version, but we are having this issue in production with the async version as well).
Repro script
PARALLELISM and REQUESTS may need adjustments to reproduce the issue.
import multiprocessing.pool
import socket
import httpx
PARALLELISM = 20
REQUESTS = 200
unavailable_listener = socket.socket()
unavailable_listener.bind(("localhost", 0))
unavailable_listener.listen()
unavailable_url = "http://{}:{}".format(*unavailable_listener.getsockname())
client = httpx.Client(limits=httpx.Limits(max_connections=1, max_keepalive_connections=0))
pool = multiprocessing.pool.ThreadPool(PARALLELISM)
# client.get(...) works well at this point
# Raises httpcore.PoolTimeout as expected because the pool has been exhausted
pool.map(lambda _: client.get(unavailable_url), range(REQUESTS))
# client.get(...) will still raise PoolTimeout even though there supposed to be no outstanding connectionsAfter running the above code, the pool is no longer operational, containing a stuck connection:
>>> client._transport._pool._pool
[<HTTPConnection [CONNECTING]>]
>>> client._transport._pool._pool[0]._connect_failed
False
>>> client._transport._pool._pool[0]._connection
>>>
>>> client._transport._pool._pool[0].is_avaliable()
>>> FalseMy hunch is that RequestStatus.set_connection() will succeed from _attempt_to_acquire_connection but status.wait_for_connection() can still time-out on an overloaded system leaving the half-created connection there. Subsequent request attempts won't be able to use this connection instance as it isn't available, and it won't be cleaned up as it is not closed nor failed.
The interesting about this situation is that a just crafted connection can be put in the pool without guaranteeing that it will be used during the current requests.
Activity
stale commentedon Dec 16, 2022
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
K900 commentedon Dec 20, 2022
Not stale.
nihilSup commentedon Jan 12, 2023
Facing this or very similar behaviour under some load in production microservice after upgrading httpx to (
httpx ==0.23.0) and python to 3.10. After some time and several ConnectTimeout/ReadTimeout exactly all requests are finished with PoolTimeout. Downgrade tohttpx ==0.18.1solved the issue.Could reproduce issue with pure httpcore (adapted @vlaci script, `httpcore==0.16.3`)
Some observations
Every task is trying to acquire connection (`AsyncConnectionPool._attempt_to_acquire_connection` in `AsyncConnectionPool.handle_async_request`).First one (lets name it t1) succeeded and passes through
status.wait_for_connectioninAsyncConnectionPool.handle_async_requestwhile other tasks hang here.Then t1 can create a real connection in
connection.handle_async_request(request)but fails with ReadTimeout.This leads to call
self.response_closed(status)and real connection is closed and connection wrapper is removed from the pool, request status is removed from separate register either. After this new request status creates another connection wrapper with empty connection.Looks like under normal conditions acquiring of new connection wrapper triggers
status.wait_for_connectionin some task and then new real connection can be established so on and so forth. But under some load asyncio event loop is not fast enough to change coroutines and happens PoolTimeout. So no one can establish real connection (because all tasks has been finished with PoolTimeout and removed) and there is connection wrapper inside pool without real connection.Now two things can happen.
First one is pool receives new request to some different url. It tries to handle it but hangs until PoolTimeout on
status.wait_for_connectionbecause pool can't create new connection wrapper and old one neither can process request nor can be removed (connection wrapper has no real connection so it is not idle or expired, it is "CONNECTING").Second is pool receives new request to same url as it is in connection wrapper inside pool ("CONNECTING" one). In this case request can't be processed because connection wrapper can handle request but wrapper is not available at the same time (failed is False and connection is None and httpx2 property is False either)
I see two approaches:
I tried second approach and it worked well - no pool timeouts and "control" requests are successfull:
c25vdw commentedon Jan 13, 2023
I have recently stumbled on this problem and I think it is a racing condition around the cleanup logic during exceptions and the
_pool_lockthat caused the connection pool to have zombie "CONNECTING" wrappers. As a result, the connection pool will grow unbounded with stale, unintialized connections.The situation happens when a pending request timed out (PoolTimeout) immediately after an active request timed out (ReadTimeout or any other exceptions from the connection.handle_async_request)
Explanation
imagine an active connection A timed out waiting for server response (ReadTimeout), while at the same time (immediately after), the next pending request B timed out waiting for pool (PoolTimeout). a walk through of the events:
httpcore/httpcore/_async/connection_pool.py
Lines 236 to 237 in 7eb2022
httpcore/httpcore/_async/connection_pool.py
Lines 226 to 234 in 7eb2022
_pool_lockand callsresponse_closed()B.set_connection()_pool_lock. It removes itself frompool._requestsand raised thePoolTimeoutand break early.Now, since B raised exception and no longer proceed, the connection in the pool was never initialized or deleted. Its
_connect_failedwill always beFalse, and the underlying http1.1 connection will always beNone. Mostly fatally, a spot in the connection pool is forever taken and there is no way to clean it up.Potential Solution
The root cause of the issue is that the connection handling logic of B is skipped due to PoolTimeout. But in this case, the PoolTimeout is almost meaningless: it was a few milliseconds away, if not less, for request B to make it in time. With that said, I suggest that B continues to execute if it realizes that a connection is assigned in its PoolTimeout exception handling.
That is, for line 232-234 in async/connection_pool.py, replace with:
I have personally tested this in my local setup, and it gave the expected improvement. However, I am not totally familiar with the library and whether this will affect anything unrelated, hopefully not. Anyways, I hope this can be considered or at least help to clear up the mist.
Thank you for creating and maintaining this library!
K900 commentedon Jan 13, 2023
@tomchristie @florimondmanca apologies for the ping, but can you take a look at the comments here? It seems like an important issue.
nihilSup commentedon Jan 13, 2023
Few thoughts. I think it is safer to handle this way in a separate "except branch" for PoolTimeout only. Also reusing connection after general PoolTimeout exception maybe unsafe and unexpected. Tested your approach locally and it worked, I like it, it's quite simple
cdeler commentedon Jan 14, 2023
Let me check it. I'll try to reproduce it locally first
lovelydinosaur commentedon Jan 16, 2023
Can confirm that I've also reproduced the issue.
I used adapted @nihilSup's example slightly, using `trio`...
I was able to confirm the
ReadError/PoolTimeoutcombination caused the error, and that it resulted in a state where we're handling thePoolTimeout, but the connection has been assigned to the waiting request.I was also able to confirm the suggested fix, although I'm not yet entirely comfortable with it. It seems a little brittle, and I'd prefer to understand first if we're able to avoid this state.
I wonder if we're able to reliably reproduce this state, using a mocked out network backend similar to some of the cases in
tests/_async/test_connection_pool.py?nihilSup commentedon Jan 16, 2023
@tomchristie On weekend I prepared draft PR with tests both for sync and async implementations.
httpcore/backends/mock.py
tests/_async/test_connection_pool.py
tests/_sync/test_connection_pool.py
assert pool.connections[0].is_connecting()can be skipped because it contains my helper methodc25vdw commentedon Jan 16, 2023
An observation after I played with your test code locally @nihilSup
We can reproduce this zombie situation without
ReadTimeout, and even, without the hanging backend:under
tests/_async/test_connection_pool.pyI think the reason why this achieves the same outcome is because as we set timeout to 0,
status.wait_for_connection(timeout=0)will immediately raise PoolTimeout, but before that it was assigned a connection inhttpcore/httpcore/_async/connection_pool.py
Line 221 in 7eb2022
This could be a more minimal reproduction of the scenario. But the difference is that the connection assignment in real situation happened in another request by the ReadTimeout.
nihilSup commentedon Jan 16, 2023
Good catch, I think this fact shows clearly "missed state" in connection's "state machine". As for unit tests I think we want to simulate some situation, e.g peak load with errors and pool saturation and test that pool can be operational after this, so maybe my test is a little bit more "unbiased"
nihilSup commentedon Jan 17, 2023
Played with a more minimal reproduction by @c25vdw. I think we need it also as separate unit-test to check corner case of 0 PoolTimeout. Also another possible zombie cleanup:
httpcore/httpcore/_async/connection_pool.py
Lines 228 to 234 in 7eb2022
Replace with this
This cleanup also based on the same assumption that if we have CONNECTING connection and there is no requests which need it we can safely remove it from pool.
Fix pool poisoning on overloaded systems (encode#550)
17 remaining items
Fix linters (encode#550)
nihilSup commentedon Jan 29, 2023
Sorry for this mess, didn't expect this, passing all the linters in ci took a very long time, so I had to convert the PR to a draft beforehand.
camlee commentedon Mar 7, 2023
I think that I'm running into this issue as well.
@nihilSup : it looks like you've got all the linting issues sorted out now, right? What's left to move this fix forward and is there anything someone else can do to help? I'm looking at the code but am a little out of my depth here.
nihilSup commentedon Apr 19, 2023
@camlee yes
JacobHenner commentedon May 2, 2023
Indeed - I've run into this issue in two of my prod codebases and would like to fix ASAP - please let me know what I can do to help.
lovelydinosaur commentedon May 18, 2023
I'm going to take a punt and work on the assumption that #688 has closed this for us.
If that's not the case then we can reopen and reassess this.
konstantin-baidin-y42 commentedon Dec 20, 2023
I experimented with versions
0.16.3,0.17.1and0.18.0.Having some long-responding server on
localhost:5522the following code fails with PoolTImeout. You can get and run such a long-responding server from this comment: encode/httpx#1171 (comment)I expect that the following code will not raise PoolTimeout, especially when I canceled all the tasks and waited for an additional 5 seconds for cancelation to complete.
My case is probably related to the issue discussed here.
jhaenchen commentedon Jun 6, 2024
I'm still seeing this on httpcore 1.0.5. I'm using the repro from the very first post on this issue:
lovelydinosaur commentedon Jun 10, 2024
Okay, looks like you've got a nice simple reproducible case there, shall we work through that and then consider re-opening?
Next step... reduce the case above to just use the
httpcoreAPI, rather than throughhttpx.amurais commentedon Oct 31, 2024
This issue appears to have been solved for AsyncConnectionPool since 1.0.3 thanks to #880 that removed the chance of a context switch during exception handling.
It's still reproducible with the sync connection pool in a multi-threaded scenario.
kylesutton commentedon Aug 20, 2025
We recently switched to httpx and are hitting this now.
Is there any effort to close out this issue for sync connections in a multi-threaded scenario, or is the sync side of httpx being de-emphasized?
GrigoryEvko commentedon May 15, 2026
Very poor handling of this critical issue, this was not resolved and projects are basically unmaintained at this point. After long painful debugging sessions migrated to aiohttp.
mbeijen commentedon May 15, 2026
Yeah it is unfortunate; it has fixed in httpxyz with this commit --> https://codeberg.org/httpxyz/httpcorexyz/commit/a7500e4
nikoswsn commentedon Jun 25, 2026
I'd like to bump this issue since pool poisoning persists. This is the latest discussion I could find around that which includes more info, reproduction and potential workaround