patx/wred
Release Wred 0.15 with tolerant query decoding
Commit f81438c · patx · 2026-11-01T16:06:27-05:00
Comments
No comments yet.
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."""