Demo, all content is generated
Question

FastAPI gets completely stuck when one user is waiting for the OpenAI response

Solved · 3103 views · asked by chen_wei · edited

My FastAPI backend has an endpoint that asks OpenAI to summarize a document (takes 15–30 s). While that is running, every other request, even /health, just hangs until it's done. With two users it's unusable.

@app.post("/summarize")
async def summarize(doc: Doc):
    client = OpenAI()
    resp = client.chat.completions.create(model="gpt-4o-mini", messages=[...])
    return {"summary": resp.choices[0].message.content}

Running with uvicorn main:app on a small VPS.

What I’ve tried

Claude Code suggested adding --workers 4, which helps a bit but with 5 users it's stuck again. Also tried increasing the timeout.

Comment
Is that the sync OpenAI client inside async def? That's almost always it. olu_backend · edited

3 answers

Marked as helpful by the asker
olu_backend · edited

You're calling a blocking client inside an async def. That freezes the event loop, the single thread that serves all requests, for 30 seconds.

Two correct fixes, pick one:

A. Use the async client (preferred):

from openai import AsyncOpenAI
client = AsyncOpenAI()  # create once, at module level

@app.post("/summarize")
async def summarize(doc: Doc):
    resp = await client.chat.completions.create(model="gpt-4o-mini", messages=[...])
    return {"summary": resp.choices[0].message.content}

B. Make the endpoint plain def. FastAPI then runs it in a threadpool, so it doesn't block the loop.

What you must not do is mix: async def + sync library. Same applies to requests, time.sleep, sync database drivers and so on. Also create the client once instead of per request.

Comment
Switched to AsyncOpenAI, 10 parallel requests now and /health answers instantly. I had no idea async def could make it worse. chen_wei · edited
The 'must not mix' rule should be printed on the FastAPI homepage rosa_m · edited
It kind of is, in the 'Concurrency and async / await' docs page. Nobody reads it until this happens :) grace_mw · edited
grace_mw · edited

Adding to olu_backend: if some sync library has no async version, wrap just that call:

from starlette.concurrency import run_in_threadpool
result = await run_in_threadpool(some_blocking_fn, arg)

And for 30 second jobs, consider streaming the response to the client so the user sees progress instead of a spinner.

Comment
I have exactly one of those, the PDF parser. Wrapping it now chen_wei · edited
felix_codes · edited

Had the exact same thing. Tip for finding the other places: search your code for async def and then check every call inside that isn't awaited. That's where the blockers hide.

Comment