Skip to content

Commit 64a216a

Browse files
fix: add throttling for influx query endpoints (#6453)
1 parent ed2f460 commit 64a216a

File tree

6 files changed

+72
-2
lines changed

6 files changed

+72
-2
lines changed

api/app/settings/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@
320320
"mfa_code": "5/min",
321321
"invite": "10/min",
322322
"user": USER_THROTTLE_RATE,
323+
"influx_query": "5/min",
323324
},
324325
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
325326
"DEFAULT_RENDERER_CLASSES": [

api/app/settings/test.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"invite": "10/min",
1212
"signup": "100/min",
1313
"user": "100000/day",
14+
"influx_query": "50/min",
1415
}
1516

1617
AWS_SSE_LOGS_BUCKET_NAME = "test_bucket"

api/app_analytics/throttles.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from django.views import View
2+
from rest_framework.request import Request
3+
from rest_framework.throttling import SimpleRateThrottle
4+
5+
6+
class InfluxQueryThrottle(SimpleRateThrottle):
7+
scope = "influx_query"
8+
9+
def get_cache_key(self, request: Request, view: View) -> str:
10+
"""
11+
Since we want to throttle requests across multiple views and viewsets that don't
12+
necessarily have the option to define the scope themselves, we override the
13+
get_cache_key method to ensure that the throttling logic behaves as expected.
14+
"""
15+
if request.user and request.user.is_authenticated:
16+
return self.cache_format % {
17+
"scope": self.scope,
18+
"ident": request.user.pk,
19+
}
20+
return self.cache_format % { # pragma: no cover
21+
"scope": self.scope,
22+
"ident": self.get_ident(request),
23+
}

api/app_analytics/views.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from common.core.utils import using_database_replica
55
from drf_yasg.utils import swagger_auto_schema # type: ignore[import-untyped]
66
from rest_framework import status
7-
from rest_framework.decorators import api_view, permission_classes
7+
from rest_framework.decorators import api_view, permission_classes, throttle_classes
88
from rest_framework.fields import IntegerField
99
from rest_framework.generics import CreateAPIView
1010
from rest_framework.permissions import IsAuthenticated
@@ -20,6 +20,7 @@
2020
from app_analytics.mappers import (
2121
map_request_to_labels,
2222
)
23+
from app_analytics.throttles import InfluxQueryThrottle
2324
from environments.authentication import EnvironmentKeyAuthentication
2425
from environments.permissions.permissions import EnvironmentKeyPermissions
2526
from features.models import FeatureState
@@ -135,6 +136,7 @@ class SelfHostedTelemetryAPIView(CreateAPIView): # type: ignore[type-arg]
135136
)
136137
@api_view(["GET"])
137138
@permission_classes([IsAuthenticated, UsageDataPermission])
139+
@throttle_classes([InfluxQueryThrottle])
138140
def get_usage_data_total_count_view(request: Request, organisation_pk: int) -> Response:
139141
organisation = using_database_replica(Organisation.objects).get(id=organisation_pk)
140142
count = get_total_events_count(organisation)
@@ -151,6 +153,7 @@ def get_usage_data_total_count_view(request: Request, organisation_pk: int) -> R
151153
)
152154
@api_view(["GET"])
153155
@permission_classes([IsAuthenticated, UsageDataPermission])
156+
@throttle_classes([InfluxQueryThrottle])
154157
def get_usage_data_view(request: Request, organisation_pk: int) -> Response:
155158
filters = UsageDataQuerySerializer(data=request.query_params)
156159
filters.is_valid(raise_exception=True)

api/organisations/views.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
get_events_for_organisation,
2222
get_multiple_event_list_for_organisation,
2323
)
24+
from app_analytics.throttles import InfluxQueryThrottle
2425
from core.helpers import get_current_site_url
2526
from organisations.chargebee import webhook_event_types, webhook_handlers
2627
from organisations.exceptions import OrganisationHasNoPaidSubscription
@@ -164,6 +165,7 @@ def remove_users(self, request, pk): # type: ignore[no-untyped-def]
164165
@action(
165166
detail=True,
166167
methods=["GET"],
168+
throttle_classes=[InfluxQueryThrottle],
167169
)
168170
def usage(self, request, pk): # type: ignore[no-untyped-def]
169171
organisation = self.get_object()
@@ -240,7 +242,12 @@ def get_hosted_page_url_for_subscription_upgrade(self, request, pk): # type: ig
240242
operation_description="Please use /api/v1/organisations/{organisation_pk}/usage-data/",
241243
query_serializer=InfluxDataQuerySerializer(),
242244
)
243-
@action(detail=True, methods=["GET"], url_path="influx-data")
245+
@action(
246+
detail=True,
247+
methods=["GET"],
248+
url_path="influx-data",
249+
throttle_classes=[InfluxQueryThrottle],
250+
)
244251
def get_influx_data(self, request, pk): # type: ignore[no-untyped-def]
245252
filters = InfluxDataQuerySerializer(data=request.query_params)
246253
filters.is_valid(raise_exception=True)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from django.urls import reverse
2+
from pytest_mock import MockerFixture
3+
from rest_framework import status
4+
from rest_framework.test import APIClient
5+
6+
7+
def test_influx_data_endpoint_is_throttled(
8+
admin_client: APIClient,
9+
organisation: int,
10+
mocker: MockerFixture,
11+
reset_cache: None,
12+
) -> None:
13+
# Given
14+
mocker.patch(
15+
"app_analytics.throttles.InfluxQueryThrottle.get_rate", return_value="1/minute"
16+
)
17+
mocker.patch(
18+
"organisations.views.get_multiple_event_list_for_organisation", return_value=[]
19+
)
20+
21+
url = reverse(
22+
"api-v1:organisations:organisation-get-influx-data",
23+
args=[organisation],
24+
)
25+
26+
# When - first request should be successful
27+
first_response = admin_client.get(url)
28+
second_response = admin_client.get(url)
29+
30+
# Then
31+
# The first response should have been successful
32+
assert first_response.status_code == status.HTTP_200_OK
33+
34+
# But the second request should have been throttled
35+
assert second_response.status_code == status.HTTP_429_TOO_MANY_REQUESTS

0 commit comments

Comments
 (0)