Skip to content

Commit 9227968

Browse files
authored
feat(starlette): Allow to configure status codes to report to Sentry (#3008)
1 parent ac4d657 commit 9227968

File tree

5 files changed

+154
-16
lines changed

5 files changed

+154
-16
lines changed

sentry_sdk/_types.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010

1111
if TYPE_CHECKING:
12-
from collections.abc import MutableMapping
12+
from collections.abc import Container, MutableMapping
1313

1414
from datetime import datetime
1515

@@ -220,3 +220,5 @@
220220
},
221221
total=False,
222222
)
223+
224+
HttpStatusCodeRange = Union[int, Container[int]]

sentry_sdk/integrations/_wsgi_common.py

+21-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import sentry_sdk
55
from sentry_sdk.scope import should_send_default_pii
6-
from sentry_sdk.utils import AnnotatedValue
6+
from sentry_sdk.utils import AnnotatedValue, logger
77
from sentry_sdk._types import TYPE_CHECKING
88

99
try:
@@ -18,7 +18,7 @@
1818
from typing import Mapping
1919
from typing import Optional
2020
from typing import Union
21-
from sentry_sdk._types import Event
21+
from sentry_sdk._types import Event, HttpStatusCodeRange
2222

2323

2424
SENSITIVE_ENV_KEYS = (
@@ -200,3 +200,22 @@ def _filter_headers(headers):
200200
)
201201
for k, v in headers.items()
202202
}
203+
204+
205+
def _in_http_status_code_range(code, code_ranges):
206+
# type: (int, list[HttpStatusCodeRange]) -> bool
207+
for target in code_ranges:
208+
if isinstance(target, int):
209+
if code == target:
210+
return True
211+
continue
212+
213+
try:
214+
if code in target:
215+
return True
216+
except TypeError:
217+
logger.warning(
218+
"failed_request_status_codes has to be a list of integers or containers"
219+
)
220+
221+
return False

sentry_sdk/integrations/starlette.py

+14-4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from sentry_sdk.consts import OP
88
from sentry_sdk.integrations import DidNotEnable, Integration
99
from sentry_sdk.integrations._wsgi_common import (
10+
_in_http_status_code_range,
1011
_is_json_content_type,
1112
request_body_within_bounds,
1213
)
@@ -30,7 +31,7 @@
3031
if TYPE_CHECKING:
3132
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
3233

33-
from sentry_sdk._types import Event
34+
from sentry_sdk._types import Event, HttpStatusCodeRange
3435

3536
try:
3637
import starlette # type: ignore
@@ -71,14 +72,17 @@ class StarletteIntegration(Integration):
7172

7273
transaction_style = ""
7374

74-
def __init__(self, transaction_style="url"):
75-
# type: (str) -> None
75+
def __init__(self, transaction_style="url", failed_request_status_codes=None):
76+
# type: (str, Optional[list[HttpStatusCodeRange]]) -> None
7677
if transaction_style not in TRANSACTION_STYLE_VALUES:
7778
raise ValueError(
7879
"Invalid value for transaction_style: %s (must be in %s)"
7980
% (transaction_style, TRANSACTION_STYLE_VALUES)
8081
)
8182
self.transaction_style = transaction_style
83+
self.failed_request_status_codes = failed_request_status_codes or [
84+
range(500, 599)
85+
]
8286

8387
@staticmethod
8488
def setup_once():
@@ -198,12 +202,18 @@ def _sentry_middleware_init(self, *args, **kwargs):
198202

199203
async def _sentry_patched_exception_handler(self, *args, **kwargs):
200204
# type: (Any, Any, Any) -> None
205+
integration = sentry_sdk.get_client().get_integration(
206+
StarletteIntegration
207+
)
208+
201209
exp = args[0]
202210

203211
is_http_server_error = (
204212
hasattr(exp, "status_code")
205213
and isinstance(exp.status_code, int)
206-
and exp.status_code >= 500
214+
and _in_http_status_code_range(
215+
exp.status_code, integration.failed_request_status_codes
216+
)
207217
)
208218
if is_http_server_error:
209219
_capture_exception(exp, handled=True)

tests/integrations/fastapi/test_fastapi.py

+53-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from unittest import mock
55

66
import pytest
7-
from fastapi import FastAPI, Request
7+
from fastapi import FastAPI, HTTPException, Request
88
from fastapi.testclient import TestClient
99
from fastapi.middleware.trustedhost import TrustedHostMiddleware
1010

@@ -501,3 +501,55 @@ def test_transaction_name_in_middleware(
501501
assert (
502502
transaction_event["transaction_info"]["source"] == expected_transaction_source
503503
)
504+
505+
506+
@pytest.mark.parametrize(
507+
"failed_request_status_codes,status_code,expected_error",
508+
[
509+
(None, 500, True),
510+
(None, 400, False),
511+
([500, 501], 500, True),
512+
([500, 501], 401, False),
513+
([range(400, 499)], 401, True),
514+
([range(400, 499)], 500, False),
515+
([range(400, 499), range(500, 599)], 300, False),
516+
([range(400, 499), range(500, 599)], 403, True),
517+
([range(400, 499), range(500, 599)], 503, True),
518+
([range(400, 403), 500, 501], 401, True),
519+
([range(400, 403), 500, 501], 405, False),
520+
([range(400, 403), 500, 501], 501, True),
521+
([range(400, 403), 500, 501], 503, False),
522+
([None], 500, False),
523+
],
524+
)
525+
def test_configurable_status_codes(
526+
sentry_init,
527+
capture_events,
528+
failed_request_status_codes,
529+
status_code,
530+
expected_error,
531+
):
532+
sentry_init(
533+
integrations=[
534+
StarletteIntegration(
535+
failed_request_status_codes=failed_request_status_codes
536+
),
537+
FastApiIntegration(failed_request_status_codes=failed_request_status_codes),
538+
]
539+
)
540+
541+
events = capture_events()
542+
543+
app = FastAPI()
544+
545+
@app.get("/error")
546+
async def _error():
547+
raise HTTPException(status_code)
548+
549+
client = TestClient(app)
550+
client.get("/error")
551+
552+
if expected_error:
553+
assert len(events) == 1
554+
else:
555+
assert not events

tests/integrations/starlette/test_starlette.py

+63-8
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
AuthenticationError,
2626
SimpleUser,
2727
)
28+
from starlette.exceptions import HTTPException
2829
from starlette.middleware import Middleware
2930
from starlette.middleware.authentication import AuthenticationMiddleware
3031
from starlette.middleware.trustedhost import TrustedHostMiddleware
@@ -258,7 +259,7 @@ async def my_send(*args, **kwargs):
258259

259260

260261
@pytest.mark.asyncio
261-
async def test_starlettrequestextractor_content_length(sentry_init):
262+
async def test_starletterequestextractor_content_length(sentry_init):
262263
scope = SCOPE.copy()
263264
scope["headers"] = [
264265
[b"content-length", str(len(json.dumps(BODY_JSON))).encode()],
@@ -270,7 +271,7 @@ async def test_starlettrequestextractor_content_length(sentry_init):
270271

271272

272273
@pytest.mark.asyncio
273-
async def test_starlettrequestextractor_cookies(sentry_init):
274+
async def test_starletterequestextractor_cookies(sentry_init):
274275
starlette_request = starlette.requests.Request(SCOPE)
275276
extractor = StarletteRequestExtractor(starlette_request)
276277

@@ -281,7 +282,7 @@ async def test_starlettrequestextractor_cookies(sentry_init):
281282

282283

283284
@pytest.mark.asyncio
284-
async def test_starlettrequestextractor_json(sentry_init):
285+
async def test_starletterequestextractor_json(sentry_init):
285286
starlette_request = starlette.requests.Request(SCOPE)
286287

287288
# Mocking async `_receive()` that works in Python 3.7+
@@ -295,7 +296,7 @@ async def test_starlettrequestextractor_json(sentry_init):
295296

296297

297298
@pytest.mark.asyncio
298-
async def test_starlettrequestextractor_form(sentry_init):
299+
async def test_starletterequestextractor_form(sentry_init):
299300
scope = SCOPE.copy()
300301
scope["headers"] = [
301302
[b"content-type", b"multipart/form-data; boundary=fd721ef49ea403a6"],
@@ -323,7 +324,7 @@ async def test_starlettrequestextractor_form(sentry_init):
323324

324325

325326
@pytest.mark.asyncio
326-
async def test_starlettrequestextractor_body_consumed_twice(
327+
async def test_starletterequestextractor_body_consumed_twice(
327328
sentry_init, capture_events
328329
):
329330
"""
@@ -361,7 +362,7 @@ async def test_starlettrequestextractor_body_consumed_twice(
361362

362363

363364
@pytest.mark.asyncio
364-
async def test_starlettrequestextractor_extract_request_info_too_big(sentry_init):
365+
async def test_starletterequestextractor_extract_request_info_too_big(sentry_init):
365366
sentry_init(
366367
send_default_pii=True,
367368
integrations=[StarletteIntegration()],
@@ -392,7 +393,7 @@ async def test_starlettrequestextractor_extract_request_info_too_big(sentry_init
392393

393394

394395
@pytest.mark.asyncio
395-
async def test_starlettrequestextractor_extract_request_info(sentry_init):
396+
async def test_starletterequestextractor_extract_request_info(sentry_init):
396397
sentry_init(
397398
send_default_pii=True,
398399
integrations=[StarletteIntegration()],
@@ -423,7 +424,7 @@ async def test_starlettrequestextractor_extract_request_info(sentry_init):
423424

424425

425426
@pytest.mark.asyncio
426-
async def test_starlettrequestextractor_extract_request_info_no_pii(sentry_init):
427+
async def test_starletterequestextractor_extract_request_info_no_pii(sentry_init):
427428
sentry_init(
428429
send_default_pii=False,
429430
integrations=[StarletteIntegration()],
@@ -1078,3 +1079,57 @@ def test_transaction_name_in_middleware(
10781079
assert (
10791080
transaction_event["transaction_info"]["source"] == expected_transaction_source
10801081
)
1082+
1083+
1084+
@pytest.mark.parametrize(
1085+
"failed_request_status_codes,status_code,expected_error",
1086+
[
1087+
(None, 500, True),
1088+
(None, 400, False),
1089+
([500, 501], 500, True),
1090+
([500, 501], 401, False),
1091+
([range(400, 499)], 401, True),
1092+
([range(400, 499)], 500, False),
1093+
([range(400, 499), range(500, 599)], 300, False),
1094+
([range(400, 499), range(500, 599)], 403, True),
1095+
([range(400, 499), range(500, 599)], 503, True),
1096+
([range(400, 403), 500, 501], 401, True),
1097+
([range(400, 403), 500, 501], 405, False),
1098+
([range(400, 403), 500, 501], 501, True),
1099+
([range(400, 403), 500, 501], 503, False),
1100+
([None], 500, False),
1101+
],
1102+
)
1103+
def test_configurable_status_codes(
1104+
sentry_init,
1105+
capture_events,
1106+
failed_request_status_codes,
1107+
status_code,
1108+
expected_error,
1109+
):
1110+
sentry_init(
1111+
integrations=[
1112+
StarletteIntegration(
1113+
failed_request_status_codes=failed_request_status_codes
1114+
)
1115+
]
1116+
)
1117+
1118+
events = capture_events()
1119+
1120+
async def _error(request):
1121+
raise HTTPException(status_code)
1122+
1123+
app = starlette.applications.Starlette(
1124+
routes=[
1125+
starlette.routing.Route("/error", _error, methods=["GET"]),
1126+
],
1127+
)
1128+
1129+
client = TestClient(app)
1130+
client.get("/error")
1131+
1132+
if expected_error:
1133+
assert len(events) == 1
1134+
else:
1135+
assert not events

0 commit comments

Comments
 (0)