Skip to content

Commit b7679a5

Browse files
authored
fix: Test transport rate limits parsing and enforcement (getsentry#652)
Also fix a bug where missing categories ("123::project") would not enforce a rate limit for all categories, as they were parsed as category "" instead of category None.
1 parent 7049ecc commit b7679a5

File tree

2 files changed

+139
-15
lines changed

2 files changed

+139
-15
lines changed

sentry_sdk/transport.py

+25-14
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,22 @@
1515
from sentry_sdk._types import MYPY
1616

1717
if MYPY:
18-
from typing import Type
1918
from typing import Any
20-
from typing import Optional
19+
from typing import Callable
2120
from typing import Dict
21+
from typing import Iterable
22+
from typing import Optional
23+
from typing import Tuple
24+
from typing import Type
2225
from typing import Union
23-
from typing import Callable
26+
2427
from urllib3.poolmanager import PoolManager # type: ignore
2528
from urllib3.poolmanager import ProxyManager
2629

2730
from sentry_sdk._types import Event
2831

32+
DataCategory = Optional[str]
33+
2934
try:
3035
from urllib.request import getproxies
3136
except ImportError:
@@ -94,6 +99,21 @@ def __del__(self):
9499
pass
95100

96101

102+
def _parse_rate_limits(header, now=None):
103+
# type: (Any, Optional[datetime]) -> Iterable[Tuple[DataCategory, datetime]]
104+
if now is None:
105+
now = datetime.utcnow()
106+
107+
for limit in header.split(","):
108+
try:
109+
retry_after, categories, _ = limit.strip().split(":", 2)
110+
retry_after = now + timedelta(seconds=int(retry_after))
111+
for category in categories and categories.split(";") or (None,):
112+
yield category, retry_after
113+
except (LookupError, ValueError):
114+
continue
115+
116+
97117
class HttpTransport(Transport):
98118
"""The default HTTP transport."""
99119

@@ -107,7 +127,7 @@ def __init__(
107127
assert self.parsed_dsn is not None
108128
self._worker = BackgroundWorker()
109129
self._auth = self.parsed_dsn.to_auth("sentry.python/%s" % VERSION)
110-
self._disabled_until = {} # type: Dict[Any, datetime]
130+
self._disabled_until = {} # type: Dict[DataCategory, datetime]
111131
self._retry = urllib3.util.Retry()
112132
self.options = options
113133

@@ -129,16 +149,7 @@ def _update_rate_limits(self, response):
129149
# no matter of the status code to update our internal rate limits.
130150
header = response.headers.get("x-sentry-rate-limit")
131151
if header:
132-
for limit in header.split(","):
133-
try:
134-
retry_after, categories, _ = limit.strip().split(":", 2)
135-
retry_after = datetime.utcnow() + timedelta(
136-
seconds=int(retry_after)
137-
)
138-
for category in categories.split(";") or (None,):
139-
self._disabled_until[category] = retry_after
140-
except (LookupError, ValueError):
141-
continue
152+
self._disabled_until.update(_parse_rate_limits(header))
142153

143154
# old sentries only communicate global rate limit hits via the
144155
# retry-after header on 429. This header can also be emitted on new

tests/test_transport.py

+114-1
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
import logging
33
import pickle
44

5-
from datetime import datetime
5+
from datetime import datetime, timedelta
66

77
import pytest
88

99
from sentry_sdk import Hub, Client, add_breadcrumb, capture_message
10+
from sentry_sdk.transport import _parse_rate_limits
1011

1112

1213
@pytest.fixture(params=[True, False])
@@ -54,3 +55,115 @@ def test_transport_works(
5455
assert httpserver.requests
5556

5657
assert any("Sending event" in record.msg for record in caplog.records) == debug
58+
59+
60+
NOW = datetime(2014, 6, 2)
61+
62+
63+
@pytest.mark.parametrize(
64+
"input,expected",
65+
[
66+
# Invalid rate limits
67+
("", {}),
68+
("invalid", {}),
69+
(",,,", {}),
70+
(
71+
"42::organization, invalid, 4711:foobar;transaction;security:project",
72+
{
73+
None: NOW + timedelta(seconds=42),
74+
"transaction": NOW + timedelta(seconds=4711),
75+
"security": NOW + timedelta(seconds=4711),
76+
# Unknown data categories
77+
"foobar": NOW + timedelta(seconds=4711),
78+
},
79+
),
80+
(
81+
"4711:foobar;;transaction:organization",
82+
{
83+
"transaction": NOW + timedelta(seconds=4711),
84+
# Unknown data categories
85+
"foobar": NOW + timedelta(seconds=4711),
86+
"": NOW + timedelta(seconds=4711),
87+
},
88+
),
89+
],
90+
)
91+
def test_parse_rate_limits(input, expected):
92+
assert dict(_parse_rate_limits(input, now=NOW)) == expected
93+
94+
95+
def test_simple_rate_limits(httpserver, capsys, caplog):
96+
client = Client(dsn="http://foobar@{}/123".format(httpserver.url[len("http://") :]))
97+
httpserver.serve_content("no", 429, headers={"Retry-After": "4"})
98+
99+
client.capture_event({"type": "transaction"})
100+
client.flush()
101+
102+
assert len(httpserver.requests) == 1
103+
del httpserver.requests[:]
104+
105+
assert set(client.transport._disabled_until) == set([None])
106+
107+
client.capture_event({"type": "transaction"})
108+
client.capture_event({"type": "event"})
109+
client.flush()
110+
111+
assert not httpserver.requests
112+
113+
114+
@pytest.mark.parametrize("response_code", [200, 429])
115+
def test_data_category_limits(httpserver, capsys, caplog, response_code):
116+
client = Client(
117+
dict(dsn="http://foobar@{}/123".format(httpserver.url[len("http://") :]))
118+
)
119+
httpserver.serve_content(
120+
"hm",
121+
response_code,
122+
headers={"X-Sentry-Rate-Limit": "4711:transaction:organization"},
123+
)
124+
125+
client.capture_event({"type": "transaction"})
126+
client.flush()
127+
128+
assert len(httpserver.requests) == 1
129+
del httpserver.requests[:]
130+
131+
assert set(client.transport._disabled_until) == set(["transaction"])
132+
133+
client.transport.capture_event({"type": "transaction"})
134+
client.transport.capture_event({"type": "transaction"})
135+
client.flush()
136+
137+
assert not httpserver.requests
138+
139+
client.capture_event({"type": "event"})
140+
client.flush()
141+
142+
assert len(httpserver.requests) == 1
143+
144+
145+
@pytest.mark.parametrize("response_code", [200, 429])
146+
def test_complex_limits_without_data_category(
147+
httpserver, capsys, caplog, response_code
148+
):
149+
client = Client(
150+
dict(dsn="http://foobar@{}/123".format(httpserver.url[len("http://") :]))
151+
)
152+
httpserver.serve_content(
153+
"hm", response_code, headers={"X-Sentry-Rate-Limit": "4711::organization"},
154+
)
155+
156+
client.capture_event({"type": "transaction"})
157+
client.flush()
158+
159+
assert len(httpserver.requests) == 1
160+
del httpserver.requests[:]
161+
162+
assert set(client.transport._disabled_until) == set([None])
163+
164+
client.transport.capture_event({"type": "transaction"})
165+
client.transport.capture_event({"type": "transaction"})
166+
client.capture_event({"type": "event"})
167+
client.flush()
168+
169+
assert len(httpserver.requests) == 0

0 commit comments

Comments
 (0)