n4nAI

asyncio.wait_for and LLM API timeouts in Python

Use python asyncio wait_for timeout llm api calls to bound latency and prevent hung event loops, with runnable async code and verification.

n4n Team3 min read710 words

Audio narration

Coming soon — every post will get a voice note here.

A missing client-side deadline is the fastest way to stall an entire asyncio event loop when a model endpoint goes quiet. Using python asyncio wait_for timeout llm api calls lets you bound latency per request and shed load instead of blocking forever, but the wrapper has sharp edges around cancellation and streaming that bite in production.

Step 1: Build an async HTTP client with baseline timeouts

Do not use requests in an asyncio service. Use httpx or aiohttp. httpx exposes an AsyncClient that speaks OpenAI-compatible JSON and lets you set granular network timeouts independent of wait_for.

import httpx

BASE_URL = "https://api.openai.com/v1"  # swap for any OpenAI-compatible endpoint
API_KEY = "sk-your-key"

def make_client() -> httpx.AsyncClient:
    return httpx.AsyncClient(
        base_url=BASE_URL,
        headers={"Authorization": f"Bearer {API_KEY}"},
        timeout=httpx.Timeout(connect=5.0, read=30.0, write=5.0, pool=5.0),
    )

The httpx.Timeout above kills a stuck socket read at 30 seconds. That is a network-level safety net. It does not, however, cancel your coroutine tree or enforce a business-level latency budget such as “no user waits more than 2 seconds for the first token.” That is what python asyncio wait_for timeout llm api patterns add.

If you front requests with n4n.ai, its automatic fallback switches providers on degradation, but a slow-but-alive upstream can still exceed your latency budget; wait_for remains the client-side backstop.

Step 2: Wrap the completion call in asyncio.wait_for

Write a small function that posts to /chat/completions and wrap the await in asyncio.wait_for. The timeout argument is a float in seconds. When it elapses, wait_for cancels the inner coroutine and raises asyncio.TimeoutError.

import asyncio

async def complete(prompt: str, client: httpx.AsyncClient, timeout: float = 10.0) -> str:
    payload = {
        "model": "gpt-4o-mini",
        "messages": [{"role": "user", "content": prompt}],
        "max_tokens": 200,
    }
    try:
        resp = await asyncio.wait_for(
            client.post("/chat/completions", json=payload),
            timeout=timeout,
        )
    except asyncio.TimeoutError:
        # Inner post coroutine is already cancelled by wait_for.
        raise
    resp.raise_for_status()
    data = resp.json()
    return data["choices"][0]["message"]["content"]

Key point: wait_for cancels the task executing client.post. httpx handles cancellation by closing the connection. You do not need to call task.cancel() yourself.

Step 3: Handle cancellation and avoid leaked tasks

A common bug is catching TimeoutError, then later awaiting the same coroutine again. Once cancelled, a coroutine cannot be reused. Build a fresh call per attempt.

import logging
logger = logging.getLogger(__name__)

async def safe_complete(prompt: str, client: httpx.AsyncClient, budget: float = 8.0) -> str | None:
    try:
        return await asyncio.wait_for(complete(prompt, client), timeout=budget)
    except asyncio.TimeoutError:
        logger.warning("LLM call exceeded %.1fs budget", budget)
        return None

If you need retry, re-enter safe_complete or call complete anew. Never wrap an already-awaited coroutine.

When wait_for times out, it calls task.cancel() on the inner task and waits for it to finish cancelling. In Python 3.8+, the cancellation propagates; in older versions you had to do this manually. Assume modern Python.

Step 4: Apply timeouts to streaming responses

Streaming LLM output complicates python asyncio wait_for timeout llm api usage because the response is not a single await but a long-lived generator. You have two options: enforce a total deadline on the whole stream, or enforce a per-chunk deadline.

Total deadline is simpler and usually correct:

async def stream_complete(prompt: str, client: httpx.AsyncClient, total_timeout: float = 20.0):
    payload = {
        "model": "gpt-4o-mini",
        "messages": [{"role": "user", "content": prompt}],
        "stream": True,
    }
    async def _consume():
        async with client.stream("POST", "/chat/completions", json=payload) as resp:
            resp.raise_for_status()
            async for line in resp.aiter_lines():
                if line.strip():
                    yield line
    # wrap the consumer coroutine, not the stream context alone
    async for line in asyncio.wait_for(_consume(), timeout=total_timeout):
        print(line)

If you need to abort when a single chunk stalls for more than 2 seconds, wrap the anext call inside the loop with wait_for. That requires manually driving the async generator.

async def stream_with_chunk_deadline(gen, chunk_timeout: float = 2.0):
    while True:
        try:
            line = await asyncio.wait_for(gen.__anext__(), timeout=chunk_timeout)
        except StopAsyncIteration:
            break
        except asyncio.TimeoutError:
            await gen.aclose()
            raise
        yield line

Pick one model. Mixing both total and per-chunk timeouts creates confusing cascade failures.

Step 5: Compose timeouts with concurrency limits

Unbounded concurrency will get you rate-limited. Put a Semaphore around the wait_for-wrapped call so the event loop stays responsive under load.

sem = asyncio.Semaphore(10)

async def bounded_complete(prompt: str, client: httpx.AsyncClient) -> str | None:
    async with sem:
        return await safe_complete(prompt, client, budget=8.0)

async def batch(prompts: list[str], client: httpx.AsyncClient) -> list[str | None]:
    return await asyncio.gather(*(bounded_complete(p, client) for p in prompts))

Each item still respects its own wait_for deadline. If the gateway behind the endpoint (such as n4n.ai) honors client routing directives and forwards provider cache-control hints, a timeout may simply mean the cache missed and the provider was slow; your semaphore prevents that slowness from cascading.

Step 6: Verify the timeout with a fault-injected test

A timeout path that has never been exercised is a lie. Use httpx.MockTransport to simulate a slow upstream and assert that asyncio.TimeoutError (or your wrapped None) surfaces.

import pytest
import httpx

async def slow_handler(request: httpx.Request) -> httpx.Response:
    await asyncio.sleep(5)
    return httpx.Response(200, json={"choices": [{"message": {"content": "late"}}]})

@pytest.mark.asyncio
async def test_llm_timeout():
    client = httpx.AsyncClient(transport=httpx.MockTransport(slow_handler))
    with pytest.raises(asyncio.TimeoutError):
        await complete("hello", client, timeout=0.2)
    # ensure no pending tasks leaked
    pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
    assert not pending

Run pytest -q and confirm the test passes. If you see pending tasks after the test, your cancellation cleanup is incomplete—usually because you caught CancelledError and swallowed it somewhere in the stack.

Verifying success in production

In a live service, export a counter for safe_complete returning None and alert when the rate crosses 1% of requests. Add a trace span around the wait_for call so you can see whether timeouts cluster on a specific model or route. The code above gives you the hook; wire it to your telemetry.

Step 7: Choose timeout values from p99 latency, not guesses

Pull the real p99 completion latency for your model and set wait_for to p99 + 30%. If p99 is 600 ms, a 2-second budget is reasonable. Setting it to 10 seconds hides a degraded provider behind a slow user experience. The python asyncio wait_for timeout llm api call is a product decision, not just a defensive guard.

Remember that wait_for does not retry. It cancels. If you need retries, wrap safe_complete in a loop with backoff, but cap total attempt time with another outer wait_for so the user-facing deadline holds.

async def complete_with_retry(prompt: str, client: httpx.AsyncClient, attempts: int = 3):
    for i in range(attempts):
        try:
            return await asyncio.wait_for(complete(prompt, client), timeout=2.0)
        except asyncio.TimeoutError:
            if i == attempts - 1:
                raise
            await asyncio.sleep(0.1 * (2 ** i))

That is the full pattern: network-level timeout in the client, business-level deadline in wait_for, concurrency bound by a semaphore, streaming handled explicitly, and a fault test proving the path works. Ship it.

Tagspythonasynciotimeoutsllm-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All python async/await streaming (asyncio) posts →