-
Notifications
You must be signed in to change notification settings - Fork 626
UN-2972 [FEAT] Implement API deployment rate limiting with Django cache and per-org locks #1649
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
ba3eb70
UN-2972: Implement API deployment rate limiting with Django cache and…
ritwik-g a4ec69d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] f1bbd7b
UN-2972 [FIX] Move signal imports to top of models.py to fix Ruff E402
ritwik-g 61867aa
Merge branch 'UN-2972-api-deployment-rate-limiting' of github.com:Zip…
ritwik-g 54be0bc
Fix organization lookup in rate limit management commands
ritwik-g c28a555
Add delete_org_rate_limit management command and improve error logging
ritwik-g 9138279
Add cache clearing command and improve cache TTL behavior
ritwik-g dfcef37
Add comprehensive rate limiting documentation and update default limits
ritwik-g fed3ce6
Use centralized constants and remove Retry-After functionality
ritwik-g c78d929
Fix late imports and comparison bug in rate limit management commands
ritwik-g 3d9af8d
Refactor rate limiting to view layer with dual release paths
ritwik-g 2032e3b
Use logger.exception() to capture full traceback in rate limit except…
ritwik-g 434f7ec
Update documentation with generic organization IDs
ritwik-g eb22388
Address PR review comments: remove dead code and clarify TTL behavior
ritwik-g File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,37 @@ | ||
| from django.contrib import admin | ||
|
|
||
| from .models import APIDeployment, APIKey | ||
| from .models import APIDeployment, APIKey, OrganizationRateLimit | ||
|
|
||
|
|
||
| @admin.register(OrganizationRateLimit) | ||
| class OrganizationRateLimitAdmin(admin.ModelAdmin): | ||
| list_display = [ | ||
| "organization", | ||
| "concurrent_request_limit", | ||
| "created_at", | ||
| "modified_at", | ||
| ] | ||
| list_filter = ["created_at", "modified_at"] | ||
| search_fields = ["organization__name", "organization__organization_id"] | ||
| readonly_fields = ["created_at", "modified_at"] | ||
| fieldsets = ( | ||
| ( | ||
| None, | ||
| { | ||
| "fields": ( | ||
| "organization", | ||
| "concurrent_request_limit", | ||
| ) | ||
| }, | ||
| ), | ||
| ( | ||
| "Timestamps", | ||
| { | ||
| "fields": ("created_at", "modified_at"), | ||
| "classes": ("collapse",), | ||
| }, | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| admin.site.register([APIDeployment, APIKey]) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
Empty file.
158 changes: 158 additions & 0 deletions
158
backend/api_v2/management/commands/clear_org_rate_limit_cache.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| from account_v2.models import Organization | ||
| from django.core.cache import cache | ||
| from django.core.management.base import BaseCommand | ||
|
|
||
| from api_v2.models import OrganizationRateLimit | ||
| from api_v2.rate_limit_constants import RateLimitKeys | ||
|
|
||
|
|
||
| class Command(BaseCommand): | ||
| help = ( | ||
| "Clear rate limit cache for organizations (useful after changing default limit)" | ||
| ) | ||
|
|
||
| def add_arguments(self, parser): | ||
| parser.add_argument( | ||
| "--org-id", | ||
| type=str, | ||
| help="Clear cache for specific organization ID or name (default: all orgs)", | ||
| ) | ||
| parser.add_argument( | ||
| "--all", | ||
| action="store_true", | ||
| help="Clear cache for ALL organizations (with or without custom limits)", | ||
| ) | ||
|
|
||
| def handle(self, *args, **options): | ||
| org_id = options.get("org_id") | ||
| clear_all = options["all"] | ||
|
|
||
| if org_id: | ||
| # Clear cache for specific organization | ||
| self._clear_org_cache(org_id) | ||
| elif clear_all: | ||
| # Clear cache for ALL organizations | ||
| self._clear_all_orgs_cache() | ||
| else: | ||
| # Clear cache for organizations with custom limits | ||
| self._clear_custom_limits_cache() | ||
|
|
||
| def _clear_org_cache(self, org_id: str): | ||
| """Clear cache for a specific organization.""" | ||
| # Get organization | ||
| try: | ||
| organization = Organization.objects.get(organization_id=org_id) | ||
| except Organization.DoesNotExist: | ||
| try: | ||
| organization = Organization.objects.get(name=org_id) | ||
| except Organization.DoesNotExist: | ||
| self.stdout.write(self.style.ERROR(f'Organization "{org_id}" not found')) | ||
| return | ||
|
|
||
| cache_key = RateLimitKeys.get_org_limit_cache_key( | ||
| str(organization.organization_id) | ||
| ) | ||
| cache.delete(cache_key) | ||
|
|
||
| self.stdout.write( | ||
| self.style.SUCCESS( | ||
| f"✓ Cleared cache for {organization.name} ({organization.organization_id})" | ||
| ) | ||
| ) | ||
|
|
||
| def _clear_custom_limits_cache(self): | ||
| """Clear cache for organizations with custom rate limits.""" | ||
| org_limits = OrganizationRateLimit.objects.select_related("organization").all() | ||
|
|
||
| if not org_limits: | ||
| self.stdout.write( | ||
| self.style.WARNING("No custom rate limits found - nothing to clear") | ||
| ) | ||
| return | ||
|
|
||
| count = 0 | ||
| for org_limit in org_limits: | ||
| org_id = str(org_limit.organization.organization_id) | ||
| cache_key = RateLimitKeys.get_org_limit_cache_key(org_id) | ||
| cache.delete(cache_key) | ||
| count += 1 | ||
|
|
||
| self.stdout.write( | ||
| self.style.SUCCESS( | ||
| f"✓ Cleared cache for {count} organizations with custom limits" | ||
| ) | ||
| ) | ||
|
|
||
| def _clear_all_orgs_cache(self): | ||
| """Clear cache for ALL organizations (with or without custom limits).""" | ||
| self.stdout.write( | ||
| self.style.WARNING( | ||
| "Clearing cache for ALL organizations (including those using defaults)..." | ||
| ) | ||
| ) | ||
|
|
||
| # Try pattern-based deletion first (works with Redis cache backend) | ||
| if self._try_pattern_delete(): | ||
| self.stdout.write( | ||
| self.style.SUCCESS( | ||
| "✓ Cleared all organization rate limit caches using pattern deletion" | ||
| ) | ||
| ) | ||
| else: | ||
| # Fallback: iterate through all organizations | ||
| self._clear_all_orgs_individually() | ||
|
|
||
| self.stdout.write( | ||
| self.style.WARNING( | ||
| "Note: Cache will be repopulated on next API request for each org" | ||
| ) | ||
| ) | ||
|
|
||
| def _try_pattern_delete(self) -> bool: | ||
| """Try to delete cache keys using pattern (Redis-specific). | ||
|
|
||
| Returns: | ||
| True if pattern deletion succeeded, False if not supported | ||
| """ | ||
| try: | ||
| # Check if cache backend supports delete_pattern (Redis cache) | ||
| if hasattr(cache, "delete_pattern"): | ||
| pattern = RateLimitKeys.ORG_LIMIT_CACHE_KEY_PATTERN.replace( | ||
| "{org_id}", "*" | ||
| ) | ||
| deleted_count = cache.delete_pattern(pattern) | ||
| self.stdout.write(f"Deleted {deleted_count} cache keys using pattern") | ||
| return True | ||
| return False | ||
| except Exception as e: | ||
| self.stdout.write(self.style.WARNING(f"Pattern deletion failed: {e}")) | ||
| return False | ||
|
|
||
| def _clear_all_orgs_individually(self): | ||
| """Fallback: Clear cache by iterating through all organizations.""" | ||
| organizations = Organization.objects.all() | ||
| count = organizations.count() | ||
|
|
||
| if count == 0: | ||
| self.stdout.write(self.style.WARNING("No organizations found")) | ||
| return | ||
|
|
||
| # Confirm for large number of orgs | ||
| if count > 50: | ||
| self.stdout.write( | ||
| f"This will clear cache for {count} organizations individually." | ||
| ) | ||
| confirm = input("Continue? [y/N]: ") | ||
| if confirm.lower() != "y": | ||
| self.stdout.write(self.style.WARNING("Cancelled")) | ||
| return | ||
|
|
||
| cleared = 0 | ||
| for org in organizations: | ||
| cache_key = RateLimitKeys.get_org_limit_cache_key(str(org.organization_id)) | ||
| cache.delete(cache_key) | ||
| cleared += 1 | ||
|
|
||
| self.stdout.write( | ||
| self.style.SUCCESS(f"✓ Cleared cache for {cleared} organizations") | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.