Skip to content

Commit 274c2a9

Browse files
fix(django): support async_only_middleware [backport 3.13] (#14533)
Backport 941cf9c from #14512 to 3.13. Fixes #14506 In Python 3.13 it seems we may eagerly start a coroutine as soon as it is created. This means in our integration when we `return func(*args, **kwargs)` if the result is a coroutine we may start executing, then if the caller is doing `await wrapped_func()` we'll get the `ValueError: coroutine already executing` error being raised. To reproduce you can follow the guide in #14521, or check the regression middleware added to the `test_asgi_200` test function. This only occurs with Python 3.13. ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) Co-authored-by: Brett Langdon <[email protected]>
1 parent e8c28a9 commit 274c2a9

File tree

6 files changed

+264
-223
lines changed

6 files changed

+264
-223
lines changed

ddtrace/contrib/internal/django/middleware.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from inspect import iscoroutinefunction
12
from inspect import isfunction
23
from types import FunctionType
34
from typing import Any
@@ -134,17 +135,42 @@ def traced_auth_middleware_process_request(func: FunctionType, args: Tuple[Any],
134135
def traced_middleware_factory(func: FunctionType, args: Tuple[Any], kwargs: Dict[str, Any]) -> Any:
135136
middleware = func(*args, **kwargs)
136137

137-
if isfunction(middleware):
138-
if hasattr(func, "__module__") and hasattr(func, "__qualname__"):
139-
resource = f"{func.__module__}.{func.__qualname__}"
140-
else:
141-
resource = func_name(func)
138+
if not isfunction(middleware):
139+
return middleware
140+
141+
if hasattr(func, "__module__") and hasattr(func, "__qualname__"):
142+
resource = f"{func.__module__}.{func.__qualname__}"
143+
else:
144+
resource = func_name(func)
145+
146+
if iscoroutinefunction(middleware):
147+
# Handle async middleware - create async wrapper
148+
async def traced_async_middleware_func(*args, **kwargs):
149+
# The first argument for all middleware is the request object
150+
# DEV: Do `optional=true` to avoid raising an error for middleware that don't follow the convention
151+
# DEV: This is a function, so no `self` argument, so request is at position 0
152+
request = get_argument_value(args, kwargs, 0, "request", optional=True)
153+
154+
with core.context_with_data(
155+
"django.middleware.func",
156+
span_name="django.middleware",
157+
resource=resource,
158+
tags={
159+
COMPONENT: config_django.integration_name,
160+
},
161+
tracer=config_django._tracer,
162+
request=request,
163+
):
164+
return await middleware(*args, **kwargs)
142165

166+
return traced_async_middleware_func
167+
else:
168+
# Handle sync middleware - use original wrapping approach
143169
def traced_middleware_func(func: FunctionType, args: Tuple[Any], kwargs: Dict[str, Any]) -> Any:
144170
# The first argument for all middleware is the request object
145171
# DEV: Do `optional=true` to avoid raising an error for middleware that don't follow the convention
146172
# DEV: This is a function, so no `self` argument, so request is at position 0
147-
request = get_argument_value(args, kwargs, 0, "request")
173+
request = get_argument_value(args, kwargs, 0, "request", optional=True)
148174

149175
with core.context_with_data(
150176
"django.middleware.func",
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
fixes:
3+
- |
4+
django: Fixes issue causing ``ValueError: coroutine already executing`` on Python 3.13+ with ``django.utils.decorators.async_only_middleware``.

tests/contrib/django/django_app/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@
8686
"tests.contrib.django.middleware.EverythingMiddleware",
8787
]
8888

89+
if os.getenv("TEST_INCLUDE_ASYNC_ONLY_MIDDLEWARE") == "1":
90+
# DEV: Add to the front, since adding at the end causes it to not get called?
91+
MIDDLEWARE = ["tests.contrib.django.middleware.async_only_middleware"] + MIDDLEWARE
92+
8993
INSTALLED_APPS = [
9094
"django.contrib.admin",
9195
"django.contrib.auth",

tests/contrib/django/middleware.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,18 @@ def process_view(self, request, view_func, view_args, view_kwargs):
9696

9797
def process_template_response(self, req, resp):
9898
return resp
99+
100+
101+
try:
102+
from django.utils.decorators import async_only_middleware
103+
104+
@async_only_middleware
105+
def my_async_only_middleware(get_response):
106+
async def handle(request):
107+
raise Exception()
108+
return await get_response(request)
109+
110+
return handle
111+
112+
except ImportError:
113+
pass

tests/contrib/django/test_django_snapshots.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,8 @@ def test_psycopg3_query_default(always_create_database_spans: bool, client, snap
262262
)
263263
@pytest.mark.parametrize("django_asgi", ["application", "channels_application"])
264264
def test_asgi_200(django_asgi):
265-
with daphne_client(django_asgi) as (client, _):
265+
env = {"TEST_INCLUDE_ASYNC_ONLY_MIDDLEWARE": "1"}
266+
with daphne_client(django_asgi, additional_env=env) as (client, _):
266267
resp = client.get("/", timeout=10)
267268
assert resp.status_code == 200
268269
assert resp.content == b"Hello, test app."

0 commit comments

Comments
 (0)