Skip to content

Commit 6c690af

Browse files
[FIX] Surface non_field_errors and name resources in 404s in the central DRF error path (#2099)
* [FIX] Surface non_field_errors as a toast instead of failing silently When a form passes setBackendErrors, useExceptionHandler routed validation errors to inline field rendering only and returned no alert. Errors whose attr is non_field_errors (or has no attr) map to no input field, and getBackendErrorDetail matches errors by attr, so these were never rendered anywhere -> the request appeared to silently succeed. This surfaced after the DRF 3.15 bump, where duplicate-create errors arrive as nested non_field_errors ("...must make a unique set") instead of a top-level detail string. Affected user-visible flows include duplicate LLM profile name and table settings save. Keep inline field errors as-is; additionally collect any non-field/attr-less errors and show them in a toast. The toast relays only the backend-provided detail (not the attr label), so nothing beyond the message itself is exposed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8hAHc4HUo42zY1g9LAjKu * [FIX] Address review: guard non-array errors, use bullet separator - Array.isArray guard before filter (avoids throw on non-array error payload) - Join multiple non-field messages with bullet, matching the other branch Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8hAHc4HUo42zY1g9LAjKu * [MISC] Tighten non-field-error comments to concise WHY-only Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8hAHc4HUo42zY1g9LAjKu * [FIX] Name the resource in DRF 404s via verbose_name DRF returns a bare "Not found." for every 404 across all apps, giving users no context. Enrich the detail in the central exception handler using the view's queryset model `verbose_name`, so 404s read "<Resource> not found." app-wide with no per-view work. Models can tune their label via `Meta.verbose_name`; views without a static `queryset` keep the generic message. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X8t7DFHq7Dj655kEywKk3K * [FIX] Drop DEBUG=True from test settings (SonarCloud S4507) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X8t7DFHq7Dj655kEywKk3K * [FIX] Guard non-dict error items in 404 enrichment (CodeRabbit) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X8t7DFHq7Dj655kEywKk3K --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 550f9e5 commit 6c690af

3 files changed

Lines changed: 104 additions & 0 deletions

File tree

backend/middleware/exception.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,37 @@ def drf_logging_exc_handler(exc: Exception, context: Any) -> Response | None:
4747
return response
4848

4949
response: Response | None = exception_handler(exc=exc, context=context)
50+
_enrich_not_found_detail(response=response, context=context)
5051
ExceptionLoggingMiddleware.format_exc_and_log(
5152
request=request, response=response, exception=exc
5253
)
5354
return response
5455

5556

57+
def _enrich_not_found_detail(response: Response | None, context: Any) -> None:
58+
"""Replace DRF's generic "Not found." with the resource's name.
59+
60+
Derives a human label from the view's `queryset` model so every 404
61+
reads "<Resource> not found." app-wide, no per-view work. Uses the
62+
static `queryset` attr (not `get_queryset()`) to avoid running view
63+
logic during error handling; views without it keep the generic message.
64+
Override `Meta.verbose_name` to tune a model's label.
65+
"""
66+
if response is None or getattr(response, "status_code", None) != 404:
67+
return
68+
data = getattr(response, "data", None)
69+
if not isinstance(data, dict):
70+
return
71+
model = getattr(getattr(context.get("view"), "queryset", None), "model", None)
72+
if model is None:
73+
return
74+
label = str(model._meta.verbose_name).capitalize()
75+
for err in data.get("errors", []):
76+
# Guard: a raise here would mask the original error with a 500.
77+
if isinstance(err, dict) and err.get("code") == "not_found":
78+
err["detail"] = f"{label} not found."
79+
80+
5681
class ExceptionLoggingMiddleware:
5782
"""Custom middleware to log unhandled errors.
5883
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Unit checks for the 404 detail enrichment in the DRF exception handler.
2+
3+
Pure-logic; uses fakes so no DB or real models are needed. Importing the
4+
handler pulls DRF, which reads settings at import — configure a minimal
5+
settings object when the suite hasn't already, so this runs standalone too.
6+
"""
7+
8+
import django
9+
from django.conf import settings
10+
11+
if not settings.configured:
12+
settings.configure(INSTALLED_APPS=[], DATABASES={})
13+
django.setup()
14+
15+
from middleware.exception import _enrich_not_found_detail # noqa: E402
16+
17+
18+
class _Meta:
19+
verbose_name = "lookup definition"
20+
21+
22+
class _Model:
23+
_meta = _Meta
24+
25+
26+
class _QuerySet:
27+
model = _Model
28+
29+
30+
class _View:
31+
queryset = _QuerySet
32+
33+
34+
class _Resp:
35+
def __init__(self, status_code, data):
36+
self.status_code = status_code
37+
self.data = data
38+
39+
40+
def test_404_with_model_uses_verbose_name():
41+
resp = _Resp(404, {"errors": [{"code": "not_found", "detail": "Not found."}]})
42+
_enrich_not_found_detail(resp, {"view": _View()})
43+
assert resp.data["errors"][0]["detail"] == "Lookup definition not found."
44+
45+
46+
def test_404_without_queryset_keeps_generic():
47+
resp = _Resp(404, {"errors": [{"code": "not_found", "detail": "Not found."}]})
48+
_enrich_not_found_detail(resp, {"view": object()})
49+
assert resp.data["errors"][0]["detail"] == "Not found."
50+
51+
52+
def test_non_404_untouched():
53+
resp = _Resp(400, {"errors": [{"code": "not_found", "detail": "Not found."}]})
54+
_enrich_not_found_detail(resp, {"view": _View()})
55+
assert resp.data["errors"][0]["detail"] == "Not found."
56+
57+
58+
def test_none_response_is_safe():
59+
_enrich_not_found_detail(None, {})
60+
61+
62+
def test_non_dict_error_item_does_not_raise():
63+
resp = _Resp(
64+
404, {"errors": ["unexpected", {"code": "not_found", "detail": "Not found."}]}
65+
)
66+
_enrich_not_found_detail(resp, {"view": _View()})
67+
assert resp.data["errors"][1]["detail"] == "Lookup definition not found."

frontend/src/hooks/useExceptionHandler.jsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ const useExceptionHandler = () => {
4848
// Handle validation errors
4949
if (setBackendErrors) {
5050
setBackendErrors(err?.response?.data);
51+
// Non-field errors map to no input and would vanish silently;
52+
// surface them as a toast. Field-bound errors render inline.
53+
const nonFieldErrors = (Array.isArray(errors) ? errors : []).filter(
54+
(error) => !error?.attr || error.attr === "non_field_errors",
55+
);
56+
if (nonFieldErrors.length > 0) {
57+
return alert(
58+
nonFieldErrors
59+
.map((error) => error?.detail || errMessage)
60+
.join(" • "),
61+
);
62+
}
5163
} else {
5264
// Handle both single error and array of errors
5365
let errorMessage = "Validation error";

0 commit comments

Comments
 (0)