Skip to content

Native server

Veloce.run() starts a from-scratch HTTP/1.1 server built on a raw asyncio transport — no uvicorn, no ASGI layer in the request path. This page covers the run() signature, the native WebSocket handshake, the two-phase graceful drain on shutdown, and the hardening knobs that bound slowloris attacks, idle connections, and connection floods. It also states the two ways the native path diverges from running under uvicorn.

pip install veloceframework

The built-in server is for development

app.run() logs a startup reminder that it is a development server. For production, run the same app under a hardened ASGI server (uvicorn module:app) or the gunicorn VeloceWorker. The native server is single-process and intentionally minimal; the ASGI path is the supported production target.

Running without uvicorn

app.py
from veloce import Veloce

app = Veloce()


@app.get("/")
async def index():
    return {"message": "Hello from the native server"}


if __name__ == "__main__":
    app.run(port=8000)

app.run() brings up the server on its own event loop. The same app object also works as an ASGI application, so nothing about the app changes between the two paths.

run() runs startup lifecycle hooks, creates the listening socket via loop.create_server, installs SIGINT/SIGTERM handlers, and serves until a signal arrives. On exit it runs the graceful drain and the shutdown lifecycle hooks. It uses uvloop automatically when it is installed and importable; otherwise it falls back to the stdlib asyncio loop.

The run() signature

run() takes only the arguments below. There is no log_level and no --workers fan-out.

app.py
import ssl

from veloce import Veloce

app = Veloce()


@app.get("/")
async def index():
    return {"ok": True}


if __name__ == "__main__":
    app.run(
        host="127.0.0.1",
        port=8000,
        access_log=True,
        ssl_context=None,
        bind_all=False,
    )
Argument Default Purpose
host None"127.0.0.1" Interface to bind. Unset binds to localhost only.
port 8000 TCP port to listen on.
workers 1 Must be 1; any other value raises ValueError.
access_log True Print the startup banner (Listening on ...).
ssl_context None An ssl.SSLContext to serve HTTPS; handed to create_server(ssl=...).
bind_all False Bind 0.0.0.0 (all interfaces) instead of localhost.
reload False Auto-restart on source changes (development only).

When host is left unset it resolves to "127.0.0.1", so the dev server is reachable only from the local machine. Pass bind_all=True to bind 0.0.0.0.

Auto-reload

app.run(reload=True) (or veloce run --reload) supervises a child process that serves requests and restarts it whenever a project .py file changes — no uvicorn required. It uses a stdlib file watcher by default, or watchfiles automatically if it is installed (pip install watchfiles) for OS-level change events. The watching runs in the supervisor, so the served child carries no overhead. Development only.

host= and bind_all=True are mutually exclusive

Passing both raises ValueError to prevent a silent widening of the bind. Binding 0.0.0.0 exposes the dev server to every reachable network; never combine it with debug=True, which leaks source and tracebacks. run() logs a warning if you bind a non-local host with debug=True.

workers must be 1

The built-in server is single-process and does not pre-fork. workers != 1 raises ValueError. For multiple processes, run under uvicorn module:app --workers N or the gunicorn VeloceWorker.

Serving HTTPS for local testing

Pass an ssl.SSLContext to terminate TLS in the dev server. Left None, the serving path is byte-for-byte identical to plain HTTP — the TLS cost is paid only when a context is supplied.

app.py
import ssl

from veloce import Veloce

app = Veloce()


@app.get("/")
async def index():
    return {"secure": True}


if __name__ == "__main__":
    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    context.load_cert_chain("cert.pem", "key.pem")
    app.run(port=8443, ssl_context=context)

Production should still terminate TLS at uvicorn or a reverse proxy rather than in the application.

Native WebSocket handshake

On the native path, HttpProtocol performs the RFC 6455 handshake itself. When httptools flags a Connection: Upgrade request, the protocol scans the upgrade triplet (Connection: upgrade, Upgrade: websocket, Sec-WebSocket-Key), matches a @app.websocket route, sends the 101 Switching Protocols response with the computed Sec-WebSocket-Accept, and diverts the byte stream into the WebSocket frame parser.

app.py
from veloce import Veloce, WebSocket

app = Veloce()


@app.websocket("/ws")
async def ws(websocket: WebSocket):
    await websocket.accept()
    await websocket.send_text("connected")
    async for message in websocket.iter_text():
        await websocket.send_text(f"echo: {message}")


if __name__ == "__main__":
    app.run(port=8000)

The same handler runs unchanged under an ASGI server; only the handshake mechanics differ. The accept-key math lives once in websocket.compute_accept and is shared by both paths.

Pre-101 refusals are ordinary HTTP responses, not close frames, because the protocol switch has not completed yet:

Condition Response
Sec-WebSocket-Version is not 13 426 Upgrade Required with Sec-WebSocket-Version: 13
Missing Sec-WebSocket-Key 400 Bad Request
No matching websocket route 404 Not Found
Host / Origin allow-list rejects the upgrade 403 Forbidden

Native subprotocol negotiation is unsupported

On the native (Veloce.run) path the 101 is written synchronously before your handler's accept() runs, so there is nothing left to negotiate. Calling accept(subprotocol=...) — or passing custom handshake headers= — raises RuntimeError rather than silently dropping the value. Run under an ASGI server (uvicorn/hypercorn) when you need to negotiate a subprotocol.

The native path does not percent-decode the request path

HttpProtocol ascii-decodes the raw request target without percent-decoding it, so a handler sees /%41 as the literal /%41. Under uvicorn (and the ASGI spec) the path arrives already decoded to /A. Match against decoded values, or run under uvicorn, if your routes depend on percent-decoded path segments.

Graceful drain on shutdown

app.py
import asyncio

from veloce import Veloce

app = Veloce()


@app.get("/slow")
async def slow():
    await asyncio.sleep(2)
    return {"done": True}


@app.on_shutdown
async def goodbye():
    app.logger.info("shutdown hooks running after the drain")


if __name__ == "__main__":
    app.run(port=8000)

A request already running when SIGTERM arrives runs to completion; the shutdown hooks fire only after the drain finishes.

On SIGINT/SIGTERM (or Ctrl+C), run() stops accepting new connections and runs a two-phase graceful drain so in-flight requests are never cut off mid-pipeline.

Phase one flips a drain flag on every live connection. Each connection finishes the request it is already dispatching, then closes at the request boundary instead of being cancelled. A connection admitted during the shutdown window serves at most its first request before quiescing; pipelined follow-ups are declined so the client retries on a fresh connection.

Phase two is a hard fallback: any dispatch task still running after a bounded drain window is awaited with a 30-second timeout, then cancelled, so a stuck handler can never hang the process. The shutdown lifecycle hooks run last.

Hardening knobs

Every limit below is read from app.config at the point it is needed, so you set it once before run(). The native path enforces the same MAX_CONTENT_LENGTH body cap as the ASGI path.

app.py
from veloce import Veloce

app = Veloce()
app.config["REQUEST_TIMEOUT"] = 30
app.config["KEEP_ALIVE_TIMEOUT"] = 75
app.config["MAX_CONCURRENT_CONNECTIONS"] = 1000
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024


@app.get("/")
async def index():
    return {"ok": True}


if __name__ == "__main__":
    app.run(port=8000)
Config key Default Purpose
REQUEST_TIMEOUT 30 Slowloris guard: once a request's bytes start, the whole request line, headers, and body must complete within this budget or the connection is dropped with 408.
KEEP_ALIVE_TIMEOUT 75 Idle keep-alive timeout: an open connection with no request in flight is closed after this many seconds.
MAX_CONCURRENT_CONNECTIONS 1000 Per-process cap on simultaneously-open connections; a connection over the cap is refused with 503.
MAX_CONTENT_LENGTH 104857600 (100 MiB) Maximum request body size. An over-limit declared Content-Length is refused with 413 before any body is read. Set None for unlimited.
REQUEST_HANDLER_TIMEOUT 30 Per-request handler timeout; a handler exceeding it yields 504 Gateway Timeout.

The slowloris and idle timers are mutually exclusive per connection: the idle KEEP_ALIVE_TIMEOUT governs a connection waiting for its next request, and the shorter REQUEST_TIMEOUT takes over the moment a request's first bytes arrive.

The connection cap is enforced under a lock at connection_made, so a burst of parallel connections cannot over-admit past the limit.

The body cap is enforced twice

MAX_CONTENT_LENGTH rejects an honest over-limit Content-Length up front with 413, and the streamed running total is a backstop: a client that lies about its length or chunks past the cap is still cut off with 413 once the body crosses the limit.

TCP keepalive

OS-level TCP keepalive is on by default (TCP_KEEPALIVE, default True), so the kernel reaps a peer that died without sending a FIN — a half-open connection the application idle timer never observes because no further bytes arrive. The per-socket tuning options are platform-specific and applied only where the platform exposes them.

Config key Default Purpose
TCP_KEEPALIVE True Enable SO_KEEPALIVE on accepted connection sockets.
TCP_KEEPALIVE_IDLE None Idle seconds before the first probe (Linux/macOS only).
TCP_KEEPALIVE_INTERVAL None Seconds between probes (Linux only).
TCP_KEEPALIVE_COUNT None Failed probes before the connection is dropped (Linux only).

Note

Windows exposes only SO_KEEPALIVE via setsockopt, so the idle/interval/count options are silently skipped there. A None value leaves the OS default in place on every platform.

Native versus uvicorn

The native path and the ASGI path converge on the same dispatch core, so handlers, dependencies, and middleware behave identically. The differences are at the transport edge.

Aspect Native (app.run()) Under uvicorn
Process model Single process, no pre-fork --workers N pre-forks
Request path Not percent-decoded Decoded per the ASGI spec
WebSocket subprotocol accept(subprotocol=...) raises Negotiated normally
TLS ssl_context= for local testing Terminate at uvicorn or a proxy
Hardening knobs REQUEST_TIMEOUT, KEEP_ALIVE_TIMEOUT, MAX_CONCURRENT_CONNECTIONS uvicorn's own limits

For production, prefer the ASGI path: multi-worker scaling, a percent-decoded path matching the ASGI spec, and full subprotocol negotiation all come for free.

Next steps