Skip to content

Commit 6af129f

Browse files
authored
fix(django): make middleware truly hybrid-compatible with sync and async Django stacks (#348)
Address code review feedback and critical issues from PR #328. Changes: - Keep __call__ as sync method that conditionally routes to __acall__ for async paths - Use markcoroutinefunction() to properly mark instances when async is detected - Detect async/sync at init time via iscoroutinefunction(get_response) - Remove process_exception method - it was non-functional (Django doesn't call it on new-style middleware without MiddlewareMixin) - Fix markcoroutinefunction fallback to be a simple no-op instead of accessing private API - Exception capture works correctly via contexts.new_context() which has built-in exception handling - Add comprehensive test coverage for sync, async, and hybrid middleware behavior - Add async exception capture tests - Refactor tests to use proper middleware initialization This implementation follows Django's recommended hybrid middleware pattern where both sync_capable and async_capable are True, allowing Django to pass requests without conversion while the middleware adapts based on the detected mode. The sync path behavior is identical to version 6.7.4 (pre-async), ensuring perfect backward compatibility for WSGI deployments. Addresses #329 Related to #328
1 parent 02e82a6 commit 6af129f

File tree

4 files changed

+328
-47
lines changed

4 files changed

+328
-47
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# 6.7.10 - 2025-10-24
2+
3+
- fix(django): Make middleware truly hybrid - compatible with both sync (WSGI) and async (ASGI) Django stacks without breaking sync-only deployments
4+
- fix(django): Exception capture works correctly via context manager (addresses #329)
5+
16
# 6.7.9 - 2025-10-22
27

38
- fix(flags): multi-condition flags with static cohorts returning wrong variants

posthog/integrations/django.py

Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@
33
from posthog.client import Client
44

55
try:
6-
from asgiref.sync import iscoroutinefunction
6+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
77
except ImportError:
8-
# Fallback for older Django versions
8+
# Fallback for older Django versions without asgiref
99
import asyncio
1010

1111
iscoroutinefunction = asyncio.iscoroutinefunction
1212

13+
# No-op fallback for markcoroutinefunction
14+
# Older Django versions without asgiref typically don't support async middleware anyway
15+
def markcoroutinefunction(func):
16+
return func
17+
18+
1319
if TYPE_CHECKING:
1420
from django.http import HttpRequest, HttpResponse # noqa: F401
1521
from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401
@@ -39,26 +45,24 @@ class PosthogContextMiddleware:
3945
See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to
4046
associate all events captured in the middleware context with the same distinct ID and session as currently active on the
4147
frontend. See the documentation for `set_context_session` and `identify_context` for more details.
48+
49+
This middleware is hybrid-capable: it supports both WSGI (sync) and ASGI (async) Django applications. The middleware
50+
detects at initialization whether the next middleware in the chain is async or sync, and adapts its behavior accordingly.
51+
This ensures compatibility with both pure sync and pure async middleware chains, as well as mixed chains in ASGI mode.
4252
"""
4353

44-
# Django middleware capability flags
4554
sync_capable = True
4655
async_capable = True
4756

4857
def __init__(self, get_response):
4958
# type: (Union[Callable[[HttpRequest], HttpResponse], Callable[[HttpRequest], Awaitable[HttpResponse]]]) -> None
59+
self.get_response = get_response
5060
self._is_coroutine = iscoroutinefunction(get_response)
51-
self._async_get_response = None # type: Optional[Callable[[HttpRequest], Awaitable[HttpResponse]]]
52-
self._sync_get_response = None # type: Optional[Callable[[HttpRequest], HttpResponse]]
5361

62+
# Mark this instance as a coroutine function if get_response is async
63+
# This is required for Django to correctly detect async middleware
5464
if self._is_coroutine:
55-
self._async_get_response = cast(
56-
"Callable[[HttpRequest], Awaitable[HttpResponse]]", get_response
57-
)
58-
else:
59-
self._sync_get_response = cast(
60-
"Callable[[HttpRequest], HttpResponse]", get_response
61-
)
65+
markcoroutinefunction(self)
6266

6367
from django.conf import settings
6468

@@ -181,40 +185,38 @@ def extract_request_user(self, request):
181185
return user_id, email
182186

183187
def __call__(self, request):
184-
# type: (HttpRequest) -> HttpResponse
185-
# Purely defensive around django's internal sync/async handling - this should be unreachable, but if it's reached, we may
186-
# as well return something semi-meaningful
187-
if self._is_coroutine:
188-
raise RuntimeError(
189-
"PosthogContextMiddleware received sync call but get_response is async"
190-
)
188+
# type: (HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]]
189+
"""
190+
Unified entry point for both sync and async request handling.
191191
192-
if self.request_filter and not self.request_filter(request):
193-
assert self._sync_get_response is not None
194-
return self._sync_get_response(request)
192+
When sync_capable and async_capable are both True, Django passes requests
193+
without conversion. This method detects the mode and routes accordingly.
194+
"""
195+
if self._is_coroutine:
196+
return self.__acall__(request)
197+
else:
198+
# Synchronous path
199+
if self.request_filter and not self.request_filter(request):
200+
return self.get_response(request)
195201

196-
with contexts.new_context(self.capture_exceptions, client=self.client):
197-
for k, v in self.extract_tags(request).items():
198-
contexts.tag(k, v)
202+
with contexts.new_context(self.capture_exceptions, client=self.client):
203+
for k, v in self.extract_tags(request).items():
204+
contexts.tag(k, v)
199205

200-
assert self._sync_get_response is not None
201-
return self._sync_get_response(request)
206+
return self.get_response(request)
202207

203208
async def __acall__(self, request):
204-
# type: (HttpRequest) -> HttpResponse
209+
# type: (HttpRequest) -> Awaitable[HttpResponse]
210+
"""
211+
Asynchronous entry point for async request handling.
212+
213+
This method is called when the middleware chain is async.
214+
"""
205215
if self.request_filter and not self.request_filter(request):
206-
if self._async_get_response is not None:
207-
return await self._async_get_response(request)
208-
else:
209-
assert self._sync_get_response is not None
210-
return self._sync_get_response(request)
216+
return await self.get_response(request)
211217

212218
with contexts.new_context(self.capture_exceptions, client=self.client):
213219
for k, v in self.extract_tags(request).items():
214220
contexts.tag(k, v)
215221

216-
if self._async_get_response is not None:
217-
return await self._async_get_response(request)
218-
else:
219-
assert self._sync_get_response is not None
220-
return self._sync_get_response(request)
222+
return await self.get_response(request)

0 commit comments

Comments
 (0)