patx/wred
Remove custom exceptions and propagate errors to ASGI servers
Commit e981cc2 · patx · 2026-10-15T20:31:56-04:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index 2403414..7db17df 100644
--- a/README.md
+++ b/README.md
@@ -91,15 +91,13 @@ with pretty much every ASGI application you can think of.
## Requests
-| API | Returns |
-| --- | --- |
-| `request.path` | Path without the query string. |
-| `request.method` | HTTP method. |
-| `request.headers` | Decoded header dict; use lowercase keys. |
-| `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 raw bytes. |
-| `await request.json()` | Parsed JSON; an empty body becomes `{}`. |
+* `request.path` -> Path without the query string
+* `request.method` -> HTTP method
+* `request.headers` -> Decoded header dict; use lowercase keys
+* `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
+* `await request.json()` -> Parsed JSON body; an empty body becomes `{}`
```python
@app.route('/search')
@@ -116,26 +114,21 @@ signed, not encrypted. Wred doesn't supply a session property.
## Responses
-Dicts and lists become JSON with HTTP 200. Bytes default to
-`application/octet-stream`. Custom responses use
-`(body, content_type, status[, headers])`:
+Dicts and lists become `application/json` with HTTP 200. Bytes default to
+`application/octet-stream`. Custom responses use `(body, content_type, status[, headers])`.
+Headers are string pairs; repeated `Set-Cookie` headers are supported.
-| Handler return | Response |
-| --- | --- |
-| `{'hello': 'world'}` | JSON object, 200. |
-| `[{'id': 1}, {'id': 2}]` | JSON array, 200. |
-| `b'hello', 'text/plain', 200` | Text response. |
-| `{'id': 3}, 'application/json', 201, [('Location', '/items/3')]` | Created resource with a custom header. |
-| `b'', 'text/plain', 303, [('Location', '/')]` | Redirect. |
-| `b'', 'application/json', 204` | No content. |
+* `{'hello': 'world'}` -> JSON object, 200
+* `[{'id': 1}, {'id': 2}]` -> JSON array, 200
+* `b'hello', 'text/plain', 200` -> Text response
+* `{'id': 3}, 'application/json', 201, [('Location', '/items/3')]` -> Created resource with a custom header
+* `b'', 'text/plain', 303, [('Location', '/')]` -> Redirect
+* `b'', 'application/json', 204` -> No content
-Dicts and lists always use `application/json`, including inside tuples.
-Other tuple bodies must be bytes. Headers are string pairs; repeated
-`Set-Cookie` headers are supported.
-
-Return a callable ASGI response object to let it manage status, headers, and
+You can also return a callable ASGI response object to let it manage status, headers, and
body through `(scope, receive, send)`. Reuse external response classes for
streaming, downloads, or SSE. Use ASGI middleware for behavior across routes.
+For example Starlette's StreamingResponse can be returned directly in a Wred handler.
## Static files
@@ -158,10 +151,31 @@ Small files. Don't feed it a movie.
## Errors
-Malformed JSON, invalid UTF-8 query strings, and `WredRequestError` produce
-JSON 400 responses: `{'error': message}`. Raise `WredRequestError` from a handler
-for application validation failures. Unsupported response types produce JSON
-500 errors; other exceptions propagate.
+Exceptions propagate to your ASGI server or middleware. Malformed JSON raises
+`orjson.JSONDecodeError`, invalid UTF-8 query strings raise `UnicodeDecodeError`,
+client disconnects during body reads raise `ConnectionError`, and unsupported
+response types raise `TypeError`. Wred does not turn exceptions into responses.
+
+Return response tuples for expected application errors. Catch parsing errors
+in your handler when you want a JSON 400 response:
+
+```python
+import orjson
+
[email protected]('/items', methods=['POST'])
+async def create_item(request):
+ try:
+ data = await request.json()
+ except orjson.JSONDecodeError:
+ return {'error': 'Invalid JSON body'}, 'application/json', 400
+ if not isinstance(data, dict) or not data.get('title'):
+ return {'error': 'A title is required'}, 'application/json', 400
+ return data, 'application/json', 201
+```
+
+Validate inputs before returning a streaming response. Once streaming starts,
+errors cannot replace its status or body; an unhandled exception propagates to
+the server without Wred attempting a second response.
## Performance
diff --git a/example_app/auth.py b/example_app/auth.py
index 9b08300..6e18398 100644
--- a/example_app/auth.py
+++ b/example_app/auth.py
@@ -7,7 +7,8 @@ from http.cookies import CookieError, SimpleCookie
from secrets import compare_digest, token_bytes, token_urlsafe
from time import time
-from wred import WredRequestError
+import orjson
+
from .rate_limit import rate_limit
token_lifetime = 3600
@@ -61,15 +62,18 @@ def token_response(token, username):
async def read_credentials(request):
- data = await request.json()
+ try:
+ data = await request.json()
+ except orjson.JSONDecodeError:
+ raise ValueError('Invalid JSON body') from None
if not isinstance(data, dict):
- raise WredRequestError('Send a JSON object with username and password')
+ raise ValueError('Send a JSON object with username and password')
username, password = data.get('username'), data.get('password')
if not isinstance(username, str) or not isinstance(password, str):
- raise WredRequestError('Username and password must be strings')
+ raise ValueError('Username and password must be strings')
username = username.strip().casefold()
if not 1 <= len(username) <= 64 or not 15 <= len(password) <= 128:
- raise WredRequestError('Use a username of 1–64 and a password of 15–128 characters')
+ raise ValueError('Use a username of 1–64 and a password of 15–128 characters')
return username, password
@@ -115,7 +119,10 @@ def register_routes(app, database):
@app.route('/api/signup', methods=['POST'])
@rate_limit(requests=10, seconds=60)
async def signup(request):
- username, password = await read_credentials(request)
+ try:
+ username, password = await read_credentials(request)
+ except ValueError as error:
+ return {'error': str(error)}, 'application/json', 400
salt = token_bytes(16)
password_hash = await hash_password(password, salt)
# MongoDB's unique _id also handles signups in different workers.
@@ -132,7 +139,10 @@ def register_routes(app, database):
'application/json',
403,
)
- username, password = await read_credentials(request)
+ try:
+ username, password = await read_credentials(request)
+ except ValueError as error:
+ return {'error': str(error)}, 'application/json', 400
# Use a dummy record for unknown users so failed logins still perform password hashing.
record = await database.get_user(username)
salt = record['salt'] if record else bytes(16)
diff --git a/example_app/notes.py b/example_app/notes.py
index 66542b5..1b9790f 100644
--- a/example_app/notes.py
+++ b/example_app/notes.py
@@ -3,24 +3,28 @@
from datetime import UTC, datetime
from secrets import token_urlsafe
-from wred import WredRequestError
+import orjson
+
from .auth import protected
async def read_note(request, partial=False):
- data = await request.json()
+ try:
+ data = await request.json()
+ except orjson.JSONDecodeError:
+ raise ValueError('Invalid JSON body') from None
if not isinstance(data, dict) or not data or data.keys() - {'title', 'body', 'pinned'}:
- raise WredRequestError('Send an object containing title, body, or pinned')
+ raise ValueError('Send an object containing title, body, or pinned')
if not partial and 'title' not in data:
- raise WredRequestError('A title is required')
+ raise ValueError('A title is required')
if 'title' in data and (
not isinstance(data['title'], str) or not 1 <= len(data['title'].strip()) <= 100
):
- raise WredRequestError('Title must contain 1–100 characters')
+ raise ValueError('Title must contain 1–100 characters')
if 'body' in data and (not isinstance(data['body'], str) or len(data['body']) > 10_000):
- raise WredRequestError('Body must be a string of at most 10,000 characters')
+ raise ValueError('Body must be a string of at most 10,000 characters')
if 'pinned' in data and not isinstance(data['pinned'], bool):
- raise WredRequestError('Pinned must be a boolean')
+ raise ValueError('Pinned must be a boolean')
if 'title' in data:
data['title'] = data['title'].strip()
return data if partial else {'body': '', 'pinned': False, **data}
@@ -43,25 +47,31 @@ def register_routes(app, database):
async def list_notes(request):
# Keep this demo's query budget at 20 fields, including repeated keys.
if request.scope.get('query_string', b'').count(b'&') >= 20:
- raise WredRequestError('Invalid query string')
- query = request.query()
+ return {'error': 'Invalid query string'}, 'application/json', 400
+ try:
+ query = request.query()
+ except UnicodeDecodeError:
+ return {'error': 'Invalid query string'}, 'application/json', 400
try:
limit = int(query.get('limit') or '5')
offset = int(query.get('offset') or '0')
except ValueError:
- raise WredRequestError('Limit and offset must be integers')
+ return {'error': 'Limit and offset must be integers'}, 'application/json', 400
if not 1 <= limit <= 50 or offset < 0:
- raise WredRequestError('Limit must be 1–50; offset must be zero or greater')
+ return {'error': 'Limit must be 1–50; offset must be zero or greater'}, 'application/json', 400
search = query.get('q', '').strip().casefold()
pinned = query.get('pinned', '')
if pinned not in ('', 'true', 'false'):
- raise WredRequestError('Pinned filter must be true or false')
+ return {'error': 'Pinned filter must be true or false'}, 'application/json', 400
return await database.list_notes(request.user['id'], search, pinned, limit, offset)
@app.route('/api/notes', methods=['POST'])
@protected(database)
async def create_note(request):
- data = await read_note(request)
+ try:
+ data = await read_note(request)
+ except ValueError as error:
+ return {'error': str(error)}, 'application/json', 400
note_id = token_urlsafe(9)
now = datetime.now(UTC).isoformat(timespec='microseconds')
note = {
@@ -85,7 +95,10 @@ def register_routes(app, database):
note = await owned_note(database, note_id, request.user)
if isinstance(note, tuple):
return note
- data = await read_note(request, partial=request.method == 'PATCH')
+ try:
+ data = await read_note(request, partial=request.method == 'PATCH')
+ except ValueError as error:
+ return {'error': str(error)}, 'application/json', 400
data['updated_at'] = datetime.now(UTC).isoformat(timespec='microseconds')
updated = await database.update_note(note_id, request.user['id'], data)
if updated is None:
diff --git a/tests/test_wred.py b/tests/test_wred.py
index c72b26b..8188d87 100644
--- a/tests/test_wred.py
+++ b/tests/test_wred.py
@@ -180,28 +180,23 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
{'type': 'http.response.body', 'body': body},
])
- async def test_custom_status_and_json_errors(self):
- # Covers the error paths that previously sent ASGI messages separately,
- # plus a response tuple returned through dynamic routing.
+ async def test_custom_status_and_explicit_json_errors(self):
app = App()
@app.route('/{name}/created')
async def created(request, name):
return {'name': name}, 'application/json', 201
- @app.route('/invalid')
- async def invalid(request):
- return 'not a supported response'
-
@app.route('/input', methods=['POST'])
async def input_data(request):
- return await request.json()
+ try:
+ return await request.json()
+ except orjson.JSONDecodeError:
+ return {'error': 'Invalid JSON body'}, 'application/json', 400
cases = [
('GET', '/alice/created', b'', 201, b'{"name":"alice"}'),
('GET', '/missing', b'', 404, b'{"error":"Not Found"}'),
- ('GET', '/invalid', b'', 500,
- b'{"error":"Response must be a dictionary, list, bytes, or response tuple"}'),
('POST', '/input', b'{', 400, b'{"error":"Invalid JSON body"}'),
('POST', '/input', b'', 200, b'{}'),
('GET', '/alice/missing', b'', 404, b'{"error":"Not Found"}'),
@@ -219,6 +214,71 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
{'type': 'http.response.body', 'body': body},
])
+ async def test_parsing_and_response_errors_propagate_without_sending(self):
+ app = App()
+
+ @app.route('/input', methods=['POST'])
+ async def input_data(request):
+ return await request.json()
+
+ @app.route('/search')
+ async def search(request):
+ return request.query()
+
+ @app.route('/invalid')
+ async def invalid(request):
+ return response
+
+ cases = [
+ ('POST', '/input', b'{', b'', None, orjson.JSONDecodeError),
+ ('GET', '/search', b'', b'\xff', None, UnicodeDecodeError),
+ ('GET', '/invalid', b'', b'', 'unsupported response', TypeError),
+ ('GET', '/invalid', b'', b'', (b'bad', 'text/plain'), TypeError),
+ ]
+ for method, path, body, query_string, response, error in cases:
+ with self.subTest(path=path, response=response):
+ messages = []
+
+ async def receive():
+ return {'type': 'http.request', 'body': body}
+
+ async def send(message):
+ messages.append(message)
+
+ with self.assertRaises(error):
+ await app({'type': 'http', 'path': path, 'method': method,
+ 'query_string': query_string}, receive, send)
+ self.assertEqual(messages, [])
+
+ async def test_callable_response_errors_do_not_replace_a_started_stream(self):
+ app = App()
+
+ @app.route('/stream', methods=['POST'])
+ async def stream(request):
+ async def response(scope, receive, send):
+ await send({'type': 'http.response.start', 'status': 200,
+ 'headers': [(b'content-type', b'text/plain')]})
+ await send({'type': 'http.response.body', 'body': b'hello\n', 'more_body': True})
+ await request.json()
+
+ return response
+
+ async def receive():
+ return {'type': 'http.request', 'body': b'{'}
+
+ messages = []
+
+ async def send(message):
+ messages.append(message)
+
+ with self.assertRaises(orjson.JSONDecodeError):
+ await app({'type': 'http', 'path': '/stream', 'method': 'POST'}, receive, send)
+ self.assertEqual(messages, [
+ {'type': 'http.response.start', 'status': 200,
+ 'headers': [(b'content-type', b'text/plain')]},
+ {'type': 'http.response.body', 'body': b'hello\n', 'more_body': True},
+ ])
+
async def test_route_priority_and_method_registration(self):
app = App()
@@ -264,7 +324,6 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
{'params': {'q': 'C++', 'label': 'café', 'x&y': 'a=b'}, 'q': 'C++'}),
(b'q=&bare&tag=first&tag=last', 200,
{'params': {'q': '', 'bare': '', 'tag': 'last'}, 'q': ''}),
- (b'\xff', 400, {'error': 'Invalid query string'}),
]
for query_string, status, body in cases:
with self.subTest(query_string=query_string):
@@ -326,12 +385,15 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
raise AssertionError('Body reading continued after disconnect')
return {'type': 'http.disconnect'}
- messages = await call_app(app, {'type': 'http', 'path': '/input', 'method': 'POST'}, receive)
- self.assertEqual(messages, [
- {'type': 'http.response.start', 'status': 400,
- 'headers': [(b'content-type', b'application/json')]},
- {'type': 'http.response.body', 'body': b'{"error":"Client disconnected"}'},
- ])
+ messages = []
+
+ async def send(message):
+ messages.append(message)
+
+ with self.assertRaisesRegex(ConnectionError, 'Client disconnected'):
+ await app({'type': 'http', 'path': '/input', 'method': 'POST'}, receive, send)
+ self.assertEqual(messages, [])
+ self.assertEqual(reads, 1)
async def test_unsupported_protocols_raise_without_receiving_or_sending(self):
app = App()
@@ -383,8 +445,6 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
((b'<p>Hello</p>', 'text/html; charset=utf-8', 202),
202, b'text/html; charset=utf-8', b'<p>Hello</p>', []),
(([], 'application/json', 201), 201, b'application/json', b'[]', []),
- ((b'bad', 'text/plain'), 500, b'application/json',
- b'{"error":"Response must be a dictionary, list, bytes, or response tuple"}', []),
(({'error': 'Unauthorized'}, 'application/json', 401,
[('WWW-Authenticate', 'Bearer'), ('Cache-Control', 'no-store')]),
401, b'application/json', b'{"error":"Unauthorized"}',
diff --git a/wred.py b/wred.py
index b06264f..8d35faa 100644
--- a/wred.py
+++ b/wred.py
@@ -10,7 +10,7 @@ Copyright (c) 2026, Harrison Erd.
__author__ = 'Harrison Erd'
__email__ = '[email protected]'
-__version__ = '0.06'
+__version__ = '0.07'
__license__ = 'BSD-3-Clause'
from collections.abc import Awaitable, Callable
@@ -22,15 +22,6 @@ from urllib.parse import parse_qsl
import orjson
-class WredResponseError(Exception):
- """Raised when a handler returns a non-JSON response."""
- pass
-
-class WredRequestError(Exception):
- """Raised when a request cannot be processed (e.g., invalid body)."""
- pass
-
-
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]]):
@@ -46,10 +37,7 @@ class Request:
def query(self) -> dict[str, str]:
"""Parse and cache query parameters; repeated keys keep their last value."""
if self._query is None:
- try:
- self._query = dict(parse_qsl(self.scope.get('query_string', b'').decode('utf-8'), keep_blank_values=True))
- except UnicodeDecodeError:
- raise WredRequestError("Invalid query string")
+ self._query = dict(parse_qsl(self.scope.get('query_string', b'').decode('utf-8'), keep_blank_values=True))
return self._query
async def body(self) -> bytes:
@@ -59,7 +47,7 @@ class Request:
while True:
message = await self.receive()
if message['type'] != 'http.request':
- raise WredRequestError("Client disconnected")
+ raise ConnectionError("Client disconnected")
chunks.append(message.get('body', b''))
if not message.get('more_body', False):
break
@@ -68,11 +56,8 @@ class Request:
async def json(self) -> dict[str, Any]:
"""Parse the request body as JSON."""
- try:
- body = await self.body()
- return orjson.loads(body) if body else {}
- except orjson.JSONDecodeError:
- raise WredRequestError("Invalid JSON body")
+ body = await self.body()
+ return orjson.loads(body) if body else {}
class App:
@@ -96,29 +81,21 @@ class App:
if root_path and (path == root_path or path.startswith(root_path + '/')):
path = path[len(root_path):] or '/'
method = request.method
- try:
- # Check exact matches first
- if path in self.routes and method in self.routes[path]:
- handler = self.routes[path][method]
- response = await handler(request)
+ if path in self.routes and method in self.routes[path]:
+ handler = self.routes[path][method]
+ response = await handler(request)
+ await self.dispatch_response(request, response)
+ return
+
+ for pattern, method_handlers in self.pattern_routes.items():
+ params = self.match_path(pattern, path)
+ if params is not None and method in method_handlers:
+ handler = method_handlers[method]
+ response = await handler(request, **params)
await self.dispatch_response(request, response)
return
- # Check dynamic routes
- for pattern, method_handlers in self.pattern_routes.items():
- params = self.match_path(pattern, path)
- if params is not None and method in method_handlers:
- handler = method_handlers[method]
- response = await handler(request, **params)
- await self.dispatch_response(request, response)
- return
-
- # No match found
- await self.send_response(request.send, {"error": "Not Found"}, status=404)
-
- except (WredResponseError, WredRequestError) as e:
- status = 400 if isinstance(e, WredRequestError) else 500
- await self.send_response(request.send, {"error": str(e)}, status=status)
+ await self.send_response(request.send, {"error": "Not Found"}, status=404)
async def dispatch_response(self, request: Request, response: Any) -> None:
"""Invoke an ASGI response or serialize an ordinary handler result."""
@@ -138,7 +115,7 @@ class App:
if isinstance(body, (dict, list)):
body, content_type = orjson.dumps(body), 'application/json'
elif not isinstance(body, bytes):
- raise WredResponseError("Response must be a dictionary, list, bytes, or response tuple")
+ raise TypeError("Response must be a dictionary, list, bytes, or response tuple")
await send({
'type': 'http.response.start',
'status': status,