Skip to content

Commit 885aeaf

Browse files
fix: read request body after handler so receive stream is not deprived
1 parent 7ae6866 commit 885aeaf

File tree

4 files changed

+146
-5
lines changed

4 files changed

+146
-5
lines changed

sentry_sdk/integrations/fastapi.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from sentry_sdk.integrations.starlette import (
1919
StarletteIntegration,
2020
StarletteRequestExtractor,
21+
_patch_request,
2122
)
2223
except DidNotEnable:
2324
raise DidNotEnable("Starlette is not installed")
@@ -103,11 +104,16 @@ async def _sentry_app(*args, **kwargs):
103104
return await old_app(*args, **kwargs)
104105

105106
request = args[0]
107+
_patch_request(request)
106108

107109
_set_transaction_name_and_source(
108110
sentry_sdk.get_current_scope(), integration.transaction_style, request
109111
)
110112
sentry_scope = sentry_sdk.get_isolation_scope()
113+
sentry_scope._name = FastApiIntegration.identifier
114+
115+
response = await old_app(*args, **kwargs)
116+
111117
extractor = StarletteRequestExtractor(request)
112118
info = await extractor.extract_request_info()
113119

@@ -129,12 +135,11 @@ def event_processor(event, hint):
129135

130136
return event_processor
131137

132-
sentry_scope._name = FastApiIntegration.identifier
133138
sentry_scope.add_event_processor(
134139
_make_request_event_processor(request, integration)
135140
)
136141

137-
return await old_app(*args, **kwargs)
142+
return response
138143

139144
return _sentry_app
140145

sentry_sdk/integrations/starlette.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,36 @@ def _is_async_callable(obj):
422422
)
423423

424424

425+
def _patch_request(request):
426+
_original_body = request.body
427+
_original_json = request.json
428+
_original_form = request.form
429+
430+
def restore_original_methods():
431+
request.body = _original_body
432+
request.json = _original_json
433+
request.form = _original_form
434+
435+
async def sentry_body():
436+
request.scope.setdefault("state", {})["sentry_sdk.is_body_cached"] = True
437+
restore_original_methods()
438+
return await _original_body()
439+
440+
async def sentry_json():
441+
request.scope.setdefault("state", {})["sentry_sdk.is_body_cached"] = True
442+
restore_original_methods()
443+
return await _original_json()
444+
445+
async def sentry_form():
446+
request.scope.setdefault("state", {})["sentry_sdk.is_body_cached"] = True
447+
restore_original_methods()
448+
return await _original_form()
449+
450+
request.body = sentry_body
451+
request.json = sentry_json
452+
request.form = sentry_form
453+
454+
425455
def patch_request_response():
426456
# type: () -> None
427457
old_request_response = starlette.routing.request_response
@@ -442,6 +472,7 @@ async def _sentry_async_func(*args, **kwargs):
442472
return await old_func(*args, **kwargs)
443473

444474
request = args[0]
475+
_patch_request(request)
445476

446477
_set_transaction_name_and_source(
447478
sentry_sdk.get_current_scope(),
@@ -450,6 +481,10 @@ async def _sentry_async_func(*args, **kwargs):
450481
)
451482

452483
sentry_scope = sentry_sdk.get_isolation_scope()
484+
sentry_scope._name = StarletteIntegration.identifier
485+
486+
response = await old_func(*args, **kwargs)
487+
453488
extractor = StarletteRequestExtractor(request)
454489
info = await extractor.extract_request_info()
455490

@@ -471,12 +506,11 @@ def event_processor(event, hint):
471506

472507
return event_processor
473508

474-
sentry_scope._name = StarletteIntegration.identifier
475509
sentry_scope.add_event_processor(
476510
_make_request_event_processor(request, integration)
477511
)
478512

479-
return await old_func(*args, **kwargs)
513+
return response
480514

481515
func = _sentry_async_func
482516

@@ -623,6 +657,18 @@ async def extract_request_info(self):
623657
request_info["data"] = AnnotatedValue.removed_because_over_size_limit()
624658
return request_info
625659

660+
# Avoid hangs by not parsing body when ASGI stream is consumed
661+
is_body_cached = (
662+
"state" in self.request.scope
663+
and "sentry_sdk.is_body_cached" in self.request.scope["state"]
664+
and self.request.scope["state"]["sentry_sdk.is_body_cached"]
665+
)
666+
if self.request.is_disconnected() and not is_body_cached:
667+
request_info["data"] = (
668+
AnnotatedValue.removed_because_body_consumed_and_not_cached()
669+
)
670+
return request_info
671+
626672
# Add JSON body, if it is a JSON request
627673
json = await self.json()
628674
if json:

tests/integrations/fastapi/test_fastapi.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
from tests.integrations.conftest import parametrize_test_configurable_status_codes
2525
from tests.integrations.starlette import test_starlette
2626

27+
BODY_JSON = {"some": "json", "for": "testing", "nested": {"numbers": 123}}
28+
2729

2830
def fastapi_app_factory():
2931
app = FastAPI()
@@ -72,6 +74,29 @@ async def _thread_ids_async():
7274
return app
7375

7476

77+
def test_stream_available_in_handler(sentry_init):
78+
sentry_init(
79+
integrations=[StarletteIntegration(), FastApiIntegration()],
80+
)
81+
82+
app = FastAPI()
83+
84+
@app.post("/consume")
85+
async def _consume_stream_body(request):
86+
# Avoid cache by constructing new request
87+
wrapped_request = Request(request.scope, request.receive)
88+
89+
assert await wrapped_request.json() == BODY_JSON
90+
91+
return {"status": "ok"}
92+
93+
client = TestClient(app)
94+
client.post(
95+
"/consume",
96+
json=BODY_JSON,
97+
)
98+
99+
75100
@pytest.mark.asyncio
76101
async def test_response(sentry_init, capture_events):
77102
# FastAPI is heavily based on Starlette so we also need
@@ -223,6 +248,7 @@ def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, en
223248
@pytest.mark.asyncio
224249
async def test_original_request_not_scrubbed(sentry_init, capture_events):
225250
sentry_init(
251+
default_integrations=False,
226252
integrations=[StarletteIntegration(), FastApiIntegration()],
227253
traces_sample_rate=1.0,
228254
)

tests/integrations/starlette/test_starlette.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
AuthenticationError,
2727
SimpleUser,
2828
)
29+
from starlette.requests import Request
2930
from starlette.exceptions import HTTPException
3031
from starlette.middleware import Middleware
3132
from starlette.middleware.authentication import AuthenticationMiddleware
@@ -435,6 +436,7 @@ async def test_starletterequestextractor_extract_request_info(sentry_init):
435436
side_effect = [_mock_receive(msg) for msg in JSON_RECEIVE_MESSAGES]
436437
starlette_request._receive = mock.Mock(side_effect=side_effect)
437438

439+
starlette_request.scope["state"] = {"sentry_sdk.is_body_cached": True}
438440
extractor = StarletteRequestExtractor(starlette_request)
439441

440442
request_info = await extractor.extract_request_info()
@@ -447,6 +449,37 @@ async def test_starletterequestextractor_extract_request_info(sentry_init):
447449
assert request_info["data"] == BODY_JSON
448450

449451

452+
@pytest.mark.asyncio
453+
async def test_starletterequestextractor_extract_request_info_not_cached(sentry_init):
454+
sentry_init(
455+
send_default_pii=True,
456+
integrations=[StarletteIntegration()],
457+
)
458+
scope = SCOPE.copy()
459+
scope["headers"] = [
460+
[b"content-type", b"application/json"],
461+
[b"content-length", str(len(json.dumps(BODY_JSON))).encode()],
462+
[b"cookie", b"yummy_cookie=choco; tasty_cookie=strawberry"],
463+
]
464+
465+
starlette_request = starlette.requests.Request(scope)
466+
467+
# Mocking async `_receive()` that works in Python 3.7+
468+
side_effect = [_mock_receive(msg) for msg in JSON_RECEIVE_MESSAGES]
469+
starlette_request._receive = mock.Mock(side_effect=side_effect)
470+
471+
extractor = StarletteRequestExtractor(starlette_request)
472+
473+
request_info = await extractor.extract_request_info()
474+
475+
assert request_info
476+
assert request_info["cookies"] == {
477+
"tasty_cookie": "strawberry",
478+
"yummy_cookie": "choco",
479+
}
480+
assert request_info["data"].metadata == {"rem": [["!consumed", "x"]]}
481+
482+
450483
@pytest.mark.asyncio
451484
async def test_starletterequestextractor_extract_request_info_no_pii(sentry_init):
452485
sentry_init(
@@ -466,6 +499,7 @@ async def test_starletterequestextractor_extract_request_info_no_pii(sentry_init
466499
side_effect = [_mock_receive(msg) for msg in JSON_RECEIVE_MESSAGES]
467500
starlette_request._receive = mock.Mock(side_effect=side_effect)
468501

502+
starlette_request.scope["state"] = {"sentry_sdk.is_body_cached": True}
469503
extractor = StarletteRequestExtractor(starlette_request)
470504

471505
request_info = await extractor.extract_request_info()
@@ -475,6 +509,32 @@ async def test_starletterequestextractor_extract_request_info_no_pii(sentry_init
475509
assert request_info["data"] == BODY_JSON
476510

477511

512+
def test_stream_available_in_handler(sentry_init):
513+
sentry_init(
514+
integrations=[StarletteIntegration()],
515+
)
516+
517+
async def _consume_stream_body(request):
518+
# Avoid cache by constructing new request
519+
wrapped_request = Request(request.scope, request.receive)
520+
521+
assert await wrapped_request.json() == BODY_JSON
522+
523+
return starlette.responses.JSONResponse({"status": "ok"})
524+
525+
app = starlette.applications.Starlette(
526+
routes=[
527+
starlette.routing.Route("/consume", _consume_stream_body, methods=["POST"]),
528+
],
529+
)
530+
531+
client = TestClient(app)
532+
client.post(
533+
"/consume",
534+
json=BODY_JSON,
535+
)
536+
537+
478538
@pytest.mark.parametrize(
479539
"url,transaction_style,expected_transaction,expected_source",
480540
[
@@ -942,7 +1002,11 @@ def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, en
9421002

9431003

9441004
def test_original_request_not_scrubbed(sentry_init, capture_events):
945-
sentry_init(integrations=[StarletteIntegration()])
1005+
sentry_init(
1006+
default_integrations=False,
1007+
integrations=[StarletteIntegration()],
1008+
traces_sample_rate=1.0,
1009+
)
9461010

9471011
events = capture_events()
9481012

0 commit comments

Comments
 (0)