Reject unsupported ASGI protocols and centralize response dispatch

Commit 03b8e05 · patx · 2026-10-13T03:54:55-04:00

Changeset
03b8e0524050df380f195061476b74a5d62fc18c
Parents
53355aee335c789e64c96fc7226330ec7b05e557

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 5b4cde5..3a0495b 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,9 @@ And smoke it:
 uv run granian --interface asginl app:app
 ```
 
-`asginl` disables lifespan handling. Wred doesn't implement it.
+`asginl` disables lifespan handling. Wred is HTTP-only and rejects lifespan,
+WebSocket, and other unsupported scopes with `RuntimeError`, without sending
+HTTP messages. This follows the [ASGI protocol guidance](https://asgi.readthedocs.io/en/latest/specs/main.html#protocol-specifications).
 
 The server can run a middleware wrapper around Wred as its `app` too.
 [Starlette's middleware](https://starlette.dev/middleware/#using-middleware-in-other-frameworks)
diff --git a/tests/test_wred.py b/tests/test_wred.py
index 9ec2228..c345f03 100644
--- a/tests/test_wred.py
+++ b/tests/test_wred.py
@@ -292,18 +292,22 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
             {'type': 'http.response.body', 'body': b'{"error":"Client disconnected"}'},
         ])
 
-    async def test_unsupported_scope_does_not_read_http_fields(self):
+    async def test_unsupported_protocols_raise_without_receiving_or_sending(self):
         app = App()
 
         async def receive():
             raise AssertionError('An unsupported scope should not be read')
 
-        messages = await call_app(app, {'type': 'unsupported'}, receive)
-        self.assertEqual(messages, [
-            {'type': 'http.response.start', 'status': 400,
-             'headers': [(b'content-type', b'application/json')]},
-            {'type': 'http.response.body', 'body': b'{"error":"Unsupported scope type"}'},
-        ])
+        for protocol in ['lifespan', 'websocket', 'unsupported']:
+            with self.subTest(protocol=protocol):
+                messages = []
+
+                async def send(message):
+                    messages.append(message)
+
+                with self.assertRaisesRegex(RuntimeError, 'Unsupported ASGI scope type'):
+                    await app({'type': protocol}, receive, send)
+                self.assertEqual(messages, [])
 
     async def test_file_read_failure_returns_404(self):
         app = App()
diff --git a/wred.py b/wred.py
index 2bbd0d7..e113a69 100644
--- a/wred.py
+++ b/wred.py
@@ -72,8 +72,7 @@ class App:
     async def __call__(self, scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
         """Handle ASGI requests."""
         if scope['type'] != 'http':
-            await self.send_response(send, {"error": "Unsupported scope type"}, status=400)
-            return
+            raise RuntimeError(f"Unsupported ASGI scope type: {scope['type']}")
 
         request = Request(scope, receive, send)
         await self.handle_http(request)
@@ -90,10 +89,7 @@ class App:
             if path in self.routes and method in self.routes[path]:
                 handler = self.routes[path][method]
                 response = await handler(request)
-                if callable(response):
-                    await response(request.scope, request.receive, request.send)
-                else:
-                    await self.send_response(request.send, response)
+                await self.dispatch_response(request, response)
                 return
 
             # Check dynamic routes
@@ -102,10 +98,7 @@ class App:
                 if params is not None and method in method_handlers:
                     handler = method_handlers[method]
                     response = await handler(request, **params)
-                    if callable(response):
-                        await response(request.scope, request.receive, request.send)
-                    else:
-                        await self.send_response(request.send, response)
+                    await self.dispatch_response(request, response)
                     return
 
             # No match found
@@ -115,6 +108,13 @@ class App:
             status = 400 if isinstance(e, WredRequestError) else 500
             await self.send_response(request.send, {"error": str(e)}, status=status)
 
+    async def dispatch_response(self, request: Request, response: Any) -> None:
+        """Invoke an ASGI response or serialize an ordinary handler result."""
+        if callable(response):
+            await response(request.scope, request.receive, request.send)
+        else:
+            await self.send_response(request.send, response)
+
     async def send_response(self, send: Callable[[dict[str, Any]], Awaitable[None]], response: Any, status: int = 200) -> None:
         """Send JSON or (body, content_type, status[, headers]) with string header pairs."""
         body, content_type = response, 'application/octet-stream'