fix(search): complete the query error surface across every endpoint

Four pieces of the same surface:

whoosh-compat's emit() documents a two-part host contract: both a parse
diagnostic and the QueryEmitError/UnsupportedQueryError pair are
user-input errors. Only the latter half was caught; QueryEmitError now
maps to SearchQueryError too. Messages pass through a cleanup that
strips the library's DIVERGENCES.md references and replaces the
fast=True host-configuration advice with user language, so no
library-internal vocabulary reaches a searching user.

The bulk selection paths (bulk edit, the legacy bulk endpoint, bulk
download) reached the backend with no SearchQueryError handler, so a
bad date or number in a selection filter raised straight to a DRF 500.
They now share the search list endpoint's exact mapping (a new
search_query_error_messages helper flattens MultipleSearchQueryErrors
in one place), returning the same 400 body for the same bad query.

QueryParserError means a whoosh-compat parser bug, not user-fixable
input, per its own contract; the list endpoint's blanket handler was
converting it to a generic 400. It now re-raises and surfaces as a 500
that monitoring can see.

All behavior is pinned test-first: bulk edit and bulk download API
tests assert 400s naming the bad value (previously unhandled
exceptions), a unit test pins the QueryEmitError mapping, three
parametrized checks assert no internal vocabulary leaks for the
unsupported query shapes, and a mocked parser-bug test asserts the 500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMsn6DgzbvSqh1pwy66VVF
This commit is contained in:
Trenton Holmes
2026-08-19 13:36:53 -07:00
committed by stumpylog
co-authored by Claude Fable 5
parent 98edd75220
commit efb6e4b0f1
7 changed files with 180 additions and 19 deletions
+31 -17
View File
@@ -2416,12 +2416,14 @@ class UnifiedSearchViewSet(DocumentViewSet):
if not self._is_search_request():
return super().list(request)
from documents.search import MultipleSearchQueryErrors
from whoosh_compat.errors import QueryParserError
from documents.search import SearchHit
from documents.search import SearchQueryError
from documents.search import TantivyBackend
from documents.search import TantivyRelevanceList
from documents.search import get_backend
from documents.search import search_query_error_messages
def parse_search_params() -> SearchParams:
"""Extract query string, search mode, and ordering from request."""
@@ -2615,12 +2617,12 @@ class UnifiedSearchViewSet(DocumentViewSet):
# User-fixable query error(s) (e.g. unparsable dates/numbers):
# surface every offending field's message, not just the first,
# so the user can fix them all in one round-trip.
messages = (
[str(sub) for sub in e.errors]
if isinstance(e, MultipleSearchQueryErrors)
else [str(e)]
)
raise ValidationError({"query": messages}) from e
raise ValidationError({"query": search_query_error_messages(e)}) from e
except QueryParserError:
# A whoosh-compat parser BUG (its own contract: not user-fixable
# input). Let it surface as a 500 monitoring can see instead of
# a 400 blaming the user for a library defect.
raise
except Exception as e:
logger.warning(f"An error occurred listing search results: {e!s}")
return HttpResponseBadRequest(
@@ -2767,17 +2769,29 @@ class DocumentSelectionMixin:
backend = get_backend()
search_user = None if user.is_superuser else user
if filter_name == "more_like_id":
more_like_doc_id = _get_more_like_id(filters, user)
from documents.search import SearchQueryError
from documents.search import search_query_error_messages
search_ids = backend.more_like_this_ids(more_like_doc_id, user=search_user)
else:
query_str, search_mode = _get_tantivy_query_and_mode(filters)
search_ids = backend.search_ids(
query_str,
user=search_user,
search_mode=search_mode,
)
try:
if filter_name == "more_like_id":
more_like_doc_id = _get_more_like_id(filters, user)
search_ids = backend.more_like_this_ids(
more_like_doc_id,
user=search_user,
)
else:
query_str, search_mode = _get_tantivy_query_and_mode(filters)
search_ids = backend.search_ids(
query_str,
user=search_user,
search_mode=search_mode,
)
except SearchQueryError as e:
# Same user-fixable-query mapping as the search list endpoint:
# a bad date/number in a bulk selection filter is a 400 naming
# the value, never a 500.
raise ValidationError({"query": search_query_error_messages(e)}) from e
return search_ids