diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 478d7a0dd7dd..52f98dafead5 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -302,6 +302,7 @@ LOGIN_THROTTLE_RATE = env("LOGIN_THROTTLE_RATE", "20/min") SIGNUP_THROTTLE_RATE = env("SIGNUP_THROTTLE_RATE", "10000/min") USER_THROTTLE_RATE = env("USER_THROTTLE_RATE", "500/min") +IDENTITY_SEARCH_THROTTLE_RATE = env("IDENTITY_SEARCH_THROTTLE_RATE", "30/min") DEFAULT_THROTTLE_CLASSES = env.list("DEFAULT_THROTTLE_CLASSES", subcast=str, default=[]) REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], @@ -320,6 +321,7 @@ "mfa_code": "5/min", "invite": "10/min", "user": USER_THROTTLE_RATE, + "identity_search": IDENTITY_SEARCH_THROTTLE_RATE, }, "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], "DEFAULT_RENDERER_CLASSES": [ diff --git a/api/app/settings/test.py b/api/app/settings/test.py index a0018f64dac1..925568a12a72 100644 --- a/api/app/settings/test.py +++ b/api/app/settings/test.py @@ -11,6 +11,7 @@ "invite": "10/min", "signup": "100/min", "user": "100000/day", + "identity_search": "100/min", } AWS_SSE_LOGS_BUCKET_NAME = "test_bucket" diff --git a/api/environments/identities/views.py b/api/environments/identities/views.py index f108953f1be3..ba64cd25d348 100644 --- a/api/environments/identities/views.py +++ b/api/environments/identities/views.py @@ -16,6 +16,7 @@ from rest_framework import status, viewsets from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle from app.pagination import CustomPagination from core.constants import FLAGSMITH_UPDATED_AT_HEADER, SDK_ENVIRONMENT_KEY_HEADER @@ -41,6 +42,15 @@ class IdentityViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] serializer_class = IdentitySerializer pagination_class = CustomPagination + throttle_scope = "identity_search" + + def get_throttles(self): # type: ignore[no-untyped-def] + """ + Apply identity_search throttle only to list (search) requests. + """ + if getattr(self, "action", None) == "list": + return [ScopedRateThrottle()] + return [] def get_queryset(self): # type: ignore[no-untyped-def] if getattr(self, "swagger_fake_view", False): diff --git a/api/tests/unit/environments/identities/test_unit_identities_views.py b/api/tests/unit/environments/identities/test_unit_identities_views.py index dff9ed67bead..1605434164aa 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_views.py +++ b/api/tests/unit/environments/identities/test_unit_identities_views.py @@ -305,6 +305,31 @@ def test_search_identities_still_allows_paging( assert response2.data["results"] +def test_identity_search_is_throttled( + admin_client: APIClient, + environment: Environment, + reset_cache: None, + mocker: MockerFixture, +) -> None: + # Given - mock the throttle rate to be restrictive for testing + mocker.patch( + "rest_framework.throttling.ScopedRateThrottle.get_rate", return_value="1/minute" + ) + base_url = reverse( + "api-v1:environments:environment-identities-list", + args=[environment.api_key], + ) + url = f"{base_url}?q=test" + + # When - make 2 requests in quick succession + response1 = admin_client.get(url) + response2 = admin_client.get(url) + + # Then - first should succeed, second should be throttled + assert response1.status_code == status.HTTP_200_OK + assert response2.status_code == status.HTTP_429_TOO_MANY_REQUESTS + + def test_can_delete_identity( environment: Environment, admin_client: APIClient, diff --git a/frontend/common/useDebouncedSearch.ts b/frontend/common/useDebouncedSearch.ts index afa82cde3e79..3d2de60014c7 100644 --- a/frontend/common/useDebouncedSearch.ts +++ b/frontend/common/useDebouncedSearch.ts @@ -4,10 +4,10 @@ import useDebounce from './useDebounce' export default function useDebouncedSearch(initialValue = '') { const [searchInput, setSearchInput] = useState(initialValue) const [search, setSearch] = useState(initialValue) - const [debounceTime, setDebounceTime] = useState(500) + const [debounceTime, setDebounceTime] = useState(750) useEffect(() => { - setDebounceTime(searchInput.length < 1 ? 0 : 500) + setDebounceTime(searchInput.length < 1 ? 0 : 750) }, [searchInput]) const debouncedSearch = useDebounce((value: string) => {