aiohttp — Server & WebSockets¶
aiohttp.web is a small async web framework: routes, handlers, middlewares, shared application state and WebSockets. There is no ORM, validation layer or OpenAPI generation; for those use FastAPI. Examples were run on aiohttp 3.14.5; the outputs shown are from real requests to a local instance.
Application, Routes and Handlers¶
A handler is an async def taking a web.Request and returning a web.Response (or raising an HTTP exception):
import json
from aiohttp import web
routes = web.RouteTableDef()
@routes.get(r"/users/{uid:\d+}")
async def get_user(request: web.Request) -> web.Response:
uid = int(request.match_info["uid"])
user = request.app[db_key].get(uid)
if user is None:
raise web.HTTPNotFound(
text=json.dumps({"error": "no user"}), content_type="application/json"
)
return web.json_response(user)
@routes.post("/users")
async def create_user(request: web.Request) -> web.Response:
try:
body = await request.json()
except json.JSONDecodeError:
raise web.HTTPBadRequest(text="bad json")
uid = max(request.app[db_key], default=0) + 1
request.app[db_key][uid] = body
return web.json_response({"id": uid}, status=201, headers={"Location": f"/users/{uid}"})
| Part | Notes |
|---|---|
@routes.get/post/put/patch/delete(path) |
Decorators on a RouteTableDef; register with app.add_routes(routes) |
web.get(path, handler) |
Same without decorators: app.add_routes([web.get("/hello", hello)]) |
{name} / {name:regex} |
Path variable, read from request.match_info["name"]. /users/abc against {uid:\d+} gives a 404 |
request.query |
Query parameters (request.query.get("q")) |
await request.json() / .text() / .post() / .read() |
Body (.post() parses form data) |
request.headers, request.cookies |
Headers and cookies |
web.json_response(data, status=, headers=) |
JSON body with the right content type |
raise web.HTTPNotFound(...) etc. |
HTTP errors are exceptions you raise; they are also responses |
Requests to unknown paths return 404 and unsupported methods return 405 automatically.
Application State with AppKey¶
Shared objects (database pool, settings, an HTTP client) live on the application and are looked up with a typed key:
from aiohttp import web
db_key = web.AppKey("db", dict)
app = web.Application()
app[db_key] = {1: {"name": "Ann"}}
Plain string keys still work but emit NotAppKeyWarning; AppKey lets type checkers know the value type. Inside a handler read it with request.app[db_key].
Startup and Cleanup: cleanup_ctx¶
Use one async generator per resource. Code before yield runs at startup, code after at shutdown, in reverse order:
async def db_ctx(app: web.Application):
app[db_key] = {1: {"name": "Ann"}} # startup: open pool
yield
app[db_key].clear() # shutdown: close pool
def make_app() -> web.Application:
app = web.Application(client_max_size=1024 * 1024)
app.cleanup_ctx.append(db_ctx)
app.add_routes(routes)
return app
Build the app in a factory function (make_app()), not at import time: tests create a fresh app per test, and nothing starts until the app runs. The older on_startup / on_cleanup lists exist, but cleanup_ctx keeps open and close next to each other and cleans up even when a later startup step fails: with contexts a and b, where b raises at startup, the run recorded a-start, b-start, a-clean.
Shared client session¶
A service that calls other HTTP services should own one ClientSession for its whole life:
import aiohttp
from aiohttp import web
session_key = web.AppKey("session", aiohttp.ClientSession)
upstream_key = web.AppKey("upstream", str)
async def client_ctx(app: web.Application):
async with aiohttp.ClientSession(
app[upstream_key], timeout=aiohttp.ClientTimeout(total=5)
) as session:
app[session_key] = session
yield # session is closed on shutdown
async def proxy(request: web.Request) -> web.Response:
session = request.app[session_key]
async with session.get("/item", params={"n": request.query["n"]}) as resp:
return web.json_response(await resp.json())
Run with app[upstream_key] = "https://api.example.com" and app.cleanup_ctx.append(client_ctx). The session exists before the first request and is closed after the last.
Middleware¶
A middleware wraps every handler. Decorate with @web.middleware; middlewares run in list order (first = outermost):
@web.middleware
async def errors(request: web.Request, handler):
try:
return await handler(request)
except web.HTTPException:
raise # 404, 400, ... pass through unchanged
except Exception:
return web.json_response({"error": "internal"}, status=500)
@web.middleware
async def stamp(request: web.Request, handler):
resp = await handler(request)
resp.headers["X-Handled"] = "1"
return resp
app = web.Application(middlewares=[errors, stamp])
With these two middlewares a handler that raises RuntimeError returns 500 {'error': 'internal'} and every normal response carries X-Handled: 1. Let web.HTTPException pass through, otherwise your 404s become 500s. For authentication, request IDs, metrics and error mapping prefer middlewares to repeated code in handlers.
Request Size Limits¶
web.Application(client_max_size=...) caps the body read by request.json(), .read(), .post(). The default is 1 MiB; a larger body gets 413 Payload Too Large:
app = web.Application(client_max_size=1024) # POST of 5000 bytes → 413
Raise it deliberately for upload endpoints, or stream the body with request.content.iter_chunked(...) instead of reading it whole.
Responses¶
| Class | Use |
|---|---|
web.Response(text=, body=, status=, headers=, content_type=) |
Ordinary response |
web.json_response(data) |
JSON |
web.FileResponse(path) |
Serve a file efficiently |
web.StreamResponse() |
Stream chunks: await resp.prepare(request), await resp.write(b"..."), await resp.write_eof() |
web.HTTPFound("/new") etc. |
Redirects (raise them) |
Static files: app.router.add_static("/static", path="static"). Sub-applications for modules: app.add_subapp("/api/v1", api_app).
WebSockets¶
A WebSocket handler upgrades the connection and loops over messages:
from aiohttp import WSMsgType, web
@routes.get("/ws")
async def websocket(request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse(heartbeat=10)
await ws.prepare(request)
async for msg in ws:
if msg.type == WSMsgType.TEXT:
await ws.send_str("echo:" + msg.data)
elif msg.type == WSMsgType.ERROR:
break
return ws
Client side:
async with session.ws_connect("/ws") as ws:
await ws.send_str("hi")
msg = await ws.receive()
print(msg.data) # echo:hi
await ws.close()
| Item | Notes |
|---|---|
heartbeat=10 |
Sends a ping every 10 s and closes a dead connection; use it behind proxies that drop idle sockets |
msg.type |
TEXT, BINARY, PING, PONG, CLOSE, CLOSING, CLOSED, ERROR |
send_json(obj) / receive_json() |
JSON helpers |
await ws.close() |
Close handshake; the async for loop ends when the peer closes |
| Broadcast | Keep a set of WebSocketResponse in app[...], remove on disconnect, and await asyncio.gather(*(w.send_str(m) for w in clients), return_exceptions=True) |
| Authentication | Check the token before ws.prepare() and raise web.HTTPUnauthorized |
Remember to close every open WebSocket at shutdown (an on_shutdown handler), otherwise web.run_app waits for them until its shutdown_timeout.
For protocol-level details and test strategies see WebSocket.
Running¶
if __name__ == "__main__":
web.run_app(make_app(), host="0.0.0.0", port=8080)
run_app handles SIGINT/SIGTERM, runs the startup and cleanup hooks, and stops accepting connections on shutdown. Useful parameters: shutdown_timeout (default 60 s, how long to wait for in-flight handlers), keepalive_timeout (75 s), access_log (a logger or None), ssl_context, path / sock for Unix sockets, handler_cancellation=False (set True to cancel handlers when the client disconnects).
In production run several processes behind a reverse proxy (one container per process, or Gunicorn with aiohttp.GunicornWebWorker; check the aiohttp deployment docs for the exact app-factory syntax), and put TLS on the proxy.
Common Mistakes¶
| Mistake | Effect | Fix |
|---|---|---|
Blocking call in a handler (requests, time.sleep, heavy CPU) |
Every request stalls | await async APIs, asyncio.to_thread(), or a process pool |
Building web.Application() at import time |
Hard to test, side effects on import | make_app() factory |
| Module-level globals for DB or sessions | Leaks between tests, no clean shutdown | app[key] and cleanup_ctx |
Catching Exception in middleware without re-raising HTTPException |
404 and 400 become 500 | except web.HTTPException: raise |
Forgetting await on request.json() |
A coroutine instead of data | await request.json() |
Reading huge bodies with request.read() |
Memory use, or 413 at the default limit | Stream, or raise client_max_size knowingly |
| Not closing WebSockets on shutdown | Slow stop (waits for shutdown_timeout) |
on_shutdown closes them |
Untyped string keys in app[...] |
Warnings, no type safety | web.AppKey |