From a228deff90826a21a1c4b0074acfe60783b39519 Mon Sep 17 00:00:00 2001 From: kokokoXUY <13682395396@163.com> Date: Sat, 26 Sep 2026 06:37:09 +0800 Subject: [PATCH] Fix JSON exception responses for request headers (#1949) --- datasette/handle_exception.py | 15 +++++++++----- docs/json_api.rst | 9 +++++---- tests/test_error_shape.py | 37 +++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/datasette/handle_exception.py b/datasette/handle_exception.py index ef6c7b7e..8393e15b 100644 --- a/datasette/handle_exception.py +++ b/datasette/handle_exception.py @@ -55,14 +55,19 @@ def handle_exception(datasette, request, exception): headers = {} if datasette.cors: add_cors_headers(headers) - if request.path.split("?")[0].endswith(".json"): - body = dict(info) - body.update(error_body(plain_message or message, status)) - return Response.json(body, status=status, headers=headers) - if request.path.split("?")[0].endswith(".csv"): + path = request.path.split("?")[0] + if path.endswith(".csv"): return Response.text( plain_message or message, status=status, headers=headers ) + if ( + path.endswith(".json") + or "application/json" in (request.headers.get("accept") or "") + or request.headers.get("content-type") == "application/json" + ): + body = dict(info) + body.update(error_body(plain_message or message, status)) + return Response.json(body, status=status, headers=headers) info.update( { "ok": False, diff --git a/docs/json_api.rst b/docs/json_api.rst index 73212e70..b0a18019 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -129,10 +129,11 @@ Some endpoints add extra context keys. For example, a SQL error from a ``"rows"`` and ``"truncated"`` keys of the response it was unable to produce. -Permission errors use the same format: a request that fails a permission -check receives a ``403`` with this JSON error body when the URL ends in -``.json`` or the request sends an ``Accept: application/json`` or -``Content-Type: application/json`` header. +Errors handled by Datasette's exception and permission handlers use this +format when the URL ends in ``.json`` or the request sends an +``Accept: application/json`` or ``Content-Type: application/json`` header. +Without one of these signals, an error on an HTML page is rendered as HTML. +Errors on explicit ``.csv`` URLs remain plain text, regardless of headers. .. _json_api_custom_sql: diff --git a/tests/test_error_shape.py b/tests/test_error_shape.py index 94c9a7c9..b6279133 100644 --- a/tests/test_error_shape.py +++ b/tests/test_error_shape.py @@ -63,6 +63,43 @@ async def test_not_found_error_shape(ds_client): assert_canonical_error(response, 404) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "headers", + ( + {"Accept": "application/json"}, + {"Content-Type": "application/json"}, + ), +) +async def test_not_found_error_shape_with_json_request_headers(ds_client, headers): + response = await ds_client.get("/fixtures/no_such_table", headers=headers) + assert_canonical_error(response, 404) + + +@pytest.mark.asyncio +async def test_not_found_without_json_request_headers_returns_html(ds_client): + response = await ds_client.get("/fixtures/no_such_table") + assert response.status_code == 404 + assert response.headers["content-type"].startswith("text/html") + + +@pytest.mark.asyncio +async def test_csv_error_keeps_text_format_with_json_accept_header(ds_client): + response = await ds_client.get( + "/fixtures/no_such_table.csv", headers={"Accept": "application/json"} + ) + assert response.status_code == 404 + assert response.headers["content-type"].startswith("text/plain") + + +@pytest.mark.asyncio +async def test_write_api_missing_database_error_shape(ds_client): + response = await ds_client.post( + "/no_such/-/create", json={}, headers={"Content-Type": "application/json"} + ) + assert_canonical_error(response, 404) + + @pytest.mark.asyncio async def test_datasette_error_with_title_omits_title_key(ds_client): # DatasetteError(title="Invalid SQL") previously leaked a "title" key