Defer header parsing and inline request dispatch

Commit ab97b8e · patx · 2026-10-18T13:11:10-04:00

Changeset
ab97b8e039c1bc21d7006b7be8d01f6ac8e8bbb0
Parents
c97bae779b1223e421e42d49534ef10068592205

View source at this commit

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.

Log in to comment

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: