Skip to content

feat(api): Allow per-org rate limit overrides for project transfer#113507

Merged
vgrozdanic merged 6 commits into
masterfrom
feat/project-transfer-rate-limit-override
Apr 21, 2026
Merged

feat(api): Allow per-org rate limit overrides for project transfer#113507
vgrozdanic merged 6 commits into
masterfrom
feat/project-transfer-rate-limit-override

Conversation

@cleptric

@cleptric cleptric commented Apr 20, 2026

Copy link
Copy Markdown
Member

The ProjectTransferEndpoint POST rate limit was a hardcoded 3/hour per
user. Some orgs legitimately need a higher ceiling (e.g. during a bulk
project migration) and bumping the value previously required a code
deploy.

The callable form is required because the rate-limit middleware reads
view_cls.rate_limits once per class import for static configs — only a
callable is re-evaluated per request, which is necessary for the option
to actually take effect at runtime.

The ProjectTransferEndpoint POST rate limit was a hardcoded 3/hour per
user. Some orgs legitimately need a higher ceiling (e.g. during a bulk
migration) and changing the value previously required a code deploy.

Introduce the `api.project-transfer.rate-limit-overrides` option, a dict
keyed by org id/slug mapping to `{"limit": int, "window": int}`. When a
request's org has no entry (or the option is unset), the endpoint falls
back to the original 3/hour default.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Apr 20, 2026
Comment thread src/sentry/core/endpoints/project_transfer.py Outdated
The option is typed as Dict but nested values aren't validated, so a
misconfiguration like `my-org: 100` would cause `override.get(...)` to
raise AttributeError and return 500. Fall back to the default when the
per-org entry isn't a dict.
@github-actions

github-actions Bot commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Backend Test Failures

Failures on 5a545fc in this run:

tests/snuba/tagstore/test_tagstore_backend.py::TagStorageTest::test_get_group_tag_keys_and_top_values_generic_issuelog
[gw0] linux -- Python 3.13.1 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/snuba/tagstore/test_tagstore_backend.py:305: in test_get_group_tag_keys_and_top_values_generic_issue
    assert set(tags) == {"foo", "biz", "environment", "sentry:user", "level", "sentry:release"}
E   AssertionError: assert set() == {'biz', 'envi...'sentry:user'}
E     
E     Extra items in the right set:
E     'level'
E     'environment'
E     'sentry:user'
E     'foo'
E     'sentry:release'
E     'biz'
E     
E     Full diff:
E     + set()
E     - {
E     -     'biz',
E     -     'environment',
E     -     'foo',
E     -     'level',
E     -     'sentry:release',
E     -     'sentry:user',
E     - }
tests/snuba/tagstore/test_tagstore_backend.py::TagStorageTest::test_get_group_tag_value_count_genericlog
[gw1] linux -- Python 3.13.1 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/snuba/tagstore/test_tagstore_backend.py:410: in test_get_group_tag_value_count_generic
    assert (
E   AssertionError: assert 0 == 1
E    +  where 0 = <bound method SnubaTagStorage.get_group_tag_value_count of <sentry.tagstore.snuba.backend.SnubaTagStorage object at 0x7f735cf6b520>>(<Group at 0x7f7376785e50: id=232, project_id=4558003108446240>, 179, 'foo', {'organization_id': 1234, 'referrer': 'tagstore.get_group_tag_value_count'})
E    +    where <bound method SnubaTagStorage.get_group_tag_value_count of <sentry.tagstore.snuba.backend.SnubaTagStorage object at 0x7f735cf6b520>> = <sentry.tagstore.snuba.backend.SnubaTagStorage object at 0x7f735cf6b520>.get_group_tag_value_count
E    +      where <sentry.tagstore.snuba.backend.SnubaTagStorage object at 0x7f735cf6b520> = <tests.snuba.tagstore.test_tagstore_backend.TagStorageTest testMethod=test_get_group_tag_value_count_generic>.ts
E    +    and   179 = <Environment at 0x7f73786180d0: id=179, organization_id=4558003108380704, name='test'>.id
tests/snuba/tagstore/test_tagstore_backend.py::TagStorageTest::test_get_group_tag_key_genericlog
[gw1] linux -- Python 3.13.1 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/snuba/tagstore/test_tagstore_backend.py:564: in test_get_group_tag_key_generic
    assert (
src/sentry/tagstore/snuba/backend.py:808: in get_group_tag_key
    return self.__get_tag_key_and_top_values(
src/sentry/tagstore/snuba/backend.py:429: in __get_tag_key_and_top_values
    raise TagKeyNotFound if group is None else GroupTagKeyNotFound
E   sentry.tagstore.exceptions.GroupTagKeyNotFound
tests/snuba/tagstore/test_tagstore_backend.py::TagStorageTest::test_get_top_group_tag_values_genericlog
[gw1] linux -- Python 3.13.1 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/snuba/tagstore/test_tagstore_backend.py:377: in test_get_top_group_tag_values_generic
    resp = self.ts.get_top_group_tag_values(
src/sentry/tagstore/snuba/backend.py:1194: in get_top_group_tag_values
    tag = self.__get_tag_key_and_top_values(
src/sentry/tagstore/snuba/backend.py:429: in __get_tag_key_and_top_values
    raise TagKeyNotFound if group is None else GroupTagKeyNotFound
E   sentry.tagstore.exceptions.GroupTagKeyNotFound

Comment on lines +52 to +55
RateLimitCategory.USER: RateLimit(
limit=override.get("limit", 3),
window=override.get("window", 60 * 60),
),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd probably recommend putting some kind of reasonable upper limit on this. In the past, when we allow increased limits we've usually just made it a feature flag with a hard coded higher rate limit/quota/whatever.

So we can either take that approach here, or cap it to some amount per hour.

I'd probably remove the ability to change the window, and just allow the limit to be modified. Just easier to reason about if we know that it's an hourly rate-limit and we allow you to increase it from 3 up to (say) 50

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took it over from Michi, moved it to only a limit with a fixed window, we can set the limit to something reasonable in settings (e.g. 20 per hour) to help the user with migration, and bring it back down until there is a need again for it

Comment thread src/sentry/core/endpoints/project_transfer.py Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Backend Test Failures

Failures on af81eb5 in this run:

tests/sentry/core/endpoints/test_project_transfer.py::ProjectTransferTest::test_rate_limitlog
[gw0] linux -- Python 3.13.1 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/sentry/core/endpoints/test_project_transfer.py:106: in test_rate_limit
    assert response.status_code == 429
E   assert 404 == 429
E    +  where 404 = <Response status_code=404, "application/json">.status_code

@vgrozdanic
vgrozdanic marked this pull request as ready for review April 21, 2026 11:58
@vgrozdanic
vgrozdanic requested a review from a team as a code owner April 21, 2026 11:58
Comment thread src/sentry/core/endpoints/project_transfer.py
@vgrozdanic
vgrozdanic merged commit 02e5628 into master Apr 21, 2026
78 checks passed
@vgrozdanic
vgrozdanic deleted the feat/project-transfer-rate-limit-override branch April 21, 2026 12:11
@github-actions github-actions Bot locked and limited conversation to collaborators May 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants