Return CSV errors as plain text, closes #2129

This commit is contained in:
Simon Willison 2026-09-15 15:51:01 -07:00
commit faff4c8820
4 changed files with 66 additions and 1 deletions

View file

@ -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,

View file

@ -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

View file

@ -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_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 <https://github.com/RamiNoodle733>`__. (: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 <https://github.com/AnkitaAdvitot>`__. (:issue:`2902`, :pr:`2903`)

View file

@ -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")