patx/wred
Defer header parsing and inline request dispatch
Commit ab97b8e · patx · 2026-10-18T13:11:10-04:00
Defer header parsing and inline request dispatch Cache decoded headers on first access while preserving mutation and reassignment. Fold routing and response dispatch into the ASGI entry point, add lazy-header regression coverage, and document static-file error handling and production serving.
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index 53b4e70..ad53df9 100644
--- a/README.md
+++ b/README.md
@@ -102,7 +102,7 @@ with pretty much every ASGI application you can think of.
* `request.path` -> Path without the query string
* `request.method` -> HTTP method
-* `request.headers` -> Header dict with lowercase names and Latin-1 values; repeated Cookie fields join with `; `, other repeated names keep their last value
+* `request.headers` -> Header dict decoded and cached on first access, with lowercase names and Latin-1 values; repeated Cookie fields join with `; `, other repeated names keep their last value
* `request.scope` -> Original ASGI scope, including client, scheme, and middleware state
* `request.query()` -> Cached dict of string keys and values. No `await`!
* `await request.body()` -> Cached body raw bytes
@@ -150,7 +150,10 @@ async def index(request):
@app.route('/static/{filename}')
async def asset(request, filename):
- return await app.static(filename)
+ try:
+ return await app.static(filename)
+ except (OSError, ValueError):
+ return {'error': 'Not Found'}, 'application/json', 404
```
`static(filename, root='static')` returns `(bytes, content_type, status)`.
@@ -166,6 +169,8 @@ The helper does not turn these errors into HTTP responses; catch exceptions in
your application or middleware to return a 404 or another status.
Small files. Don't feed it a movie.
+Use Nginx or a similar web server to serve your app's static files in production.
+
## Errors
Exceptions propagate to your ASGI server or middleware. Malformed JSON raises
diff --git a/tests/test_wred.py b/tests/test_wred.py
index ee8deee..66c5a13 100644
--- a/tests/test_wred.py
+++ b/tests/test_wred.py
@@ -540,6 +540,50 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(messages[0]['status'], status)
self.assertEqual(orjson.loads(messages[1]['body']), body)
+ async def test_headers_are_lazy_cached_and_replaceable(self):
+ app = App()
+ reads = []
+
+ class Headers(list):
+ def __iter__(self):
+ reads.append(True)
+ return super().__iter__()
+
+ @app.route('/unused')
+ async def unused(request):
+ return {'ok': True}
+
+ @app.route('/headers')
+ async def headers(request):
+ values = request.headers
+ values['x-local'] = 'added'
+ self.assertIs(request.headers, values)
+ self.assertEqual(len(reads), 1)
+ request.headers = {'replacement': 'works'}
+ return request.headers
+
+ async def receive():
+ raise AssertionError('Header access should not read the body')
+
+ for raw in ([], [(b'X-Example', b'value')]):
+ with self.subTest(headers=raw):
+ reads.clear()
+ scope = {
+ 'type': 'http',
+ 'path': '/unused',
+ 'method': 'GET',
+ 'headers': Headers(raw),
+ }
+ messages = await call_app(app, scope, receive)
+ self.assertEqual(messages[0]['status'], 200)
+ self.assertEqual(reads, [])
+ scope['path'] = '/headers'
+ messages = await call_app(app, scope, receive)
+ self.assertEqual(
+ orjson.loads(messages[1]['body']),
+ {'replacement': 'works'},
+ )
+
async def test_streamed_body_is_cached_and_headers_are_available(self):
app = App()
reads = 0
diff --git a/wred.py b/wred.py
index ac175bf..1aa6782 100644
--- a/wred.py
+++ b/wred.py
@@ -8,6 +8,7 @@ __author__, __email__, __license__ = 'Harrison Erd', '[email protected]', 'B
__version__ = '0.08'
from collections.abc import Awaitable, Callable
from asyncio import to_thread
+from functools import cached_property
from mimetypes import guess_type
from pathlib import Path
from typing import Any
@@ -17,15 +18,20 @@ import orjson
class Request:
"""A request object encapsulating ASGI scope, receive, and send."""
def __init__(self, scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
- """Store the ASGI connection and decode request headers."""
+ """Store the ASGI connection and initialize request caches."""
self.scope, self.receive, self.send = scope, receive, send
self.path, self.method = scope['path'], scope['method']
- self.headers: dict[str, str] = {}
- for k, v in scope.get('headers', []):
- k, v = k.decode('latin-1').lower(), v.decode('latin-1')
- self.headers[k] = self.headers[k] + '; ' + v if k == 'cookie' and k in self.headers else v
self._body = self._query = None
+ @cached_property
+ def headers(self) -> dict[str, str]:
+ """Decode and cache headers on first access."""
+ headers: dict[str, str] = {}
+ for k, v in self.scope.get('headers', []):
+ k, v = k.decode('latin-1').lower(), v.decode('latin-1')
+ headers[k] = headers[k] + '; ' + v if k == 'cookie' and k in headers else v
+ return headers
+
def query(self) -> dict[str, str]:
"""Parse and cache query parameters; repeated keys keep their last value."""
if self._query is None:
@@ -62,10 +68,7 @@ class App:
"""Handle ASGI requests."""
if scope['type'] != 'http':
raise RuntimeError(f"Unsupported ASGI scope type: {scope['type']}")
- await self.handle_http(Request(scope, receive, send))
-
- async def handle_http(self, request: Request) -> None:
- """Handle HTTP requests with routing and JSON response support."""
+ request = Request(scope, receive, send)
path = request.path
root = request.scope.get('root_path', '').rstrip('/')
if root and (path == root or path.startswith(root + '/')):
@@ -73,15 +76,15 @@ class App:
path = path.rstrip('/') or '/'
method = request.method
if path in self.routes and method in self.routes[path]:
- return await self.dispatch_response(request, await self.routes[path][method](request))
- parts = path.split('/')
- for pattern, handlers in self.pattern_routes.items():
- if (params := self.match_path(pattern, parts)) is not None and method in handlers:
- return await self.dispatch_response(request, await handlers[method](request, **params))
- await self.send_response(request.send, {"error": "Not Found"}, 404)
-
- async def dispatch_response(self, request: Request, response: Any) -> None:
- """Invoke an ASGI response or serialize an ordinary handler result."""
+ response = await self.routes[path][method](request)
+ else:
+ parts = path.split('/')
+ for pattern, handlers in self.pattern_routes.items():
+ if (params := self.match_path(pattern, parts)) is not None and method in handlers:
+ response = await handlers[method](request, **params)
+ break
+ else:
+ return await self.send_response(request.send, {"error": "Not Found"}, 404)
if callable(response):
await response(request.scope, request.receive, request.send)
else: