Skip to content

Commit f9f0af9

Browse files
ericapisaniclaude
andcommitted
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 <noreply@anthropic.com>
1 parent fa88d92 commit f9f0af9

File tree

2 files changed

+107
-2
lines changed

2 files changed

+107
-2
lines changed

sentry_sdk/integrations/wsgi.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def __init__(
8686

8787
def __call__(
8888
self, environ: "Dict[str, str]", start_response: "Callable[..., Any]"
89-
) -> "_ScopedResponse":
89+
) -> "Any":
9090
if _wsgi_middleware_applied.get(False):
9191
return self.app(environ, start_response)
9292

@@ -135,9 +135,51 @@ def __call__(
135135
finally:
136136
_wsgi_middleware_applied.set(False)
137137

138+
# Within the uWSGI subhandler, the use of the "offload" mechanism for file responses
139+
# is determined by a pointer equality check on the response object
140+
# (see https://github.com/unbit/uwsgi/blob/8d116f7ea2b098c11ce54d0b3a561c54dcd11929/plugins/python/wsgi_subhandler.c#L278).
141+
#
142+
# If we were to return a _ScopedResponse, this would cause the check to always fail
143+
# since it's checking the files are exactly the same.
144+
#
145+
# To avoid this and ensure that the offloading mechanism works as expected when it's
146+
# enabled, we check if the response is a file-like object (determined by the presence
147+
# of `fileno`), if the wsgi.file_wrapper is available in the environment (as if so,
148+
# it would've been used in handling the file in the response), and if uWSGI's
149+
# offload-threads option is configured (since offloading only occurs when offload
150+
# threads are enabled).
151+
#
152+
# If all conditions are met, we return the original response object directly,
153+
# allowing uWSGI to handle it as intended.
154+
if (
155+
_is_uwsgi_offload_threads_enabled()
156+
and environ.get("wsgi.file_wrapper")
157+
and getattr(response, "fileno", None) is not None
158+
):
159+
return response
160+
138161
return _ScopedResponse(scope, response)
139162

140163

164+
def _is_uwsgi_offload_threads_enabled() -> bool:
165+
try:
166+
from uwsgi import opt
167+
except ImportError:
168+
return False
169+
170+
value = opt.get("offload-threads") or opt.get(b"offload-threads")
171+
if not value:
172+
return False
173+
if isinstance(value, bytes):
174+
try:
175+
return int(value.decode()) > 0
176+
except (ValueError, UnicodeDecodeError):
177+
return False
178+
if isinstance(value, int):
179+
return value > 0
180+
return False
181+
182+
141183
def _sentry_start_response(
142184
old_start_response: "StartResponse",
143185
transaction: "Optional[Transaction]",

tests/integrations/wsgi/test_wsgi.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import sentry_sdk
88
from sentry_sdk import capture_message
9-
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
9+
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware, _ScopedResponse
1010

1111

1212
@pytest.fixture
@@ -500,3 +500,66 @@ def dogpark(environ, start_response):
500500
(event,) = events
501501

502502
assert event["contexts"]["trace"]["origin"] == "auto.dogpark.deluxe"
503+
504+
505+
@pytest.mark.parametrize(
506+
"uwsgi_opt, has_file_wrapper, has_fileno, expect_wrapped",
507+
[
508+
({"offload-threads": 1}, True, True, False), # all conditions met → unwrapped
509+
({"offload-threads": 0}, True, True, True), # offload disabled → wrapped
510+
({"offload-threads": 1}, False, True, True), # no file_wrapper → wrapped
511+
({"offload-threads": 1}, True, False, True), # no fileno → wrapped
512+
(None, True, True, True), # uwsgi not installed → wrapped
513+
({"offload-threads": b"1"}, True, True, False), # bytes value → unwrapped
514+
(
515+
{b"offload-threads": b"1"},
516+
True,
517+
True,
518+
False,
519+
), # bytes key + bytes value → unwrapped
520+
],
521+
)
522+
def test_uwsgi_offload_threads_response_wrapping(
523+
sentry_init, uwsgi_opt, has_file_wrapper, has_fileno, expect_wrapped
524+
):
525+
sentry_init()
526+
527+
response_mock = mock.MagicMock()
528+
if not has_fileno:
529+
del response_mock.fileno
530+
531+
def app(environ, start_response):
532+
start_response("200 OK", [])
533+
return response_mock
534+
535+
environ_extra = {}
536+
if has_file_wrapper:
537+
environ_extra["wsgi.file_wrapper"] = mock.MagicMock()
538+
539+
middleware = SentryWsgiMiddleware(app)
540+
541+
if uwsgi_opt is not None:
542+
uwsgi_mock = mock.MagicMock()
543+
uwsgi_mock.opt = uwsgi_opt
544+
patch_ctx = mock.patch.dict("sys.modules", uwsgi=uwsgi_mock)
545+
else:
546+
patch_ctx = mock.patch.dict("sys.modules", {"uwsgi": None})
547+
548+
with patch_ctx:
549+
result = middleware(
550+
{
551+
"REQUEST_METHOD": "GET",
552+
"PATH_INFO": "/",
553+
"SERVER_NAME": "localhost",
554+
"SERVER_PORT": "80",
555+
"wsgi.url_scheme": "http",
556+
"wsgi.input": mock.MagicMock(),
557+
**environ_extra,
558+
},
559+
lambda status, headers: None,
560+
)
561+
562+
if expect_wrapped:
563+
assert isinstance(result, _ScopedResponse)
564+
else:
565+
assert result is response_mock

0 commit comments

Comments
 (0)