diff --git a/sentry_sdk/integrations/django/middleware.py b/sentry_sdk/integrations/django/middleware.py index 76ee45018f..a14ec96ff5 100644 --- a/sentry_sdk/integrations/django/middleware.py +++ b/sentry_sdk/integrations/django/middleware.py @@ -32,8 +32,9 @@ if not DJANGO_SUPPORTS_ASYNC_MIDDLEWARE: _asgi_middleware_mixin_factory = lambda _: object + iscoroutinefunction = lambda _: False else: - from .asgi import _asgi_middleware_mixin_factory + from .asgi import _asgi_middleware_mixin_factory, iscoroutinefunction def patch_django_middlewares() -> None: @@ -104,15 +105,39 @@ def _check_middleware_span( def _get_wrapped_method(old_method: "F") -> "F": with capture_internal_exceptions(): + # Middleware hooks (e.g. `process_view`, `process_exception`) may be + # `async def` when the middleware is async. A synchronous wrapper + # would hide the coroutine from Django's `iscoroutinefunction` check, + # causing Django to call the hook synchronously and never await the + # returned coroutine. Wrap async hooks with an async wrapper so the + # wrapped method continues to report as a coroutine function. + if iscoroutinefunction is not None and iscoroutinefunction(old_method): - def sentry_wrapped_method(*args: "Any", **kwargs: "Any") -> "Any": - middleware_span = _check_middleware_span(old_method) + async def async_sentry_wrapped_method( + *args: "Any", **kwargs: "Any" + ) -> "Any": + middleware_span = _check_middleware_span(old_method) - if middleware_span is None: - return old_method(*args, **kwargs) + if middleware_span is None: + return await old_method(*args, **kwargs) - with middleware_span: - return old_method(*args, **kwargs) + with middleware_span: + return await old_method(*args, **kwargs) + + sentry_wrapped_method = async_sentry_wrapped_method + + else: + + def sync_sentry_wrapped_method(*args: "Any", **kwargs: "Any") -> "Any": + middleware_span = _check_middleware_span(old_method) + + if middleware_span is None: + return old_method(*args, **kwargs) + + with middleware_span: + return old_method(*args, **kwargs) + + sentry_wrapped_method = sync_sentry_wrapped_method try: # fails for __call__ of function on Python 2 (see py2.7-django-1.11) diff --git a/tests/integrations/django/asgi/test_asgi.py b/tests/integrations/django/asgi/test_asgi.py index 4e9eb95556..5c2389e0f6 100644 --- a/tests/integrations/django/asgi/test_asgi.py +++ b/tests/integrations/django/asgi/test_asgi.py @@ -1049,3 +1049,50 @@ async def test_transaction_http_method_custom( (event1, event2) = events assert event1["request"]["method"] == "OPTIONS" assert event2["request"]["method"] == "HEAD" + + +@pytest.mark.asyncio +@pytest.mark.skipif( + django.VERSION < (3, 1), + reason="async views/middleware introduced in Django 3.1", +) +async def test_async_middleware_process_view_is_awaited( + sentry_init, settings, make_asgi_application +): + """Regression test for async ``process_view`` being coerced to sync.""" + sentry_init(integrations=[DjangoIntegration()]) + + settings.MIDDLEWARE = [ + "tests.integrations.django.myapp.middleware.AsyncProcessViewMiddleware" + ] + application = make_asgi_application() + + comm = HttpCommunicator(application, "GET", "/simple_async_view") + response = await comm.get_response() + await comm.wait() + + assert response["status"] == 200 + + +@pytest.mark.asyncio +@pytest.mark.skipif( + django.VERSION < (3, 1), + reason="async views/middleware introduced in Django 3.1", +) +async def test_async_middleware_process_exception_is_awaited( + sentry_init, settings, make_asgi_application +): + """Regression test for async ``process_exception`` being coerced to sync.""" + sentry_init(integrations=[DjangoIntegration()]) + + settings.MIDDLEWARE = [ + "tests.integrations.django.myapp.middleware.AsyncProcessExceptionMiddleware" + ] + application = make_asgi_application() + + comm = HttpCommunicator(application, "GET", "/view-exc") + response = await comm.get_response() + await comm.wait() + + assert response["status"] == 200 + assert response["body"] == b"handled by async process_exception" diff --git a/tests/integrations/django/myapp/middleware.py b/tests/integrations/django/myapp/middleware.py index b1a63364f6..4d9c6882e2 100644 --- a/tests/integrations/django/myapp/middleware.py +++ b/tests/integrations/django/myapp/middleware.py @@ -1,13 +1,16 @@ import django if django.VERSION >= (3, 1): - import asyncio - from django.utils.decorators import sync_and_async_middleware + from sentry_sdk.integrations.django.asgi import ( + iscoroutinefunction, + markcoroutinefunction, + ) + @sync_and_async_middleware def simple_middleware(get_response): - if asyncio.iscoroutinefunction(get_response): + if iscoroutinefunction(get_response): async def middleware(request): response = await get_response(request) @@ -21,6 +24,53 @@ def middleware(request): return middleware + class AsyncProcessViewMiddleware: + """A correctly-written async-only middleware exposing an async ``process_view``. + see: https://docs.djangoproject.com/en/5.2/topics/http/middleware/#asynchronous-support + + This is the supported way to write async middleware per the Django docs: + declare ``async_capable`` and mark the instance as a coroutine when the + inner ``get_response`` is async. + """ + + async_capable = True + sync_capable = False + + def __init__(self, get_response): + self.get_response = get_response + if iscoroutinefunction(self.get_response): + markcoroutinefunction(self) + + async def __call__(self, request): + return await self.get_response(request) + + async def process_view(self, request, view_func, view_args, view_kwargs): + return None + + class AsyncProcessExceptionMiddleware: + """Async-only middleware exposing an async ``process_exception`` hook. + + The view raises, so Django invokes ``process_exception``. If the async + ``process_exception`` is awaited correctly, it returns an ``HttpResponse`` + that short-circuits the error and the request succeeds with status 200. + """ + + async_capable = True + sync_capable = False + + def __init__(self, get_response): + self.get_response = get_response + if iscoroutinefunction(self.get_response): + markcoroutinefunction(self) + + async def __call__(self, request): + return await self.get_response(request) + + async def process_exception(self, request, exception): + from django.http import HttpResponse + + return HttpResponse("handled by async process_exception", status=200) + def custom_urlconf_middleware(get_response): def middleware(request):