Remove custom exceptions and propagate errors to ASGI servers

Commit e981cc2 · patx · 2026-10-15T20:31:56-04:00

Changeset
e981cc244367e01f3be26a593e54b59bea483db2
Parents
0e3f7906d39c87e6d2b4f7ce6b7627b5ddf52be4

View source at this commit

Comments

No comments yet.

Log in to comment

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,