httpx AsyncClient RuntimeError: Event loop is closed (tested)

A module-level httpx.AsyncClient reused across two asyncio.run() calls raises RuntimeError: Event loop is closed. Create the client inside the running loop with async with httpx.AsyncClient() as client:.

An httpx.AsyncClient keeps pooled connections that belong to the event loop they were opened on. Reuse the client on a different loop (a second asyncio.run(), a new test loop) and httpx tries to use connections from the old, closed loop. Measured with httpx 0.28.1 on Python 3.13.5 against a local http.server (srv).

1. RuntimeError: Event loop is closed

import asyncio, httpx, srv
url = srv.start()
client = httpx.AsyncClient()          # module-level, shared

async def fetch():
    r = await client.get(url + "/x")
    return r.status_code

print("first asyncio.run:", asyncio.run(fetch()))
try:
    print("second asyncio.run:", asyncio.run(fetch()))
except RuntimeError as e:
    print("RuntimeError:", e)

Output:

first asyncio.run: 200
RuntimeError: Event loop is closed

The first asyncio.run opened a keep-alive connection on loop 1 and then closed that loop. The second run created loop 2, the client picked the pooled connection, and touching it raised RuntimeError: Event loop is closed.

2. Fix: create the AsyncClient inside the running loop

import asyncio, httpx, srv
url = srv.start()

async def fetch(client):
    return (await client.get(url + "/x")).status_code

async def main():
    async with httpx.AsyncClient() as client:      # created inside the running loop
        print(await asyncio.gather(fetch(client), fetch(client)))

asyncio.run(main())
asyncio.run(main())
print("two asyncio.run calls, no error")

Output:

[200, 200]
[200, 200]
two asyncio.run calls, no error

Create the client inside the coroutine with async with httpx.AsyncClient() and pass it to the functions that need it. One client per loop still gives connection reuse within that run (see AsyncClient connection pool limits). In web apps, create it in the framework's startup/lifespan hook and close it on shutdown; in pytest, create it in a fixture that runs on the test's loop.

3. A different error: the client has been closed

import asyncio, httpx, srv
url = srv.start()

async def main():
    client = httpx.AsyncClient()
    r = await client.get(url + "/x")
    print(r.status_code, "client.is_closed =", client.is_closed)
    await client.aclose()
    print("after aclose: client.is_closed =", client.is_closed)
    try:
        await client.get(url + "/x")
    except RuntimeError as e:
        print("RuntimeError:", e)

asyncio.run(main())

Output:

200 client.is_closed = False
after aclose: client.is_closed = True
RuntimeError: Cannot send a request, as the client has been closed.

If the message is Cannot send a request, as the client has been closed, the problem is not the loop: the client was used after aclose() or after its async with block ended. Check client.is_closed.

Related: pytest async def functions are not natively supported, aiohttp Unclosed client session, httpx MockTransport for tests, httpx vs requests differences.

Frequently asked questions

Why does httpx AsyncClient raise Event loop is closed?

Its pooled connections belong to the loop they were opened on. Reusing the client after that loop closed (e.g. a second asyncio.run) touches the dead loop.

Can I share one httpx AsyncClient globally?

Only within one event loop. Create it in the loop (startup hook or async with) and close it before the loop ends.

What does 'Cannot send a request, as the client has been closed' mean?

The AsyncClient was used after aclose() or after its async with block ended.

Written with AI assistance. Measured on 2026-10-23 with httpx 0.28.1 on Python 3.13.5 against a local http.server. Each block was run verbatim and the output pasted unedited. PlainHeaders is our extension; httpx is free.