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/sendcallable.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
appobject inapi/main.pycould, 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.
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 autochooses betweenuvloopandasyncio, 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
--workersflag 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/sendcallable named in The idea underneath.PEP 3333: the WSGI specification, the synchronous predecessor ASGI was written to replace.
Tutorials and blogs#
how gunicorn + uvicorn + uvloop + httptools: a maintainer discussion confirming that Uvicorn’s
"auto"loop and HTTP settings pick upuvloopandhttptoolsautomatically once installed, which is the mechanism behind the tradeoff in Sharp edges and limits.
Videos#
Demystifying AsyncIO: Building Your Own Event Loop in Python (Arthur Pastel, EuroPython Conference): builds an event loop from first principles, the mechanics behind the description in The idea underneath.