Release Wred 0.15 with tolerant query decoding

Commit f81438c · patx · 2026-11-01T16:06:27-05:00

Changeset
f81438c5eff9ddc9467aed32d6f2b3cc7ae11059
Parents
a4f75c6089d9a6febbfc98cb33463e03a67a2e82

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 779fb35..5f9c80b 100644
--- a/README.md
+++ b/README.md
@@ -126,7 +126,9 @@ async def search(request):
 ```
 
 Query parameters preserve blank values; repeated keys keep the last value.
-Invalid UTF-8 in raw or percent-encoded query bytes raises `UnicodeDecodeError`.
+Since Wred 0.15, invalid UTF-8 in raw or percent-encoded query bytes is replaced
+with `�` (U+FFFD) in both keys and values instead of raising `UnicodeDecodeError`.
+For example, `?q=%FF` produces `{'q': '�'}`. Query parsing remains lazy and cached.
 JSON can be an object, array, or scalar. Wred does not validate it or convert
 field types.
 
@@ -213,8 +215,8 @@ Exceptions propagate to your ASGI server or middleware. Wred does not turn
 exceptions into responses.
 
 * Malformed JSON raises `orjson.JSONDecodeError`.
-* Invalid UTF-8 query bytes (raw or percent-encoded) and non-ASCII request
-  header names raise `UnicodeDecodeError` when those properties are accessed.
+* Non-ASCII request header names raise `UnicodeDecodeError` when
+  `request.headers` is accessed.
 * Client disconnects during body reads raise `ConnectionError`.
 * Bodies that `orjson.dumps` cannot serialize raise `orjson.JSONEncodeError`
   (a `TypeError` subclass).
diff --git a/tests.py b/tests.py
index 174b4ec..dc59e63 100644
--- a/tests.py
+++ b/tests.py
@@ -254,19 +254,12 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
         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', '/search', b'', b'q=%FF', None, UnicodeDecodeError),
-            ('GET', '/search', b'', b'%FF=value', None, UnicodeDecodeError),
             ('GET', '/invalid', b'', b'', object(), TypeError),
             ('GET', '/invalid', b'', b'', (object(), [], 200), TypeError),
             ('GET', '/invalid', b'', b'', (b'bad', []), ValueError),
@@ -407,6 +400,10 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
         cases = [
             (None, 200, {'params': {}, 'q': ''}),
             (b'', 200, {'params': {}, 'q': ''}),
+            (b'\xff', 200, {'params': {'�': ''}, 'q': ''}),
+            (b'q=\xff', 200, {'params': {'q': '�'}, 'q': '�'}),
+            (b'q=%FF', 200, {'params': {'q': '�'}, 'q': '�'}),
+            (b'%FF=value', 200, {'params': {'�': 'value'}, 'q': ''}),
             (
                 b'q=coffee+ideas&limit=20',
                 200,
diff --git a/wred.py b/wred.py
index 69e0cad..f9d60a5 100644
--- a/wred.py
+++ b/wred.py
@@ -3,7 +3,7 @@ Wred: An ultra micro ASGI API framework.
 Copyright (c) 2026, Harrison Erd.
 """
 __author__, __email__, __license__ = 'Harrison Erd', '[email protected]', 'BSD-3-Clause'
-__version__ = '0.14'
+__version__ = '0.15'
 from collections.abc import Awaitable, Callable
 from functools import cached_property
 from typing import Any
@@ -29,8 +29,8 @@ class Request:
 
     @cached_property
     def query(self) -> dict[str, str]:
-        """Parse and cache query parameters on first access; repeated keys keep their last value."""
-        return dict(parse_qsl(self.scope.get('query_string', b'').decode(), keep_blank_values=True, errors='strict'))
+        """Parse query parameters; invalid UTF-8 becomes U+FFFD; repeated keys keep their last value."""
+        return dict(parse_qsl(self.scope.get('query_string', b'').decode('utf-8', 'replace'), keep_blank_values=True))
 
     async def body(self) -> bytes:
         """Read the request body from the ASGI receive stream."""