Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6e67085
feat: implement plan-based API rate limits
rohan-chaturvedi Dec 4, 2025
ac1c030
feat: disable default throttling for self-hosted environments
rohan-chaturvedi Dec 5, 2025
427d080
fix: apply default rate to unauthenticated requests
rohan-chaturvedi Dec 5, 2025
88b0d4f
feat: add test coverage for rate limits
rohan-chaturvedi Dec 8, 2025
e9418a6
feat: add throttling selectively to public APIs
rohan-chaturvedi Dec 8, 2025
94da683
Merge branch 'main' into feat--api-rate-limits
rohan-chaturvedi Dec 8, 2025
6072d8d
chore: add pytest-django
rohan-chaturvedi Dec 8, 2025
d53d29e
chore: cleanup
rohan-chaturvedi Dec 10, 2025
5b36b44
fix: centralize redis env var parsing
rohan-chaturvedi Dec 13, 2025
d03cd04
Merge branch 'main' into feat--api-rate-limits
nimish-ks Dec 14, 2025
7775600
fix: add support for legacy service tokens in PlanBasedRateThrottle
rohan-chaturvedi Dec 15, 2025
c62b692
Merge branch 'main' into feat--api-rate-limits
rohan-chaturvedi Dec 16, 2025
fbc4cd3
fix: update rate limits and Redis SSL configuration in settings.py
nimish-ks Dec 20, 2025
2a9f219
Merge branch 'main' into feat--api-rate-limits
nimish-ks Dec 20, 2025
194808f
feat: cast pgport as integer
nimish-ks Dec 30, 2025
13a39b4
feat: cast redis port as integrer
nimish-ks Dec 30, 2025
debce9f
feat: enhance Redis SSL configuration in settings.py
nimish-ks Dec 30, 2025
9fb5869
feat: enhance Redis authentication configuration in settings.py
nimish-ks Dec 30, 2025
9eb321a
Merge branch 'main' into feat--api-rate-limits
nimish-ks Dec 30, 2025
7a8c5bb
feat: update Redis username handling in settings.py
nimish-ks Dec 30, 2025
9658ea7
feat: enhance PostgreSQL SSL configuration in settings.py
nimish-ks Dec 30, 2025
5980d35
refactor: clean up settings.py by removing commented-out code
nimish-ks Dec 30, 2025
6874524
chore: update Dockerfile to include ca-certificates
nimish-ks Dec 30, 2025
2323d30
feat: add AWS RDS CA bundle to Dockerfile
nimish-ks Dec 30, 2025
22ea5b6
refactor: remove redundant SSL validation in settings.py
nimish-ks Dec 30, 2025
3b68e4c
refactor: update SSL configuration in settings.py
nimish-ks Dec 30, 2025
dc9de3e
refactor: improve SSL configuration handling in settings.py
nimish-ks Dec 30, 2025
d65d36c
Merge branch 'main' into feat--api-rate-limits
rohan-chaturvedi Jan 1, 2026
00548fa
Merge branch 'main' into feat--api-rate-limits
nimish-ks Jan 8, 2026
9c72103
Merge branch 'main' into feat--api-rate-limits
nimish-ks Jan 11, 2026
f843ec5
refactor: rename REDIS_USERNAME to REDIS_USER for consistency in sett…
nimish-ks Jan 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions backend/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

class ServiceAccountUser:
"""Mock ServiceAccount user"""

def __init__(self, service_account):
self.userId = service_account.id
self.id = service_account.id
Expand Down Expand Up @@ -63,14 +63,20 @@ def authenticate(self, request):
if secret_id:
found = False
try:
secret = Secret.objects.get(id=secret_id)
# Pre-fetch environment, app, and organisation
secret = Secret.objects.select_related(
"environment__app__organisation"
).get(id=secret_id)
env = secret.environment
found = True
except Secret.DoesNotExist:
pass
if not found:
try:
dyn_secret = DynamicSecret.objects.get(id=secret_id)
# Pre-fetch environment, app, and organisation
dyn_secret = DynamicSecret.objects.select_related(
"environment__app__organisation"
).get(id=secret_id)
env = dyn_secret.environment
found = True
except DynamicSecret.DoesNotExist:
Expand All @@ -84,7 +90,10 @@ def authenticate(self, request):
# Try resolving env from header
if env_id:
try:
env = Environment.objects.get(id=env_id)
# Pre-fetch app and organisation
env = Environment.objects.select_related("app__organisation").get(
id=env_id
)
except Environment.DoesNotExist:
raise exceptions.AuthenticationFailed("Environment not found")

Expand All @@ -99,7 +108,10 @@ def authenticate(self, request):
)
if not env_name:
raise exceptions.AuthenticationFailed("Missing env parameter")
env = Environment.objects.get(app_id=app_id, name__iexact=env_name)
# Pre-fetch app and organisation
env = Environment.objects.select_related("app__organisation").get(
app_id=app_id, name__iexact=env_name
)
except Environment.DoesNotExist:
# Check if the app exists to give a more specific error
App = apps.get_model("api", "App")
Expand Down Expand Up @@ -134,14 +146,14 @@ def authenticate(self, request):

try:
service_token = get_service_token(auth_token)
service_account = get_service_account_from_token(auth_token)
service_account = get_service_account_from_token(auth_token)

creator = getattr(service_token, "created_by", None)
if creator:
user = creator.user
else:
user = ServiceAccountUser(service_account)

auth["service_account"] = service_account
auth["service_account_token"] = service_token

Expand Down
51 changes: 51 additions & 0 deletions backend/api/throttling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from rest_framework.throttling import SimpleRateThrottle
from django.conf import settings
from api.models import Organisation


class PlanBasedRateThrottle(SimpleRateThrottle):
"""
Limits the rate of API calls based on the Organisation's plan.
Uses the pre-fetched organisation data from request.auth to avoid DB lookups.
"""

scope = "plan_based"

def get_cache_key(self, request, view):
if not request.user.is_authenticated or not request.auth:
return None

# Identify the user or service account
ident = self.get_ident(request)
if request.auth.get("org_member"):
ident = f"user_{request.auth['org_member'].id}"
elif request.auth.get("service_account"):
ident = f"sa_{request.auth['service_account'].id}"

return self.cache_format % {"scope": self.scope, "ident": ident}

def allow_request(self, request, view):
"""
Override allow_request to dynamically set the rate based on the request user's plan.
"""
# Default fallback (reads from REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['plan_based'])
new_rate = self.get_rate()

if request.user.is_authenticated and request.auth:
env = request.auth.get("environment")
if env:
try:
plan = env.app.organisation.plan
new_rate = self.get_rate_for_plan(plan)
except AttributeError:
pass

# Update the throttle configuration for this specific request
self.rate = new_rate
self.num_requests, self.duration = self.parse_rate(self.rate)

return super().allow_request(request, view)

@staticmethod
def get_rate_for_plan(plan):
return settings.PLAN_RATE_LIMITS.get(plan, settings.PLAN_RATE_LIMITS["DEFAULT"])
Copy link

Choose a reason for hiding this comment

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

Plan rate limit fallback fails for unconfigured plans

In get_rate_for_plan, the expression settings.PLAN_RATE_LIMITS.get(plan, settings.PLAN_RATE_LIMITS["DEFAULT"]) doesn't correctly fall back to the default rate when a plan's rate is None. Since PLAN_RATE_LIMITS is populated with os.getenv() calls that return None for unset environment variables, the keys (FR, PR, EN) always exist in the dictionary with None values. Python's dict.get() only uses the default when the key is missing, not when the value is None. In cloud mode, if RATE_LIMIT_FREE is unset but RATE_LIMIT_DEFAULT is configured, Free plan users would get None as their rate, effectively disabling rate limiting for them.

Additional Locations (1)

Fix in CursorΒ Fix in Web

35 changes: 35 additions & 0 deletions backend/backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,13 +241,27 @@ def get_version():
"USER_DETAILS_SERIALIZER": "api.serializers.CustomUserSerializer"
}

# Define rate limits per plan (keys match Organisation model constants: FR, PR, EN)
PLAN_RATE_LIMITS = {
"FR": os.getenv("RATE_LIMIT_FREE", "120/min"),
"PR": os.getenv("RATE_LIMIT_PRO", "240/min"),
"EN": os.getenv("RATE_LIMIT_ENTERPRISE", "1000/min"),
"DEFAULT": os.getenv("RATE_LIMIT_DEFAULT", "120/min"),
}

REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_THROTTLE_CLASSES": [
"api.throttling.PlanBasedRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"plan_based": PLAN_RATE_LIMITS["DEFAULT"],
},
"EXCEPTION_HANDLER": "backend.exceptions.custom_exception_handler",
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
Expand Down Expand Up @@ -294,6 +308,27 @@ def get_version():
},
}

REDIS_HOST = os.getenv("REDIS_HOST")
REDIS_PORT = os.getenv("REDIS_PORT")
REDIS_PASSWORD = get_secret("REDIS_PASSWORD")
REDIS_SSL = os.getenv("REDIS_SSL", "False").lower() == "true"
REDIS_PROTOCOL = "rediss" if REDIS_SSL else "redis"
REDIS_AUTH = f":{REDIS_PASSWORD}@" if REDIS_PASSWORD else ""

CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": f"{REDIS_PROTOCOL}://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/1",
"OPTIONS": (
{
"ssl_cert_reqs": None,
}
if REDIS_SSL
else {}
),
}
}

DYNAMODB = {
"TABLE": os.getenv("DYNAMODB_LOGS_TABLE"),
"INDEX": os.getenv("DYNAMODB_LOGS_TIMESTAMP_INDEX"),
Expand Down