Skip to content

Commit 98e3226

Browse files
authored
fix(django): handle request.user in async middleware context (#358)
1 parent 700c922 commit 98e3226

File tree

21 files changed

+1588
-17
lines changed

21 files changed

+1588
-17
lines changed

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ name: CI
33
on:
44
- pull_request
55

6+
permissions:
7+
contents: read
8+
69
jobs:
710
code-quality:
811
name: Code quality checks
@@ -68,3 +71,34 @@ jobs:
6871
- name: Run posthog tests
6972
run: |
7073
pytest --verbose --timeout=30
74+
75+
django5-integration:
76+
name: Django 5 integration tests
77+
runs-on: ubuntu-latest
78+
79+
steps:
80+
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
81+
with:
82+
fetch-depth: 1
83+
84+
- name: Set up Python 3.12
85+
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
86+
with:
87+
python-version: 3.12
88+
89+
- name: Install uv
90+
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
91+
with:
92+
enable-cache: true
93+
pyproject-file: 'integration_tests/django5/pyproject.toml'
94+
95+
- name: Install Django 5 test project dependencies
96+
shell: bash
97+
working-directory: integration_tests/django5
98+
run: |
99+
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync
100+
101+
- name: Run Django 5 middleware integration tests
102+
working-directory: integration_tests/django5
103+
run: |
104+
uv run pytest test_middleware.py test_exception_capture.py --verbose

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ pyrightconfig.json
1919
.env
2020
.DS_Store
2121
posthog-python-references.json
22+
.claude/settings.local.json

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# 6.7.14 - 2025-11-03
2+
3+
- fix(django): Handle request.user access in async middleware context to prevent SynchronousOnlyOperation errors in Django 5+ (fixes #355)
4+
- test(django): Add Django 5 integration test suite with real ASGI application testing async middleware behavior
5+
16
# 6.7.13 - 2025-11-02
27

38
- fix(llma): cache cost calculation in the LangChain callback
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
db.sqlite3
2+
*.pyc
3+
__pycache__/
4+
.pytest_cache/
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env python
2+
"""Django's command-line utility for administrative tasks."""
3+
4+
import os
5+
import sys
6+
7+
8+
def main():
9+
"""Run administrative tasks."""
10+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
11+
try:
12+
from django.core.management import execute_from_command_line
13+
except ImportError as exc:
14+
raise ImportError(
15+
"Couldn't import Django. Are you sure it's installed and "
16+
"available on your PYTHONPATH environment variable? Did you "
17+
"forget to activate a virtual environment?"
18+
) from exc
19+
execute_from_command_line(sys.argv)
20+
21+
22+
if __name__ == "__main__":
23+
main()
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[project]
2+
name = "test-django5"
3+
version = "0.1.0"
4+
requires-python = ">=3.12"
5+
dependencies = [
6+
"django~=5.2.7",
7+
"uvicorn[standard]~=0.38.0",
8+
"posthog",
9+
"pytest~=8.4.2",
10+
"pytest-asyncio~=1.2.0",
11+
"pytest-django~=4.11.1",
12+
"httpx~=0.28.1",
13+
]
14+
15+
[tool.uv]
16+
required-version = ">=0.5"
17+
18+
[tool.uv.sources]
19+
posthog = { path = "../..", editable = true }
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""
2+
Test that verifies exception capture functionality.
3+
4+
These tests verify that exceptions are actually captured to PostHog, not just that
5+
500 responses are returned.
6+
7+
Without process_exception(), view exceptions are NOT captured to PostHog (v6.7.11 and earlier).
8+
With process_exception(), Django calls this method to capture exceptions before
9+
converting them to 500 responses.
10+
"""
11+
12+
import os
13+
import django
14+
15+
# Setup Django before importing anything else
16+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
17+
django.setup()
18+
19+
import pytest # noqa: E402
20+
from httpx import AsyncClient, ASGITransport # noqa: E402
21+
from django.core.asgi import get_asgi_application # noqa: E402
22+
23+
24+
@pytest.fixture(scope="session")
25+
def asgi_app():
26+
"""Shared ASGI application for all tests."""
27+
return get_asgi_application()
28+
29+
30+
@pytest.mark.asyncio
31+
async def test_async_exception_is_captured(asgi_app):
32+
"""
33+
Test that async view exceptions are captured to PostHog.
34+
35+
The middleware's process_exception() method ensures exceptions are captured.
36+
Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned.
37+
"""
38+
from unittest.mock import patch
39+
40+
# Track captured exceptions
41+
captured = []
42+
43+
def mock_capture(exception, **kwargs):
44+
"""Mock capture_exception to record calls."""
45+
captured.append(
46+
{
47+
"exception": exception,
48+
"type": type(exception).__name__,
49+
"message": str(exception),
50+
}
51+
)
52+
53+
# Patch at the posthog module level where middleware imports from
54+
with patch("posthog.capture_exception", side_effect=mock_capture):
55+
async with AsyncClient(
56+
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
57+
) as ac:
58+
response = await ac.get("/test/async-exception")
59+
60+
# Django returns 500
61+
assert response.status_code == 500
62+
63+
# CRITICAL: Verify PostHog captured the exception
64+
assert len(captured) > 0, "Exception was NOT captured to PostHog!"
65+
66+
# Verify it's the right exception
67+
exception_data = captured[0]
68+
assert exception_data["type"] == "ValueError"
69+
assert "Test exception from Django 5 async view" in exception_data["message"]
70+
71+
72+
@pytest.mark.asyncio
73+
async def test_sync_exception_is_captured(asgi_app):
74+
"""
75+
Test that sync view exceptions are captured to PostHog.
76+
77+
The middleware's process_exception() method ensures exceptions are captured.
78+
Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned.
79+
"""
80+
from unittest.mock import patch
81+
82+
# Track captured exceptions
83+
captured = []
84+
85+
def mock_capture(exception, **kwargs):
86+
"""Mock capture_exception to record calls."""
87+
captured.append(
88+
{
89+
"exception": exception,
90+
"type": type(exception).__name__,
91+
"message": str(exception),
92+
}
93+
)
94+
95+
# Patch at the posthog module level where middleware imports from
96+
with patch("posthog.capture_exception", side_effect=mock_capture):
97+
async with AsyncClient(
98+
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
99+
) as ac:
100+
response = await ac.get("/test/sync-exception")
101+
102+
# Django returns 500
103+
assert response.status_code == 500
104+
105+
# CRITICAL: Verify PostHog captured the exception
106+
assert len(captured) > 0, "Exception was NOT captured to PostHog!"
107+
108+
# Verify it's the right exception
109+
exception_data = captured[0]
110+
assert exception_data["type"] == "ValueError"
111+
assert "Test exception from Django 5 sync view" in exception_data["message"]
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""
2+
Tests for PostHog Django middleware in async context.
3+
4+
These tests verify that the middleware correctly handles:
5+
1. Async user access (request.auser() in Django 5)
6+
2. Exception capture in both sync and async views
7+
3. No SynchronousOnlyOperation errors in async context
8+
9+
Tests run directly against the ASGI application without needing a server.
10+
"""
11+
12+
import os
13+
import django
14+
15+
# Setup Django before importing anything else
16+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
17+
django.setup()
18+
19+
import pytest # noqa: E402
20+
from httpx import AsyncClient, ASGITransport # noqa: E402
21+
from django.core.asgi import get_asgi_application # noqa: E402
22+
23+
24+
@pytest.fixture(scope="session")
25+
def asgi_app():
26+
"""Shared ASGI application for all tests."""
27+
return get_asgi_application()
28+
29+
30+
@pytest.mark.asyncio
31+
async def test_async_user_access(asgi_app):
32+
"""
33+
Test that middleware can access request.user in async context.
34+
35+
In Django 5, this requires using await request.auser() instead of request.user
36+
to avoid SynchronousOnlyOperation error.
37+
38+
Without authentication, request.user is AnonymousUser which doesn't
39+
trigger the lazy loading bug. This test verifies the middleware works
40+
in the common case.
41+
"""
42+
async with AsyncClient(
43+
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
44+
) as ac:
45+
response = await ac.get("/test/async-user")
46+
47+
assert response.status_code == 200
48+
data = response.json()
49+
assert data["status"] == "success"
50+
assert "django_version" in data
51+
52+
53+
@pytest.mark.django_db(transaction=True)
54+
@pytest.mark.asyncio
55+
async def test_async_authenticated_user_access(asgi_app):
56+
"""
57+
Test that middleware can access an authenticated user in async context.
58+
59+
This is the critical test that triggers the SynchronousOnlyOperation bug
60+
in v6.7.11. When AuthenticationMiddleware sets request.user to a
61+
SimpleLazyObject wrapping a database query, accessing user.pk or user.email
62+
in async context causes the error.
63+
64+
In v6.7.11, extract_request_user() does getattr(user, "is_authenticated", False)
65+
which triggers the lazy object evaluation synchronously.
66+
67+
The fix uses await request.auser() instead to avoid this.
68+
"""
69+
from django.contrib.auth import get_user_model
70+
from django.test import Client
71+
from asgiref.sync import sync_to_async
72+
from django.test import override_settings
73+
74+
# Create a test user (must use sync_to_async since we're in async test)
75+
User = get_user_model()
76+
77+
@sync_to_async
78+
def create_or_get_user():
79+
user, created = User.objects.get_or_create(
80+
username="testuser",
81+
defaults={
82+
"email": "[email protected]",
83+
},
84+
)
85+
if created:
86+
user.set_password("testpass123")
87+
user.save()
88+
return user
89+
90+
user = await create_or_get_user()
91+
92+
# Create a session with authenticated user (sync operation)
93+
@sync_to_async
94+
def create_session():
95+
client = Client()
96+
client.force_login(user)
97+
return client.cookies.get("sessionid")
98+
99+
session_cookie = await create_session()
100+
101+
if not session_cookie:
102+
pytest.skip("Could not create authenticated session")
103+
104+
# Make request with session cookie - this should trigger the bug in v6.7.11
105+
# Disable exception capture to see the SynchronousOnlyOperation clearly
106+
with override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False):
107+
async with AsyncClient(
108+
transport=ASGITransport(app=asgi_app),
109+
base_url="http://testserver",
110+
cookies={"sessionid": session_cookie.value},
111+
) as ac:
112+
response = await ac.get("/test/async-user")
113+
114+
assert response.status_code == 200
115+
data = response.json()
116+
assert data["status"] == "success"
117+
assert data["user_authenticated"]
118+
119+
120+
@pytest.mark.asyncio
121+
async def test_sync_user_access(asgi_app):
122+
"""
123+
Test that middleware works with sync views.
124+
125+
This should always work regardless of middleware version.
126+
"""
127+
async with AsyncClient(
128+
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
129+
) as ac:
130+
response = await ac.get("/test/sync-user")
131+
132+
assert response.status_code == 200
133+
data = response.json()
134+
assert data["status"] == "success"
135+
136+
137+
@pytest.mark.asyncio
138+
async def test_async_exception_capture(asgi_app):
139+
"""
140+
Test that middleware handles exceptions from async views.
141+
142+
The middleware's process_exception() method captures view exceptions to PostHog
143+
before Django converts them to 500 responses. This test verifies the exception
144+
causes a 500 response. See test_exception_capture.py for tests that verify
145+
actual exception capture to PostHog.
146+
"""
147+
async with AsyncClient(
148+
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
149+
) as ac:
150+
response = await ac.get("/test/async-exception")
151+
152+
# Django returns 500 for unhandled exceptions
153+
assert response.status_code == 500
154+
155+
156+
@pytest.mark.asyncio
157+
async def test_sync_exception_capture(asgi_app):
158+
"""
159+
Test that middleware handles exceptions from sync views.
160+
161+
The middleware's process_exception() method captures view exceptions to PostHog.
162+
This test verifies the exception causes a 500 response.
163+
"""
164+
async with AsyncClient(
165+
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
166+
) as ac:
167+
response = await ac.get("/test/sync-exception")
168+
169+
# Django returns 500 for unhandled exceptions
170+
assert response.status_code == 500

integration_tests/django5/testdjango/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)