Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 15f7f40

Browse files
roagabillyvg
authored andcommittedJun 18, 2025
chore(seer): add seer scanner rate limit (#93652)
Adds a rate limit for starting seer scanner tasks (default 1000 per hour per project) to prevent surprise bills.
1 parent 9f3c306 commit 15f7f40

File tree

6 files changed

+161
-20
lines changed

6 files changed

+161
-20
lines changed
 

‎src/sentry/autofix/utils.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
from django.conf import settings
88
from pydantic import BaseModel
99

10+
from sentry import features, options, ratelimits
1011
from sentry.issues.auto_source_code_config.code_mapping import get_sorted_code_mapping_configs
12+
from sentry.models.organization import Organization
1113
from sentry.models.project import Project
1214
from sentry.models.repository import Repository
1315
from sentry.seer.signed_seer_api import sign_with_seer_secret
@@ -157,3 +159,53 @@ class SeerAutomationSource(enum.Enum):
157159
ISSUE_DETAILS = "issue_details"
158160
ALERT = "alert"
159161
POST_PROCESS = "post_process"
162+
163+
164+
def is_seer_scanner_rate_limited(
165+
project: Project, organization: Organization
166+
) -> tuple[bool, int, int]:
167+
"""
168+
Check if Seer Scanner automation is rate limited for a given project and organization.
169+
170+
Returns:
171+
tuple[bool, int, int]:
172+
- is_rate_limited: Whether the seer scanner is rate limited.
173+
- current: The current number of seer scanner runs.
174+
- limit: The limit for seer scanner runs.
175+
"""
176+
if features.has("organizations:unlimited-auto-triggered-autofix-runs", organization):
177+
return False, 0, 0
178+
179+
limit = options.get("seer.max_num_scanner_autotriggered_per_hour", 1000)
180+
is_rate_limited, current, _ = ratelimits.backend.is_limited_with_value(
181+
project=project,
182+
key="seer.scanner.auto_triggered",
183+
limit=limit,
184+
window=60 * 60, # 1 hour
185+
)
186+
return is_rate_limited, current, limit
187+
188+
189+
def is_seer_autotriggered_autofix_rate_limited(
190+
project: Project, organization: Organization
191+
) -> tuple[bool, int, int]:
192+
"""
193+
Check if Seer Autofix automation is rate limited for a given project and organization.
194+
195+
Returns:
196+
tuple[bool, int, int]:
197+
- is_rate_limited: Whether Autofix is rate limited.
198+
- current: The current number of Autofix runs.
199+
- limit: The limit for Autofix runs.
200+
"""
201+
if features.has("organizations:unlimited-auto-triggered-autofix-runs", organization):
202+
return False, 0, 0
203+
204+
limit = options.get("seer.max_num_autofix_autotriggered_per_hour", 20)
205+
is_rate_limited, current, _ = ratelimits.backend.is_limited_with_value(
206+
project=project,
207+
key="autofix.auto_triggered",
208+
limit=limit,
209+
window=60 * 60, # 1 hour
210+
)
211+
return is_rate_limited, current, limit

‎src/sentry/integrations/utils/issue_summary_for_alerts.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import sentry_sdk
66

77
from sentry import features, options
8-
from sentry.autofix.utils import SeerAutomationSource
8+
from sentry.autofix.utils import SeerAutomationSource, is_seer_scanner_rate_limited
99
from sentry.issues.grouptype import GroupCategory
1010
from sentry.models.group import Group
1111
from sentry.seer.issue_summary import get_issue_summary
@@ -31,6 +31,17 @@ def fetch_issue_summary(group: Group) -> dict[str, Any] | None:
3131
if not get_seer_org_acknowledgement(org_id=group.organization.id):
3232
return None
3333

34+
is_rate_limited, current, limit = is_seer_scanner_rate_limited(project, group.organization)
35+
if is_rate_limited:
36+
sentry_sdk.set_tags(
37+
{
38+
"scanner_run_count": current,
39+
"scanner_run_limit": limit,
40+
}
41+
)
42+
logger.error("Seer scanner auto-trigger rate limit hit")
43+
return None
44+
3445
from sentry import quotas
3546
from sentry.constants import DataCategory
3647

‎src/sentry/options/defaults.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,10 @@
601601
register("alerts.issue_summary_timeout", default=5, flags=FLAG_AUTOMATOR_MODIFIABLE)
602602
# Issue Summary Auto-trigger rate (max number of autofix runs auto-triggered per project per hour)
603603
register("seer.max_num_autofix_autotriggered_per_hour", default=20, flags=FLAG_AUTOMATOR_MODIFIABLE)
604+
# Seer Scanner Auto-trigger rate (max number of scans auto-triggered per project per hour)
605+
register(
606+
"seer.max_num_scanner_autotriggered_per_hour", default=1000, flags=FLAG_AUTOMATOR_MODIFIABLE
607+
)
604608

605609
# Codecov Integration
606610
register("codecov.client-secret", flags=FLAG_CREDENTIAL | FLAG_PRIORITIZE_DISK)

‎src/sentry/seer/issue_summary.py‎

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010
from django.conf import settings
1111
from django.contrib.auth.models import AnonymousUser
1212

13-
from sentry import eventstore, features, options, quotas, ratelimits
13+
from sentry import eventstore, features, quotas
1414
from sentry.api.serializers import EventSerializer, serialize
1515
from sentry.api.serializers.rest_framework.base import convert_dict_key_case, snake_to_camel_case
16-
from sentry.autofix.utils import SeerAutomationSource, get_autofix_state
16+
from sentry.autofix.utils import (
17+
SeerAutomationSource,
18+
get_autofix_state,
19+
is_seer_autotriggered_autofix_rate_limited,
20+
)
1721
from sentry.constants import DataCategory, ObjectStatus
1822
from sentry.eventstore.models import Event, GroupEvent
1923
from sentry.locks import locks
@@ -307,24 +311,18 @@ def _run_automation(
307311

308312
if _is_issue_fixable(group, issue_summary.scores.fixability_score):
309313

310-
# Rate limit auto-triggered autofix runs to prevent giant bills.
311-
if not features.has("organizations:unlimited-auto-triggered-autofix-runs", organization):
312-
limit = options.get("seer.max_num_autofix_autotriggered_per_hour") or 20
313-
is_rate_limited, current, _ = ratelimits.backend.is_limited_with_value(
314-
project=project,
315-
key="autofix.auto_triggered",
316-
limit=limit,
317-
window=60 * 60, # 1 hour
314+
is_rate_limited, current, limit = is_seer_autotriggered_autofix_rate_limited(
315+
project, organization
316+
)
317+
if is_rate_limited:
318+
sentry_sdk.set_tags(
319+
{
320+
"auto_run_count": current,
321+
"auto_run_limit": limit,
322+
}
318323
)
319-
if is_rate_limited:
320-
sentry_sdk.set_tags(
321-
{
322-
"auto_run_count": current,
323-
"auto_run_limit": limit,
324-
}
325-
)
326-
logger.error("Autofix auto-trigger rate limit hit")
327-
return
324+
logger.error("Autofix auto-trigger rate limit hit")
325+
return
328326

329327
has_budget: bool = quotas.backend.has_available_reserved_budget(
330328
org_id=group.organization.id,

‎src/sentry/tasks/post_process.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,6 +1594,19 @@ def kick_off_seer_automation(job: PostProcessJob) -> None:
15941594
if not seer_enabled:
15951595
return
15961596

1597+
from sentry.autofix.utils import is_seer_scanner_rate_limited
1598+
1599+
is_rate_limited, current, limit = is_seer_scanner_rate_limited(project, group.organization)
1600+
if is_rate_limited:
1601+
sentry_sdk.set_tags(
1602+
{
1603+
"scanner_run_count": current,
1604+
"scanner_run_limit": limit,
1605+
}
1606+
)
1607+
logger.error("Seer scanner auto-trigger rate limit hit")
1608+
return
1609+
15971610
from sentry import quotas
15981611
from sentry.constants import DataCategory
15991612

‎tests/sentry/autofix/test_utils.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
get_autofix_repos_from_project_code_mappings,
1111
get_autofix_state,
1212
get_autofix_state_from_pr_id,
13+
is_seer_autotriggered_autofix_rate_limited,
14+
is_seer_scanner_rate_limited,
1315
)
1416
from sentry.testutils.cases import TestCase
17+
from sentry.testutils.helpers import with_feature
1518
from sentry.utils import json
1619

1720

@@ -183,3 +186,63 @@ def test_get_autofix_state_http_error(self, mock_post):
183186

184187
# Assertions
185188
assert "HTTP Error" in str(context.value)
189+
190+
191+
class TestAutomationRateLimiting(TestCase):
192+
@with_feature("organizations:unlimited-auto-triggered-autofix-runs")
193+
def test_scanner_rate_limited_with_unlimited_flag(self):
194+
"""Test scanner rate limiting bypassed with unlimited feature flag"""
195+
project = self.create_project()
196+
organization = project.organization
197+
198+
is_rate_limited, current, limit = is_seer_scanner_rate_limited(project, organization)
199+
200+
assert is_rate_limited is False
201+
assert current == 0
202+
assert limit == 0
203+
204+
@patch("sentry.autofix.utils.ratelimits.backend.is_limited_with_value")
205+
def test_scanner_rate_limited_logic(self, mock_is_limited):
206+
"""Test scanner rate limiting logic"""
207+
project = self.create_project()
208+
organization = project.organization
209+
210+
mock_is_limited.return_value = (True, 950, None)
211+
212+
with self.options({"seer.max_num_scanner_autotriggered_per_hour": 1000}):
213+
is_rate_limited, current, limit = is_seer_scanner_rate_limited(project, organization)
214+
215+
assert is_rate_limited is True
216+
assert current == 950
217+
assert limit == 1000
218+
219+
@with_feature("organizations:unlimited-auto-triggered-autofix-runs")
220+
def test_autofix_rate_limited_with_unlimited_flag(self):
221+
"""Test autofix rate limiting bypassed with unlimited feature flag"""
222+
project = self.create_project()
223+
organization = project.organization
224+
225+
is_rate_limited, current, limit = is_seer_autotriggered_autofix_rate_limited(
226+
project, organization
227+
)
228+
229+
assert is_rate_limited is False
230+
assert current == 0
231+
assert limit == 0
232+
233+
@patch("sentry.autofix.utils.ratelimits.backend.is_limited_with_value")
234+
def test_autofix_rate_limited_logic(self, mock_is_limited):
235+
"""Test autofix rate limiting logic"""
236+
project = self.create_project()
237+
organization = project.organization
238+
239+
mock_is_limited.return_value = (True, 19, None)
240+
241+
with self.options({"seer.max_num_autofix_autotriggered_per_hour": 20}):
242+
is_rate_limited, current, limit = is_seer_autotriggered_autofix_rate_limited(
243+
project, organization
244+
)
245+
246+
assert is_rate_limited is True
247+
assert current == 19
248+
assert limit == 20

0 commit comments

Comments
 (0)
Please sign in to comment.