From f9f0af9a693fa2a05bb3c45121b7824e11067beb Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 26 Feb 2026 11:21:43 -0500 Subject: [PATCH 1/3] fix(wsgi): Do not wrap file responses when uWSGI offload-threads is enabled uWSGI determines whether to offload a file response using a pointer equality check on the response object. Wrapping the response in _ScopedResponse changes the pointer, causing the check to always fail and silently disabling the offloading optimization. When uWSGI offload-threads is enabled, wsgi.file_wrapper is present in the environ, and the response has a fileno attribute, return the original response directly so uWSGI's offloading works as expected. Fixes PY-1977 Co-Authored-By: Claude --- sentry_sdk/integrations/wsgi.py | 44 ++++++++++++++++++- tests/integrations/wsgi/test_wsgi.py | 65 +++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index 1576e21a17..b4d49701fb 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -86,7 +86,7 @@ def __init__( def __call__( self, environ: "Dict[str, str]", start_response: "Callable[..., Any]" - ) -> "_ScopedResponse": + ) -> "Any": if _wsgi_middleware_applied.get(False): return self.app(environ, start_response) @@ -135,9 +135,51 @@ def __call__( finally: _wsgi_middleware_applied.set(False) + # Within the uWSGI subhandler, the use of the "offload" mechanism for file responses + # is determined by a pointer equality check on the response object + # (see https://github.com/unbit/uwsgi/blob/8d116f7ea2b098c11ce54d0b3a561c54dcd11929/plugins/python/wsgi_subhandler.c#L278). + # + # If we were to return a _ScopedResponse, this would cause the check to always fail + # since it's checking the files are exactly the same. + # + # To avoid this and ensure that the offloading mechanism works as expected when it's + # enabled, we check if the response is a file-like object (determined by the presence + # of `fileno`), if the wsgi.file_wrapper is available in the environment (as if so, + # it would've been used in handling the file in the response), and if uWSGI's + # offload-threads option is configured (since offloading only occurs when offload + # threads are enabled). + # + # If all conditions are met, we return the original response object directly, + # allowing uWSGI to handle it as intended. + if ( + _is_uwsgi_offload_threads_enabled() + and environ.get("wsgi.file_wrapper") + and getattr(response, "fileno", None) is not None + ): + return response + return _ScopedResponse(scope, response) +def _is_uwsgi_offload_threads_enabled() -> bool: + try: + from uwsgi import opt + except ImportError: + return False + + value = opt.get("offload-threads") or opt.get(b"offload-threads") + if not value: + return False + if isinstance(value, bytes): + try: + return int(value.decode()) > 0 + except (ValueError, UnicodeDecodeError): + return False + if isinstance(value, int): + return value > 0 + return False + + def _sentry_start_response( old_start_response: "StartResponse", transaction: "Optional[Transaction]", diff --git a/tests/integrations/wsgi/test_wsgi.py b/tests/integrations/wsgi/test_wsgi.py index a741d1c57b..d731abef67 100644 --- a/tests/integrations/wsgi/test_wsgi.py +++ b/tests/integrations/wsgi/test_wsgi.py @@ -6,7 +6,7 @@ import sentry_sdk from sentry_sdk import capture_message -from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware +from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware, _ScopedResponse @pytest.fixture @@ -500,3 +500,66 @@ def dogpark(environ, start_response): (event,) = events assert event["contexts"]["trace"]["origin"] == "auto.dogpark.deluxe" + + +@pytest.mark.parametrize( + "uwsgi_opt, has_file_wrapper, has_fileno, expect_wrapped", + [ + ({"offload-threads": 1}, True, True, False), # all conditions met → unwrapped + ({"offload-threads": 0}, True, True, True), # offload disabled → wrapped + ({"offload-threads": 1}, False, True, True), # no file_wrapper → wrapped + ({"offload-threads": 1}, True, False, True), # no fileno → wrapped + (None, True, True, True), # uwsgi not installed → wrapped + ({"offload-threads": b"1"}, True, True, False), # bytes value → unwrapped + ( + {b"offload-threads": b"1"}, + True, + True, + False, + ), # bytes key + bytes value → unwrapped + ], +) +def test_uwsgi_offload_threads_response_wrapping( + sentry_init, uwsgi_opt, has_file_wrapper, has_fileno, expect_wrapped +): + sentry_init() + + response_mock = mock.MagicMock() + if not has_fileno: + del response_mock.fileno + + def app(environ, start_response): + start_response("200 OK", []) + return response_mock + + environ_extra = {} + if has_file_wrapper: + environ_extra["wsgi.file_wrapper"] = mock.MagicMock() + + middleware = SentryWsgiMiddleware(app) + + if uwsgi_opt is not None: + uwsgi_mock = mock.MagicMock() + uwsgi_mock.opt = uwsgi_opt + patch_ctx = mock.patch.dict("sys.modules", uwsgi=uwsgi_mock) + else: + patch_ctx = mock.patch.dict("sys.modules", {"uwsgi": None}) + + with patch_ctx: + result = middleware( + { + "REQUEST_METHOD": "GET", + "PATH_INFO": "/", + "SERVER_NAME": "localhost", + "SERVER_PORT": "80", + "wsgi.url_scheme": "http", + "wsgi.input": mock.MagicMock(), + **environ_extra, + }, + lambda status, headers: None, + ) + + if expect_wrapped: + assert isinstance(result, _ScopedResponse) + else: + assert result is response_mock From e1b733bd711f70138994547cbf3cdf1546a3ee1b Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 26 Feb 2026 11:26:14 -0500 Subject: [PATCH 2/3] style(wsgi): Add type: ignore comment to uwsgi import Co-Authored-By: Claude --- sentry_sdk/integrations/wsgi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index b4d49701fb..42a2536613 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -163,7 +163,7 @@ def __call__( def _is_uwsgi_offload_threads_enabled() -> bool: try: - from uwsgi import opt + from uwsgi import opt # type: ignore except ImportError: return False From 869866136a34dcd4eefc1e280b041bc43bf7bbce Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 26 Feb 2026 13:25:50 -0500 Subject: [PATCH 3/3] ref(wsgi): Simplify file response bypass to not require uWSGI offload-threads check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the _is_uwsgi_offload_threads_enabled() helper and the associated uWSGI import. The broader condition — skip wrapping whenever wsgi.file_wrapper is present and the response is file-like (has fileno) — is sufficient and correct since uWSGI applies file-handling optimizations beyond just offload-threads. Co-Authored-By: Claude --- sentry_sdk/integrations/wsgi.py | 29 +++------------ tests/integrations/wsgi/test_wsgi.py | 54 ++++++++++------------------ 2 files changed, 24 insertions(+), 59 deletions(-) diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index 42a2536613..ea7ebbea4d 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -145,15 +145,15 @@ def __call__( # To avoid this and ensure that the offloading mechanism works as expected when it's # enabled, we check if the response is a file-like object (determined by the presence # of `fileno`), if the wsgi.file_wrapper is available in the environment (as if so, - # it would've been used in handling the file in the response), and if uWSGI's - # offload-threads option is configured (since offloading only occurs when offload - # threads are enabled). + # it would've been used in handling the file in the response). + # + # Even if the offload mechanism is not enabled, there are optimizations that uWSGI does for file-like objects, + # so we want to make sure we don't interfere with those either. # # If all conditions are met, we return the original response object directly, # allowing uWSGI to handle it as intended. if ( - _is_uwsgi_offload_threads_enabled() - and environ.get("wsgi.file_wrapper") + environ.get("wsgi.file_wrapper") and getattr(response, "fileno", None) is not None ): return response @@ -161,25 +161,6 @@ def __call__( return _ScopedResponse(scope, response) -def _is_uwsgi_offload_threads_enabled() -> bool: - try: - from uwsgi import opt # type: ignore - except ImportError: - return False - - value = opt.get("offload-threads") or opt.get(b"offload-threads") - if not value: - return False - if isinstance(value, bytes): - try: - return int(value.decode()) > 0 - except (ValueError, UnicodeDecodeError): - return False - if isinstance(value, int): - return value > 0 - return False - - def _sentry_start_response( old_start_response: "StartResponse", transaction: "Optional[Transaction]", diff --git a/tests/integrations/wsgi/test_wsgi.py b/tests/integrations/wsgi/test_wsgi.py index d731abef67..1878be4866 100644 --- a/tests/integrations/wsgi/test_wsgi.py +++ b/tests/integrations/wsgi/test_wsgi.py @@ -503,24 +503,16 @@ def dogpark(environ, start_response): @pytest.mark.parametrize( - "uwsgi_opt, has_file_wrapper, has_fileno, expect_wrapped", + "has_file_wrapper, has_fileno, expect_wrapped", [ - ({"offload-threads": 1}, True, True, False), # all conditions met → unwrapped - ({"offload-threads": 0}, True, True, True), # offload disabled → wrapped - ({"offload-threads": 1}, False, True, True), # no file_wrapper → wrapped - ({"offload-threads": 1}, True, False, True), # no fileno → wrapped - (None, True, True, True), # uwsgi not installed → wrapped - ({"offload-threads": b"1"}, True, True, False), # bytes value → unwrapped - ( - {b"offload-threads": b"1"}, - True, - True, - False, - ), # bytes key + bytes value → unwrapped + (True, True, False), # both conditions met → unwrapped + (False, True, True), # no file_wrapper → wrapped + (True, False, True), # no fileno → wrapped + (False, False, True), # neither condition → wrapped ], ) -def test_uwsgi_offload_threads_response_wrapping( - sentry_init, uwsgi_opt, has_file_wrapper, has_fileno, expect_wrapped +def test_file_response_wrapping( + sentry_init, has_file_wrapper, has_fileno, expect_wrapped ): sentry_init() @@ -538,26 +530,18 @@ def app(environ, start_response): middleware = SentryWsgiMiddleware(app) - if uwsgi_opt is not None: - uwsgi_mock = mock.MagicMock() - uwsgi_mock.opt = uwsgi_opt - patch_ctx = mock.patch.dict("sys.modules", uwsgi=uwsgi_mock) - else: - patch_ctx = mock.patch.dict("sys.modules", {"uwsgi": None}) - - with patch_ctx: - result = middleware( - { - "REQUEST_METHOD": "GET", - "PATH_INFO": "/", - "SERVER_NAME": "localhost", - "SERVER_PORT": "80", - "wsgi.url_scheme": "http", - "wsgi.input": mock.MagicMock(), - **environ_extra, - }, - lambda status, headers: None, - ) + result = middleware( + { + "REQUEST_METHOD": "GET", + "PATH_INFO": "/", + "SERVER_NAME": "localhost", + "SERVER_PORT": "80", + "wsgi.url_scheme": "http", + "wsgi.input": mock.MagicMock(), + **environ_extra, + }, + lambda status, headers: None, + ) if expect_wrapped: assert isinstance(result, _ScopedResponse)