FastAPI#
Why FastAPI#
The engine, SchemaAnalyzer, RapidAdapter, GeneratorFactory, and
DefensiveGeneratorManager, is reachable primarily through the tdw
command registered in pyproject.toml under [project.scripts]. FastAPI
gives that same engine a second, optional way in: a teammate’s script, a
browser hitting /docs, or a CI job can call POST /v4/schema/analyze or
POST /v4/deployment/rapid over HTTP without shelling out to the CLI.
It was picked over hand-rolling a Starlette app, or reaching for a WSGI
framework, because it turns work already being done for another reason,
writing Pydantic request and response models (see Pydantic), directly
into two things for free: request validation, and an OpenAPI document that
/docs (Swagger UI) and /redoc (ReDoc) render without a separate
schema-writing step. Nothing about the API layer needed to be described twice.
The CLI remains primary. There is no console-script entry for the API in
pyproject.toml; starting it is a deliberate, separate action (see
App construction and lifespan), and the HTTP surface it exposes is thinner
than the CLI in places this page describes plainly in
Sharp edges and limits.
The idea underneath#
FastAPI is built on ASGI, the Asynchronous Server Gateway Interface. ASGI
is the async successor to WSGI (PEP 3333), the older synchronous calling
convention between a Python web application and its server: under WSGI, one
worker (a thread or process) is occupied for the full duration of one request,
so serving many slow, I/O-bound connections at once means provisioning many
workers. Under ASGI, a single process runs an event loop that holds many
in-flight requests as suspended coroutines, resuming each one exactly when its
I/O (a network read, a database round trip) completes. Uvicorn, the ASGI
server this project depends on (uvicorn>=0.24.0 in pyproject.toml), is
what actually runs that event loop; FastAPI (via Starlette) is the application
that speaks the ASGI protocol to it. The specification itself lives at the
ASGI documentation.
The important nuance: this is concurrency, not parallelism. One event loop
means one thread making progress on many requests by interleaving them at
await points, not multiple requests literally executing Python bytecode at
the same instant. A coroutine that never actually yields, because it is doing
synchronous, blocking work instead of awaiting real I/O, blocks that entire
loop, not just its own request. This matters concretely for this codebase; see
Sharp edges and limits.
FastAPI’s other core idea is dependency injection: a path operation can
declare a parameter as Depends(some_callable), and FastAPI resolves and
calls it per request, commonly used for shared database sessions,
authentication, or pagination parameters. It is worth naming here because it
is one of FastAPI’s signature features, but it is also worth being precise
that none of the six routers in this project use it. Grepping
src/test_data_workbench/api/ for Depends turns up nothing; every
handler here is self-contained, either calling straight into the core engine
or building its response directly.
The third idea is the OpenAPI document as a generated contract rather than a
hand-maintained one. FastAPI walks every path operation, its declared
response_model, and the Pydantic field metadata behind it, and builds a
JSON Schema-based document from that, per the OpenAPI Specification (formerly “Swagger”). Nobody edits
that document by hand; it is a byproduct of type hints that were going to
exist anyway for validation.
How it fits this project#
src/test_data_workbench/api/main.py builds one FastAPI instance and
mounts six routers on it, one per concern. Two of those routers,
schema_router and deployment_router, call directly into the same
SchemaAnalyzer and RapidAdapter classes the CLI uses (see
Architecture); the API is a second door onto the same
engine, not a parallel implementation of it.
The API also shows up reflexively inside the project’s own demo-readiness
tooling. core/demo_coordinator.py defines DemoCoordinator, which tests
six features before a presentation, and one of them, "Live API Demo", is
tested by _test_live_api: it imports app from
test_data_workbench.api.main, spins up
fastapi.testclient.TestClient(app), and asserts that GET / returns
200. The FastAPI app is, among other things, one of the things this
project checks is alive before someone demos it.
App construction and lifespan#
main.py builds the app with an @asynccontextmanager lifespan function,
the current FastAPI-recommended replacement for the older
@app.on_event("startup") style:
@asynccontextmanager
async def lifespan(app: FastAPI):
from test_data_workbench.core.feature_flags import FeatureFlags
from test_data_workbench.core.defensive_manager import DefensiveGeneratorManager
app.state.feature_flags = FeatureFlags()
app.state.defensive_manager = DefensiveGeneratorManager(app.state.feature_flags)
yield
app = FastAPI(
title="V4 Adaptive Framework API",
version="4.0.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
)
app.state is where the two process-lifetime objects live: one
FeatureFlags and one DefensiveGeneratorManager, each constructed once
at startup, not per request. Handlers read them defensively, through
getattr(app.state, "feature_flags", None) rather than a direct attribute
access, so a handler still returns a sensible response even if it somehow runs
before or after the lifespan context is active, for example under a test
harness that does not enter app as a context manager.
Router organisation and versioned prefixes#
api/endpoints/ has one module per area, schema, generators,
templates, validation, deployment, team, each exporting a bare
APIRouter(). main.py mounts all six under a versioned prefix with a
tag for OpenAPI grouping:
app.include_router(schema_router, prefix="/v4/schema", tags=["Schema Analysis"])
app.include_router(generator_router, prefix="/v4/generators", tags=["Generator Creation"])
app.include_router(template_router, prefix="/v4/templates", tags=["Team Templates"])
app.include_router(validation_router, prefix="/v4/validation", tags=["Quality Gates"])
app.include_router(deployment_router, prefix="/v4/deployment", tags=["Rapid Deployment"])
app.include_router(team_router, prefix="/v4/team", tags=["Team Coordination"])
This is what gives /docs its grouped, tagged Swagger UI with no manual
OpenAPI authoring: FastAPI walks the routers, their path operations, and the
tags attached at mount time to build the document. A handful of routes,
/, /health, and /v4/team/status, are registered directly on
app in main.py instead of through a router; the /v4/team/status
one duplicates ground also covered by team_router’s own
GET /dashboard, so team-related state is readable from two different
paths.
Request and response models#
Every request body and response shape in the API layer is a Pydantic
BaseModel defined in api/models.py: DatabaseConnection,
SchemaAnalysisRequest/Response, GeneratorCreationRequest/
Response, DataGenerationRequest/Response,
RapidDeploymentRequest/Response, TeamTemplate,
ContributionValidationRequest/Response, and more. That boundary,
Pydantic in, Pydantic out, dataclasses everywhere else in the codebase, is
covered in depth in Pydantic; this page only notes the mechanics that
are FastAPI’s rather than Pydantic’s.
main.py pulls all of them in with from .models import * rather than
naming them individually. api/models.py defines no __all__, so that
star import re-exports everything at module scope, including names models
itself imported: BaseModel, ConfigDict, Field, Dict, List,
Any, Optional, datetime, and EntityType. None of those happen
to collide with anything else main.py defines, so it works, but it means
main.py’s namespace holds several names it never asked for directly.
response_model= on a path operation is what ties a handler’s return value
to a schema FastAPI validates the outgoing response against and documents in
/docs:
@router.post("/analyze", response_model=SchemaAnalysisResponse)
async def analyze_production_schema(request: SchemaAnalysisRequest,
background_tasks: BackgroundTasks):
...
Cross-cutting middleware#
Two middleware layers wrap every request. CORSMiddleware allows requests
from two hardcoded local development origins:
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:8080"], # Add your frontend URLs
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
The inline comment, “Add your frontend URLs”, is accurate: this list is not read from configuration anywhere, so pointing a real deployed frontend at this API means editing this literal and redeploying.
A small custom middleware records per-request timing onto a response header and increments a module-level counter:
@app.middleware("http")
async def track_requests(request, call_next):
global request_count
request_count += 1
start_time = time.time()
response = await call_next(request)
response.headers["X-Process-Time"] = str(time.time() - start_time)
return response
request_count is what /health later reports as requests_served.
Because ASGI runs on one event loop per worker process and this increment has
no await between the read and the write, it is safe from interleaving
within a single worker; it is not safe from being wrong if the API is ever run
behind more than one worker process, since each process keeps its own
independent counter.
Error handling: what a caller actually sees on failure#
Two custom exception handlers replace FastAPI’s default error bodies with structured ones:
@app.exception_handler(404)
async def not_found_handler(request, exc):
return JSONResponse(status_code=404, content={
"error": "Endpoint not found",
"available_endpoints": [...],
"suggestion": "Check the API documentation at /docs for available endpoints",
})
@app.exception_handler(500)
async def internal_error_handler(request, exc):
...
return JSONResponse(status_code=500, content={
"error": "Internal server error",
"incident_id": f"inc_{int(time.time())}",
...
})
The 404 handler does fire for a genuinely unmatched route (tests/test_api.py
asserts this directly against GET /nonexistent/path), because Starlette’s
routing raises an HTTPException(status_code=404) internally when nothing
matches, and a handler registered for an integer status code intercepts
HTTPException instances carrying that code. See
Sharp edges and limits for why the 500 handler does not behave the same
way for a genuine unhandled exception.
The schema-analysis and rapid-deployment endpoints do not lean on either
handler at all. Both wrap their real work in a broad
except Exception as e and return a normal 200 with success=False,
folding the error into the payload instead of raising:
except Exception as e:
logger.error(f"Schema analysis failed: {str(e)}")
return SchemaAnalysisResponse(
success=False,
database_name="unknown",
tables_found=0,
...
analysis_metadata={"error": str(e), "fallback_mode": True, ...},
)
This is the “Degrade, never fail” principle from Design Principles
applied at the HTTP boundary: a caller gets a 200 with a machine-readable
failure description it can branch on, rather than parsing exception text out
of a 500 body. It also means a client cannot trust the HTTP status code
alone to detect failure on these two routes; it has to inspect success in
the body.
Background tasks for the one real deferred job#
BackgroundTasks shows up in two routers, both to defer cleanup work past
the point the response has already been sent. The schema-analysis endpoint
schedules cache trimming:
background_tasks.add_task(cleanup_old_cache_entries)
which keeps the in-memory analysis_cache dict at 50 entries or fewer. The
rapid-deployment endpoint schedules something with a much longer horizon:
async def cleanup_deployment(deployment_id: str):
await asyncio.sleep(3600)
if deployment_id in active_deployments:
del active_deployments[deployment_id]
asyncio.sleep does not block the event loop, so this does not stall other
requests, but it does mean every rapid deployment leaves a coroutine parked
for an hour, and there is no cap on how many of those can be outstanding at
once. Both caches, analysis_cache and active_deployments, are plain
process-local dictionaries with no persistence: a restart forgets everything
in them, cleanup tasks included.
Testing the endpoints#
tests/test_api.py exercises every router through Starlette’s
TestClient, using it as a context manager:
@pytest.fixture
def client():
with TestClient(app) as c:
yield c
The with block is not decorative. Entering TestClient as a context
manager is what triggers the app’s lifespan startup, so
app.state.feature_flags and app.state.defensive_manager exist before
any test request is sent, and shutdown runs when the fixture tears down. A
bare TestClient(app) without the with would skip both.
The suite covers the root and health endpoints, every router’s happy path, the
404 fallback response, and the X-Process-Time header the timing middleware
adds. pyproject.toml’s dev extra pins httpx2 for this, with an
inline comment noting it is “required by starlette.testclient (FastAPI
TestClient) on current starlette”. The broader philosophy behind the test
suite, including that API endpoints are one of the areas it covers on every
push, is described in Testing and Quality.
Learn more resources#
Official documentation#
Lifespan Events: the
@asynccontextmanagerstartup/shutdown pattern used in App construction and lifespan.Bigger Applications, Multiple Files: the
APIRouterandinclude_router(prefix=..., tags=...)pattern behind Router organisation and versioned prefixes.Background Tasks: the
BackgroundTasksmechanism used in Background tasks for the one real deferred job.Handling Errors: custom exception handlers and how they interact with the default error paths, background for Error handling: what a caller actually sees on failure.
Testing:
TestClientusage, background for Testing the endpoints.Concurrency and async / await: FastAPI’s own explanation of when
async defhelps and when a blocking call inside one negates it, directly relevant to the blocking-call sharp edge above.The ASGI specification: the protocol FastAPI and Uvicorn speak to each other, referenced in The idea underneath.
OpenAPI Specification v3.1.0: the document format FastAPI generates and that Swagger UI (
/docs) renders.
Tutorials and blogs#
What actually blocks your FastAPI event loop: a focused, practical walkthrough of exactly the failure mode described above, a synchronous call sitting inside an
async defthat is invisible at low request rates and serializes everything at higher ones.Cannot add a custom handler for HTTPExceptions with status_code=500: a Starlette maintainer discussion confirming, from the framework side, why
@app.exception_handler(500)does not see genuinely unhandled exceptions.
Videos#
Every ASGI Concept In a Single Video (Code Collider): a from-first- principles walkthrough of ASGI itself, the protocol underneath the concurrency model described in The idea underneath.