patx/wred
Reject unsupported ASGI protocols and centralize response dispatch
Commit 03b8e05 · patx · 2026-10-13T03:54:55-04:00
Comments
No comments yet.
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'