Python for Actuarial Students · Course 4
Lesson 4

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 def and async def, and vectorize batch requests
75 min
Concepts & worked examples
75 min
Lab: a live quote service

Companion notebook: lesson-04-fastapi.ipynb — worked examples and self-checking exercises.

Why an APIOne rating engine, many callers

Web quote formone vehicle
Agents’ systemtheir software, not Python
PortalLesson 5
Batch jobsrenewals, 1,000 at a time

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

RequestMeans
GET /healthread: is the service up?
POST /quotescreate: price one vehicle, store the quote
POST /quotes/batchcreate many in one call
GET /quotes/42read quote 42
StatusMeans
200 OKhere is what you asked for
201 Createdstored; here it is
401 Unauthorizedno key, or the wrong one
404 Not Foundno such quote
422 Unprocessablethe request broke the contract
500 Server Errorour fault: a bug
Part 1

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 misspelt drivr_age is 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: QuoteRequest tells 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: ModelLiability or ManualLiability, 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"}
LIABManualModel
Base risk (Sedan, 45, 12,000 mi, non-urban)938.09726.73
22, Sports, 16,000 mi, urban2,521.594,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 of extra="forbid"
  • In a batch, one bad vehicle rejects the whole request and nothing is stored; the error’s loc says 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-Key header; the expected key comes from the environment or a secret file (Lesson 7), never the code
  • secrets.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 time
  • No key configured means no access, not open access
  • SecretStr prints 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.

The Swagger UI for the Motor Rating API version 1.0.0: operations GET /health, GET /model, GET /monitoring/drift; quotes POST /quotes, POST /quotes/batch, GET /quotes/{quote_id}
Part 2

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 sWall time
async def + await0.23 s
plain def (thread pool)0.23 s
async def + blocking call4.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

MeasureResult
One quote, round trip (median / p95)11.3 ms / 12.3 ms
… of which inside the service10.1 ms
Throughput, 1 client84 quotes/s
Throughput, 20 concurrent clients92 quotes/s
Batch of 1,000, one model call per vehicle6.44 s
Batch of 1,000, one model call in total0.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 key
  • TestClient calls 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

  1. QuoteRequest and QuoteResponse models with examples
  2. create_app() with a lifespan that loads the manual and the Course 3 model
  3. POST /quotes (201), GET /quotes/{id} (404 when unknown), GET /health
  4. An API-key dependency, key from an environment variable
  5. A vectorized POST /quotes/batch; test it matches single quotes
  6. Tests with TestClient for every status code
  7. Run it with uvicorn, open /docs, and quote from curl

Answer key

Base risk, model LIAB726.73
22, Sports, urban: LIAB4,648.59
… total7,008.88
Bad age + style422, 2 errors
No key401

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 def only when everything inside awaits
  • Batch endpoints make one model call
  • Tests exercise the whole app through TestClient
Up next
Lesson 5

Interactive Dashboards with Streamlit

The Executive Underwriting Portal: move a rating factor, see premium, loss ratio, margin, and customer dislocation change at once.