8000 feat(metrics): Implement metric_bucket rate limits (#2933) · getsentry/sentry-python@a151a2a · GitHub
[go: up one dir, main page]

Skip to content

Commit a151a2a

Browse files
authored
feat(metrics): Implement metric_bucket rate limits (#2933)
1 parent 2d09161 commit a151a2a

File tree

2 files changed

+140
-4
lines changed

2 files changed

+140
-4
lines changed

sentry_sdk/transport.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,22 @@ def _parse_rate_limits(header, now=None):
144144

145145
for limit in header.split(","):
146146
try:
147-
retry_after, categories, _ = limit.strip().split(":", 2)
147+
parameters = limit.strip().split(":")
148+
retry_after, categories = parameters[:2]
149+
148150
retry_after = now + timedelta(seconds=int(retry_after))
149151
for category in categories and categories.split(";") or (None,):
150-
yield category, retry_after
152+
if category == "metric_bucket":
153+
try:
154+
namespaces = parameters[4].split(";")
155+
except IndexError:
156+
namespaces = []
157+
158+
if not namespaces or "custom" in namespaces:
159+
yield category, retry_after
160+
161+
else:
162+
yield category, retry_after
151163
except (LookupError, ValueError):
152164
continue
153165

@@ -210,6 +222,12 @@ def record_lost_event(
210222
# quantity of 0 is actually 1 as we do not want to count
211223
# empty attachments as actually empty.
212224
quantity = len(item.get_bytes()) or 1
225+
if data_category == "statsd":
226+
# The envelope item type used for metrics is statsd
227+
# whereas the client report category for discarded events
228+
# is metric_bucket
229+
data_category = "metric_bucket"
230+
213231
elif data_category is None:
214232
raise TypeError("data category not provided")
215233

@@ -336,7 +354,14 @@ def _check_disabled(self, category):
336354
# type: (str) -> bool
337355
def _disabled(bucket):
338356
# type: (Any) -> bool
357+
358+
# The envelope item type used for metrics is statsd
359+
# whereas the rate limit category is metric_bucket
360+
if bucket == "statsd":
361+
bucket = "metric_bucket"
362+
339363
ts = self._disabled_until.get(bucket)
364+
340365
return ts is not None and ts > datetime_utcnow()
341366

342367
return _disabled(category) or _disabled(None)
@@ -402,7 +427,7 @@ def _send_envelope(
402427
new_items = []
403428
for item in envelope.items:
404429
if self._check_disabled(item.data_category):
405-
if item.data_category in ("transaction", "error", "default"):
430+
if item.data_category in ("transaction", "error", "default", "statsd"):
406431
self.on_dropped_event("self_rate_limits")
407432
self.record_lost_event("ratelimit_backoff", item=item)
408433
else:

tests/test_transport.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from sentry_sdk import Hub, Client, add_breadcrumb, capture_message, Scope
1515
from sentry_sdk._compat import datetime_utcnow
1616
from sentry_sdk.transport import KEEP_ALIVE_SOCKET_OPTIONS, _parse_rate_limits
17-
from sentry_sdk.envelope import Envelope, parse_json
17+
from sentry_sdk.envelope import Envelope, Item, parse_json
1818
from sentry_sdk.integrations.logging import LoggingIntegration
1919

2020
try:
@@ -466,3 +466,114 @@ def test_complex_limits_without_data_category(
466466
client.flush()
467467

468468
assert len(capturing_server.captured) == 0
469+
470+
471+
@pytest.mark.parametrize("response_code", [200, 429])
472+
def test_metric_bucket_limits(capturing_server, response_code, make_client):
473+
client = make_client()
474+
capturing_server.respond_with(
475+
code=response_code,
476+
headers={
477+
"X-Sentry-Rate-Limits": "4711:metric_bucket:organization:quota_exceeded:custom"
478+
},
479+
)
480+
481+
envelope = Envelope()
482+
envelope.add_item(Item(payload=b"{}", type="statsd"))
483+
client.transport.capture_envelope(envelope)
484+
client.flush()
485+
486+
assert len(capturing_server.captured) == 1
487+
assert capturing_server.captured[0].path == "/api/132/envelope/"
488+
capturing_server.clear_captured()
489+
490+
assert set(client.transport._disabled_until) == set(["metric_bucket"])
491+
492+
client.transport.capture_envelope(envelope)
493+
client.capture_event({"type": "transaction"})
494+
client.flush()
495+
496+
assert len(capturing_server.captured) == 2
497+
498+
envelope = capturing_server.captured[0].envelope
499+
assert envelope.items[0].type == "transaction"
500+
envelope = capturing_server.captured[1].envelope
501+
assert envelope.items[0].type == "client_report"
502+
report = parse_json(envelope.items[0].get_bytes())
503+
assert report["discarded_events"] == [
504+
{"category": "metric_bucket", "reason": "ratelimit_backoff", "quantity": 1},
505+
]
506+
507+
508+
@pytest.mark.parametrize("response_code", [200, 429])
509+
def test_metric_bucket_limits_with_namespace(
510+
capturing_server, response_code, make_client
511+
):
512+
client = make_client()
513+
capturing_server.respond_with(
514+
code=response_code,
515+
headers={
516+
"X-Sentry-Rate-Limits": "4711:metric_bucket:organization:quota_exceeded:foo"
517+
},
518+
)
519+
520+
envelope = Envelope()
521+
envelope.add_item(Item(payload=b"{}", type="statsd"))
522+
client.transport.capture_envelope(envelope)
523+
client.flush()
524+
525+
assert len(capturing_server.captured) == 1
526+
assert capturing_server.captured[0].path == "/api/132/envelope/"
527+
capturing_server.clear_captured()
528+
529+
assert set(client.transport._disabled_until) == set([])
530+
531+
client.transport.capture_envelope(envelope)
532+
client.capture_event({"type": "transaction"})
533+
client.flush()
534+
535+
assert len(capturing_server.captured) == 2
536+
537+
envelope = capturing_server.captured[0].envelope
538+
assert envelope.items[0].type == "statsd"
539+
envelope = capturing_server.captured[1].envelope
540+
assert envelope.items[0].type == "transaction"
541+
542+
543+
@pytest.mark.parametrize("response_code", [200, 429])
544+
def test_metric_bucket_limits_with_all_namespaces(
545+
capturing_server, response_code, make_client
546+
):
547+
client = make_client()
548+
capturing_server.respond_with(
549+
code=response_code,
550+
headers={
551+
"X-Sentry-Rate-Limits": "4711:metric_bucket:organization:quota_exceeded"
552+
},
553+
)
554+
555+
envelope = Envelope()
556+
envelope.add_item(Item(payload=b"{}", type="statsd"))
557+
client.transport.capture_envelope(envelope)
558+
client.flush()
559+
560+
assert len(capturing_server.captured) == 1
561+
assert capturing_server.captured[0].path == "/api/132/envelope/"
562+
capturing_server.clear_captured()
563+
564+
assert set(client.transport._disabled_until) == set(["metric_bucket"])
565+
566+
client.transport.capture_envelope(envelope)
567+
client.capture_event({"type": "transaction"})
568+
client.flush()
569+
570+
assert len(capturing_server.captured) == 2
571+
572+
envelope = capturing_server.captured[0].envelope
573+
assert envelope.items[0].type == "transaction"
574+
envelope = capturing_server.captured[1].envelope
575+
assert envelope.items[0].type == "client_report"
576+
report = parse_json(envelope.items[0].get_bytes())
577+
assert report["discarded_events"] == [
578+
{"category": "metric_bucket", "reason": "ratelimit_backoff", "quantity": 1},
579+
]

0 commit comments

Comments
 (0)
0