Fixing Unclosed Client Session Warnings¶
Unclosed client session is the most-seen warning in async Python, and it is usually dismissed as noise because the code appears to work. It is not noise: the message means a client and its connection pool were garbage collected while still holding open sockets, so every occurrence is a descriptor that was leaked until the collector happened to run. Verified against aiohttp 3.14 and httpx 0.28, the two libraries report the same problem differently — aiohttp names the session and its connector, httpx surfaces the underlying transport and socket — and both are pointing at the same fix.
Prerequisites¶
- Python 3.11+ with
aiohttporhttpx. - Descriptor counting from detecting leaked sockets and file descriptors.
- Client lifecycle from managing ASGI lifespan startup and shutdown.
1. Read what the warning actually says¶
An aiohttp.ClientSession created and never closed produced two warnings at garbage collection:
ResourceWarning: Unclosed client session <aiohttp.client.ClientSession object at 0x...>
ResourceWarning: Unclosed connector <aiohttp.connector.TCPConnector object at 0x...>
with the connector's message listing the connections it still held. The same leak with httpx.AsyncClient produced:
ResourceWarning: unclosed <socket.socket fd=8, family=2, type=1, proto=6,
laddr=('127.0.0.1', 55690), raddr=('127.0.0.1', 8099)>
ResourceWarning: unclosed transport <_SelectorSocketTransport fd=8 read=idle ...>
The httpx version is more useful for diagnosis, because raddr names the upstream. In both cases the timing is the important part: the warning appears when the object is collected, which may be long after the code that leaked it ran — and never, if something still holds a reference.
Closing both properly produced no warnings at all, which is the state to aim for.
Verify: run your test suite with -W always::ResourceWarning and count the messages.
2. Make the warnings findable¶
ResourceWarning is suppressed by default. Two flags turn it into an actionable report:
python -W always::ResourceWarning -X tracemalloc=5 -m myapp
-X tracemalloc=5 adds the allocation traceback to the warning, so the message names the line that created the session rather than only the object. That is almost always enough to close the case.
In production, route warnings to the logger rather than stderr:
logging.captureWarnings(True)
warnings.simplefilter("always", ResourceWarning)
And in tests, make them fatal, so a leak fails at the moment it is introduced:
# pytest.ini
filterwarnings =
error::ResourceWarning
This is the single highest-value line in this guide: a leak caught by a failing test costs minutes, and the same leak found through Errno 24 in production costs an incident.
Verify: a deliberately leaked client fails your test suite.
3. Give every client an owner¶
There are exactly two correct shapes, and the choice depends on lifetime.
Short-lived — a script, a one-off call:
async with httpx.AsyncClient() as client: # closed on every path, including errors
response = await client.get(url)
Long-lived — a service:
@contextlib.asynccontextmanager
async def lifespan(app):
async with httpx.AsyncClient(timeout=10) as client:
yield {"http": client} # one client for the process
async def handler(request):
return await request.state.http.get(UPSTREAM) # reused, keep-alive
The anti-pattern is a client constructed inside a request handler. Besides the leak, it discards connection reuse: measured in the descriptor guide, 50 per-request clients held 100 descriptors while 50 requests through one shared client used two.
The same rule covers aiohttp.ClientSession, database pools, Redis clients and gRPC channels: create once where there is a guaranteed teardown, and never at module import, where there is no running loop and no shutdown hook.
Verify: grep for client constructors; every one is inside async with or a lifespan.
4. Distinguish the three loop-lifetime errors¶
Two related errors look like the same problem and are not.
RuntimeError: Event loop is closed — verified by creating a session under one asyncio.run() and using it under another. The client bound itself to the first loop, which no longer exists. The fix is never to let a client outlive the loop that created it, which in practice means never creating one at import time.
Future attached to a different loop — the same cause seen from the other side: an object created on loop A being awaited on loop B. Common when a synchronous entry point calls asyncio.run() more than once, as in calling async code from Celery tasks, where the fix is one long-lived loop per process.
Unclosed client session — the client was never closed at all, independent of loops.
The diagnostic question for all three is the same: which loop created this object, and who closes it? A client whose answer is "the loop that happened to be running at import" will produce one of these three messages eventually.
Verify: no client is constructed at module scope, and none is used after its loop has closed.
5. Close in the right order at shutdown¶
A service that closes its pool while requests are still in flight trades one warning for another — Cannot write to closing transport in the logs and failed requests for users. The order that works:
finally:
health.ready = False # 1. stop new traffic
await drain_in_flight() # 2. let running work finish
await client.aclose() # 3. then close clients
await pool.close()
aiohttp additionally recommends a short sleep after closing a session on some platforms, so that the underlying SSL connections finish closing before the loop stops:
await session.close()
await asyncio.sleep(0.1) # let SSL transports close cleanly
Without it you may see Unclosed connector even though close() was called, because the loop stopped before the close completed. The same idea is ssl_shutdown_timeout on the server side, covered in adding TLS to asyncio streams.
Verify: a clean shutdown produces no warnings and no failed in-flight requests.
Verification¶
Client ownership is correct when:
- No
ResourceWarningappears in tests or production logs. - Every client is created in a lifespan or an
async with. - No client is constructed at import time.
- Tests fail on
ResourceWarning. - Shutdown order is readiness, drain, close.
- Descriptor counts are flat under steady traffic.
Pitfalls & edge cases¶
- Ignoring the warning because it appears "after the test passed". It fires at collection time; the leak happened earlier.
- Calling
close()without awaiting it. Both libraries' close methods are coroutines. - A client held by a module-level cache. It is never collected, so the warning never even appears — only the descriptor count shows it.
- Creating a client per retry. A retry loop that constructs a client per attempt multiplies the leak.
asyncio.run()more than once in a process. Each call creates and closes a loop; anything that survives between them is bound to a dead one.- Suppressing warnings globally.
warnings.simplefilter("ignore")in application code hides this and every other resource problem.
Frequently Asked Questions¶
What does "Unclosed client session" mean?
That an aiohttp ClientSession was garbage collected while still open, so its connector and any pooled connections were still holding sockets. It is a real descriptor leak, not a style warning, and it appears at collection time — which can be long after the code that caused it ran.
Why does httpx not say "unclosed client session"?
Because it reports the lower-level objects instead: "unclosed transport" and "unclosed
How do I find which code leaked the session?
Run with -W always::ResourceWarning and -X tracemalloc=5. The second flag attaches the allocation traceback to the warning, so it names the line that created the client. In tests, set filterwarnings = error::ResourceWarning so the leak fails the build.
Why do I get "Event loop is closed" when using a client?
Because the client was created on a loop that has since ended — typically at import time, or under a previous asyncio.run(). Clients bind to the loop that created them, so create them inside the loop that will use them and close them before it stops.
Should I create one HTTP client for the whole service?
Yes, for long-lived services: one client created in the lifespan, stored in state, closed at shutdown. It keeps connections warm and descriptor counts flat. Reserve async with for scripts and genuinely one-off calls.
Related¶
- Memory & Resource Leaks — up to the topic overview.
- Detecting leaked sockets and file descriptors — counting what these warnings describe.
- Reusing aiohttp ClientSession across requests — the performance side of the same rule.
- Resilience, Cancellation & Error Handling — the section overview.