Actuarial APIs with FastAPI
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.
Lesson 4 · OverviewBy the end of this lesson you can…
- Explain why a rating engine is exposed as a service, and what HTTP gives you
- Build FastAPI endpoints with pydantic request and response models
- Return the right status codes: 201, 401, 404, 422
- Protect endpoints with an API key held as a secret
- Use the generated OpenAPI documentation and test with
TestClient - Choose between
defandasync def, and vectorize batch requests
Companion notebook: lesson-04-fastapi.ipynb — worked examples and self-checking exercises.
Why an APIOne rating engine, many callers
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.
- One implementation of the rates: no copies drifting apart in spreadsheets and other systems
- The model can be retrained and redeployed without changing any caller
- Every quote can be logged with the model version that priced it
The contract (what goes in, what comes out) is the part other teams depend on. Change the model freely; change the contract carefully.
HTTP in one slideMethods, paths, status codes
| 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 |
Building the Service
Contract first, then endpoints.
The contractRequest and response as pydantic models
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
- The same validation as Lesson 1’s
Vehicle, now guarding the front door of a service extra="forbid": a misspeltdrivr_ageis an error, not silently ignored- Descriptions and examples flow into the documentation
- The response says which model priced the quote: essential when a customer or regulator asks later
EndpointsA function per operation
app = 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
- The type hint
body: QuoteRequesttells FastAPI to parse and validate the JSON body Depends(...): things an endpoint needs (a database session, an API key check), built per request- The model loads once at start-up, checked against its manifest (Lesson 8A), not per request
- The liability algorithm is Lesson 1’s Strategy:
ModelLiabilityorManualLiability, chosen by configuration
A quoteWhat a caller sends and receives
$ 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).
ErrorsValidation you didn’t have to write
{"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"}
]}
- Every problem at once, each with its location: the caller can fix the form, not guess
- Record rules too:
["LIAB"]with a COLL deductible → “COLL deductible must be given if and only if COLL is bought” - An unknown field (
credit_score) → 422, because ofextra="forbid" - In a batch, one bad vehicle rejects the whole request and nothing is stored; the error’s
locsays which item:["body", 1, "driver_age"]
AuthenticationAn API key, kept as a secret
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")
- The key arrives in the
X-API-Keyheader; the expected key comes from the environment or a secret file (Lesson 7), never the code secrets.compare_digesttakes the same time whether the first or last character is wrong, so the key can’t be guessed one character at a time- No key configured means no access, not open access
SecretStrprints as**********in logs and tracebacks
API keys identify a calling system. Customer-facing logins need a proper identity provider (OAuth2 / OpenID Connect): FastAPI supports both.
DocumentationOpenAPI, generated from the code
/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.
Concurrency & Speed
Waiting is not working.
async / awaitWhat the event loop does
@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.
MeasuredThe service, containerized, with PostgreSQL behind it
| 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.
TestingThe whole service, no server needed
@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 keyTestClientcalls the app in-process: real routing, validation, dependencies, database- The capstone has 12 API tests: status codes, auth, validation, batch limits, transactions, the OpenAPI schema
Actuarial Lab · 75 minA live quote service
QuoteRequestandQuoteResponsemodels with examplescreate_app()with a lifespan that loads the manual and the Course 3 modelPOST /quotes(201),GET /quotes/{id}(404 when unknown),GET /health- An API-key dependency, key from an environment variable
- A vectorized
POST /quotes/batch; test it matches single quotes - Tests with
TestClientfor every status code - Run it with
uvicorn, open/docs, and quote fromcurl
Answer key
| 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 |
RecapLesson 4 checklist
- My API has explicit request and response models
- Invalid requests get 422 with the field and reason
- Writes need a key, compared in constant time
- Every quote records the model version that priced it
- The model loads once, at start-up
- I use
async defonly when everything inside awaits - Batch endpoints make one model call
- Tests exercise the whole app through
TestClient
Interactive Dashboards with Streamlit
The Executive Underwriting Portal: move a rating factor, see premium, loss ratio, margin, and customer dislocation change at once.