Skip to content

Commit 752863d

Browse files
authored
Merge pull request #44 from vaisu-bhut/rate_limit
Rate-limitting with Redis
2 parents c050009 + a8dcd8c commit 752863d

11 files changed

Lines changed: 482 additions & 5 deletions

File tree

docker-compose.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ services:
2525
- db
2626
- sentinel
2727
- datadog-agent
28+
- redis
2829
volumes:
2930
- ./services/gateway/app:/app/app
3031
- dogstatsd-socket:/var/run/datadog
@@ -196,6 +197,26 @@ services:
196197
timeout: 5s
197198
retries: 5
198199

200+
# Redis - Rate Limiting & Caching
201+
redis:
202+
image: redis:7-alpine
203+
restart: always
204+
ports:
205+
- "6379:6379"
206+
volumes:
207+
- redis_data:/data
208+
networks:
209+
- clestiq-network
210+
healthcheck:
211+
test: ["CMD", "redis-cli", "ping"]
212+
interval: 10s
213+
timeout: 5s
214+
retries: 5
215+
labels:
216+
com.datadoghq.tags.service: "clestiq-shield-redis"
217+
com.datadoghq.tags.env: "development"
218+
com.datadoghq.tags.version: "7.0.0"
219+
199220
# Datadog Agent
200221
datadog-agent:
201222
image: gcr.io/datadoghq/agent:latest
@@ -257,3 +278,4 @@ networks:
257278
volumes:
258279
postgres_data:
259280
dogstatsd-socket:
281+
redis_data:

scripts/test_rate_limits.ps1

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# Test-RateLimits.ps1
2+
# Verifies Rate Limiting for Tokens, App Creation, Key Creation, and Penalties.
3+
4+
$GatewayUrl = "http://localhost:8000"
5+
$Username = "ratetestuser_$(Get-Random)"
6+
$Password = "TestPass123!"
7+
8+
function Invoke-RestMethodWithMetrics {
9+
param(
10+
[string]$Uri,
11+
[string]$Method,
12+
[hashtable]$Headers,
13+
[object]$Body,
14+
[bool]$SkipError = $true
15+
)
16+
try {
17+
if ($Body) {
18+
$jsonBody = $Body | ConvertTo-Json -Depth 10
19+
return Invoke-RestMethod -Uri $Uri -Method $Method -Headers $Headers -Body $jsonBody -ContentType "application/json" -ErrorAction Stop
20+
} else {
21+
return Invoke-RestMethod -Uri $Uri -Method $Method -Headers $Headers -ContentType "application/json" -ErrorAction Stop
22+
}
23+
} catch {
24+
if ($SkipError) {
25+
if ($_.Exception.Response) {
26+
# Attempt to read the error stream
27+
$stream = $_.Exception.Response.GetResponseStream()
28+
if ($stream) {
29+
$reader = New-Object System.IO.StreamReader($stream)
30+
$errorBody = $reader.ReadToEnd()
31+
try {
32+
# Add a fake property to mimic Invoke-RestMethod object so downstream checks pass
33+
$errObj = $errorBody | ConvertFrom-Json
34+
# We need to attach StatusCode somehow or just return the raw PSCustomObject
35+
# But our checks look for .StatusCode or .status_code property validation wrapper
36+
# Let's return a custom object
37+
return [PSCustomObject]@{
38+
StatusCode = [int]$_.Exception.Response.StatusCode
39+
status_code = [int]$_.Exception.Response.StatusCode # For compatibility
40+
Body = $errObj
41+
Raw = $errorBody
42+
IsError = $true
43+
}
44+
} catch {
45+
return [PSCustomObject]@{
46+
StatusCode = [int]$_.Exception.Response.StatusCode
47+
status_code = [int]$_.Exception.Response.StatusCode
48+
Body = $errorBody
49+
IsError = $true
50+
}
51+
}
52+
}
53+
return $_.Exception.Response
54+
} else {
55+
Write-Host "Error request failed with no response object: $($_.Exception.Message)" -ForegroundColor Red
56+
return $null
57+
}
58+
} else {
59+
throw $_
60+
}
61+
}
62+
}
63+
64+
Write-Host "--- Rate Limit Verification Script ---" -ForegroundColor Cyan
65+
66+
# 1. Setup User & Auth
67+
Write-Host "`n[Setup] Creating User and Logging in..."
68+
$userBody = @{ email = "$Username@example.com"; password = $Password; full_name = "Rate Test User" }
69+
$signup = Invoke-RestMethodWithMetrics -Uri "$GatewayUrl/api/v1/auth/register" -Method Post -Body $userBody -SkipError $false
70+
Write-Host "User created: $($signup.id)"
71+
72+
$loginBody = @{ username = "$Username@example.com"; password = $Password }
73+
# Note: Login expects form-data usually, but let's try JSON or adjust if needed. EagleEye auth often uses OAuth2 form.
74+
# If Gateway proxies /api/v1/auth/login, it might expect form data.
75+
$formBody = "username=$Username@example.com&password=$Password"
76+
try {
77+
$tokenResponse = Invoke-RestMethod -Uri "$GatewayUrl/api/v1/auth/login" -Method Post -Body $formBody -ContentType "application/x-www-form-urlencoded"
78+
} catch {
79+
Write-Error "Login failed. Ensure Gateway is running and proxies to EagleEye."
80+
exit 1
81+
}
82+
$token = $tokenResponse.access_token
83+
$authHeader = @{ "Authorization" = "Bearer $token" }
84+
Write-Host "Got Token."
85+
86+
# 2. App Creation Limit Test (Limit: 2)
87+
Write-Host "`n[Test 1] App Creation Limit (Target: 2)"
88+
for ($i = 1; $i -le 3; $i++) {
89+
$rand = Get-Random
90+
$appBody = @{ name = "App_$($i)_$rand"; description = "Test App" }
91+
$response = Invoke-RestMethodWithMetrics -Uri "$GatewayUrl/api/v1/apps/" -Method Post -Headers $authHeader -Body $appBody
92+
93+
if ($null -eq $response) { continue }
94+
if ($response.IsError -eq $true -or $response.GetType().Name -eq "HttpResponseMessageWrapper") {
95+
# Error response
96+
$code = if ($response.StatusCode) { $response.StatusCode } else { $response.status_code }
97+
if ([int]$code -eq 429) {
98+
Write-Host "[$i] Request blocked as expected (429)." -ForegroundColor Green
99+
} else {
100+
Write-Host "[$i] Request failed with unexpected code: $code" -ForegroundColor Red
101+
Write-Host "DEBUG Info: Type=$($response.GetType().Name)" -ForegroundColor DarkGray
102+
# Attempt to print body if exists
103+
try { Write-Host "DEBUG Body: $($response.Body | ConvertTo-Json -Depth 2)" -ForegroundColor DarkGray } catch {}
104+
}
105+
} else {
106+
Write-Host "[$i] App created: $($response.id)" -ForegroundColor Yellow
107+
if ($i -eq 1) { $global:appId = $response.id; $global:appName = $response.name }
108+
}
109+
}
110+
111+
# 3. Key Creation Limit Test (Limit: 4)
112+
Write-Host "`n[Test 2] Key Creation Limit (Target: 4)"
113+
if (-not $global:appId) {
114+
Write-Warning "Skipping Test 2: No App ID available from Test 1."
115+
} else {
116+
# Use the first app created
117+
for ($i = 1; $i -le 5; $i++) {
118+
$keyBody = @{ name = "Key_$i" }
119+
$response = Invoke-RestMethodWithMetrics -Uri "$GatewayUrl/api/v1/apps/$global:appId/keys" -Method Post -Headers $authHeader -Body $keyBody
120+
121+
if ($null -eq $response) { continue }
122+
if ($response.IsError -eq $true -or $response.GetType().Name -eq "HttpResponseMessageWrapper") {
123+
$code = if ($response.StatusCode) { $response.StatusCode } else { $response.status_code }
124+
if ([int]$code -eq 429) {
125+
Write-Host "[$i] Request blocked as expected (429)." -ForegroundColor Green
126+
} else {
127+
Write-Host "[$i] Request failed with unexpected code: $code" -ForegroundColor Red
128+
}
129+
} else {
130+
Write-Host "[$i] Key created: $($response.key_prefix)..." -ForegroundColor Yellow
131+
if ($i -eq 1) { $global:apiKey = $response.api_key; $global:keyId = $response.id }
132+
}
133+
}
134+
}
135+
136+
# 4. Token Limit & Penalty Test
137+
Write-Host "`n[Test 3] Token Usage & Penalty (Limit: 5k/5min, 2 Strikes)"
138+
if (-not $global:apiKey) {
139+
Write-Warning "Skipping Test 3: No API Key available from Test 2."
140+
} else {
141+
$headers = @{ "X-API-Key" = $global:apiKey; "Content-Type" = "application/json" }
142+
$chatBody = @{
143+
query = "Write a 500 word story about a space adventure to Mars."
144+
model = "gemini-3-flash-preview"
145+
moderation = "moderate"
146+
max_output_tokens = 1000
147+
}
148+
149+
# We need to loop until we hit 10k tokens.
150+
# Assuming each request uses ~100 tokens. 100 requests.
151+
$simulatedTokens = 0
152+
$limit = 5000
153+
$count = 0
154+
155+
while ($simulatedTokens -lt $limit + 2000) { # Go a bit over
156+
$count++
157+
$response = Invoke-RestMethodWithMetrics -Uri "$GatewayUrl/chat/" -Method Post -Headers $headers -Body $chatBody
158+
159+
if ($null -eq $response) { continue }
160+
if ($response.IsError -eq $true -or $response.GetType().Name -eq "HttpResponseMessageWrapper") {
161+
$code = if ($response.StatusCode) { $response.StatusCode } else { $response.status_code }
162+
163+
if ([int]$code -eq 429) {
164+
Write-Host "[$count] Rate Limit Hit (429)!" -ForegroundColor Green
165+
# Checking penalty
166+
# To trigger penalty (disable key), we need to hit 429 TWICE.
167+
# We just hit it once. We should wait a second and hit it again.
168+
Start-Sleep -Seconds 1
169+
Write-Host "Attempting to trigger 2nd strike..."
170+
$response2 = Invoke-RestMethodWithMetrics -Uri "$GatewayUrl/chat/" -Method Post -Headers $headers -Body $chatBody
171+
$code2 = if ($response2.StatusCode) { $response2.StatusCode } else { $response2.status_code }
172+
173+
if ([int]$code2 -eq 429) {
174+
Write-Host "2nd Strike recorded." -ForegroundColor Green
175+
}
176+
177+
# Now, 3rd attempt should be 403 Forbidden (Key Disabled)
178+
Write-Host "Verifying Key Disabling..."
179+
$response3 = Invoke-RestMethodWithMetrics -Uri "$GatewayUrl/chat/" -Method Post -Headers $headers -Body $chatBody
180+
$code3 = if ($response3.StatusCode) { $response3.StatusCode } else { $response3.status_code }
181+
182+
if ([int]$code3 -eq 403) {
183+
Write-Host "SUCCESS: Key has been disabled (403: Blocked by app)." -ForegroundColor Green
184+
185+
# Try to print the message
186+
if ($response3.detail) {
187+
Write-Host "Message: $($response3.detail)" -ForegroundColor Cyan
188+
} elseif ($response3.message) {
189+
Write-Host "Message: $($response3.message)" -ForegroundColor Cyan
190+
} else {
191+
# Fallback for raw stream reading if needed, but Invoke-RestMethod parsed it
192+
Write-Host "Response Body: $($response3 | ConvertTo-Json -Depth 1 -Compress)" -ForegroundColor Cyan
193+
}
194+
break
195+
} else {
196+
Write-Host "FAILURE: Key was not disabled. Code: $code3" -ForegroundColor Red
197+
break
198+
}
199+
} else {
200+
Write-Host "[$count] Failed: $code" -ForegroundColor Red
201+
Write-Host "DEBUG Info: Type=$($response.GetType().Name)" -ForegroundColor DarkGray
202+
try { Write-Host "DEBUG Body: $($response.Body | ConvertTo-Json -Depth 2)" -ForegroundColor DarkGray } catch {}
203+
break
204+
}
205+
} else {
206+
# Valid response
207+
$usage = $response.metrics.token_usage.total_tokens
208+
$simulatedTokens += $usage
209+
Write-Host "[$count] OK. Used $usage tokens. Total: $simulatedTokens / $limit" -NoNewline
210+
if ($count % 5 -eq 0) { Write-Host "" } else { Write-Host " | " -NoNewline }
211+
}
212+
}
213+
}
214+
215+
Write-Host "`nTest Complete."

services/gateway/app/api/deps.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,32 @@ async def get_api_key(
3030
result = await db.execute(
3131
select(ApiKey)
3232
.options(selectinload(ApiKey.application))
33-
.filter(ApiKey.key_hash == hashed_key, ApiKey.is_active)
33+
.filter(ApiKey.key_hash == hashed_key)
3434
)
3535
api_key_obj = result.scalars().first()
3636

37-
if not api_key_obj or not api_key_obj.application:
38-
logger.warning("Authentication failed", api_key_prefix=api_key[:4] + "...")
37+
if not api_key_obj:
38+
logger.warning(
39+
"Authentication failed: Key not found", api_key_prefix=api_key[:4] + "..."
40+
)
3941
raise HTTPException(
4042
status_code=status.HTTP_401_UNAUTHORIZED,
4143
detail="Invalid API Key",
4244
)
4345

46+
if not api_key_obj.is_active:
47+
logger.warning(
48+
"Authentication failed: Key disabled", api_key_prefix=api_key[:4] + "..."
49+
)
50+
raise HTTPException(
51+
status_code=status.HTTP_403_FORBIDDEN,
52+
detail="API Key blocked by application",
53+
)
54+
55+
if not api_key_obj.application:
56+
raise HTTPException(
57+
status_code=status.HTTP_401_UNAUTHORIZED,
58+
detail="Invalid API Key (No App)",
59+
)
60+
4461
return api_key_obj

services/gateway/app/api/v1/endpoints/chat.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
TokenUsage,
1717
)
1818
from app.core.telemetry import telemetry
19+
from app.main import rate_limiter
1920

2021
router = APIRouter()
2122
logger = structlog.get_logger()
@@ -64,6 +65,51 @@ async def chat_request(
6465
moderation=body.moderation,
6566
)
6667

68+
# --- RATE LIMIT CHECK (Token Usage) ---
69+
# Convert UUID to str for Redis key
70+
key_id = str(api_key.id)
71+
# 1. Check if disabled (handled by deps.get_api_key/DB, but we double check or just trust DB)
72+
73+
# 2. Check Token Limit: 10k per 5 mins (300s)
74+
token_limit_key = f"rate:tokens:{key_id}"
75+
TOKEN_LIMIT = 5000
76+
TOKEN_WINDOW = 300
77+
78+
is_allowed = await rate_limiter.check_current_usage(token_limit_key, TOKEN_LIMIT)
79+
if not is_allowed:
80+
# Check penalties
81+
violation_key = f"rate:violations:{key_id}"
82+
VIOLATION_WINDOW = 1200 # 20 mins
83+
84+
violations = await rate_limiter.record_violation(
85+
violation_key, VIOLATION_WINDOW
86+
)
87+
logger.warning("Rate limit exceeded", key_id=key_id, violations=violations)
88+
89+
if violations >= 2:
90+
# DISABLE KEY
91+
logger.critical(
92+
"Disabling API Key due to repeated violations",
93+
key_id=key_id,
94+
app_id=str(current_app.id),
95+
)
96+
api_key.is_active = False
97+
await db.commit()
98+
99+
telemetry.increment(
100+
"clestiq.gateway.keys_disabled", tags=[f"app:{current_app.name}"]
101+
)
102+
103+
raise HTTPException(
104+
status_code=status.HTTP_403_FORBIDDEN,
105+
detail="API Key disabled due to repeated rate limit violations",
106+
)
107+
108+
raise HTTPException(
109+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
110+
detail="Token rate limit exceeded (10k tokens / 5 mins)",
111+
)
112+
67113
# Get client info
68114
client_ip = request.client.host if request.client else None
69115
user_agent = request.headers.get("user-agent")
@@ -213,6 +259,9 @@ async def chat_request(
213259
from sqlalchemy import func
214260

215261
api_key.last_used_at = func.now()
262+
api_key.last_used_at = func.now()
263+
if api_key.request_count is None:
264+
api_key.request_count = 0
216265
api_key.request_count += 1
217266

218267
# Update usage_data JSON
@@ -272,6 +321,15 @@ async def chat_request(
272321
tags=[f"app:{current_app.name}", f"model:{model_used}", "type:total"],
273322
)
274323

324+
# --- UPDATE RATE LIMITER ---
325+
# Increment token usage
326+
# We count total tokens (input + output)
327+
total_tokens_used = response_metrics.token_usage.total_tokens
328+
if total_tokens_used > 0:
329+
await rate_limiter.increment_and_check(
330+
token_limit_key, total_tokens_used, TOKEN_LIMIT, TOKEN_WINDOW
331+
)
332+
275333
# 4. Tokens Saved (Efficiency)
276334
if response_metrics.tokens_saved > 0:
277335
telemetry.increment(

0 commit comments

Comments
 (0)