Uvicorn#

Why Uvicorn#

src/test_data_workbench/api/main.py builds a FastAPI instance called app. That object, on its own, is just a Python value: a callable that knows how to turn an ASGI scope, receive, send triple into a response. Nothing about constructing it opens a network socket, listens on a port, or reads a byte of HTTP. Something else has to sit between the operating system’s network stack and that callable, accepting real TCP connections and driving the object through its protocol. That is Uvicorn’s job, and the only one it has in this codebase.

uvicorn is a plain dependency in pyproject.toml, uvicorn>=0.24.0 under [project] dependencies, not under [project.optional-dependencies]. import uvicorn appears in exactly one file in the whole repository: api/main.py. The CLI, the schema analyzer, the generator factory, none of the engine code this project exists to ship knows Uvicorn is there. It is entirely confined to the optional HTTP door onto that engine (see FastAPI for what is behind that door).

The idea underneath#

For a long time, the standard way for a Python web server to hand a request to a Python web application was WSGI, defined in PEP 3333. WSGI’s whole contract is a single synchronous callable: application(environ, start_response). The server calls it, the application computes a response and calls start_response, and the call returns. That is the entire interface. It has an unavoidable consequence: whatever worker (thread or process) is running that call is occupied for its full duration and can do nothing else. A WSGI worker has no way to say “I’m waiting on the network, go serve someone else for a moment.” Serving many slow, long-lived, or concurrent connections therefore means provisioning many workers, one tied up per in-flight request, because the interface itself has no concept of suspending one request to make progress on another.

ASGI, the Asynchronous Server Gateway Interface, is the async successor to that contract. Its specification lives at asgi.readthedocs.io. Instead of one synchronous function call, an ASGI application is an async callable, application(scope, receive, send), that a server drives on an event loop. The application can await at any point it is genuinely waiting on something, a database round trip, a downstream HTTP call, another message from the client, and hand control back to the loop while it waits, rather than blocking a whole worker for that time.

That split is the division of labour worth naming explicitly, because it is easy to blur:

  • The framework (FastAPI, via Starlette, in this project) defines what the application does: which routes exist, how a request is validated, what a response looks like. It is the scope/receive/send callable.

  • The server (Uvicorn) owns everything below that: it opens the listening socket, accepts TCP connections, parses raw bytes into HTTP requests, and runs the event loop that decides, moment to moment, which suspended coroutine gets to resume next.

  • ASGI is the contract between them, nothing more. It is why the same app object in api/main.py could, in principle, run under Uvicorn or under a different ASGI server (Daphne and Hypercorn are two others) without either the framework or the application code changing.

An event loop, concretely, is a scheduler running on one thread. It keeps a list of coroutines that are ready to run right now and a set of pending operations (socket reads, socket writes, timers) it is waiting on the operating system to report as ready. Its cycle is: run a ready coroutine until it hits an await on something not yet available, park it, move to the next ready coroutine, and when the operating system reports that a socket a parked coroutine was waiting on is now readable or writable, mark that coroutine ready again. Many connections can be “in flight” on a single thread this way because, at any given instant, most of them are not computing, they are waiting on the network, and waiting costs nothing on the thread that is not currently doing it.

This is exactly why async concurrency helps I/O-bound work and does nothing for CPU-bound work. A coroutine that is genuinely waiting on I/O frees the loop to serve other requests during that wait; that is the case the whole model is built for. A coroutine that is actually computing, running Python bytecode rather than awaiting something external, cannot be interleaved with anything else on that same loop: CPython’s global interpreter lock permits only one thread to execute Python bytecode at a time regardless of how many coroutines exist, and the coroutine currently running holds the loop’s single thread until it either finishes or reaches a real await. A synchronous, blocking call sitting inside an async def function behaves identically from the loop’s point of view: nothing about writing async def makes an ordinary blocking call yield control on its own. See FastAPI defines the application, Uvicorn serves it and Sharp edges and limits for where this happens concretely in this project.

How it fits this project#

Uvicorn’s entire footprint in this codebase is the if __name__ == "__main__": block at the bottom of api/main.py. There is no tdw serve subcommand and no console-script entry for the API in pyproject.toml’s [project.scripts]; the tdw CLI is the primary interface, and starting the HTTP server is a separate, explicit action a person or a script takes on its own (see FastAPI for why the API exists at all and how its routers are organised). Uvicorn does not appear anywhere in core, adaptation, or the CLI; it is confined to this one file, running the one app object that file builds.

Starting the server#

The dev-runner block parses four flags and passes them straight to uvicorn.run():

if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="Run V4 Adaptive Framework API")
    parser.add_argument("--host", default="127.0.0.1", help="Host to bind to")
    parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
    parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
    parser.add_argument("--log-level", default="info", help="Log level")

    args = parser.parse_args()

    uvicorn.run(
        "api.main:app",
        host=args.host,
        port=args.port,
        reload=args.reload,
        log_level=args.log_level
    )

--port defaults to 8000, matching Uvicorn’s own default. --log-level defaults to "info", also matching Uvicorn’s own default, so this argument mostly exists to let an operator turn it up (debug, trace) or down (warning, error, critical) rather than to change the out-of-the-box behaviour. --host and --reload matter more, and each gets its own section below.

Host and port binding#

args.host defaults to "127.0.0.1": loopback only, reachable only from the same machine. That is the safe default, and it is the one this project ships. It is one flag away from changing, though: passing --host 0.0.0.0 binds every network interface on the machine, making the API reachable from anywhere that can route to it. main.py registers exactly two things on app beyond its routers, CORSMiddleware (allowing two hardcoded localhost origins) and a request-timing middleware; nothing checks a credential before a request reaches a router. Binding 0.0.0.0 on a host with no firewall in front of it means every one of those routes becomes reachable to anyone who can open a TCP connection, with nothing in this codebase to stop them. See Sharp edges and limits.

Reload for development#

--reload is action="store_true", so it defaults to False: an operator has to ask for it. When set, it is passed straight through as reload=args.reload. Uvicorn’s reloader works by watching the source tree for file changes and restarting the server process when it sees one, which is exactly what makes local iteration fast and exactly why it must never run in a deployed service: a request that lands mid-restart is dropped, the watcher adds ongoing CPU and memory overhead for no runtime benefit, and (see Sharp edges and limits) reload mode cannot be combined with running more than one worker process. Nothing in main.py prevents --reload from being passed in a context that looks like production; the only thing standing between this flag and a bad deploy is an operator not passing it.

FastAPI defines the application, Uvicorn serves it#

FastAPI is not a server. It is easy to elide the two, since most people meet both at once with a single uvicorn main:app command, but this project’s own test suite draws the line cleanly. tests/test_api.py:

from test_data_workbench.api.main import app

@pytest.fixture
def client():
    with TestClient(app) as c:
        yield c

and core/demo_coordinator.py’s _test_live_api do the same thing: import app and hand it directly to Starlette’s TestClient. Neither one imports uvicorn, opens a socket, or runs an event loop. That works precisely because app is nothing more than an ASGI-callable Python object once FastAPI has built it; TestClient drives it in-process, in the same thread, without any real network involved. Uvicorn only enters the picture when something needs to be reachable over an actual TCP connection, which is exactly what the code in Starting the server is for and exactly what the test suite and the demo checker deliberately avoid needing.

Sharp edges and limits#

Plain Uvicorn, not Uvicorn’s “standard” extra. pyproject.toml and requirements.txt both pin uvicorn>=0.24.0 with no extra; there is no uvicorn[standard] anywhere in this project. That extra is what pulls in uvloop (a faster event-loop implementation) and httptools (a faster HTTP/1.1 parser). Uvicorn’s own event-loop and HTTP-implementation settings both default to auto, which means “use the fast one if it’s installed, otherwise fall back,” and here it always falls back: to Python’s built-in asyncio event loop, and to Uvicorn’s bundled pure-Python h11 HTTP parser. This is not a bug, the server works correctly either way, but it is a real, factual tradeoff: less raw throughput and higher latency under heavy concurrent load than the same code would get from the [standard] extra, in exchange for one fewer set of compiled dependencies to install. Given how secondary this API layer is to the CLI in this project (see FastAPI), that is a defensible choice, but it is a choice, not an oversight to leave undocumented.

Reload must never run in production, and nothing here enforces that. --reload defaults off, but it is a bare flag with no environment check around it (see Reload for development). Beyond the dropped-request and overhead concerns, reload mode and multi-worker mode are mutually exclusive in Uvicorn: a reload-enabled process cannot also run more than one worker. Passing --reload to a deployed instance is a discipline problem, not something the code prevents.

One process here is one event loop on one core. The argument parser in main.py defines only --host, --port, --reload, and --log-level; there is no --workers flag exposed, and uvicorn.run() is called with no workers= argument either. Everything The idea underneath says about overlapping I/O waits happens inside that one process; it adds no CPU parallelism. A deployment that needs more than one core needs an explicit multi-process setup, Uvicorn’s own --workers, or a process manager running several Uvicorn processes, that this dev-runner does not provide.

The default host is safe; the flag to widen it has no safeguard. --host defaults to 127.0.0.1, so out of the box the server is not reachable from another machine. Passing --host 0.0.0.0 changes that instantly, and nothing in api/ authenticates a request once it arrives (see Host and port binding). The default is the right one; there is simply no code-level guard stopping someone from widening it on a host that should not be exposed.

Blocking, synchronous work inside an async def stalls the whole loop, and this project has real examples of it. SchemaAnalyzer.analyze_production_schema in src/test_data_workbench/adaptation/schema_analyzer.py is async def, but it opens a synchronous SQLAlchemy engine (sa.create_engine, not an async engine), and its helper coroutines, _get_sample_values, _estimate_row_count, _detect_business_rules, call engine.connect() and execute queries with no await anywhere inside that work. Marking a function async def does not make a blocking call inside it non-blocking; per The idea underneath, a coroutine that never actually suspends holds the event loop for its whole duration, which means every other connection this Uvicorn process is holding waits too, not just the one that triggered the slow call. Against the small in-memory demo backend this is invisible; against a real, slow database it would not be.

The dev-runner’s import string does not match the installed package. uvicorn.run("api.main:app", ...) passes Uvicorn a string rather than the app object already in scope. Uvicorn has to import that string itself to find the ASGI callable, whether or not --reload is set. But the package this project actually builds is test_data_workbench ([tool.hatch.build.targets.wheel] sets packages = ["src/test_data_workbench"]), and there is no other directory named api anywhere in the repository outside src/test_data_workbench/api. There is no top-level api package for that string to resolve against once this project is installed or run from its normal working directory, so this block does not start a working server as written. The invocation that actually works is the one both tests/test_api.py and core/demo_coordinator.py already use to reach the same object: test_data_workbench.api.main:app, for example uvicorn test_data_workbench.api.main:app --host 127.0.0.1 --port 8000. Nothing in the test suite runs this __main__ block, so nothing in CI would catch a regression here.

Learn more resources#

Official documentation#

  • Uvicorn: the project’s own description of itself as an ASGI server, and where the CLI-vs-uvicorn.run() split described in Starting the server comes from.

  • Settings: the full reference for --host, --port, --reload, --workers, --log-level, --loop, and --http, all named on this page.

  • ASGI: Uvicorn’s own explanation of the ASGI callable and why it exists, background for The idea underneath.

  • Event Loop: how --loop auto chooses between uvloop and asyncio, the mechanism behind the tradeoff described in Sharp edges and limits.

  • Deployment: Uvicorn’s own “reload for local development, a process manager for production” guidance, and the --workers flag referenced in Sharp edges and limits.

  • Server Workers - Uvicorn with Workers: FastAPI’s own explanation of why a single Uvicorn process is limited to one core, and how a deployment typically runs more than one.

  • The ASGI specification: the protocol itself, defining the scope/receive/send callable named in The idea underneath.

  • PEP 3333: the WSGI specification, the synchronous predecessor ASGI was written to replace.

Tutorials and blogs#

Videos#