Skip to content

Commit 0a80ce5

Browse files
committed
feat(metrics): Implement metric_bucket rate limits
1 parent fd8a9b2 commit 0a80ce5

File tree

2 files changed

+131
-4
lines changed

2 files changed

+131
-4
lines changed

sentry_sdk/transport.py

Lines changed: 22 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

@@ -336,7 +348,14 @@ def _check_disabled(self, category):
336348
# type: (str) -> bool
337349
def _disabled(bucket):
338350
# type: (Any) -> bool
351+
352+
# The envelope item type used for metrics is statsd
353+
# whereas the rate limit category is metric_bucket
354+
if bucket == "statsd":
355+
bucket = "metric_bucket"
356+
339357
ts = self._disabled_until.get(bucket)
358+
340359
return ts is not None and ts > datetime_utcnow()
341360

342361
return _disabled(category) or _disabled(None)
@@ -402,7 +421,7 @@ def _send_envelope(
402421
new_items = []
403422
for item in envelope.items:
404423
if self._check_disabled(item.data_category):
405-
if item.data_category in ("transaction", "error", "default"):
424+
if item.data_category in ("transaction", "error", "default", "statsd"):
406425
self.on_dropped_event("self_rate_limits")
407426
self.record_lost_event("ratelimit_backoff", item=item)
408427
else:

tests/test_transport.py

Lines changed: 109 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,111 @@ def test_complex_limits_without_data_category(
466466
client.flush()
467467

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

0 commit comments

Comments
 (0)