Filter concepts by levelShowing all levels.

Django · Section 60

Async Django

Level
advanced
Read
32 min
Concepts
3

`async def` changes how a view waits, not how fast it runs. Under WSGI Django gives each async view its own one-off event loop — the `await` points work, there is no other request on that worker to run in the gaps, and you pay roughly a millisecond of context switching. Real concurrency needs ASGI *and* the condition people miss: the Django docs are explicit that a fully asynchronous stack requires no synchronous middleware loaded, because one sync entry forces a thread switch on every request. The section's own warning covers the rest — a blocking driver, a filesystem call, CPU work or a synchronous library does not become asynchronous because the function around it did, and inside an event loop a blocking call stalls every other connection on that worker rather than just its own request. Crossing the boundary has two adapters: `sync_to_async`, defaulting to `thread_sensitive=True` so the wrapped code runs on the main thread Django's thread-local connections assume, and `async_to_sync` for the other direction. The ORM's `a`-prefixed interface (`aget`, `acreate`, `async for`) exists so async code can touch the database without wrappers, but there is no async database driver underneath — each call still runs a blocking query in a thread — and **transactions do not yet work in async mode**, so a whole `atomic()` unit belongs inside a single `sync_to_async` call. Finally, async hands back responsibilities the sync world provided by accident: `await` has no deadline, cancellation arrives as a `BaseException`-derived `CancelledError` that must be re-raised, and coroutines are cheap enough that a fan-out needs an explicit `Semaphore` where a thread pool used to impose one.

What is true here

  1. Concurrency requires ASGI, an all-async middleware chain, and awaitable I/O — the keyword alone buys overhead.
  2. A blocking call inside a coroutine stalls the whole event loop, not one request.
  3. The a-prefixed ORM is ergonomics: no async driver, so queries still block a thread.
  4. Transactions do not work in async mode — keep the atomic() block inside one sync_to_async call.
  5. CancelledError derives from BaseException; catch it only to clean up, and re-raise.

What you will be able to do

  • Judge whether an async rewrite will actually gain anything for a given view
  • Cross the sync/async boundary correctly, including around transactions
  • Recognise the blocking calls that quietly stall an event loop
  • Bound a fan-out with timeouts, deadlines, and a concurrency limit
The same three upstream calls, sync and async

Sync view — the thread waits

  • +Three 200 ms calls run in sequence: about 600 ms.
  • +The thread is blocked, but only this request is affected.
  • +The thread pool caps concurrency for you, as a side effect.
  • +Socket timeouts come from the blocking client.
  • +The ORM and transactions work exactly as documented.

Async view — the loop keeps working

  • The three calls overlap: about 200 ms, the slowest one.
  • Requires ASGI and an all-async middleware chain to gain anything.
  • No ceiling any more — a Semaphore is now your job.
  • await has no deadline; the client timeout is not optional.
  • ORM calls need the a-prefixed API; transactions stay on the sync side.
  • Sync view — the thread waits
    • Three 200 ms calls run in sequence: about 600 ms.
    • The thread is blocked, but only this request is affected.
    • The thread pool caps concurrency for you, as a side effect.
    • Socket timeouts come from the blocking client.
    • The ORM and transactions work exactly as documented.
  • Async view — the loop keeps working
    • The three calls overlap: about 200 ms, the slowest one.
    • Requires ASGI and an all-async middleware chain to gain anything.
    • No ceiling any more — a Semaphore is now your job.
    • await has no deadline; the client timeout is not optional.
    • ORM calls need the a-prefixed API; transactions stay on the sync side.

Async views and what they require

The keyword, the server, the middleware chain, and the warning the roadmap states outright.

async def views, await, async middleware, and the ASGI requirement

coreadvanced

Writing `async def` in front of a view makes it a coroutine, which lets you `await` inside it. It does not, on its own, make anything faster. Under WSGI, Django runs each async view in its own one-off event loop — you can still `await` concurrent HTTP calls, but you get none of the concurrency benefit of an async stack and you pay roughly a millisecond of context-switching per request. Real concurrency needs ASGI, and the Django docs add a condition people miss: you only get a fully asynchronous stack if **no synchronous middleware is loaded**, because one sync middleware forces a thread switch for every request and can erase the advantage entirely.

Think of it as

The word "async" describes how your code *waits*, not how fast it runs. A coroutine gives up control at an `await` so the event loop can run something else — which is a win only when the thing you are waiting for is I/O that someone else is doing: a network call, a socket, a sleep. It does nothing for CPU work, and it actively hurts if the "wait" is a blocking call, because a blocking call inside a coroutine does not yield, so it stalls the entire event loop rather than one thread. That is the section's own warning, and it is the whole trap: `async def` is a promise you make to the event loop that you will not block, and Python cannot enforce it. The middleware condition follows from the same idea. The request path is a chain, and Django adapts between sync and async at every boundary where the two meet, so one synchronous middleware in a stack of eight means every request crosses into a thread and back — the async view still runs, and the concurrency it was supposed to buy is gone. Check the honest thing rather than the hopeful one: if all your I/O still goes through a blocking driver, `async def` has bought a millisecond of overhead and nothing else.

python
async def dashboard(request):
    prices, weather = await asyncio.gather(fetch_prices(), fetch_weather())
    return JsonResponse({"prices": prices, "weather": weather})

What we're doing: Fan out to three upstream services concurrently — the case where an async view genuinely wins — with a middleware that does not undo it.

dashboard/views.py + common/middleware.pypython
class TimingMiddleware:
    sync_capable = True
    async_capable = True          # both, so Django never has to adapt

    def __init__(self, get_response):
        self.get_response = get_response
        self.is_async = iscoroutinefunction(get_response)
        if self.is_async:
            markcoroutinefunction(self)

    async def __acall__(self, request):
        start = monotonic()
        response = await self.get_response(request)
        response["X-Elapsed-Ms"] = int((monotonic() - start) * 1000)
        return response

    def __call__(self, request):
        if self.is_async:
            return self.__acall__(request)
        start = monotonic()
        response = self.get_response(request)
        response["X-Elapsed-Ms"] = int((monotonic() - start) * 1000)
        return response


async def dashboard(request):
    async with httpx.AsyncClient(timeout=2.0) as client:
        prices, weather, news = await asyncio.gather(
            client.get(PRICES_URL),
            client.get(WEATHER_URL),
            client.get(NEWS_URL),
        )
    return JsonResponse({"prices": prices.json(), "weather": weather.json(),
                         "news": news.json()})
2–3
Declaring both capabilities is what keeps the chain fully async. A middleware marked only `sync_capable` makes Django adapt, and one adaptation per request is enough to lose the benefit.
7–9
`markcoroutinefunction` tells Django this instance is awaitable when it wrapped an async `get_response`. Without it, Django treats the middleware as sync and inserts a thread.
27
An explicit timeout. Without one, a stalled upstream holds the coroutine and the connection indefinitely — async does not add a deadline, it just makes waiting cheaper.
28–32
`asyncio.gather` is where the win is: three upstream calls overlap, so the view takes as long as the slowest rather than the sum. This is the only reason to have written `async def` here.

Why this works: Three sequential 200 ms calls take 600 ms; awaited together they take about 200 ms. That is a real gain, and it exists only because the I/O library is genuinely async and nothing in the chain forced a thread switch.

Making a view `async def` while its I/O stays blocking

Wrong

python
async def dashboard(request):
    prices = requests.get(PRICES_URL).json()      # blocking — does not yield
    weather = requests.get(WEATHER_URL).json()    # blocking
    return JsonResponse({"prices": prices, "weather": weather})

Better

python
async def dashboard(request):
    async with httpx.AsyncClient(timeout=2.0) as client:
        prices, weather = await asyncio.gather(
            client.get(PRICES_URL), client.get(WEATHER_URL))
    return JsonResponse({"prices": prices.json(), "weather": weather.json()})

What you see: Latency is unchanged from the sync version, and under load the whole worker gets *worse* — unrelated endpoints on the same process start timing out while this view waits on an upstream service.

Why: `requests` blocks the thread, and under ASGI that thread is running the event loop. A blocking call never yields, so nothing else on that worker progresses until it returns — one slow upstream call stalls every concurrent connection the worker is holding. `async def` is a promise not to block that the language cannot enforce; keeping it means every I/O library inside must be awaitable.

One request, and where the async advantage is won or lost
WSGIASGIyesallasync-capableblocking driver/ CPU workgenuinelyawaitable I/O

Request

Which server?

WSGI or ASGI — decided at deploy, not in the view

WSGI: one-off event loop per request

the view runs; the concurrency does not

ASGI: the middleware chain

every sync middleware forces a thread switch

Any synchronous middleware?

one is enough to erase the gain

async def view

a promise not to block, which Python cannot enforce

A blocking call inside it

stalls the loop for every other connection on this worker

await on real async I/O

asyncio.gather over several upstream calls

Concurrency actually gained

Overhead, and nothing else

  • Request
    • leads to Which server?
  • Which server? — WSGI or ASGI — decided at deploy, not in the view
    • on error, leads to WSGI: one-off event loop per request (WSGI)
    • leads to ASGI: the middleware chain (ASGI)
  • WSGI: one-off event loop per request — the view runs; the concurrency does not
    • leads to Overhead, and nothing else
  • ASGI: the middleware chain — every sync middleware forces a thread switch
    • leads to Any synchronous middleware?
  • Any synchronous middleware? — one is enough to erase the gain
    • on error, leads to Overhead, and nothing else (yes)
    • leads to async def view (all async-capable)
  • async def view — a promise not to block, which Python cannot enforce
    • on error, leads to A blocking call inside it (blocking driver / CPU work)
    • leads to await on real async I/O (genuinely awaitable I/O)
  • A blocking call inside it — stalls the loop for every other connection on this worker
    • leads to Overhead, and nothing else
  • await on real async I/O — asyncio.gather over several upstream calls
    • leads to Concurrency actually gained
  • Concurrency actually gained
  • Overhead, and nothing else

What `async def` actually buys, by deployment

What `async def` actually buys, by deployment
DeploymentAsync view runs?Concurrency gain?Cost
WSGI (Gunicorn sync worker)yes, in a one-off loop**no**~1 ms context switch per request
ASGI, all middleware async-capableyesyesnone
ASGI, one sync middlewareyeslargely losta thread switch per request
ASGI, blocking call in the viewyes**negative**the whole event loop stalls

Together

python
async def dashboard(request):
    prices, weather = await asyncio.gather(fetch_prices(), fetch_weather())
    return JsonResponse({"prices": prices, "weather": weather})

Remember: `async def` describes how a view *waits*; it makes nothing faster by itself. Under WSGI each async view gets a one-off event loop, so there is no concurrency gain and about a millisecond of overhead. Under ASGI the gain is real, but only if no synchronous middleware is loaded — one is enough to force a thread switch per request — and only if every I/O call inside is genuinely awaitable, because one blocking call stalls the event loop for every other connection on that worker.

See also: the sync async bridge · async io cancellation and limits · wsgi and asgi as interfaces · custom and async middleware

Advertisement

Crossing the boundary

The async ORM, sync_to_async and async_to_sync, and where transactions have to live.

The async ORM, sync_to_async, async_to_sync, and database access

coreadvanced

Django's ORM has an async interface — `aget()`, `acreate()`, `afirst()`, `asave()`, `aset()`, and `async for` over any queryset. What it does **not** have is an async database driver underneath: those methods run the same blocking query in a thread, so they are safe to call from a coroutine rather than genuinely concurrent. Calling the ordinary sync ORM from a coroutine raises `SynchronousOnlyOperation`. Two adapters bridge the gap in each direction: `sync_to_async(fn)` runs blocking code from async, defaulting to `thread_sensitive=True` so it uses the main thread that database connections belong to; `async_to_sync(coro)` runs a coroutine from ordinary sync code. And one limitation is worth knowing before you design around it: **transactions do not yet work in async mode**.

Think of it as

The `a`-prefixed ORM methods are an *ergonomics* feature, not a performance one, and reading them that way prevents the most common disappointment here. They exist so that an async view can touch the database without the code turning into `sync_to_async` wrappers everywhere; underneath, each one still hands a blocking query to a thread and awaits it. So the async ORM removes a class of exceptions and some noise — it does not remove the thread, and it does not make your database calls overlap. The `thread_sensitive=True` default is the other thing to understand rather than copy. It means the wrapped function runs on the main thread, sharing it with every other thread-sensitive call, which is exactly what Django's connection handling assumes — connections are thread-local, so a query that ran in one thread and a transaction opened in another would not see each other. Setting `thread_sensitive=False` gets you a fresh thread and real parallelism, and it is safe only for code that touches no Django state at all: a pure computation, a blocking library call with no ORM inside. The transaction limitation follows from the same place. `transaction.atomic()` binds to a connection on a thread, and async code has no stable thread, so the honest pattern today is to keep the whole transactional unit inside one `sync_to_async` call rather than trying to hold a transaction open across `await` points.

python
from asgiref.sync import sync_to_async, async_to_sync

order = await Order.objects.aget(pk=pk)          # async ORM interface
result = await sync_to_async(blocking_fn)(arg)   # thread_sensitive=True by default

What we're doing: An async view that reads with the async ORM, calls a blocking library safely, and keeps its transaction in one place.

orders/views.pypython
def charge_and_record(order, token):
    with transaction.atomic():                    # sync — one thread, one connection
        Payment.objects.create(order=order, token=token, amount=order.total)
        order.status = Order.Status.PAID
        order.save(update_fields=["status"])
    return order


async def checkout(request, pk):
    order = await Order.objects.select_related("customer").aget(pk=pk)

    async with httpx.AsyncClient(timeout=5.0) as client:
        auth = await client.post(PSP_URL, json={"amount": str(order.total)})

    # Blocking + transactional: keep the whole unit inside ONE sync_to_async call.
    order = await sync_to_async(charge_and_record)(order, auth.json()["token"])

    # Pure CPU, touches no Django state -> a real thread, real parallelism.
    receipt = await sync_to_async(render_receipt_pdf, thread_sensitive=False)(order)

    return FileResponse(receipt, content_type="application/pdf")
1–6
The transactional unit is an ordinary sync function. Transactions do not work in async mode, so the correct shape is to keep the whole `atomic()` block on one side of the bridge.
10
`aget()` with `select_related` — the async interface supports the full queryset API. It still runs a blocking query in a thread; what it buys is not having to wrap it.
13
The one genuinely concurrent call in the view: `httpx` is an async client, so the loop really does run other requests while this waits.
16
One `sync_to_async` wrapping the whole function, not one per ORM call. Wrapping each statement would open the transaction on the main thread and then leave it across `await` points.
19
`thread_sensitive=False` is safe here precisely because PDF rendering touches no Django state — that is the condition, not a performance preference.

Why this works: Each boundary crossing is chosen for a reason: the async ORM for readability, `httpx` for actual concurrency, one `sync_to_async` for the transaction, and a non-thread-sensitive call only where nothing Django-related is involved.

Calling the sync ORM inside an async view

Wrong

python
async def order_detail(request, pk):
    order = Order.objects.get(pk=pk)      # SynchronousOnlyOperation
    return JsonResponse({"id": order.pk})

Better

python
async def order_detail(request, pk):
    order = await Order.objects.aget(pk=pk)
    return JsonResponse({"id": order.pk})

What you see: `SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async.` — raised at request time, and only on the code paths that actually touch the database, so a view can look fine until one branch runs.

Why: The ORM is not safe to run on a thread with a running event loop: connection state is thread-local and the query blocks, so a stray sync call would stall the loop as well as confuse connection handling. Django raises rather than letting that happen silently. The `a`-prefixed methods do the wrapping for you; `sync_to_async` is the escape hatch for anything without an async equivalent.

What actually happens on each side of the bridge
Event loop
async view
Main thread
Database
  1. 1. runs the coroutine
  2. 2. await Order.objects.aget(pk=57)the a-prefixed method hands a BLOCKING query to a thread
  3. 3. SELECT … (synchronous driver)
  4. 4. row
  5. 5. awaited valuethe loop was free meanwhile — but the query itself never overlapped another
  6. 6. await sync_to_async(charge_and_record)(order)thread_sensitive=True → the SAME main thread, so connections and transactions line up
  7. 7. BEGIN … COMMITthe whole transaction lives inside one sync_to_async call
  8. 8. Order.objects.get(pk=57) — the plain sync ORM
  9. 9. SynchronousOnlyOperationthe guard against running the ORM on the loop thread
  1. Event loop → async view: runs the coroutine
  2. async view → Main thread: await Order.objects.aget(pk=57) (the a-prefixed method hands a BLOCKING query to a thread)
  3. Main thread → Database: SELECT … (synchronous driver)
  4. Database → Main thread: row
  5. Main thread → async view: awaited value (the loop was free meanwhile — but the query itself never overlapped another)
  6. async view → Main thread: await sync_to_async(charge_and_record)(order) (thread_sensitive=True → the SAME main thread, so connections and transactions line up)
  7. Main thread → Database: BEGIN … COMMIT (the whole transaction lives inside one sync_to_async call)
  8. async view → Event loop: Order.objects.get(pk=57) — the plain sync ORM
  9. Event loop → async view: SynchronousOnlyOperation (the guard against running the ORM on the loop thread)

Crossing the boundary, in both directions

Crossing the boundary, in both directions
You are inYou need to callUseWatch out for
an async viewthe ORM`await Model.objects.aget(...)`a thread underneath — no real concurrency
an async viewa blocking library`await sync_to_async(fn)(...)`the default `thread_sensitive=True` shares the main thread
an async viewa pure CPU function`sync_to_async(fn, thread_sensitive=False)`safe only if it touches no Django state
an async viewa transaction`await sync_to_async(do_it_all)()`transactions do not work in async mode
sync code (a task, a command)a coroutine`async_to_sync(coro)(...)`creates or reuses a loop; preserves contextvars

Together

python
order = await Order.objects.aget(pk=pk)
await sync_to_async(charge_and_record)(order)   # blocking + transactional, one call

Remember: The `a`-prefixed ORM methods are ergonomics, not concurrency — there is no async database driver, so each one still runs a blocking query in a thread. The plain sync ORM from a coroutine raises `SynchronousOnlyOperation`. `sync_to_async` defaults to `thread_sensitive=True`, which shares the main thread that Django's thread-local connections need; `thread_sensitive=False` gives real parallelism and is safe only for code touching no Django state. Transactions do not work in async mode — keep the whole `atomic()` unit inside one `sync_to_async` call.

See also: async views and the asgi requirement · async io cancellation and limits · atomic and nested blocks

Advertisement

Timeouts, cancellation, and limits

The responsibilities async hands back to you once the thread pool is no longer holding them.

Async HTTP clients, cancellation, timeouts, and concurrency limits

coreadvanced

An async view is only concurrent if its I/O library is awaitable, which in practice means `httpx.AsyncClient` or `aiohttp` rather than `requests`. Once you are awaiting real I/O, three things become your responsibility. **Timeouts**: `await` has no deadline of its own, so a stalled upstream holds a coroutine and a connection forever unless you set one. **Cancellation**: when a client disconnects or a timeout fires, the coroutine receives `asyncio.CancelledError` at its next `await` — which means cleanup belongs in `finally`, and swallowing that exception breaks shutdown. **Concurrency limits**: async removes the natural ceiling that a thread pool provided, so a fan-out over a thousand items will happily open a thousand upstream connections unless a `Semaphore` says otherwise.

Think of it as

Async trades a scarce resource for an unbounded one, and everything in this concept follows from that trade. With threads, the pool size was an accidental limit on how much you could do at once — unpleasant when it blocked you, useful because it stopped you flooding a dependency. Coroutines are nearly free, so the ceiling disappears, and a naive `asyncio.gather` over a list is a load generator pointed at whatever you are calling. That is why a `Semaphore` is not an optimisation here but part of writing the fan-out correctly. Cancellation is the other thing that has no sync equivalent worth comparing. A blocking call runs to completion whether or not anyone is still waiting; a coroutine can be told to stop, and `CancelledError` is how it is told. Two consequences: a bare `except Exception` will not catch it (it inherits from `BaseException` in modern Python, which is deliberate), and any `except`/`finally` you write around an `await` is now a shutdown path that has to release its resources. Timeouts tie the two together — `asyncio.timeout()` works by cancelling the block it wraps, so a timeout and a client disconnect arrive through the same mechanism and want the same cleanup.

python
async with asyncio.timeout(5):
    async with sem:                     # bound the fan-out
        response = await client.get(url)

What we're doing: Fan out to a thousand URLs without opening a thousand connections, with a deadline and cleanup that survives cancellation.

sync/fetcher.pypython
async def fetch_all(urls, *, concurrency=10, per_request_timeout=5.0):
    sem = asyncio.Semaphore(concurrency)
    results, failures = [], []

    async with httpx.AsyncClient(
        timeout=per_request_timeout,
        limits=httpx.Limits(max_connections=concurrency),
    ) as client:

        async def one(url):
            async with sem:                       # the ceiling threads used to give us
                try:
                    response = await client.get(url)
                    response.raise_for_status()
                    return url, response.json()
                except asyncio.CancelledError:
                    raise                         # never swallow: shutdown depends on it
                except httpx.HTTPError as exc:
                    failures.append((url, str(exc)))
                    return None

        try:
            async with asyncio.timeout(60):       # a deadline for the WHOLE fan-out
                done = await asyncio.gather(*(one(u) for u in urls))
        except TimeoutError:
            logger.warning("fetch_all_deadline", extra={"n": len(urls)})
            done = []

    results = [r for r in done if r is not None]
    return results, failures
5–7
One client for the whole fan-out, with its pool sized to the semaphore. A client per request would throw away connection reuse and pay a TLS handshake a thousand times.
11
The semaphore *is* the concurrency limit. Without it `gather` starts every coroutine at once, and async is happy to open a thousand sockets on your behalf.
16–17
Catching `CancelledError` only to re-raise it. It inherits from `BaseException`, so the `except httpx.HTTPError` below would never see it — but an over-broad `except BaseException` would swallow shutdown.
23–24
A deadline for the whole operation, separate from the per-request timeout. One is "this upstream is slow"; the other is "we have spent long enough on this job".
30
Failures are collected rather than raised, so one bad URL out of a thousand does not discard the 999 that succeeded.

Why this works: Two limits and two timeouts, each answering a different question: the semaphore bounds pressure on the upstream, the client pool bounds sockets, the per-request timeout bounds one call, and the outer deadline bounds the job.

Swallowing `CancelledError` in a broad except

Wrong

python
try:
    return await client.get(url)
except BaseException as exc:          # catches CancelledError too
    logger.warning("fetch failed: %s", exc)
    return None

Better

python
try:
    return await client.get(url)
except asyncio.CancelledError:
    raise                             # let cancellation propagate
except httpx.HTTPError as exc:
    logger.warning("fetch failed: %s", exc)
    return None

What you see: Graceful shutdown hangs: the server waits for tasks that were told to stop and did not, and the deploy eventually kills the process with `SIGKILL`, dropping whatever was genuinely in flight.

Why: Cancellation is delivered *as* an exception, so swallowing it converts "stop now" into "carry on". `CancelledError` was moved to inherit from `BaseException` precisely so that ordinary `except Exception` handlers do not catch it by accident — which means an explicit `except BaseException` reintroduces the bug the change was meant to prevent. Catch it only to clean up, and always re-raise.

The life of one awaited call, including the two ways it ends early
asyncwith sema slotis freeupstreamresponds in timedeadlinepassesthe callergoes awaytimeout cancelsthe blockthe servercancels the taskexcept Exceptiondoes NOT catch this

Coroutine scheduled

start

Waiting on the semaphore

Request in flight — awaiting I/O

Response received

end

asyncio.timeout fires

Client disconnected

CancelledError raised at the next await

finally: release the connection and the slot

end

  • Coroutine scheduled (start)
    • → Waiting on the semaphore when async with sem
  • Waiting on the semaphore
    • → Request in flight — awaiting I/O when a slot is free
  • Request in flight — awaiting I/O
    • → Response received when upstream responds in time
    • → asyncio.timeout fires when deadline passes
    • → Client disconnected when the caller goes away
  • Response received (end)
  • asyncio.timeout fires
    • → CancelledError raised at the next await when timeout cancels the block
  • Client disconnected
    • → CancelledError raised at the next await when the server cancels the task
  • CancelledError raised at the next await
    • → finally: release the connection and the slot when except Exception does NOT catch this
  • finally: release the connection and the slot (end)

What async removes, and what you must add back

What async removes, and what you must add back
Sync behaviourUnder asyncWhat you add
Thread pool caps concurrencycoroutines are nearly free — no ceiling`asyncio.Semaphore(n)`
Socket timeout on the blocking call`await` waits indefinitelya client timeout, or `asyncio.timeout()`
A dead client is noticed on writethe coroutine is cancelled at its next `await``finally` cleanup; re-raise `CancelledError`
One connection per threadone pool shared by many coroutinesone long-lived client, not one per request

Together

python
sem = asyncio.Semaphore(10)

async def fetch(client, url):
    async with sem:
        return await client.get(url)

Remember: Concurrency needs an awaitable client — `httpx.AsyncClient` or `aiohttp`, never `requests`. `await` has no deadline, so set a per-request timeout and, for a job, an outer `asyncio.timeout()`. Both a timeout and a client disconnect arrive as `CancelledError`, which inherits from `BaseException`: clean up in `finally`, catch it only to re-raise, and never swallow it or shutdown will hang. And because coroutines are cheap, the thread pool's accidental ceiling is gone — a `Semaphore` is how you put it back.

See also: the sync async bridge · async views and the asgi requirement · servers workers and lifecycle

Advertisement