The Course 3 champion model behind an HTTP API: request and response schemas, validation for free, authentication, OpenAPI documentation, and what async does and doesn’t buy.
TestClientdef and async def, and vectorize batch requestsCompanion notebook: lesson-04-fastapi.ipynb — worked examples and self-checking exercises.
Every caller sends JSON over HTTP and gets JSON back. None of them imports your Python, knows your model is a GLM, or needs scikit-learn installed.
The contract (what goes in, what comes out) is the part other teams depend on. Change the model freely; change the contract carefully.
| Request | Means |
|---|---|
GET /health | read: is the service up? |
POST /quotes | create: price one vehicle, store the quote |
POST /quotes/batch | create many in one call |
GET /quotes/42 | read quote 42 |
| Status | Means |
|---|---|
| 200 OK | here is what you asked for |
| 201 Created | stored; here it is |
| 401 Unauthorized | no key, or the wrong one |
| 404 Not Found | no such quote |
| 422 Unprocessable | the request broke the contract |
| 500 Server Error | our fault: a bug |
Contract first, then endpoints.
class QuoteRequest(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid",
json_schema_extra={"examples": [{
"driver_age": 22, "vehicle_style": "Sports", ...}]})
driver_age: float = Field(ge=16, le=110,
description="Age of the youngest listed driver")
vehicle_style: Style
vehicle_value: float = Field(gt=0, le=500_000)
annual_mileage: float = Field(gt=0, le=150_000)
urban: bool
coverages: Annotated[frozenset[Coverage], ...]
coll_deductible: Deductible | None = None
comp_deductible: Deductible | None = None
class QuoteResponse(BaseModel):
quote_id: int
premiums: dict[Coverage, float]
total_premium: float
liability_basis: Literal["manual", "model"]
model_version: str
created_at: datetime
Vehicle, now guarding the front door of a serviceextra="forbid": a misspelt drivr_age is an error, not silently ignoredapp = FastAPI(title="Motor Rating API", version="1.0.0",
lifespan=lifespan)
@app.post("/quotes", response_model=QuoteResponse,
status_code=201,
dependencies=[Depends(require_api_key)])
def create_quote(body: QuoteRequest, request: Request,
s: Db) -> QuoteResponse:
premiums = request.app.state.rating.quote(body)
rec = QuoteRecord(request=body.model_dump(mode="json"),
liab_premium=premiums["LIAB"], ...,
model_version=manifest["version"])
s.add(rec)
s.commit()
return to_response(rec)
@asynccontextmanager
async def lifespan(app): # once, at start-up
model, manifest = load(settings.model_dir) # verified
app.state.rating = build_engine(load_manual(), model,
settings.liability_basis, ...)
...
yield
body: QuoteRequest tells FastAPI to parse and validate the JSON bodyDepends(...): things an endpoint needs (a database session, an API key check), built per requestModelLiability or ManualLiability, chosen by configuration$ curl -s -X POST localhost:8000/quotes \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"driver_age": 22, "vehicle_style": "Sports",
"vehicle_value": 45000, "annual_mileage": 16000,
"urban": true, "coverages": ["LIAB","COLL","COMP"],
"coll_deductible": 1000, "comp_deductible": 250}'
{"quote_id": 1,
"premiums": {"LIAB": 4648.59, "COLL": 2046.76,
"COMP": 313.53},
"total_premium": 7008.88,
"liability_basis": "model",
"model_version": "glm-dcbe5b5dbb",
"created_at": "2026-09-30T15:12:17.850368Z"}
| LIAB | Manual | Model |
|---|---|---|
| Base risk (Sedan, 45, 12,000 mi, non-urban) | 938.09 | 726.73 |
| 22, Sports, 16,000 mi, urban | 2,521.59 | 4,648.59 |
Model LIAB = GLM pure premium × 1.041.5 trend ÷ 0.68 target loss ratio. COLL and COMP still come from the manual.
The model prices a young urban sports-car driver at 1.8 times the manual and the base risk 23% lower: the urban and young-driver effects the manual ignores (Course 3).
{"detail": [
{"type": "greater_than_equal",
"loc": ["body", "driver_age"],
"msg": "Input should be greater than or equal to 16",
"input": 15, "ctx": {"ge": 16.0}},
{"type": "literal_error",
"loc": ["body", "vehicle_style"],
"msg": "Input should be 'Sedan', 'SUV', 'Hatchback',
'Pickup', 'Minivan' or 'Sports'",
"input": "Coupe"}
]}
["LIAB"] with a COLL deductible → “COLL deductible must be given if and only if COLL is bought”credit_score) → 422, because of extra="forbid"loc says which item: ["body", 1, "driver_age"]def require_api_key(
x_api_key: Annotated[str | None, Header()] = None,
) -> None:
expected = settings.api_key.get_secret_value()
if (not expected or x_api_key is None
or not secrets.compare_digest(x_api_key, expected)):
raise HTTPException(401, "missing or invalid X-API-Key")
X-API-Key header; the expected key comes from the environment or a secret file (Lesson 7), never the codesecrets.compare_digest takes the same time whether the first or last character is wrong, so the key can’t be guessed one character at a timeSecretStr prints as ********** in logs and tracebacksAPI keys identify a calling system. Customer-facing logins need a proper identity provider (OAuth2 / OpenID Connect): FastAPI supports both.
/docs is an interactive page: every endpoint, every schema, and a Try it out button. /openapi.json is the same description as data, from which client code in other languages can be generated.
Documentation that is generated from the code can’t fall out of date. The descriptions and examples you put in the pydantic models appear here.
Waiting is not working.
@demo.get("/async-await")
async def good():
await asyncio.sleep(0.2) # waits; others run meanwhile
return {"ok": True}
@demo.get("/async-blocking")
async def bad():
time.sleep(0.2) # blocks the loop: all wait
return {"ok": True}
@demo.get("/sync-def")
def threaded():
time.sleep(0.2) # plain def: FastAPI runs it
return {"ok": True} # in a worker thread
| 20 simultaneous requests, each waiting 0.2 s | Wall time |
|---|---|
async def + await | 0.23 s |
plain def (thread pool) | 0.23 s |
async def + blocking call | 4.03 s |
Rule: use async def only when everything inside is awaitable (async HTTP calls, async database drivers). Pricing with scikit-learn and a normal SQLAlchemy session blocks, so the rating endpoints are plain def.
| Measure | Result |
|---|---|
| One quote, round trip (median / p95) | 11.3 ms / 12.3 ms |
| … of which inside the service | 10.1 ms |
| Throughput, 1 client | 84 quotes/s |
| Throughput, 20 concurrent clients | 92 quotes/s |
| Batch of 1,000, one model call per vehicle | 6.44 s |
| Batch of 1,000, one model call in total | 0.18 s |
Server time comes from an X-Process-Time-Ms header added by a small middleware.
def premiums(self, requests) -> list[float]:
"""One model call for many vehicles: the model's
cost is per call, not per row."""
frame = pd.DataFrame([r.model_dump(include={...})
for r in requests])
pure = (self.model.pure_premium(frame)
* self.trend_factor / self.target_loss_ratio)
return [money(p) for p in pure]
A scikit-learn pipeline costs about 6 ms per call whether it scores 1 row or 1,000. Lesson 3’s lesson again: vectorize. The batch endpoint went 36 times faster, and a test checks it gives the same premiums as one-at-a-time.
@pytest.fixture
def client(settings):
with TestClient(create_app(settings)) as c: # runs lifespan
yield c
def test_quote_is_priced_and_stored(client, auth, settings):
r = client.post("/quotes", json=BODY, headers=auth)
assert r.status_code == 201
q = r.json()
assert q["premiums"]["COLL"] == 2046.76
assert client.get(f"/quotes/{q['quote_id']}",
headers=auth).json() == q
def test_api_key_is_required(client):
assert client.post("/quotes", json=BODY).status_code == 401
def test_batch_with_one_bad_vehicle_stores_nothing(...):
...
create_app(settings) is a factory: tests build an app with a temporary SQLite file and a test keyTestClient calls the app in-process: real routing, validation, dependencies, databaseQuoteRequest and QuoteResponse models with examplescreate_app() with a lifespan that loads the manual and the Course 3 modelPOST /quotes (201), GET /quotes/{id} (404 when unknown), GET /healthPOST /quotes/batch; test it matches single quotesTestClient for every status codeuvicorn, open /docs, and quote from curl| Base risk, model LIAB | 726.73 |
| 22, Sports, urban: LIAB | 4,648.59 |
| … total | 7,008.88 |
| Bad age + style | 422, 2 errors |
| No key | 401 |
async def only when everything inside awaitsTestClientThe Executive Underwriting Portal: move a rating factor, see premium, loss ratio, margin, and customer dislocation change at once.