Return JSON 404 errors from static file helper

Commit 1f824ad · patx · 2026-10-18T00:09:01-04:00

Changeset
1f824ad885f11ea4aa0d76cdc50dbd28cd92368b
Parents
ea528879186ab1c63165ff19b31bc72857a632d9

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 95d7b3c..3a9fc5f 100644
--- a/README.md
+++ b/README.md
@@ -157,7 +157,7 @@ async def asset(request, filename):
 Omitting `root`, passing `''`, or passing `None` uses the `static` directory in the
 current working directory. Set `root` to a string or `Path` to use another directory.
 MIME types are guessed; missing, unreadable, or outside-root files return
-plain-text 404, including symlink escapes. Files are read in a worker thread, entirely into memory.
+JSON 404 (`{"error":"Not Found"}`), including symlink escapes. Files are read in a worker thread, entirely into memory.
 Small files. Don't feed it a movie.
 
 ## Errors
diff --git a/tests/test_wred.py b/tests/test_wred.py
index 4643a96..434e6d9 100644
--- a/tests/test_wred.py
+++ b/tests/test_wred.py
@@ -131,12 +131,12 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
             cases = [
                 ('index.html', 200, b'text/html', b'<p>Hello</p>'),
                 ('asset.unknownextension', 200, b'application/octet-stream', b'\x00\xff'),
-                ('missing.txt', 404, b'text/plain; charset=utf-8', b'Not Found'),
-                ('.', 404, b'text/plain; charset=utf-8', b'Not Found'),
-                ('../secret.txt', 404, b'text/plain; charset=utf-8', b'Not Found'),
-                (str(base / 'secret.txt'), 404, b'text/plain; charset=utf-8', b'Not Found'),
-                ('escape.txt', 404, b'text/plain; charset=utf-8', b'Not Found'),
-                ('invalid\x00.txt', 404, b'text/plain; charset=utf-8', b'Not Found'),
+                ('missing.txt', 404, b'application/json', b'{"error":"Not Found"}'),
+                ('.', 404, b'application/json', b'{"error":"Not Found"}'),
+                ('../secret.txt', 404, b'application/json', b'{"error":"Not Found"}'),
+                (str(base / 'secret.txt'), 404, b'application/json', b'{"error":"Not Found"}'),
+                ('escape.txt', 404, b'application/json', b'{"error":"Not Found"}'),
+                ('invalid\x00.txt', 404, b'application/json', b'{"error":"Not Found"}'),
             ]
             cases = [
                 (options, *case)
@@ -440,8 +440,8 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
                 messages = await call_app(app, {'type': 'http', 'path': '/', 'method': 'GET'}, receive)
             self.assertEqual(messages, [
                 {'type': 'http.response.start', 'status': 404,
-                 'headers': [(b'content-type', b'text/plain; charset=utf-8')]},
-                {'type': 'http.response.body', 'body': b'Not Found'},
+                 'headers': [(b'content-type', b'application/json')]},
+                {'type': 'http.response.body', 'body': b'{"error":"Not Found"}'},
             ])
 
     async def test_raw_and_custom_response_bodies(self):
diff --git a/wred.py b/wred.py
index b6e03e8..c4a01fb 100644
--- a/wred.py
+++ b/wred.py
@@ -107,9 +107,9 @@ class App:
             try:
                 directory = Path(root or 'static').resolve()
                 path = (directory / filename).resolve()
-                if not path.is_relative_to(directory) or not path.is_file(): return b'Not Found', 'text/plain; charset=utf-8', 404
+                if not path.is_relative_to(directory) or not path.is_file(): return b'{"error":"Not Found"}', 'application/json', 404
                 return path.read_bytes(), guess_type(path.name)[0] or 'application/octet-stream', 200
-            except (OSError, ValueError, RuntimeError): return b'Not Found', 'text/plain; charset=utf-8', 404
+            except (OSError, ValueError, RuntimeError): return b'{"error":"Not Found"}', 'application/json', 404
         return await to_thread(read)
 
     def route(self, path: str, methods: list[str] | tuple[str, ...] = ('GET',)) -> Callable: