fix: report the real status from handle_error instead of 405 - #3791
fix: report the real status from handle_error instead of 405#3791blarghmatey wants to merge 4 commits into
Conversation
OpenAPI ChangesNo changes detected Unexpected changes? Ensure your branch is up-to-date with |
There was a problem hiding this comment.
Pull request overview
Corrects Django error handling so non-GET requests report their actual 400/403/404 status instead of 405.
Changes:
- Enables the shared handler for common HTTP methods.
- Maps client exceptions to accurate statuses and error types.
- Adds regression coverage for webhook and unmatched-route errors.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
main/views.py |
Preserves actual client-error statuses. |
main/views_test.py |
Tests error handling across methods and statuses. |
webhooks/views_test.py |
Updates invalid-signature expectations to 403. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
handle_error is registered as handler400/handler403/handler404 but was decorated with a bare @api_view(), which DRF defaults to GET-only. Any non-GET request that raised PermissionDenied, BadRequest or Http404 was therefore answered `405 Method Not Allowed, Allow: GET, OPTIONS` with error_type MethodNotAllowed, and the actual reason never reached the caller. That is what made the Canvas ContentFile webhook failure unreadable for ten days: the serializer was rejecting course_readable_id=null and raising Django's BadRequest, but every one of those ~5,500 failures surfaced as a 405, which reads as a missing route or an edge misconfiguration rather than a payload problem. error_type is set explicitly per branch rather than derived from the exception class, because an unmatched URL raises Resolver404 and would otherwise have silently changed the published error_type from "Http404". test_ovs_video_webhook_invalid_signature asserted 405 for an invalid signature, encoding the bug as expected behaviour; it now asserts 403. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X91SVaUM8EWo2kYA6FL3tB
TRACE is in Django's View.http_method_names, so omitting it from the @api_view list left one method still answering 405 and masking the real status. Verified before the change: TRACE to an unmatched /api/v1/ route returned 405 MethodNotAllowed; it now returns 404. Added to the method regression test. The comment on test_change_form_save_of_deleted_published_is_forbidden said handle_error "renders every client error as a 404", which was the behaviour this branch removes. The test still passes at 404, but for a different reason: /admin/ has no version namespace, so DRF's NamespaceVersioning raises NotFound from initial() before the handler body runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X91SVaUM8EWo2kYA6FL3tB
2ed9ee8 to
d4c58f2
Compare
ChristopherChudzicki
left a comment
There was a problem hiding this comment.
Non-blocking: — this is better than what's on main, so if you want to merge it, sure. But rather than an explicit list of methods and isinstance-ing errors, I think a better approach is:
- Drop the DRF
api_viewandpermission_classesdecorators entirely - Use separate
handle_40xmethods.
Why: handle_40x is invoked only when DRF does NOT handle the exception itself and Django already knows the error type.
- Raised inside a DRF view and DRF recognises it (
APIException,Http404,PermissionDenied) ->handle_exception->main.exceptions.api_exception_handlerbuilds the response.handler40xis never called. - Everything else -> Django's
response_for_exception->handler40x.
So by the time handle_error runs, DRF has already passed on this one. @api_view cannot recover a status DRF didn't set; it can only add DRF's request pipeline — authentication, CSRF, throttling, content negotiation, NamespaceVersioning, method dispatch — to a request that has already proven it isn't DRF's to handle.
That second bucket is also much wider than unmatched URLs, which is why the blast radius was bigger than it looked:
- Before any view — URL-resolution
Http404,SuspiciousOperation/DisallowedHostfrom middleware. - Non-DRF views — Django admin,
oauth2_provider. - DRF views where the exception escapes
dispatch—require_signatureis stacked ondispatchviamethod_decorator, so itsPermissionDeniedfires outsideAPIView.dispatch's try/except. - DRF views where DRF declines the exception —
ContentFileWebhookView.get_dataraisesdjango.core.exceptions.BadRequest, which isn't anAPIException, sohandle_exceptionre-raises it.
As best I can tell, handle_error was introduced on an incorrect premise1.
Instead
def _error(status_code, error_type, detail):
return JsonResponse({"detail": detail, "error_type": error_type}, status=status_code)
def handle_400(request, exception=None): # noqa: ARG001
return _error(400, "BadRequest", "The request could not be processed.")
def handle_403(request, exception=None): # noqa: ARG001
return _error(403, "PermissionDenied", "You do not have permission to perform this action.")
def handle_404(request, exception=None): # noqa: ARG001
return _error(404, "Http404", "The specified resource was not found.")Comparison
Same probes against this branch and against the three handlers above, locally with DEBUG=False:
| Request | This branch | Three handlers |
|---|---|---|
POST unmatched /api/v1/... |
404 Http404 |
404 Http404 |
POST webhook, no signature |
403 PermissionDenied |
403 PermissionDenied |
GET unmatched, Accept: text/html |
404, browsable-API HTML page | 404 JSON |
GET unmatched, Accept: application/xml |
406 NotAcceptable |
404 JSON |
| multipart POST with no boundary | 404 {"detail": "Invalid version in URL path...", "error_type": "NotFound"} |
400 BadRequest |
That last row is handler400, on a resolved URL, answering 404 with a versioning error. /admin/, /o/ and /scim/v2/ all carry namespaces outside ALLOWED_VERSIONS, so determine_version raises NotFound from initial() before the body ever runs.2
Costs
Costs, in case any of them are wanted:
- Browser requests to a bad URL stop getting the browsable-API 404 page; every Django-level error becomes JSON. (mit-learn doesn't set
DEFAULT_RENDERER_CLASSES, soBrowsableAPIRendereris currently live in production.) website_content/admin_test.pygoes 404 -> 403, because versioning no longer short-circuits — which makes Copilot's second comment right after all, and lets the versioning explanation come out of that test rather than get reworded."PermissionDenied"duplicatesmain.constants.PERMISSION_DENIED_ERROR_TYPE, which is referenced nowhere in the repo — worth either using or deleting, independently of this.
main/ webhooks/ website_content/admin_test.py passes against the split — 302 passed, 1 skipped, with the admin_test.py assertion flipped to 403. Happy to push the branch if that's easier than reworking this one.
Footnotes
-
"This is a generic handler, since the api_view decorator means DRF will usurp error handling and provide whatever response is actually necessary." (Code comment) ↩
-
determine_versionreturnsdefault_versionwhen there is no namespace at all, so it is specifically namespaces that exist but are notv0/v1that raise —admin,oauth2_provider,ol-scim,scim. ↩
…ions handle_40x only fires once a DRF view has declined the exception or the error happened outside DRF entirely, so @api_view's request pipeline (versioning, content negotiation) never applied to the response it wraps - it could only add spurious side effects to a request DRF had already passed on. NamespaceVersioning under /admin/ is one such side effect: it raised NotFound from initial() before handle_error's body ran, turning a PermissionDenied into a 404 instead of the real 403. Per review feedback from ChristopherChudzicki on #3791.
|
Adopted in 95d4ff4: handle_error is now three plain handle_400/403/404 functions, no @api_view/permission_classes. Updated website_content/admin_test.py's assertion to 403 per your versioning analysis (DRF's request pipeline is gone, so it no longer short-circuits under /admin/). Left PERMISSION_DENIED_ERROR_TYPE in place and now actually used by handle_403. Full main/ webhooks/ website_content/admin_test.py suite: 303 passed, 1 skipped. |
What are the relevant tickets?
N/A
Description (What does it do?)
main/views.py:handle_erroris registered as Django'shandler400/handler403/handler404, but was decorated with a bare@api_view(). DRF defaults that to GET-only, so any non-GET request raisingPermissionDenied,BadRequest/SuspiciousOperation, orHttp404was answered405 Method Not Allowed, Allow: GET, OPTIONSwitherror_type: MethodNotAllowed, and the real reason never reached the caller.@api_view([...])so the handler is reachable at all.PermissionDenied→ 403,BadRequest/SuspiciousOperation→ 400, otherwise 404.error_typeexplicitly per branch rather than fromtype(exception).__name__— an unmatched URL raisesResolver404, which would have silently changed the publishederror_typefrom"Http404".webhooks/views_test.py::test_ovs_video_webhook_invalid_signatureasserted 405 for an invalid signature, encoding the bug as expected behaviour; it now asserts 403.This is API-wide, but the webhooks are where it did damage: an unsigned POST to a real webhook route and a POST to a nonexistent URL returned byte-identical 405s, which is what made the Canvas ContentFile failure look like a routing problem for ten days (~5,500 events on DAGSTER-13) while the real cause — a null
course_readable_idrejected by the serializer — sat in the app logs. That payload bug is already fixed by #3776 and mitodl/ol-data-platform#2567; this fixes the masking that hid it.How can this be tested?
main/views_test.pyadds coverage for each status the handler now distinguishes: an unmatched/api/v1/route is 404 for POST/PUT/PATCH/DELETE (previously 405), a signature failure is 403, and a serializer failure is 400.What I ran locally against Postgres:
pre-commit run --files main/views.py main/views_test.py webhooks/views_test.pyis clean.To confirm by hand, POST to a webhook endpoint without a signature header — it should now return 403
PermissionDeniedrather than 405MethodNotAllowed.