Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions sentry_sdk/integrations/django/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions tests/integrations/django/asgi/test_asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
56 changes: 53 additions & 3 deletions tests/integrations/django/myapp/middleware.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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):
Expand Down
Loading