Async & APIs with FastAPI
Python's async/await mapped onto JS promises (with asyncio.gather as Promise.all), then building real HTTP APIs with FastAPI — typed request/response models via pydantic, dependency injection, and an endpoint that calls an LLM.
- Map Python's
async/awaitandasyncio.gatheronto JS promises andPromise.all. - Run concurrent I/O (HTTP calls to LLM/APIs) without blocking.
- Build a FastAPI endpoint with typed request/response models (pydantic).
- Validate input and inject shared dependencies (config, clients) into handlers.
If you've written async/await in JavaScript, you already have the mental model. Python's version
looks almost identical; the differences are the event-loop entry point and the asyncio helpers.
Async in Python vs JavaScript
An async def function returns a coroutine — Python's version of a Promise. You await it to get
the value. The one thing JS does for free that Python makes explicit: starting the event loop.
import asyncio
async def fetch_user(id: str) -> dict: # `async def` ~ JS `async function`
await asyncio.sleep(1) # await a coroutine ~ awaiting a Promise (here: fake I/O)
return {"id": id, "name": "Pierre"}
# In JS you'd just call `await fetch_user(...)` at top level (in a module).
# In Python you enter the event loop once, at the program's edge:
async def main():
user = await fetch_user("42") # await inside an async function
print(user)
asyncio.run(main()) # starts the loop and runs main() to completion
Calling an async def without await does not run it — it just creates a coroutine object (like
calling a function that returns a Promise you never .then()). You'll get a "coroutine was never
awaited" warning. Always await it, or schedule it with asyncio.gather/create_task.
Concurrency: asyncio.gather is Promise.all
Awaiting in sequence is serial. To run I/O concurrently, gather the coroutines — the direct analog
of Promise.all.
# Serial: ~3 seconds total (1s each, one after another)
a = await fetch_user("1")
b = await fetch_user("2")
c = await fetch_user("3")
# Concurrent: ~1 second total — all three run at once
a, b, c = await asyncio.gather( # asyncio.gather(...) ~ Promise.all([...])
fetch_user("1"),
fetch_user("2"),
fetch_user("3"),
)
↳ Exercise 1 has you turn a serial pair of awaits into a concurrent asyncio.gather and reason
about the timing — the same speedup you'd get from Promise.all in JS.
Real async HTTP with httpx
requests is synchronous (it blocks the loop). For async HTTP use httpx's AsyncClient — the
pattern you'll use to call LLM and tool APIs concurrently.
import httpx
async def get_json(url: str) -> dict:
async with httpx.AsyncClient() as client: # `async with` = context manager that also awaits cleanup
resp = await client.get(url) # await the network call
resp.raise_for_status() # throw on 4xx/5xx (like checking res.ok and throwing)
return resp.json() # parse JSON body
# Fetch two endpoints at once:
weather, news = await asyncio.gather(get_json(URL_A), get_json(URL_B))
Only use async for I/O-bound work (network, disk, DB). It does not speed up CPU-bound work —
async gives concurrency, not parallelism. (Heavy CPU work needs processes, a later-track topic.)
↳ Exercise 2 has you fetch two URLs concurrently with httpx.AsyncClient + asyncio.gather.
Building an API with FastAPI
FastAPI is the standard for Python AI backends: async-first, and it derives validation and docs from your type hints. Minimal app:
from fastapi import FastAPI
app = FastAPI() # the app instance (like `const app = express()`)
@app.get("/health") # decorator registers a GET route (Express: app.get('/health', ...))
async def health(): # handlers can be async
return {"status": "ok"} # return a dict → FastAPI serialises it to JSON automatically
Run it with uvicorn app:app --reload (an ASGI server). FastAPI auto-generates interactive docs at
/docs from your types — no extra work.
Path and query parameters
@app.get("/topics/{topic_id}") # {topic_id} is a path param (Express :topic_id)
async def get_topic(topic_id: str, verbose: bool = False):
# ^ from the path ^ from the query string (?verbose=true), typed + default
return {"id": topic_id, "verbose": verbose}
FastAPI reads the types: topic_id comes from the path, and verbose (with a default) becomes an
optional query parameter — automatically parsed and validated to a bool.
Typed request bodies with pydantic
A pydantic BaseModel validates and parses JSON request bodies — think zod, but the schema is
the type. Invalid input yields a clean 422 error for free.
from pydantic import BaseModel
class AskRequest(BaseModel): # like a zod schema / TS type that also validates at runtime
question: str
top_k: int = 4 # optional with default
temperature: float = 0.7
class AskResponse(BaseModel):
answer: str
used_chunks: int
@app.post("/ask")
async def ask(req: AskRequest) -> AskResponse: # FastAPI parses+validates the JSON body into AskRequest
# ... call your RAG/LLM here ...
return AskResponse(answer="…", used_chunks=req.top_k)
If a client omits question or sends top_k: "four", pydantic rejects it with a 422 and a precise
error — you never hand-write that validation.
↳ Exercise 3 has you build a POST /ask endpoint with a pydantic request model and return a typed
response — the exact shape of a real LLM-backed API.
Dependency injection
FastAPI's Depends injects shared resources (a config, a DB session, an API client, an auth check)
into handlers — declared once, reused everywhere, and easy to override in tests.
from fastapi import Depends
def get_settings() -> dict: # a "dependency" — any callable that returns something
return {"model": "gpt-4o-mini"}
@app.post("/ask")
async def ask(req: AskRequest, settings: dict = Depends(get_settings)):
# ^ FastAPI calls get_settings() and passes the result in
model = settings["model"]
...
Because the dependency is injected (not imported directly), a test can swap get_settings for a fake
— the same testability win as constructor injection.
↳ Exercise 4 has you extract a shared config into a Depends dependency and inject it into your
/ask handler.
Putting it together
import asyncio
from fastapi import FastAPI, Depends
from pydantic import BaseModel
app = FastAPI()
class AskRequest(BaseModel):
question: str
top_k: int = 4
def get_model() -> str:
return "gpt-4o-mini"
async def call_llm(prompt: str, model: str) -> str:
await asyncio.sleep(0.2) # stand-in for the real async API call
return f"[{model}] answered: {prompt[:40]}"
@app.post("/ask")
async def ask(req: AskRequest, model: str = Depends(get_model)):
answer = await call_llm(req.question, model) # await the async LLM call
return {"answer": answer, "top_k": req.top_k}
That's a complete, typed, async endpoint: validated input, injected config, awaited I/O, JSON out.
Recap
async defreturns a coroutine (a Promise);awaitit. Start the loop once withasyncio.run.asyncio.gather(...)isPromise.all— use it to run I/O concurrently; use httpx (not requests) for async HTTP. Async helps I/O-bound, not CPU-bound, work.- FastAPI derives routes, validation, and docs from type hints. pydantic
BaseModelvalidates request/response bodies like zod. Dependsinjects shared config/clients/auth into handlers and keeps them testable.
Async & APIs with FastAPI — Exercises
Set up a venv and pip install fastapi uvicorn httpx. Run APIs with uvicorn app:app --reload and
poke them at http://localhost:8000/docs.
Exercise 1 — Serial → concurrent with asyncio.gather
Given this serial code (~2s total), rewrite it to run both calls concurrently (~1s total), and print how long each version takes.
import asyncio, time
async def slow(label: str) -> str:
await asyncio.sleep(1)
return label
# serial:
async def main():
a = await slow("a")
b = await slow("b")
print(a, b)
Reference solution
import asyncio, time
async def slow(label: str) -> str:
await asyncio.sleep(1)
return label
async def main():
t = time.perf_counter()
a, b = await asyncio.gather(slow("a"), slow("b")) # both run at once ~ Promise.all
print(a, b, f"{time.perf_counter() - t:.2f}s") # ~1.00s, not 2.00s
asyncio.run(main())
The two sleep(1)s overlap because gather schedules both coroutines on the event loop before
awaiting either — exactly like Promise.all([...]).
Exercise 2 — Concurrent HTTP with httpx
Fetch two JSON endpoints concurrently with httpx.AsyncClient and return both results. (Use any two
public JSON URLs, or two routes of your own FastAPI app.)
Reference solution
import asyncio, httpx
async def get_json(client: httpx.AsyncClient, url: str) -> dict:
resp = await client.get(url)
resp.raise_for_status()
return resp.json()
async def main():
async with httpx.AsyncClient() as client: # one client, reused for both calls
a, b = await asyncio.gather(
get_json(client, "https://httpbin.org/get?x=1"),
get_json(client, "https://httpbin.org/get?x=2"),
)
print(a["args"], b["args"])
asyncio.run(main())
Sharing a single AsyncClient across the gathered calls reuses the connection pool — cheaper than a
client per request.
Exercise 3 — A typed POST /ask endpoint
Build a FastAPI app with POST /ask that accepts { "question": "...", "top_k": 3 }, validates it
with a pydantic model, and returns { "answer": "...", "top_k": 3 }. Test it via /docs.
Reference solution
# file: app.py
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class AskRequest(BaseModel):
question: str
top_k: int = 4
@app.post("/ask")
async def ask(req: AskRequest):
# pretend to answer; req is already validated
return {"answer": f"You asked: {req.question}", "top_k": req.top_k}
uvicorn app:app --reload
# open http://localhost:8000/docs and POST to /ask
Send { "top_k": 3 } (no question) and confirm FastAPI returns a 422 with a clear message — that's
pydantic validation you didn't have to write.
Exercise 4 — Inject shared config with Depends
Extend Exercise 3: move the model name into a get_settings dependency and inject it into /ask.
Return the model name in the response so you can see it flow through.
Reference solution
from fastapi import FastAPI, Depends
from pydantic import BaseModel
app = FastAPI()
class AskRequest(BaseModel):
question: str
top_k: int = 4
def get_settings() -> dict:
return {"model": "gpt-4o-mini"}
@app.post("/ask")
async def ask(req: AskRequest, settings: dict = Depends(get_settings)):
return {
"answer": f"You asked: {req.question}",
"model": settings["model"], # injected, not imported
"top_k": req.top_k,
}
In a test you'd override the dependency — app.dependency_overrides[get_settings] = lambda: {"model": "fake"}
— to run without real config. That override-ability is the point of injecting rather than importing.