Service Bus send returns per-field detail on a 4xx¶
Motivation¶
When enqueueing a BLAST request onto the Service Bus request queue via the
Playground producer (POST /api/settings/service-bus/send), a body that failed
validation was rejected with:
str() on a Pydantic v2 ValidationError is a multi-line human blob that was
truncated at 400 characters, so a body failing several fields lost detail and
was not machine-parseable. The caller could not reliably tell which field
failed or why — they had to guess and retry.
The synchronous producer paths are the only place a 4xx can be returned to the
sender: the asynchronous drain → OpenAPI submit path already returned
202 queued before the sibling ever sees the request, so a drain-time 4xx is
surfaced through a failure event / failed job row, not an HTTP response.
User-facing change¶
A rejected send now returns the same diagnosable shape the native FastAPI
submit route already emits — a structured errors list plus a summary
message that names the offending field(s):
{
"code": "invalid_request",
"message": "query_fasta: Field required; program: Input should be 'blastn', ...",
"errors": [
{ "loc": "query_fasta", "msg": "Field required", "type": "missing" },
{ "loc": "program", "msg": "Input should be 'blastn', ...", "type": "literal_error" }
]
}
Backward compatible: code and message are preserved (the SPA
formatApiError reads the top-level message), and errors is additive. The
field detail exposes only loc/msg/type — never the submitted input/ctx
values, which can carry the query FASTA or other user content. The error list is
capped at 20 entries.
Hardening (post-critique)¶
A 10-round design critique + 3-round hardening pass added the following, since a
custom validator's ValueError text is the one place caller-supplied field
content can flow back into the 400 body:
- Every field
msgand the summary are run through the shared secret maskerapi.services.sanitise.sanitise(SAS / bearer / key / GUID redaction) before returning — matching the sibling_validated_submit_contractsconvention. locis length-bounded ([:200]) alongside the existingmsg([:300]), summary ([:600]), and 20-entry caps, so the 400 body is fully bounded.- The generic
except Exceptionfallback message is sanitised too.
API / IaC diff summary¶
- api/routes/settings/service_bus.py
— new
_format_validation_errors()helper;_validate_send_body()now catchespydantic.ValidationErrorand returns{code, message, errors}(the genericExceptionfallback is unchanged). New_MAX_VALIDATION_ERRORS = 20cap. - No IaC change. No new dependency (
pydanticalready a runtime dependency).
Validation evidence¶
uv run pytest -q api/tests/test_settings_service_bus.py— 29 passed (updatedtest_send_invalid_body_returns_400, newtest_send_invalid_body_reports_every_failing_field).uv run pytest -q api/tests/test_settings_service_bus.py api/tests/test_servicebus_v1_multitoken.py api/tests/test_external_blast_api.py— 183 passed.uv run ruff check api/routes/settings/service_bus.py— clean.