From faff4c8820b231bfd771592cbe588e09834f5577 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 15 Sep 2026 15:51:01 -0700 Subject: [PATCH] Return CSV errors as plain text, closes #2129 --- datasette/handle_exception.py | 4 +++ datasette/views/base.py | 2 +- docs/changelog.rst | 1 + tests/test_csv.py | 60 +++++++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/datasette/handle_exception.py b/datasette/handle_exception.py index c36d5dbe..ef6c7b7e 100644 --- a/datasette/handle_exception.py +++ b/datasette/handle_exception.py @@ -59,6 +59,10 @@ def handle_exception(datasette, request, exception): 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"): + return Response.text( + plain_message or message, status=status, headers=headers + ) info.update( { "ok": False, diff --git a/datasette/views/base.py b/datasette/views/base.py index 48108cec..7262edb4 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -35,7 +35,7 @@ class DatasetteError(Exception): self.error_dict = error_dict or {} self.status = status self.message_is_html = message_is_html - # Plain text used for JSON error responses when message is HTML + # Plain text used for JSON and CSV error responses when message is HTML self.plain_message = plain_message diff --git a/docs/changelog.rst b/docs/changelog.rst index 4d0f719e..a22f2254 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,7 @@ Datasette plugins can now use **background tasks** to run code independent of th Bug fixes ~~~~~~~~~ +- CSV endpoints now return plain-text error messages for SQL errors. (:issue:`2129`) - The :ref:`render_cell() ` plugin hook now receives an empty ``pks`` list when rendering SQL views in HTML, matching the JSON ``?_extra=render_cell`` behavior. (:issue:`2639`) - Numeric comparison filters now correctly handle decimal values, negative numbers and scientific notation when filtering computed columns and SQL views. Thanks, `Rami Abdelrazzaq `__. (:issue:`1681`, :pr:`2876`) - Fixed CSV streaming with ``?_stream=on`` on SQL views repeating the second page of results until the CSV size limit was reached. Thanks, `Ankita Advitot `__. (:issue:`2902`, :pr:`2903`) diff --git a/tests/test_csv.py b/tests/test_csv.py index f6660d9a..adae7e24 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -166,6 +166,66 @@ async def test_custom_sql_csv(ds_client): assert response.text == EXPECTED_CUSTOM_CSV +@pytest.mark.asyncio +@pytest.mark.parametrize("download", (False, True)) +@pytest.mark.parametrize( + "query_string,expected_error", + ( + ("sql=select+blah", "no such column: blah"), + ("sql=select+*+from+missing", "no such table: missing"), + ("sql=select+from", 'near "from": syntax error'), + ( + "sql=delete+from+simple_primary_key", + "Statement must be a SELECT", + ), + ("", "?sql= is required"), + ( + "sql=select+sleep(0.01)&_timelimit=5", + ( + "SQL query took too long. The time limit is" + " controlled by the sql_time_limit_ms setting." + ), + ), + ), +) +async def test_custom_sql_csv_errors(ds_client, query_string, expected_error, download): + if download: + query_string += "&_dl=1" + response = await ds_client.get(f"/fixtures/-/query.csv?{query_string}") + assert response.status_code == 400 + assert response.headers["content-type"] == "text/plain; charset=utf-8" + assert "content-disposition" not in response.headers + assert response.text == expected_error + + +@pytest.mark.asyncio +async def test_custom_sql_csv_error_head(ds_client): + response = await ds_client.head("/fixtures/-/query.csv?sql=select+blah") + assert response.status_code == 400 + assert response.headers["content-type"] == "text/plain; charset=utf-8" + assert response.content == b"" + + +@pytest.mark.asyncio +async def test_custom_sql_csv_error_cors(): + ds = Datasette(cors=True) + response = await ds.client.get("/_memory/-/query.csv?sql=select+blah") + assert response.status_code == 400 + assert response.headers["content-type"] == "text/plain; charset=utf-8" + assert response.headers["access-control-allow-origin"] == "*" + assert response.text == "no such column: blah" + + +@pytest.mark.asyncio +async def test_table_csv_error(ds_client): + response = await ds_client.get( + "/fixtures/simple_primary_key.csv?_where=blah&_stream=1" + ) + assert response.status_code == 400 + assert response.headers["content-type"] == "text/plain; charset=utf-8" + assert response.text == "no such column: blah" + + @pytest.mark.asyncio async def test_table_csv_download(ds_client): response = await ds_client.get("/fixtures/simple_primary_key.csv?_dl=1")