FastAPI - the First Endpoint
Context
A web service is a program that waits on a port for HTTP requests (the text messages a client such as curl or a browser sends to a server) and answers each one with a status, a few headers and a body. FastAPI turns a plain Python function into the answer to one such request, and Uvicorn is the server process that holds the port and hands every request to it.
Every model that another system uses sits behind an interface of this kind: a product page asking the price model for a quote, a nightly job posting a thousand listings, a dashboard polling whether the model is up. The endpoint is the contract those systems code against, its path, the methods it accepts, the status it returns and the shape of its body, and it holds whatever language the caller is written in.
Lesson 6.2.2 registered the price pipeline and loaded it back by its alias, and only a Python process that imports mlflow can reach it. Before a model goes behind a network address, the address itself has to work and be understood: a request to the wrong path, with the wrong method, or to a server that kept its old code, comes back as a status the client must read correctly, and a reader who takes a 405 for a dead server or a passing test for a live one debugs the wrong layer.
Goal
- Declare FastAPI and Uvicorn as the project's own pinned dependencies, and httpx as a dev dependency, and read from
uv tree --invertwhy an importable package is not yet a declared one. - Build
src/listings/app.py, a FastAPI app with one route,GET /health, and list every route the app serves. - Serve the app with
uv run uvicorn listings.app:app, read its startup and access log, and diagnose a wrong import string, a taken port and a server still running old code. - Read a raw response with
curl -i, and distinguish a200, a404, a405with itsallowheader and a307redirect. - Open the generated
/docspage and the/openapi.jsonschema behind it, and say which of its facts come from your code and which from FastAPI's defaults. - Test the app in-process with
TestClient, catch a renamed route as a failing test, and commit the lesson's four files.
Prerequisites
Neither is required, and the lesson stands on its own. Both are here if you want to watch a first FastAPI app built before you build yours, or read the framework's own first page.
- YouTube - "Python FastAPI Tutorial: Build a REST API in 15 Minutes" by pixegami. The chapters "Install and Get Started with FastAPI", "GET and POST Routes" and "Interactive Documentation" cover this lesson's ground, a first app served by Uvicorn, a route per method and the generated docs page.
- FastAPI documentation - "First Steps". A minimal app, the
/docs,/redocand/openapi.jsonpages, and what a path, an operation and the decorator above a path operation function are. The page starts its server withfastapi dev, a command from FastAPI'sstandardextra that this project does not install, where this lesson calls Uvicorn directly.
Table of Contents
- Declaring FastAPI and Uvicorn
- The App and Its First Route
- Serving With Uvicorn
- Reading Responses With curl -i
- The Generated Docs at /docs
- Testing With TestClient
Theory
Every command runs in ~/dev/listings-service as Lesson 6.2.2 left it, with (bootcamp) deactivated as Module 6's setup page says. The server runs in the foreground of one terminal, called Terminal 1 below, and every curl runs in a second, Terminal 2, both opened in that folder. A PID, a client port, a timing and a date header differ on your machine and every run, and every other line matches.
Declaring FastAPI and Uvicorn
What. FastAPI is a web framework (a library that maps each incoming request to the Python function that answers it), and Uvicorn is an ASGI server (the program that holds a port, reads raw HTTP off it and calls the app, ASGI being the standard interface between a Python web server and a Python web app). The project declares both in pyproject.toml with exact pins, the way Lesson 6.1.1 declared pandas.
Why. Both packages are already in the environment, pulled in by MLflow, which needs them for reasons of its own, and code that imports a package borrowed that way breaks the day the project drops the lender or a release of it stops requiring the package. The service is built on FastAPI and Uvicorn, so the project declares them rather than borrow them from a library it may not keep.
When. Before the first file that imports fastapi, and every time code starts importing a package that arrived only through a dependency the project may not keep.
Add both, pinned:
uv add fastapi==0.150.0 uvicorn==0.54.0Resolved 96 packages in 159ms
Building listings @ file:///home/goku/dev/listings-service
Built listings @ file:///home/goku/dev/listings-service
Prepared 1 package in 27ms
Uninstalled 1 package in 0.23ms
Installed 1 package in 0.78ms
~ listings==0.1.0 (from file:///home/goku/dev/listings-service)The lock resolved the same 96 packages as before and the only thing reinstalled is the project itself, ~ listings. No FastAPI and no Uvicorn were downloaded, because both were there already. uv tree --invert prints a package's dependents instead of its dependencies, so it answers who needs a package:
uv tree --invert --package fastapi --package uvicornResolved 96 packages in 1ms
fastapi v0.150.0
├── listings v0.1.0
└── mlflow-skinny v3.17.1
└── mlflow v3.17.1
└── listings v0.1.0
uvicorn v0.54.0
├── listings v0.1.0
└── mlflow-skinny v3.17.1 (*)
(*) Package tree already displayedBefore the uv add, the same command printed only the lower branch, fastapi under mlflow-skinny under mlflow under listings, and import fastapi already worked, fastapi.__version__ reading 0.150.0. mlflow-skinny is the slimmer core of MLflow that the full mlflow package depends on, and it lists fastapi<1 and uvicorn<1 among its own requirements. The top branch, listings directly, is what the uv add changed. The diff is two lines in one file and four in the other:
git diff --stat pyproject.toml | 2 ++
uv.lock | 4 ++++
2 files changed, 6 insertions(+)pyproject.toml gains "fastapi==0.150.0" and "uvicorn==0.54.0" in dependencies, sorted among the four already there, and uv.lock gains the same two names in the listings entry's dependencies and requires-dist. Every package's own entry in the lock was already present, with its version and its hashes.
DON'T:
- DON'T take "it imports" for "it is declared".
import fastapiworked before theuv add, throughmlflow-skinny, and a project that dropsmlflowwithout declaring FastAPI loses it with MLflow. - DON'T expect
uv addto download a package that is already in the lock. It resolved the same 96 packages and reinstalled onlylistings.
The App and Its First Route
What. A FastAPI app is one object, FastAPI(...), and a route is a function registered on it by a decorator (a line starting with @ above a def, which hands the function to another function the moment it is defined). @app.get("/health") registers health as the answer to an HTTP GET request (the method that asks to read something) for the path /health, and the dictionary the function returns goes back to the client as JSON, Lesson 1.5.3's text format for nested data.
Why. A health route answers the first question any caller, monitor or container runtime asks of a service, whether it is up, and it answers it without touching the model, so the plumbing is proved before anything that can fail for a model's reasons is put behind it.
When. Every service gets one before its first real route, and every route afterwards follows its shape: a decorator naming the method and the path, and a function returning the body.
Write src/listings/app.py:
"""The price service: a FastAPI app."""
from fastapi import FastAPI
app = FastAPI(title="listings-service")
@app.get("/health")
def health():
return {"status": "ok"}The decorator registers the function and gives it back unchanged, so health is still an ordinary function a script can call, and app is still an ordinary object a script can inspect. Save this as ~/dev/listings-probes/routes.py, Lesson 6.1.3's probe folder, and run it from the project folder with uv run python ../listings-probes/routes.py:
from listings.app import app, health
print(type(app).__name__, health())
for route in app.routes:
print(route.path, sorted(route.methods), route.name)FastAPI {'status': 'ok'}
/openapi.json ['GET', 'HEAD'] openapi
/docs ['GET', 'HEAD'] swagger_ui_html
/docs/oauth2-redirect ['GET', 'HEAD'] swagger_ui_redirect
/redoc ['GET', 'HEAD'] redoc_html
/health ['GET'] healthhealth() called directly returns a Python dictionary, quotes and all, and nothing in it knows about HTTP. The app serves five routes where the file wrote one: FastAPI added four of its own when FastAPI(...) ran, the docs pages of the fifth subtitle, and /health came last because the decorator ran last. Each route records the methods it accepts, and the two kinds differ: FastAPI's own pages take GET and HEAD (the method that asks for a GET's headers without its body), while the route @app.get registered takes GET alone.
DON'T:
- DON'T read the decorator as running the function.
@app.get("/health")registershealthat import, and the function runs once per request, never at import. - DON'T assume a route answers every method because it answers
GET.app.routeslists/healthwith['GET']only.
Serving With Uvicorn
What. uv run uvicorn listings.app:app starts Uvicorn inside the project's environment, imports the module named left of the colon, takes the attribute named right of it as the app, binds 127.0.0.1 on port 8000, its two defaults, and serves until stopped. Each request it answers adds one access-log line (a line per request: client, method, path, status).
Why. The app object can answer a request but cannot listen for one. Holding a port, parsing HTTP and calling the app once per request is the server's job, which keeps the app free of networking code and lets the same app run under a test client with no port at all.
When. Every time the service is run on its own, in a terminal now and inside a container later, with the same import string.
In Terminal 1:
uv run uvicorn listings.app:appINFO: Started server process [121662]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)The prompt does not come back, because the server holds the terminal in the foreground, Lesson 0.2.3's foreground process. ss -tlnp, Lesson 0.2.3's view of every listening port, finds it from Terminal 2:
ss -tlnp | grep 8000LISTEN 0 2048 127.0.0.1:8000 0.0.0.0:* users:(("python",pid=121662,fd=8))The listener is 127.0.0.1:8000, this machine only, where Lesson 0.2.3's http.server listened on 0.0.0.0, every address the machine has. The process is named python, because the uvicorn command is a small Python script in .venv/bin, and its PID is the one the startup line printed. Now ask it once, from Terminal 2:
curl http://127.0.0.1:8000/health{"status":"ok"}Terminal 1 adds one line:
INFO: 127.0.0.1:52406 - "GET /health HTTP/1.1" 200 OKThe body is the dictionary as compact JSON, double quotes and no spaces, 15 characters. The access line names the client's address and the port curl sent from, which changes every request, then the request line and the status. curl prints no newline after a body that carries none, so your prompt lands right after {"status":"ok"} on the same line.
The import string fails three ways, each with exit status 3 and the server never listening:
uv run uvicorn listings.app # no attribute after a colon
uv run uvicorn listings.app:api # an attribute that is not there
uv run uvicorn listings.ap:app # a module that is not thereERROR: Error loading ASGI app. Import string "listings.app" must be in format "<module>:<attribute>".
ERROR: Error loading ASGI app. Attribute "api" not found in module "listings.app".
ERROR: Error loading ASGI app. Could not import module "listings.ap".From outside the project, uv run finds no project and no uvicorn to run, and says so in its own words: from a folder such as your home folder, the same command prints error: Failed to spawn: uvicorn and cause: No such file or directory (os error 2).
A port holds one server at a time, Lesson 0.2.3's rule, and Uvicorn reports it in its own order. With the first server still running, a second uv run uvicorn listings.app:app in Terminal 2 prints:
INFO: Started server process [124881]
INFO: Waiting for application startup.
INFO: Application startup complete.
ERROR: [Errno 98] error while attempting to bind on address ('127.0.0.1', 8000): [errno 98] address already in use
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.The second server imported the app and ran its startup before it tried the port, and only then failed and shut down, exit status 3. Startup runs first, so whatever an app does at startup runs again in a server that is about to fail to bind.
A running server keeps the code it imported. With the server up, change the return line of health to {"status": "ready"} and save, then ask again:
curl http://127.0.0.1:8000/health{"status":"ok"}The file says ready and the server says ok, because Uvicorn imported listings.app once at startup and nothing re-reads the file. Put the line back to "ok", and restart the server after every edit to the app: Ctrl+C in Terminal 1, then the same command again. Ctrl+C prints its own ^C before the shutdown lines:
^CINFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [121662]Start it again with uv run uvicorn listings.app:app before the next subtitle, which talks to it.
DON'T:
- DON'T point the import string at the route function.
uv run uvicorn listings.app:healthstarts, printsWARNING: ASGI app factory detected. Using it, but please consider setting the --factory flag explicitly., then answersGET /healthwith500 Internal Server ErrorandTypeError: 'dict' object is not callablein its log: Uvicorn took the function for a function that builds the app, called it and got the dictionary. - DON'T trust a response from a server started before your last edit. The file said
readyand the running server answeredok.
Reading Responses With curl -i
What. curl -i prints the response's status line and headers above its body. The status line carries the HTTP version, the status code and its reason phrase, each header is a name: value line, and an empty line separates the headers from the body. -X POST sends the request with the method POST (the method that sends data to be processed) in place of GET.
Why. The body is what a route returned, and the status and headers are what the server decided about the request. Lesson 0.2.3's curl -v showed both directions of the exchange, and -i shows only the answer, which is the part a client's code branches on.
When. Every time a client gets a body it did not expect, or none, and before any code is written against a new endpoint.
The route as written, in Terminal 2:
curl -i http://127.0.0.1:8000/healthHTTP/1.1 200 OK
date: Tue, 03 Nov 2026 12:21:33 GMT
server: uvicorn
content-length: 15
content-type: application/json
{"status":"ok"}200 OK is success, content-length: 15 is the body's 15 bytes, and content-type: application/json is FastAPI's statement that the body is JSON, which is what tells a client to parse it rather than show it. The date and server headers are Uvicorn's. A path the app never registered:
curl -i http://127.0.0.1:8000/priceHTTP/1.1 404 Not Found
date: Tue, 03 Nov 2026 12:21:33 GMT
server: uvicorn
content-length: 22
content-type: application/json
{"detail":"Not Found"}404 is Lesson 0.2.3's status, written by FastAPI instead of http.server, and FastAPI sends a JSON body with it, a detail key holding the reason. The root path / answers the same 404 with the same body, because the app registered nothing at /. The right path with a method the route does not take:
curl -i -X POST http://127.0.0.1:8000/healthHTTP/1.1 405 Method Not Allowed
date: Tue, 03 Nov 2026 12:21:33 GMT
server: uvicorn
allow: GET
content-length: 31
content-type: application/json
{"detail":"Method Not Allowed"}405 says the path exists and the method is refused, and the extra header, allow: GET, lists the methods the path does take. A 404 is about the path and a 405 about the method, and that difference is the first thing to read when a client calls an endpoint the docs say exists. The same path with a trailing slash:
curl -i http://127.0.0.1:8000/health/HTTP/1.1 307 Temporary Redirect
date: Tue, 03 Nov 2026 12:21:33 GMT
server: uvicorn
content-length: 0
location: http://127.0.0.1:8000/health307 is a redirect (a response that tells the client to ask again at another address), with that address in the location header and an empty body. FastAPI sends it for a path that differs from a registered one only by its trailing slash. curl does not follow a redirect unless asked, so it prints the 307 and stops.
Terminal 1 logged all four:
INFO: 127.0.0.1:52406 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:52410 - "GET /price HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:52422 - "POST /health HTTP/1.1" 405 Method Not Allowed
INFO: 127.0.0.1:52428 - "GET /health/ HTTP/1.1" 307 Temporary RedirectEvery status, the 404 and the 405 included, is a request that reached the server and was answered, Lesson 0.2.3's point about the 404. A server that is not there gives no status at all. With Terminal 1's server stopped by Ctrl+C:
curl -i http://127.0.0.1:8000/health
echo $?curl: (7) Failed to connect to 127.0.0.1 port 8000 after 0 ms: Could not connect to server
7No status line, no headers, and curl's own exit status 7, which curl uses for a connection that failed. Start the server again before the next subtitle.
DON'T:
- DON'T read a
405as a missing endpoint. The path exists, andallow: GETnames the method it takes. - DON'T read any status code as a server that is down. A status came from a running server, and a stopped one gave
curl: (7)with no status line at all.
The Generated Docs at /docs
What. FastAPI describes the app in an OpenAPI schema (a standard JSON description of an HTTP API: its paths, its methods, what each accepts and returns) served at /openapi.json, and serves /docs, a Swagger UI page (an interactive page that reads such a schema, lists every route and sends requests from the browser), built from it. /redoc is a second, read-only page over the same schema.
Why. The schema is generated from the routes, so it cannot drift from the code the way a hand-written document does, and a teammate or a client team reads the service's contract without reading its source.
When. Every time you hand the service to someone else, and every time you want to know what the running server, rather than the file, thinks it serves.
The schema first:
curl http://127.0.0.1:8000/openapi.json{"openapi":"3.1.0","info":{"title":"listings-service","version":"0.1.0"},"paths":{"/health":{"get":{"summary":"Health","operationId":"health_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}}}}paths holds /health alone: the four routes FastAPI added for itself serve the documentation and are not part of what it documents. title is the title= passed to FastAPI(...), and summary is the function's name, health, capitalized. version is 0.1.0 because that is the default of FastAPI's own version= argument, and it matches the project's version = "0.1.0" in pyproject.toml by coincidence only. The empty "schema":{} says the route returns JSON of an undeclared shape, because health declares nothing about what it returns.
Open http://127.0.0.1:8000/docs in a browser. The page shows the title listings-service with 0.1.0 and OAS 3.1 beside it, and one route, GET /health, summarized Health. Clicking the route, then Try it out and Execute, sends a real request from the browser, and the page shows the code 200, the body {"status": "ok"} and the four headers curl -i printed. Terminal 1 logs GET /docs, then GET /openapi.json, the page fetching the schema, then the GET /health the button sent. The HTML itself is small, and it is not the whole page:
curl -s http://127.0.0.1:8000/docs | grep -o 'https://[^"]*'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css
https://fastapi.tiangolo.com/img/favicon.png
https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.jsThe 1,015 bytes FastAPI serves at /docs load Swagger UI's stylesheet and script from cdn.jsdelivr.net, a CDN (a public network of servers that hands out shared files), and that script then fetches /openapi.json from your server. A browser that cannot reach cdn.jsdelivr.net gets the HTML and an empty page, and never asks for /openapi.json at all, since nothing is left to ask for it, while curl still reads the schema, the one part your server builds.
DON'T:
- DON'T read the schema's
versionas the project's version."version":"0.1.0"isFastAPI's default, and changingpyproject.tomlwould not move it. - DON'T debug a blank
/docspage in the app before checking the browser can reachcdn.jsdelivr.net. The page's script and stylesheet come from there, andcurl http://127.0.0.1:8000/openapi.jsonshows whether the server's own half answers.
Testing With TestClient
What. fastapi.testclient.TestClient(app) sends requests to the app object inside the test process, with no server, no port and no network, and returns responses that carry status_code, headers, text and json(). It is built on httpx (an HTTP client library for Python), which the project adds to the same dev group Lesson 6.1.2 put pytest in.
Why. curl checks one server by hand and leaves no record. A test pins the contract, the path, the method and the body, so a renamed route or a changed body fails uv run pytest before any client meets it, and it runs in milliseconds with nothing to start or stop.
When. For every route, written next to the route, and run with the rest of the suite before every commit.
Write the tests first, as tests/test_app.py:
from fastapi.testclient import TestClient
from listings.app import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_unknown_route_is_404():
assert client.get("/price").status_code == 404response.json() parses the body back into a dictionary, so the test compares Python values, not the 15 bytes of text. Run the suite before adding httpx:
uv run pytest============================= test session starts ==============================
platform linux -- Python 3.12.8, pytest-9.1.1, pluggy-1.7.0
rootdir: /home/goku/dev/listings-service
configfile: pyproject.toml
testpaths: tests
plugins: anyio-4.15.1
collected 18 items / 1 error
==================================== ERRORS ====================================
______________________ ERROR collecting tests/test_app.py ______________________
tests/test_app.py:1: in <module>
from fastapi.testclient import TestClient
.venv/lib/python3.12/site-packages/fastapi/testclient.py:1: in <module>
from starlette.testclient import TestClient as TestClient # noqa
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.12/site-packages/starlette/testclient.py:34: in <module>
raise RuntimeError(
E RuntimeError: The starlette.testclient module requires the httpx2 package to be installed.
E You can install this with:
E $ pip install httpx2
=========================== short test summary info ============================
ERROR tests/test_app.py - RuntimeError: The starlette.testclient module requi...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 1.64s ===============================The error is raised by the import, before any test runs, and it stops the whole session: the 18 tests that would have passed did not run either. FastAPI's TestClient is Starlette's (the toolkit FastAPI is built on, FastAPI being a subclass of its Starlette class), and Starlette 1.9.0 needs an HTTP client to build it on. Its message names httpx2, a separate and newer HTTP client, and the package it falls back to is httpx, which this project pins. The pip install the message suggests is not this project's way: a test-only package goes into the dev group with uv add --dev:
uv add --dev httpx==0.28.1Resolved 98 packages in 490ms
Building listings @ file:///home/goku/dev/listings-service
Built listings @ file:///home/goku/dev/listings-service
Prepared 1 package in 24ms
Uninstalled 1 package in 4ms
Installed 3 packages in 2ms
+ httpcore==1.0.9
+ httpx==0.28.1
~ listings==0.1.0 (from file:///home/goku/dev/listings-service)This one was new to the environment: httpx and httpcore, the lower-level library it sends requests through, are the two packages the lock gained. Run the suite again:
uv run pytest============================= test session starts ==============================
platform linux -- Python 3.12.8, pytest-9.1.1, pluggy-1.7.0
rootdir: /home/goku/dev/listings-service
configfile: pyproject.toml
testpaths: tests
plugins: anyio-4.15.1
collected 20 items
tests/test_app.py .. [ 10%]
tests/test_data.py .... [ 30%]
tests/test_features.py ..... [ 55%]
tests/test_model.py ......... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.12/site-packages/fastapi/testclient.py:1
/home/goku/dev/listings-service/.venv/lib/python3.12/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
======================== 20 passed, 1 warning in 1.66s =========================Twenty tests pass, the two new ones and the eighteen the project held at the end of Lesson 6.2.2. The one warning is Starlette's, raised from its own testclient.py the moment FastAPI's testclient.py imports it: Starlette 1.9.0 still builds TestClient on httpx and says it would rather have httpx2. It is a notice about the library choice, not about the app, and it appears once per run whatever the number of tests.
Nothing in the run started a server. TestClient addresses the app as http://testserver, a placeholder host name, and the request goes from the test straight into the app object's code. That also means one difference from curl: TestClient follows a redirect by itself, as a browser does. Saved as ~/dev/listings-probes/redirect.py and run with uv run python ../listings-probes/redirect.py:
from fastapi.testclient import TestClient
from listings.app import app
client = TestClient(app)
response = client.get("/health/")
print(response.status_code, response.url)
response = client.get("/health/", follow_redirects=False)
print(response.status_code, response.headers["location"])200 http://testserver/health
307 http://testserver/healthBelow Starlette's deprecation warning, which any script that imports TestClient prints first, the trailing-slash request comes back 200 from /health, the redirect followed and gone from the status, and only follow_redirects=False shows the 307 that curl -i printed.
The test earns its place the first time the contract changes by accident. Rename the route to @app.get("/healthz") and run only the app's tests:
uv run pytest tests/test_app.py=================================== FAILURES ===================================
_________________________________ test_health __________________________________
def test_health():
response = client.get("/health")
> assert response.status_code == 200
E assert 404 == 200
E + where 404 = <Response [404 Not Found]>.status_code
tests/test_app.py:10: AssertionErrorand, under the warning summary, FAILED tests/test_app.py::test_health - assert 404 == 200 and 1 failed, 1 passed, 1 warning. The rename is a 404 for every client still calling /health, and the test met it first. Put the path back to "/health", check that uv run pytest prints 20 passed, 1 warning again, and commit the lesson's four files:
git add pyproject.toml uv.lock src/listings/app.py tests/test_app.py
git commit -m "feat: a FastAPI app with a health endpoint"[main 0f84e73] feat: a FastAPI app with a health endpoint
4 files changed, 65 insertions(+), 1 deletion(-)
create mode 100644 src/listings/app.py
create mode 100644 tests/test_app.pyYour commit's hash differs. git status --short prints nothing after it.
DON'T:
- DON'T install a test-only package with
pip install, or into the main dependencies.uv add --devputs httpx in the dev group, anduv sync --no-devleaves out exactly that group, uninstalling httpx, httpcore, pytest and pytest's three own dependencies, six packages, whereuv syncputs them back. - DON'T read a passing
TestClienttest as proof that a server is listening. Every test here ran with no server and no port, andcurlagainst a stopped server still printedcurl: (7). - DON'T assert on a redirect without
follow_redirects=False.TestClientfollowed the307and returned the200behind it.
Lab Exercises
Both labs and the Assignment write their files in ~/dev/listings-probes, beside the project, and run them from the project folder, so the commit above stays the project's state. uv run pytest with no path collects only tests/, by the testpaths Lesson 6.1.2 set, so a probe's test file runs only when named.
Lab Exercise 1: A Second App on a Second Port
Leave the service running in Terminal 1. In ~/dev/listings-probes/lab_columns.py, build a second FastAPI app titled listings-columns with one route, GET /columns, returning the project's column groups, NUMERIC, NOMINAL and LEVELS from listings.data, under the keys numeric, nominal and condition. Serve it in Terminal 2 on port 8001, from the project folder, with Uvicorn's --app-dir ../listings-probes, which adds that folder to the import path, so lab_columns:app resolves there. From a third terminal ask the lab server for /columns, the service for /health, and the lab server for /health with curl, the last one with -i, then show both listeners with ss -tlnp. Stop the lab server, and try to start it on port 8000. Say in a comment why the lab app answers /health with 404, and what the failed start ran before it failed.
Lab Exercise 2: Pinning the Contract
With no server running, write ~/dev/listings-probes/lab_test_contract.py, four TestClient tests of listings.app that pin what curl -i showed: a POST to /health is a 405 whose allow header is GET and whose body is {"detail": "Method Not Allowed"}, /health/ is a 307 whose location is http://testserver/health, the schema at /openapi.json lists exactly one path, /health, under the title listings-service, and /docs answers 200 with a content-type starting text/html. Run the file with uv run pytest ../listings-probes/lab_test_contract.py -v, then run it once more with follow_redirects=False removed and say in a comment what changed.
Solution 1: A Second App on a Second Port
~/dev/listings-probes/lab_columns.py:
from fastapi import FastAPI
from listings.data import LEVELS, NOMINAL, NUMERIC
app = FastAPI(title="listings-columns")
@app.get("/columns")
def columns():
return {"numeric": NUMERIC, "nominal": NOMINAL, "condition": LEVELS}In Terminal 2, then in a third terminal:
uv run uvicorn --app-dir ../listings-probes lab_columns:app --port 8001curl http://127.0.0.1:8001/columns
curl http://127.0.0.1:8000/health
curl -i http://127.0.0.1:8001/health
ss -tlnp | grep -E ':8000|:8001'{"numeric":["area_sqm","year_built","distance_km","rooms","bathrooms"],"nominal":["neighborhood","property_type","energy_rating"],"condition":["poor","fair","good","excellent"]}
{"status":"ok"}
HTTP/1.1 404 Not Found
date: Tue, 03 Nov 2026 12:25:33 GMT
server: uvicorn
content-length: 22
content-type: application/json
{"detail":"Not Found"}
LISTEN 0 2048 127.0.0.1:8001 0.0.0.0:* users:(("python",pid=127330,fd=8))
LISTEN 0 2048 127.0.0.1:8000 0.0.0.0:* users:(("python",pid=124850,fd=8))The two curl lines without -i print their bodies with no newline after them, so in a terminal the next output starts on the same line. Ctrl+C in Terminal 2, then:
# Port 8000 still belongs to the service in Terminal 1.
uv run uvicorn --app-dir ../listings-probes lab_columns:app --port 8000INFO: Started server process [127532]
INFO: Waiting for application startup.
INFO: Application startup complete.
ERROR: [Errno 98] error while attempting to bind on address ('127.0.0.1', 8000): [errno 98] address already in use
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.# Each app answers only the routes registered on it: lab_columns.py never
# registered /health, so port 8001 answers it 404 while 8000 answers ok.
# The failed start imported lab_columns, which imported listings.data,
# and ran the app's startup before the bind on 8000 was refused.The --port option moves Uvicorn off its default and changes nothing else, so two processes, two PIDs and two ports serve two different apps from one project, each started with its own import string. lab_columns:app resolves because --app-dir put ../listings-probes on the import path, and without it Uvicorn, which imports from the folder it was started in, stops with Could not import module "lab_columns" and exit status 3. listings.data resolves because the project is installed in its own environment.
DON'T:
- DON'T expect two apps in one project to share routes.
/healthlives onlistings.app's object, andlab_columns's app answered it404. - DON'T read
Application startup complete.as a server that is serving. The start on port8000printed it and then failed to bind.
Solution 2: Pinning the Contract
~/dev/listings-probes/lab_test_contract.py:
from fastapi.testclient import TestClient
from listings.app import app
client = TestClient(app)
def test_health_refuses_post():
response = client.post("/health")
assert response.status_code == 405
assert response.headers["allow"] == "GET"
assert response.json() == {"detail": "Method Not Allowed"}
def test_trailing_slash_redirects():
response = client.get("/health/", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "http://testserver/health"
def test_schema_lists_only_health():
schema = client.get("/openapi.json").json()
assert list(schema["paths"]) == ["/health"]
assert schema["info"]["title"] == "listings-service"
def test_docs_page_is_html():
response = client.get("/docs")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")uv run pytest ../listings-probes/lab_test_contract.py -v../listings-probes/lab_test_contract.py::test_health_refuses_post PASSED [ 25%]
../listings-probes/lab_test_contract.py::test_trailing_slash_redirects PASSED [ 50%]
../listings-probes/lab_test_contract.py::test_schema_lists_only_health PASSED [ 75%]
../listings-probes/lab_test_contract.py::test_docs_page_is_html PASSED [100%]and 4 passed, 1 warning, the warning Starlette's. The header reads rootdir: /home/goku/dev and no configfile, because pytest looks for its configuration in the given path's folder and the folders above it, and none of ~/dev/listings-probes, ~/dev or above holds a pyproject.toml, so the project's [tool.pytest] table does not apply to this run, and the rootdir falls back to the folder the project and the probe folder share. With follow_redirects=False removed, the second test fails:
E assert 200 == 307
E + where 200 = <Response [200 OK]>.status_code
FAILED ../listings-probes/lab_test_contract.py::test_trailing_slash_redirects
==================== 1 failed, 3 passed, 1 warning in 0.56s ====================The FAILED line has no room left on an 80-column terminal for the - assert 200 == 307 that follows a shorter node id, so pytest drops it.
# Without follow_redirects=False the client followed the 307 to /health
# and the test read the 200 at the end of it, not the redirect itself.Headers come back as a dictionary-like object keyed by name, so response.headers["allow"] reads the header curl -i printed as allow: GET. The schema test pins the path list exactly, so a route added later without a test changes the schema and fails this lab's test as well. The location names testserver, the address TestClient gave itself, where curl against the real server read 127.0.0.1:8000.
DON'T:
- DON'T compare a JSON body by its text.
response.json() == {"detail": "Method Not Allowed"}holds whatever the spacing, and{"detail": "Method Not Allowed"}written as a string is not the 31 bytes the server sends. - DON'T copy a
locationfrom acurlrun into aTestClienttest.curlreadhttp://127.0.0.1:8000/healthand the test client readshttp://testserver/health.
Theory Assessment
Q1: Importable, Not Declared
Before this lesson's uv add, uv run python -c "import fastapi" worked and uv tree --invert --package fastapi showed FastAPI under mlflow-skinny under mlflow. Say why the project still declared FastAPI and Uvicorn, what the uv add changed in the environment and in the two files, and why httpx went into the dev group where FastAPI did not.
Hint: Read what the uv add output installed beside what the inverted tree gained, then ask which code imports httpx and where that code runs.
Q2: The Server That Kept Saying ok
With the service running in Terminal 1, a teammate changes health to return {"status": "ready"}, saves, and gets {"status":"ok"} from curl. To pick up the change, they start a second uv run uvicorn listings.app:app in Terminal 2 without stopping the first, see Application startup complete., and curl again: still ok. Say what each server did, why the curl answer never changed, and the one sequence that shows ready.
Hint: Ask when Uvicorn reads app.py, and which of the two processes holds port 8000 after the second one's startup.
Q3: Four Answers From One Server
curl -i against the service printed 200 OK, 404 Not Found, 405 Method Not Allowed with allow: GET, and 307 Temporary Redirect with a location header, for four requests. Say which request produced each, what each status says about the path and the method, what the two extra headers are for, and what all four prove about the server that curl: (7) would not.
Hint: Two of the four are about the path and two are about something else. Look at what changed between the requests, not between the answers.
Q4: A Monitor That Calls the Service Down
A teammate points an uptime monitor at the service. The monitor checks with HEAD requests, and curl -I, which sends one, prints HTTP/1.1 405 Method Not Allowed for /health and HTTP/1.1 200 OK for /docs. The teammate concludes the service is broken, notes that test_health still passes, and argues the tests are wrong. Say what the two answers show, whether the service and the tests are right, and two ways to make the monitor read 200. FastAPI's @app.api_route("/health", methods=["GET", "HEAD"]) registers one function for several methods, where @app.get registers it for GET alone.
Hint: Go back to the methods app.routes printed for each of the five routes, and to what a 405 says about the path.
Answer 1:
import fastapi worked only because mlflow depends on mlflow-skinny, which lists both among its own requirements, so the project was borrowing both from a package that needs them for reasons of its own. A borrowed dependency disappears the day mlflow is removed or a release of it stops requiring them, and the service's import fastapi breaks although nothing in app.py changed. Declaring both puts them in the project's own list, where no change to MLflow can take them away. The uv add changed nothing installed: it resolved the same 96 packages and reinstalled only listings, and the inverted tree gained the top branch, listings depending on FastAPI and Uvicorn directly. pyproject.toml gained the two pins, and uv.lock the two names in the listings entry, four lines, since each package's own entry was already there. httpx is imported only by TestClient, so only the tests need it, and the dev group is installed by uv sync and left out by uv sync --no-dev, where FastAPI is needed by the service itself wherever it runs.
DON'T read an unchanged environment as an unchanged project. The uv add installed nothing and still changed what the project promises, in two files.
Answer 2:
The first server imported listings.app once, when it started, so the edit on disk never reached it, and it kept answering ok. The second server imported the edited file, so its app would have answered ready, ran its startup, printed Application startup complete., then failed to bind port 8000 with [Errno 98] ... address already in use, because the first server still held it, and shut down. Every curl went to the only process listening, the first one, which still ran the code from before the edit. The sequence that shows ready is Ctrl+C in Terminal 1, the same uv run uvicorn listings.app:app there again, and then curl. The second server's startup line looked like a server ready to serve, and it was the line before its failure.
DON'T start a second server to pick up a change. Stop the one holding the port, and start it again.
Answer 3:
GET /health produced the 200: the path is registered and the method is the one it takes. GET /price produced the 404: no such path, whatever the method. POST /health produced the 405: the path exists and refuses the method, and its allow: GET header tells the client which method it would take. GET /health/ produced the 307: the path differs from a registered one only by its trailing slash, and the location header gives the address to ask instead, http://127.0.0.1:8000/health. The 404 and the 307 are about the path, the 405 about the method, and the 200 about both being right. Every one of them came back with a status line, headers and a body or an empty body, so a running server read each request and answered it, and its access log has a line for each, where curl: (7) means no server accepted the connection and nothing was answered at all.
DON'T fix a 405 by looking for a missing route. The route is there, and the allow header names the method to use.
Answer 4:
app.routes printed ['GET'] for /health and ['GET', 'HEAD'] for FastAPI's own pages, so the two HEAD answers are exactly what the app registered: /docs takes HEAD, and /health, registered by @app.get, does not. A 405 comes from a running server that found the path and refused the method, so it proves the service is up, the reverse of the teammate's conclusion. The tests are right too: test_health sends a GET, which is what the route takes, and nothing in the suite claims HEAD works. The monitor is reading a method the service never offered. One fix is on the monitor's side, a GET check on /health. The other is on the service's, registering both methods:
@app.api_route("/health", methods=["GET", "HEAD"])
def health():
return {"status": "ok"}On that route a HEAD request returns 200 with an empty body and content-length: 15, the length of the body a GET would get, a GET still returns {"status": "ok"}, and a POST is refused with an allow header naming both methods, HEAD, GET on one start of the app and GET, HEAD on another, because FastAPI keeps a route's methods in a set. A test for the new method, client.head("/health").status_code == 200, goes in with it, so the contract the monitor relies on is pinned like the rest.
DON'T read a 405 from a monitor as an outage. Read its allow header and the method the monitor sent before touching the service.
Assignment
Assignment: A Statistics Endpoint Over the Training Data
The team building the listing form wants two facts from the training data without reading listings.csv: how many listings there are with the median and the highest price, and, for any one numeric feature, its median and how many rows leave it blank. Write ~/dev/listings-probes/assign_stats.py, a FastAPI app titled listings-stats that loads the data once, at import, with listings.data.load, and serves GET /stats as {"listings": ..., "median_price": ..., "max_price": ...} and GET /stats/{column} as {"column": ..., "median": ..., "missing": ...}.
Two things this lesson did not use. A path can hold a parameter: @app.get("/stats/{column}") above def column_stats(column: str): matches /stats/area_sqm, /stats/rooms and any other single segment, and FastAPI passes the segment to the function as column. And a route can answer with a status of its own choosing by raising fastapi.HTTPException(status_code=404, detail="..."), which FastAPI turns into that status with {"detail": "..."} as the body. /stats/{column} answers only for the five columns of NUMERIC, and every other name, price included, is a 404 whose detail reads no numeric column 'garage' for garage.
Write the first version with the pandas values returned as they come out of pandas, serve it from the project folder on port 8001 with Lab Exercise 1's --app-dir ../listings-probes, and ask it for /stats, /stats/area_sqm, /stats/garage and /stats/price with curl -i. Then write ~/dev/listings-probes/assign_test_stats.py, four TestClient tests: 3,000 listings with a median below the maximum, 125 blank area_sqm values, the garage detail, and price refused. Read what the first version does to the server and to the tests, fix it, and run the tests and the four requests again.
Hints:
- Print
type(listings["price"].max())in a scratch script before reading the server log. - The fix belongs in the return lines, and
TestClientraises the server's exception into the test wherecurlsees only a status.
Solution:
The first version:
from fastapi import FastAPI, HTTPException
from listings.data import NUMERIC, load
listings = load()
app = FastAPI(title="listings-stats")
@app.get("/stats")
def stats():
return {
"listings": len(listings),
"median_price": listings["price"].median(),
"max_price": listings["price"].max(),
}
@app.get("/stats/{column}")
def column_stats(column: str):
if column not in NUMERIC:
raise HTTPException(status_code=404, detail=f"no numeric column {column!r}")
values = listings[column]
return {"column": column, "median": values.median(), "missing": values.isna().sum()}uv run uvicorn --app-dir ../listings-probes assign_stats:app --port 8001The two names the route refuses answer as planned, and the two statistics the form needs do not:
HTTP/1.1 500 Internal Server Error
date: Tue, 03 Nov 2026 12:28:40 GMT
server: uvicorn
content-length: 21
content-type: text/plain; charset=utf-8
Internal Server Error/stats and /stats/area_sqm both print that, a plain-text body with no detail, while /stats/garage prints 404 Not Found with {"detail":"no numeric column 'garage'"} and /stats/price the same with 'price'. The server's terminal shows why. Each 500 logs its access line first and then an ERROR: Exception in ASGI application traceback, running through Uvicorn, Starlette and FastAPI's own files, that ends:
File "/home/goku/dev/listings-service/.venv/lib/python3.12/site-packages/fastapi/encoders.py", line 355, in jsonable_encoder
raise ValueError(errors) from e
ValueError: [TypeError("'numpy.int64' object is not iterable"), TypeError('vars() argument must have __dict__ attribute')]listings["price"].max() on an int64 column returns a numpy.int64, and so does values.isna().sum(), while .median() returns a numpy.float64. FastAPI's encoder turns a response into JSON-ready Python values, and it takes numpy.float64 because that class is a subclass of Python's float, and refuses numpy.int64, which is no subclass of int: it tries the value as a dictionary and as an object with attributes, fails both, and raises. The route function returned without error, so the bug lives in the step after it, and the client gets only 500 and Internal Server Error. The tests:
from fastapi.testclient import TestClient
from assign_stats import app
client = TestClient(app)
def test_stats():
body = client.get("/stats").json()
assert body["listings"] == 3000
assert body["median_price"] < body["max_price"]
def test_column_stats():
body = client.get("/stats/area_sqm").json()
assert body["column"] == "area_sqm"
assert body["missing"] == 125
def test_unknown_column_is_404():
response = client.get("/stats/garage")
assert response.status_code == 404
assert response.json() == {"detail": "no numeric column 'garage'"}
def test_target_is_not_served():
assert client.get("/stats/price").status_code == 404uv run pytest ../listings-probes/assign_test_stats.py -q --tb=lineFAILED ../listings-probes/assign_test_stats.py::test_stats - ValueError: [Typ...
FAILED ../listings-probes/assign_test_stats.py::test_column_stats - ValueErro...
2 failed, 2 passed, 1 warning in 2.22sfrom assign_stats import app finds the app because pytest puts a test file's own folder, here ~/dev/listings-probes, on the import path. --tb=line is pytest's option for the shortest traceback, and the two failures above it name fastapi/encoders.py:355 and the same ValueError the server logged. TestClient re-raises an exception from inside the app into the test by default, so the test sees the cause where curl saw only the status. The fix converts each value to a Python number in the return lines:
from fastapi import FastAPI, HTTPException
from listings.data import NUMERIC, load
listings = load()
app = FastAPI(title="listings-stats")
@app.get("/stats")
def stats():
prices = listings["price"]
return {
"listings": len(listings),
"median_price": float(prices.median()),
"max_price": int(prices.max()),
}
@app.get("/stats/{column}")
def column_stats(column: str):
if column not in NUMERIC:
raise HTTPException(status_code=404, detail=f"no numeric column {column!r}")
values = listings[column]
return {
"column": column,
"median": float(values.median()),
"missing": int(values.isna().sum()),
}uv run pytest ../listings-probes/assign_test_stats.py -q now ends 4 passed, 1 warning, and the restarted server answers:
curl http://127.0.0.1:8001/stats
curl http://127.0.0.1:8001/stats/area_sqm
curl http://127.0.0.1:8001/stats/rooms{"listings":3000,"median_price":337000.0,"max_price":1930000}
{"column":"area_sqm","median":89.2,"missing":125}
{"column":"rooms","median":3.0,"missing":0}float() on the median is not what fixed the 500, since numpy.float64 already encoded, and it is there so every value the route returns is a plain Python number whatever pandas hands back. /openapi.json now lists /stats and /stats/{column}, and the second carries a required path parameter column of type string. price is refused because it is the target, not a feature: the form's team asks about the inputs a listing carries, and the check against NUMERIC keeps the target out of an endpoint about inputs.
DON'T return a pandas or NumPy scalar from a route. numpy.int64 from .max() and .isna().sum() gave every client a 500 with no detail, and only the server's log and the test named the cause.
Additional Resources
Optional, and nothing later depends on either. Worth a look if you want FastAPI's own guide to testing, or the full list of HTTP methods behind the 405.
- FastAPI documentation - "Testing".
TestClientused with pytest, the tests moved into their own file beside the app, and an extended example with headers and a request body. The page installs httpx withuv add httpx, where this project adds it to the dev group with--dev. - MDN Web Docs - "HTTP request methods". Every method,
GET,HEAD,POSTand the rest, in one paragraph each, and the table of which methods are safe, idempotent and cacheable.
Summary
The first endpoint carries no model and still holds most of what serving a model will ask. A Python function became the answer to a request by one decorator, and everything around that function, the port, the parsing, the status codes, the headers, the schema and the docs page, came from two libraries the project had been carrying unknowingly, borrowed through MLflow until the project declared them as its own. The service's contract turned out to live in more places than the file: in what the app registered, in what the running process had imported, and in what the client sent.
Most of what went wrong here went wrong quietly and politely. A server kept answering from code that no longer existed on disk, a second server announced a complete startup one line before it gave up the port, a function served by mistake started with one warning and failed every request, and a route returning a NumPy integer handed its clients a 500 while the function itself returned without an error. Each answer was well formed, and each was only legible to someone who read the status, the header or the log line rather than the body alone.
The test client changed where those answers are read. curl showed what one running server said once, and TestClient asked the app object directly, with no server at all, so the suite now fails on a renamed route before any caller can. That is also its limit: twenty passing tests say the app is right, and nothing about whether a server is listening.
Cheat Sheet
Part A - Reference Table
| Idea | Command or code | Notes |
|---|---|---|
| Declare the libraries | uv add fastapi==0.150.0 uvicorn==0.54.0 |
already installed through mlflow-skinny, nothing downloaded |
| Who needs a package | uv tree --invert --package fastapi |
dependents, not dependencies |
| Test-only library | uv add --dev httpx==0.28.1 |
left out by uv sync --no-dev |
| The app | app = FastAPI(title="listings-service") |
adds /openapi.json, /docs, /docs/oauth2-redirect, /redoc |
| A route | @app.get("/health") above a function |
GET only, a dict returned becomes JSON |
| Several methods | @app.api_route("/health", methods=["GET", "HEAD"]) |
HEAD answers 200 with no body |
| Every route | app.routes |
path, methods, name |
| Serve | uv run uvicorn listings.app:app |
127.0.0.1:8000 by default, --port to move |
| Import string | module:attribute |
a route function there starts and answers 500 |
| Port taken | [Errno 98] ... address already in use |
after startup ran, exit status 3 |
| Old code | a running server never re-reads the file | Ctrl+C and start again |
| Raw response | curl -i URL |
status line, headers, empty line, body |
| Another method | curl -i -X POST URL |
405 with allow: GET |
| Wrong path | any unregistered path | 404, {"detail":"Not Found"} |
| Trailing slash | /health/ |
307, location: .../health |
| No server | curl: (7) Failed to connect |
exit status 7, no status line |
| Schema | /openapi.json |
paths holds your routes only, version 0.1.0 is FastAPI's default |
| Docs page | /docs |
Swagger UI from cdn.jsdelivr.net |
| In-process tests | TestClient(app) |
no server, no port, base http://testserver |
| Redirects in tests | follow_redirects=False |
TestClient follows by default |
| Without httpx | RuntimeError: ... requires the httpx2 package |
at import, the whole session stops |
| The warning | StarletteDeprecationWarning ... install httpx2 instead |
Starlette's own, once per run |
| A path parameter | @app.get("/stats/{column}"), def f(column: str) |
one segment, passed by name |
| Your own status | raise HTTPException(status_code=404, detail=...) |
body {"detail": ...} |
| NumPy in a response | int(series.max()), float(series.median()) |
numpy.int64 raises, 500 to the client |
Part B - Compact Code Block
from fastapi import FastAPI
app = FastAPI(title="listings-service")
@app.get("/health") # GET only
def health():
return {"status": "ok"} # sent as JSONfrom fastapi.testclient import TestClient
client = TestClient(app) # no server, no port
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
assert client.post("/health").headers["allow"] == "GET"
assert client.get("/health/", follow_redirects=False).status_code == 307uv run uvicorn listings.app:app # Terminal 1, Ctrl+C stops
curl -i http://127.0.0.1:8000/health
curl -i -X POST http://127.0.0.1:8000/health