Skip to content

traced_middleware_factory drops process_exception on async function-based middleware #18696

Description

@fabrice-toussaint

Description

traced_middleware_factory in ddtrace/contrib/internal/django/middleware.py drops process_exception (and other custom attributes) when wrapping async function-based Django middleware. This prevents Django from registering exception handlers, causing unhandled exceptions that the middleware is designed to catch.

Root cause

In traced_middleware_factory (line ~130), when the inner middleware function is async:

if iscoroutinefunction(middleware):
    async def traced_async_middleware_func(*args, **kwargs):
        ...
        return await middleware(*args, **kwargs)
    return traced_async_middleware_func  # ← new function, no attributes copied

The sync branch uses wrap(middleware, traced_middleware_func) which preserves attributes via wrapt. The async branch creates a plain async def and returns it, losing all attributes.

Impact

Django's BaseHandler.load_middleware checks hasattr(mw_instance, 'process_exception') to register exception middleware. After ddtrace wraps an async function-based middleware, this check returns False, and _exception_middleware is empty.

Any Django middleware that:

  1. Is function-based (not class-based)
  2. Uses @sync_and_async_middleware
  3. Attaches process_exception as a function attribute

...will have its process_exception silently dropped under ASGI when ddtrace is active.

Confirmed affected: allauth's AccountMiddleware — its process_exception handles ReauthenticationRequired (reauthentication redirect flow). Under ASGI with ddtrace, this becomes a 500 instead of a redirect.

Reproduction

import asyncio, sys, types
import django
from django.conf import settings

settings.configure(
    DEBUG=False, SECRET_KEY="x", ROOT_URLCONF=__name__,
    MIDDLEWARE=[
        "django.contrib.sessions.middleware.SessionMiddleware",
        "django.contrib.auth.middleware.AuthenticationMiddleware",
        "allauth.account.middleware.AccountMiddleware",
    ],
    INSTALLED_APPS=[
        "django.contrib.contenttypes", "django.contrib.auth",
        "django.contrib.sessions", "allauth", "allauth.account", "allauth.mfa",
    ],
    DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
    SESSION_ENGINE="django.contrib.sessions.backends.cache",
    CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}},
    ACCOUNT_DEFAULT_HTTP_PROTOCOL="http",
)
django.setup()

from django.core.handlers.base import BaseHandler

# Without ddtrace
handler = BaseHandler()
handler.load_middleware(is_async=True)
print(f"Without ddtrace: {len(handler._exception_middleware)} exception middleware")
# Output: 1

# With ddtrace
import ddtrace
ddtrace.patch(django=True)
handler2 = BaseHandler()
handler2.load_middleware(is_async=True)
print(f"With ddtrace:    {len(handler2._exception_middleware)} exception middleware")
# Output: 0

Suggested fix

Use contrib_trace_utils.wrap for the async branch (same pattern already used in wrap_middleware_class for async methods), or copy attributes from the original middleware function onto the wrapper:

if iscoroutinefunction(middleware):
    async def traced_async_middleware_func(*args, **kwargs):
        ...
        return await middleware(*args, **kwargs)
    # Preserve attributes like process_exception
    for attr in ("process_exception", "process_view", "process_template_response"):
        if hasattr(middleware, attr):
            setattr(traced_async_middleware_func, attr, getattr(middleware, attr))
    return traced_async_middleware_func

Or use wrapt's FunctionWrapper which handles this transparently.

Environment

  • ddtrace: 4.10.2
  • Django: 6.0.6
  • Python: 3.14.3
  • allauth: 65.18.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions