Skip to content

Commit 2d803f9

Browse files
committed
🛂(backend) stop throttling collaboration servers
We observe some throttling pick here and there. We observed that when the collaboration has a problem, it is retrying to connect, leading to more requests to the django backend. At one point, the throttling is reached and the user would not be able to use the application anymore. Now when the request comes from a collaboration server, we do not throttle it anymore.
1 parent 05aa225 commit 2d803f9

File tree

6 files changed

+208
-2
lines changed

6 files changed

+208
-2
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ and this project adheres to
1010

1111
- ✨(backend) allow to create a new user in a marketing system
1212

13+
### Changed
14+
15+
- 🛂(backend) stop throttling collaboration servers #1730
16+
1317
## [4.1.0] - 2025-12-09
1418

1519
### Added

src/backend/core/api/throttling.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Throttling modules for the API."""
22

3+
from django.conf import settings
4+
5+
from lasuite.drf.throttling import MonitoredScopedRateThrottle
36
from rest_framework.throttling import UserRateThrottle
47
from sentry_sdk import capture_message
58

@@ -19,3 +22,30 @@ class UserListThrottleSustained(UserRateThrottle):
1922
"""Throttle for the user list endpoint."""
2023

2124
scope = "user_list_sustained"
25+
26+
27+
class DocumentThrottle(MonitoredScopedRateThrottle):
28+
"""
29+
Throttle for document-related endpoints, with an exception for requests from the
30+
collaboration server.
31+
"""
32+
33+
scope = "document"
34+
35+
def allow_request(self, request, view):
36+
"""
37+
Override to skip throttling for requests from the collaboration server.
38+
39+
Verifies the X-Y-Provider-Key header contains a valid Y_PROVIDER_API_KEY.
40+
Using a custom header instead of Authorization to avoid triggering
41+
authentication middleware.
42+
"""
43+
44+
y_provider_header = request.headers.get("X-Y-Provider-Key", "")
45+
46+
# Check if this is a valid y-provider request and exempt from throttling
47+
y_provider_key = getattr(settings, "Y_PROVIDER_API_KEY", None)
48+
if y_provider_key and y_provider_header == y_provider_key:
49+
return True
50+
51+
return super().allow_request(request, view)

src/backend/core/api/viewsets.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@
5353

5454
from . import permissions, serializers, utils
5555
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
56-
from .throttling import UserListThrottleBurst, UserListThrottleSustained
56+
from .throttling import (
57+
DocumentThrottle,
58+
UserListThrottleBurst,
59+
UserListThrottleSustained,
60+
)
5761

5862
logger = logging.getLogger(__name__)
5963

@@ -365,6 +369,7 @@ class DocumentViewSet(
365369
permission_classes = [
366370
permissions.DocumentPermission,
367371
]
372+
throttle_classes = [DocumentThrottle]
368373
throttle_scope = "document"
369374
queryset = models.Document.objects.select_related("creator").all()
370375
serializer_class = serializers.DocumentSerializer
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""
2+
Test DocumentThrottle for regular throttling and y-provider bypass.
3+
"""
4+
5+
import pytest
6+
from rest_framework.test import APIClient
7+
8+
from core import factories
9+
10+
pytestmark = pytest.mark.django_db
11+
12+
13+
def test_api_throttling_document_throttle_regular_requests(settings):
14+
"""Test that regular requests are throttled normally."""
15+
16+
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
17+
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "3/minute"
18+
settings.Y_PROVIDER_API_KEY = "test-y-provider-key"
19+
20+
user = factories.UserFactory()
21+
client = APIClient()
22+
client.force_login(user)
23+
24+
document = factories.DocumentFactory()
25+
factories.UserDocumentAccessFactory(document=document, user=user)
26+
27+
# Make 3 requests without the y-provider key
28+
for _i in range(3):
29+
response = client.get(
30+
f"/api/v1.0/documents/{document.id!s}/",
31+
)
32+
assert response.status_code == 200
33+
34+
# 4th request should be throttled
35+
response = client.get(
36+
f"/api/v1.0/documents/{document.id!s}/",
37+
)
38+
assert response.status_code == 429
39+
40+
# Restore original rate
41+
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
42+
43+
44+
def test_api_throttling_document_throttle_y_provider_exempted(settings):
45+
"""Test that y-provider requests are exempted from throttling."""
46+
47+
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
48+
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "3/minute"
49+
settings.Y_PROVIDER_API_KEY = "test-y-provider-key"
50+
51+
user = factories.UserFactory()
52+
client = APIClient()
53+
client.force_login(user)
54+
55+
document = factories.DocumentFactory()
56+
factories.UserDocumentAccessFactory(document=document, user=user)
57+
58+
# Make many requests with the y-provider API key
59+
for _i in range(100):
60+
response = client.get(
61+
f"/api/v1.0/documents/{document.id!s}/",
62+
HTTP_X_Y_PROVIDER_KEY="test-y-provider-key",
63+
)
64+
assert response.status_code == 200
65+
66+
# Restore original rate
67+
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
68+
69+
70+
def test_api_throttling_document_throttle_invalid_token(settings):
71+
"""Test that requests with invalid tokens are throttled."""
72+
73+
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
74+
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "3/minute"
75+
settings.Y_PROVIDER_API_KEY = "test-y-provider-key"
76+
77+
user = factories.UserFactory()
78+
client = APIClient()
79+
client.force_login(user)
80+
81+
document = factories.DocumentFactory()
82+
factories.UserDocumentAccessFactory(document=document, user=user)
83+
84+
# Make 3 requests with an invalid token
85+
for _i in range(3):
86+
response = client.get(
87+
f"/api/v1.0/documents/{document.id!s}/",
88+
HTTP_X_Y_PROVIDER_KEY="invalid-token",
89+
)
90+
assert response.status_code == 200
91+
92+
# 4th request should be throttled
93+
response = client.get(
94+
f"/api/v1.0/documents/{document.id!s}/",
95+
HTTP_X_Y_PROVIDER_KEY="invalid-token",
96+
)
97+
assert response.status_code == 429
98+
99+
# Restore original rate
100+
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import axios from 'axios';
2+
import { describe, expect, test, vi } from 'vitest';
3+
4+
vi.mock('../src/env', () => ({
5+
COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000',
6+
Y_PROVIDER_API_KEY: 'test-yprovider-key',
7+
}));
8+
9+
describe('CollaborationBackend', () => {
10+
test('fetchDocument sends X-Y-Provider-Key header', async () => {
11+
const axiosGetSpy = vi.spyOn(axios, 'get').mockResolvedValue({
12+
status: 200,
13+
data: {
14+
id: 'test-doc-id',
15+
abilities: { retrieve: true, update: true },
16+
},
17+
});
18+
19+
const { fetchDocument } = await import('@/api/collaborationBackend');
20+
const documentId = 'test-document-123';
21+
22+
await fetchDocument(documentId, { cookie: 'test-cookie' });
23+
24+
expect(axiosGetSpy).toHaveBeenCalledWith(
25+
`http://app-dev:8000/api/v1.0/documents/${documentId}/`,
26+
expect.objectContaining({
27+
headers: expect.objectContaining({
28+
'X-Y-Provider-Key': 'test-yprovider-key',
29+
cookie: 'test-cookie',
30+
}),
31+
}),
32+
);
33+
34+
axiosGetSpy.mockRestore();
35+
});
36+
37+
test('fetchCurrentUser sends X-Y-Provider-Key header', async () => {
38+
const axiosGetSpy = vi.spyOn(axios, 'get').mockResolvedValue({
39+
status: 200,
40+
data: {
41+
id: 'test-user-id',
42+
43+
},
44+
});
45+
46+
const { fetchCurrentUser } = await import('@/api/collaborationBackend');
47+
48+
await fetchCurrentUser({
49+
cookie: 'test-cookie',
50+
origin: 'http://localhost:3000',
51+
});
52+
53+
expect(axiosGetSpy).toHaveBeenCalledWith(
54+
'http://app-dev:8000/api/v1.0/users/me/',
55+
expect.objectContaining({
56+
headers: expect.objectContaining({
57+
'X-Y-Provider-Key': 'test-yprovider-key',
58+
cookie: 'test-cookie',
59+
origin: 'http://localhost:3000',
60+
}),
61+
}),
62+
);
63+
64+
axiosGetSpy.mockRestore();
65+
});
66+
});

src/frontend/servers/y-provider/src/api/collaborationBackend.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { IncomingHttpHeaders } from 'http';
22

33
import axios from 'axios';
44

5-
import { COLLABORATION_BACKEND_BASE_URL } from '@/env';
5+
import { COLLABORATION_BACKEND_BASE_URL, Y_PROVIDER_API_KEY } from '@/env';
66

77
export interface User {
88
id: string;
@@ -61,6 +61,7 @@ async function fetch<T>(
6161
headers: {
6262
cookie: requestHeaders['cookie'],
6363
origin: requestHeaders['origin'],
64+
'X-Y-Provider-Key': Y_PROVIDER_API_KEY,
6465
},
6566
},
6667
);

0 commit comments

Comments
 (0)