Repository: getsentry/sentry-python

Issue:
Request body exceeded settings.DATA_UPLOAD_MAX_MEMORY_SIZEon 1.35.0
### How do you use Sentry?

self hosted

### Version

1.35.0

### Steps to Reproduce

When uploading files through Django I get the following error on 1.35.0:
```
Traceback (most recent call last):
  File "E:\projects\test\venv311\Lib\site-packages\channels\routing.py", line 62, in __call__
    return await application(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "E:\projects\test\venv311\Lib\site-packages\sentry_sdk\integrations\asgi.py", line 146, in _run_asgi3
    return await self._run_app(scope, receive, send, asgi_version=3)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "E:\projects\test\venv311\Lib\site-packages\sentry_sdk\integrations\asgi.py", line 241, in _run_app
    raise exc from None
  File "E:\projects\test\venv311\Lib\site-packages\sentry_sdk\integrations\asgi.py", line 234, in _run_app
    return await self.app(
           ^^^^^^^^^^^^^^^
  File "E:\projects\test\venv311\Lib\site-packages\sentry_sdk\integrations\django\asgi.py", line 83, in sentry_patched_asgi_handler
    return await middleware(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "E:\projects\test\venv311\Lib\site-packages\sentry_sdk\integrations\asgi.py", line 146, in _run_asgi3
    return await self._run_app(scope, receive, send, asgi_version=3)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "E:\projects\test\venv311\Lib\site-packages\sentry_sdk\integrations\asgi.py", line 161, in _run_app
    raise exc from None
  File "E:\projects\test\venv311\Lib\site-packages\sentry_sdk\integrations\asgi.py", line 157, in _run_app
    return await self.app(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "E:\projects\test\venv311\Lib\site-packages\django\core\handlers\asgi.py", line 160, in __call__
    await self.handle(scope, receive, send)
  File "E:\projects\test\venv311\Lib\site-packages\django\core\handlers\asgi.py", line 177, in handle
    request, error_response = self.create_request(scope, body_file)
                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "E:\projects\test\venv311\Lib\site-packages\sentry_sdk\integrations\django\asgi.py", line 104, in sentry_patched_create_request
    _ = request.body
        ^^^^^^^^^^^^
  File "E:\projects\test\venv311\Lib\site-packages\django\http\request.py", line 337, in body
    raise RequestDataTooBig(
django.core.exceptions.RequestDataTooBig: Request body exceeded settings.DATA_UPLOAD_MAX_MEMORY_SIZE.
ERROR    2023-11-15 11:05:58,330 django.channels.server    75700 70060 HTTP POST /photo_gallery/2703/upload-image/ 500 [0.34, 127.0.0.1:49724]
```
I'm using sentry replay integration. Error was reported to sentry and it seems to be originating from `sentry_sdk.integrations.asgi.SentryAsgiMiddleware`.

It works on 1.34.0 though.


### Expected Result

File to be uploaded

### Actual Result

Request body exceeded settings.DATA_UPLOAD_MAX_MEMORY_SIZE error is thrown.



Discussion:
Hey @aljazkosir, thank you for reporting this. This indeed looks like a regression introduced in 1.35.0, so please stay on 1.34 until we fix this.
Hey @aljazkosir ! Just to double check, you have Django with Channels and run in on Daphne (or another ASGI server) right?

Oh, and what is your `DATA_UPLOAD_MAX_MEMORY_SIZE` setting and what is the size of the file you try to upload?

Hey @aljazkosir 

I just created a demo application. With 1.35.0 I get the error you mentioned. But only when the uploaded file size is over the default limit. But I also could verify that with 1.34.0 the error does not appear. 

The cause for the problem is that in ASGI you can not read the body multiple times, so Sentry tries to read the body in any case so Django caches it. This enables your code to read the body and Sentry to read the body to attach it to exceptions. 

But I guess I have to change my code that the body is only read when absolutely necessary to not trigger Djangos DATA_UPLOAD_MAX_MEMORY_SIZE check.
Django with channels on Daphe. DATA_UPLOAD_MAX_MEMORY_SIZE is default which is 2.5MB I think. File I tried to upload was 6MB. However it seems to work for smaller files on this release aswell.
Thanks for the info!

Note to self: 
This is the fix that cased this regression: https://github.com/getsentry/sentry-python/pull/2495

Resolving patch:
diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py
--- a/sentry_sdk/integrations/_wsgi_common.py
+++ b/sentry_sdk/integrations/_wsgi_common.py
@@ -1,3 +1,5 @@
+from __future__ import absolute_import
+
 import json
 from copy import deepcopy
 
@@ -7,6 +9,12 @@
 
 from sentry_sdk._types import TYPE_CHECKING
 
+try:
+    from django.http.request import RawPostDataException
+except ImportError:
+    RawPostDataException = None
+
+
 if TYPE_CHECKING:
     import sentry_sdk
 
@@ -67,10 +75,22 @@ def extract_into_event(self, event):
         if not request_body_within_bounds(client, content_length):
             data = AnnotatedValue.removed_because_over_size_limit()
         else:
+            # First read the raw body data
+            # It is important to read this first because if it is Django
+            # it will cache the body and then we can read the cached version
+            # again in parsed_body() (or json() or wherever).
+            raw_data = None
+            try:
+                raw_data = self.raw_data()
+            except (RawPostDataException, ValueError):
+                # If DjangoRestFramework is used it already read the body for us
+                # so reading it here will fail. We can ignore this.
+                pass
+
             parsed_body = self.parsed_body()
             if parsed_body is not None:
                 data = parsed_body
-            elif self.raw_data():
+            elif raw_data:
                 data = AnnotatedValue.removed_because_raw_data()
             else:
                 data = None
diff --git a/sentry_sdk/integrations/django/asgi.py b/sentry_sdk/integrations/django/asgi.py
--- a/sentry_sdk/integrations/django/asgi.py
+++ b/sentry_sdk/integrations/django/asgi.py
@@ -94,12 +94,6 @@ def sentry_patched_create_request(self, *args, **kwargs):
 
             with hub.configure_scope() as scope:
                 request, error_response = old_create_request(self, *args, **kwargs)
-
-                # read the body once, to signal Django to cache the body stream
-                # so we can read the body in our event processor
-                # (otherwise Django closes the body stream and makes it impossible to read it again)
-                _ = request.body
-
                 scope.add_event_processor(_make_asgi_request_event_processor(request))
 
                 return request, error_response
diff --git a/sentry_sdk/integrations/django/views.py b/sentry_sdk/integrations/django/views.py
--- a/sentry_sdk/integrations/django/views.py
+++ b/sentry_sdk/integrations/django/views.py
@@ -47,13 +47,13 @@ def sentry_patched_make_view_atomic(self, *args, **kwargs):
 
         hub = Hub.current
         integration = hub.get_integration(DjangoIntegration)
-
         if integration is not None and integration.middleware_spans:
-            if (
+            is_async_view = (
                 iscoroutinefunction is not None
                 and wrap_async_view is not None
                 and iscoroutinefunction(callback)
-            ):
+            )
+            if is_async_view:
                 sentry_wrapped_callback = wrap_async_view(hub, callback)
             else:
                 sentry_wrapped_callback = _wrap_sync_view(hub, callback)


Test patch:
diff --git a/tests/integrations/django/asgi/image.png b/tests/integrations/django/asgi/image.png
new file mode 100644
Binary files /dev/null and b/tests/integrations/django/asgi/image.png differ
diff --git a/tests/integrations/django/asgi/test_asgi.py b/tests/integrations/django/asgi/test_asgi.py
--- a/tests/integrations/django/asgi/test_asgi.py
+++ b/tests/integrations/django/asgi/test_asgi.py
@@ -1,4 +1,6 @@
+import base64
 import json
+import os
 
 import django
 import pytest
@@ -370,16 +372,105 @@ async def test_trace_from_headers_if_performance_disabled(sentry_init, capture_e
     assert error_event["contexts"]["trace"]["trace_id"] == trace_id
 
 
+PICTURE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "image.png")
+BODY_FORM = """--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="username"\r\n\r\nJane\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="password"\r\n\r\nhello123\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="photo"; filename="image.png"\r\nContent-Type: image/png\r\nContent-Transfer-Encoding: base64\r\n\r\n{{image_data}}\r\n--fd721ef49ea403a6--\r\n""".replace(
+    "{{image_data}}", base64.b64encode(open(PICTURE, "rb").read()).decode("utf-8")
+).encode(
+    "utf-8"
+)
+BODY_FORM_CONTENT_LENGTH = str(len(BODY_FORM)).encode("utf-8")
+
+
 @pytest.mark.parametrize("application", APPS)
 @pytest.mark.parametrize(
-    "body,expected_return_data",
+    "send_default_pii,method,headers,url_name,body,expected_data",
     [
         (
+            True,
+            "POST",
+            [(b"content-type", b"text/plain")],
+            "post_echo_async",
+            b"",
+            None,
+        ),
+        (
+            True,
+            "POST",
+            [(b"content-type", b"text/plain")],
+            "post_echo_async",
+            b"some raw text body",
+            "",
+        ),
+        (
+            True,
+            "POST",
+            [(b"content-type", b"application/json")],
+            "post_echo_async",
             b'{"username":"xyz","password":"xyz"}',
             {"username": "xyz", "password": "xyz"},
         ),
-        (b"hello", ""),
-        (b"", None),
+        (
+            True,
+            "POST",
+            [(b"content-type", b"application/xml")],
+            "post_echo_async",
+            b'<?xml version="1.0" encoding="UTF-8"?><root></root>',
+            "",
+        ),
+        (
+            True,
+            "POST",
+            [
+                (b"content-type", b"multipart/form-data; boundary=fd721ef49ea403a6"),
+                (b"content-length", BODY_FORM_CONTENT_LENGTH),
+            ],
+            "post_echo_async",
+            BODY_FORM,
+            {"password": "hello123", "photo": "", "username": "Jane"},
+        ),
+        (
+            False,
+            "POST",
+            [(b"content-type", b"text/plain")],
+            "post_echo_async",
+            b"",
+            None,
+        ),
+        (
+            False,
+            "POST",
+            [(b"content-type", b"text/plain")],
+            "post_echo_async",
+            b"some raw text body",
+            "",
+        ),
+        (
+            False,
+            "POST",
+            [(b"content-type", b"application/json")],
+            "post_echo_async",
+            b'{"username":"xyz","password":"xyz"}',
+            {"username": "xyz", "password": "[Filtered]"},
+        ),
+        (
+            False,
+            "POST",
+            [(b"content-type", b"application/xml")],
+            "post_echo_async",
+            b'<?xml version="1.0" encoding="UTF-8"?><root></root>',
+            "",
+        ),
+        (
+            False,
+            "POST",
+            [
+                (b"content-type", b"multipart/form-data; boundary=fd721ef49ea403a6"),
+                (b"content-length", BODY_FORM_CONTENT_LENGTH),
+            ],
+            "post_echo_async",
+            BODY_FORM,
+            {"password": "[Filtered]", "photo": "", "username": "Jane"},
+        ),
     ],
 )
 @pytest.mark.asyncio
@@ -388,28 +479,42 @@ async def test_trace_from_headers_if_performance_disabled(sentry_init, capture_e
     django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
 )
 async def test_asgi_request_body(
-    sentry_init, capture_envelopes, application, body, expected_return_data
+    sentry_init,
+    capture_envelopes,
+    application,
+    send_default_pii,
+    method,
+    headers,
+    url_name,
+    body,
+    expected_data,
 ):
-    sentry_init(integrations=[DjangoIntegration()], send_default_pii=True)
+    sentry_init(
+        send_default_pii=send_default_pii,
+        integrations=[
+            DjangoIntegration(),
+        ],
+    )
 
     envelopes = capture_envelopes()
 
     comm = HttpCommunicator(
         application,
-        method="POST",
-        path=reverse("post_echo_async"),
+        method=method,
+        headers=headers,
+        path=reverse(url_name),
         body=body,
-        headers=[(b"content-type", b"application/json")],
     )
     response = await comm.get_response()
-
     assert response["status"] == 200
+
+    await comm.wait()
     assert response["body"] == body
 
     (envelope,) = envelopes
     event = envelope.get_event()
 
-    if expected_return_data is not None:
-        assert event["request"]["data"] == expected_return_data
+    if expected_data is not None:
+        assert event["request"]["data"] == expected_data
     else:
         assert "data" not in event["request"]
diff --git a/tests/integrations/django/myapp/views.py b/tests/integrations/django/myapp/views.py
--- a/tests/integrations/django/myapp/views.py
+++ b/tests/integrations/django/myapp/views.py
@@ -237,10 +237,10 @@ def thread_ids_sync(*args, **kwargs):
     )
 
     exec(
-        """@csrf_exempt
-def post_echo_async(request):
+        """async def post_echo_async(request):
     sentry_sdk.capture_message("hi")
-    return HttpResponse(request.body)"""
+    return HttpResponse(request.body)
+post_echo_async.csrf_exempt = True"""
     )
 else:
     async_message = None