From bde1a5af328cd3048acbfa5adb0a100fccdce6b1 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Sun, 21 Jun 2026 00:32:42 +0530 Subject: [PATCH 1/6] [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) Claude-Session: https://claude.ai/code/session_01G8hAHc4HUo42zY1g9LAjKu --- frontend/src/hooks/useExceptionHandler.jsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/hooks/useExceptionHandler.jsx b/frontend/src/hooks/useExceptionHandler.jsx index 62917a8f0a..8ce2660bf4 100644 --- a/frontend/src/hooks/useExceptionHandler.jsx +++ b/frontend/src/hooks/useExceptionHandler.jsx @@ -48,6 +48,20 @@ const useExceptionHandler = () => { // Handle validation errors if (setBackendErrors) { setBackendErrors(err?.response?.data); + // Field-bound errors render inline next to their input. Errors not + // tied to a field (non_field_errors / attr-less) map to no input and + // would otherwise vanish silently — surface them as a toast. + const nonFieldErrors = (errors || []).filter( + (error) => !error?.attr || error.attr === "non_field_errors", + ); + if (nonFieldErrors.length > 0) { + return alert( + nonFieldErrors + .map((error) => error?.detail || errMessage) + .join("\n"), + ); + } + // No non-field errors: field-level errors are rendered inline. } else { // Handle both single error and array of errors let errorMessage = "Validation error"; From fba80b75cc8f63f5f25f410bc6da537f91b3a347 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 22 Jun 2026 21:00:47 +0530 Subject: [PATCH 2/6] [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 Claude-Session: https://claude.ai/code/session_01G8hAHc4HUo42zY1g9LAjKu --- frontend/src/hooks/useExceptionHandler.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/useExceptionHandler.jsx b/frontend/src/hooks/useExceptionHandler.jsx index 8ce2660bf4..5144c1a194 100644 --- a/frontend/src/hooks/useExceptionHandler.jsx +++ b/frontend/src/hooks/useExceptionHandler.jsx @@ -51,14 +51,14 @@ const useExceptionHandler = () => { // Field-bound errors render inline next to their input. Errors not // tied to a field (non_field_errors / attr-less) map to no input and // would otherwise vanish silently — surface them as a toast. - const nonFieldErrors = (errors || []).filter( + const nonFieldErrors = (Array.isArray(errors) ? errors : []).filter( (error) => !error?.attr || error.attr === "non_field_errors", ); if (nonFieldErrors.length > 0) { return alert( nonFieldErrors .map((error) => error?.detail || errMessage) - .join("\n"), + .join(" • "), ); } // No non-field errors: field-level errors are rendered inline. From 1b654ed5aedd60c5f9958d1a3b6e820a7dd3c225 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 23 Jun 2026 12:24:34 +0530 Subject: [PATCH 3/6] [MISC] Tighten non-field-error comments to concise WHY-only Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01G8hAHc4HUo42zY1g9LAjKu --- frontend/src/hooks/useExceptionHandler.jsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/src/hooks/useExceptionHandler.jsx b/frontend/src/hooks/useExceptionHandler.jsx index 5144c1a194..eef7dbfc7b 100644 --- a/frontend/src/hooks/useExceptionHandler.jsx +++ b/frontend/src/hooks/useExceptionHandler.jsx @@ -48,9 +48,8 @@ const useExceptionHandler = () => { // Handle validation errors if (setBackendErrors) { setBackendErrors(err?.response?.data); - // Field-bound errors render inline next to their input. Errors not - // tied to a field (non_field_errors / attr-less) map to no input and - // would otherwise vanish silently — surface them as a toast. + // Non-field errors map to no input and would vanish silently; + // surface them as a toast. Field-bound errors render inline. const nonFieldErrors = (Array.isArray(errors) ? errors : []).filter( (error) => !error?.attr || error.attr === "non_field_errors", ); @@ -61,7 +60,6 @@ const useExceptionHandler = () => { .join(" • "), ); } - // No non-field errors: field-level errors are rendered inline. } else { // Handle both single error and array of errors let errorMessage = "Validation error"; From e6613addd983f15f62a7899d9ded52cbca3b4600 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 24 Jun 2026 12:06:23 +0530 Subject: [PATCH 4/6] [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 " 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 Claude-Session: https://claude.ai/code/session_01X8t7DFHq7Dj655kEywKk3K --- backend/middleware/exception.py | 24 +++++++++++ backend/middleware/test_exception.py | 59 ++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 backend/middleware/test_exception.py diff --git a/backend/middleware/exception.py b/backend/middleware/exception.py index f5ea1a6f7c..14829ce509 100644 --- a/backend/middleware/exception.py +++ b/backend/middleware/exception.py @@ -47,12 +47,36 @@ def drf_logging_exc_handler(exc: Exception, context: Any) -> Response | None: return response response: Response | None = exception_handler(exc=exc, context=context) + _enrich_not_found_detail(response=response, context=context) ExceptionLoggingMiddleware.format_exc_and_log( request=request, response=response, exception=exc ) return response +def _enrich_not_found_detail(response: Response | None, context: Any) -> None: + """Replace DRF's generic "Not found." with the resource's name. + + Derives a human label from the view's `queryset` model so every 404 + reads " not found." app-wide, no per-view work. Uses the + static `queryset` attr (not `get_queryset()`) to avoid running view + logic during error handling; views without it keep the generic message. + Override `Meta.verbose_name` to tune a model's label. + """ + if response is None or getattr(response, "status_code", None) != 404: + return + data = getattr(response, "data", None) + if not isinstance(data, dict): + return + model = getattr(getattr(context.get("view"), "queryset", None), "model", None) + if model is None: + return + label = str(model._meta.verbose_name).capitalize() + for err in data.get("errors", []): + if err.get("code") == "not_found": + err["detail"] = f"{label} not found." + + class ExceptionLoggingMiddleware: """Custom middleware to log unhandled errors. diff --git a/backend/middleware/test_exception.py b/backend/middleware/test_exception.py new file mode 100644 index 0000000000..0caa279425 --- /dev/null +++ b/backend/middleware/test_exception.py @@ -0,0 +1,59 @@ +"""Unit checks for the 404 detail enrichment in the DRF exception handler. + +Pure-logic; uses fakes so no DB or real models are needed. Importing the +handler pulls DRF, which reads settings at import — configure a minimal +settings object when the suite hasn't already, so this runs standalone too. +""" + +import django +from django.conf import settings + +if not settings.configured: + settings.configure(DEBUG=True, INSTALLED_APPS=[], DATABASES={}) + django.setup() + +from middleware.exception import _enrich_not_found_detail # noqa: E402 + + +class _Meta: + verbose_name = "lookup definition" + + +class _Model: + _meta = _Meta + + +class _QuerySet: + model = _Model + + +class _View: + queryset = _QuerySet + + +class _Resp: + def __init__(self, status_code, data): + self.status_code = status_code + self.data = data + + +def test_404_with_model_uses_verbose_name(): + resp = _Resp(404, {"errors": [{"code": "not_found", "detail": "Not found."}]}) + _enrich_not_found_detail(resp, {"view": _View()}) + assert resp.data["errors"][0]["detail"] == "Lookup definition not found." + + +def test_404_without_queryset_keeps_generic(): + resp = _Resp(404, {"errors": [{"code": "not_found", "detail": "Not found."}]}) + _enrich_not_found_detail(resp, {"view": object()}) + assert resp.data["errors"][0]["detail"] == "Not found." + + +def test_non_404_untouched(): + resp = _Resp(400, {"errors": [{"code": "not_found", "detail": "Not found."}]}) + _enrich_not_found_detail(resp, {"view": _View()}) + assert resp.data["errors"][0]["detail"] == "Not found." + + +def test_none_response_is_safe(): + _enrich_not_found_detail(None, {}) From 7e43f35a4703beb41da9cf7795120127edd50877 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 24 Jun 2026 12:11:44 +0530 Subject: [PATCH 5/6] [FIX] Drop DEBUG=True from test settings (SonarCloud S4507) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X8t7DFHq7Dj655kEywKk3K --- backend/middleware/test_exception.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/middleware/test_exception.py b/backend/middleware/test_exception.py index 0caa279425..52c6ac7bef 100644 --- a/backend/middleware/test_exception.py +++ b/backend/middleware/test_exception.py @@ -9,7 +9,7 @@ from django.conf import settings if not settings.configured: - settings.configure(DEBUG=True, INSTALLED_APPS=[], DATABASES={}) + settings.configure(INSTALLED_APPS=[], DATABASES={}) django.setup() from middleware.exception import _enrich_not_found_detail # noqa: E402 From df89a2378059f1ee7f8c1049de3774bc1efa6903 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 24 Jun 2026 12:17:48 +0530 Subject: [PATCH 6/6] [FIX] Guard non-dict error items in 404 enrichment (CodeRabbit) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X8t7DFHq7Dj655kEywKk3K --- backend/middleware/exception.py | 3 ++- backend/middleware/test_exception.py | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/middleware/exception.py b/backend/middleware/exception.py index 14829ce509..ce6b1abadf 100644 --- a/backend/middleware/exception.py +++ b/backend/middleware/exception.py @@ -73,7 +73,8 @@ def _enrich_not_found_detail(response: Response | None, context: Any) -> None: return label = str(model._meta.verbose_name).capitalize() for err in data.get("errors", []): - if err.get("code") == "not_found": + # Guard: a raise here would mask the original error with a 500. + if isinstance(err, dict) and err.get("code") == "not_found": err["detail"] = f"{label} not found." diff --git a/backend/middleware/test_exception.py b/backend/middleware/test_exception.py index 52c6ac7bef..217a3aaa36 100644 --- a/backend/middleware/test_exception.py +++ b/backend/middleware/test_exception.py @@ -57,3 +57,11 @@ def test_non_404_untouched(): def test_none_response_is_safe(): _enrich_not_found_detail(None, {}) + + +def test_non_dict_error_item_does_not_raise(): + resp = _Resp( + 404, {"errors": ["unexpected", {"code": "not_found", "detail": "Not found."}]} + ) + _enrich_not_found_detail(resp, {"view": _View()}) + assert resp.data["errors"][1]["detail"] == "Lookup definition not found."