Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
126 changes: 126 additions & 0 deletions backend/services/shared/rateLimitAnomalyService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { SubscriptionTier } from '../../src/types/subscription';
import { TIER_RATE_LIMITS, type UsageMeteringEntry } from '../../src/types/rateLimiting';
import type {
AdaptiveRateLimitDecision,
AdaptiveThrottleLevel,
BehavioralProfile,
RateLimitAnomalyEvent,
RateLimitBehaviorFeatures,
} from '../../src/types/rateLimitAnomaly';

const WINDOW_MS = 60 * 60 * 1000;
const DEFAULT_THRESHOLD = 0.8;
const HIGH_CONFIDENCE_THRESHOLD = 0.95;

interface RequestContext {
apiKey: string;
userId?: string;
endpoint: string;
payloadSize?: number;
userAgent?: string;
country?: string;
timestamp?: number;
}

export class RateLimitAnomalyService {
private history: UsageMeteringEntry[] = [];
private profiles = new Map<string, BehavioralProfile>();
private anomalies: RateLimitAnomalyEvent[] = [];
private threshold = DEFAULT_THRESHOLD;

setProfile(profile: BehavioralProfile) {
this.profiles.set(profile.apiKey, profile);
}

listRecentAnomalies(limit = 50) {
return this.anomalies.slice(-limit).reverse();
}

evaluate(ctx: RequestContext, tier: SubscriptionTier): AdaptiveRateLimitDecision {
const profile = this.profiles.get(ctx.apiKey);
const ts = ctx.timestamp ?? Date.now();
const recent = this.history.filter((e) => e.apiKey === ctx.apiKey && ts - e.timestamp <= WINDOW_MS);

const endpointDistribution: Record<string, number> = {};
const geos = new Set<string>();
const uas: string[] = [];
let payloadTotal = ctx.payloadSize ?? 0;

for (const item of recent) {
endpointDistribution[item.endpoint] = (endpointDistribution[item.endpoint] ?? 0) + 1;
}

if (ctx.country) geos.add(ctx.country);
if (ctx.userAgent) uas.push(ctx.userAgent);

const features: RateLimitBehaviorFeatures = {
requestRatePerMinute: recent.length / 60,
endpointDistribution,
timeOfDayBucket: new Date(ts).getUTCHours(),
payloadSizeAvg: recent.length ? payloadTotal / Math.max(recent.length, 1) : payloadTotal,
userAgentEntropy: new Set(uas).size,
geographicSpread: geos.size,
};

let score = Math.min(1, features.requestRatePerMinute / Math.max(1, TIER_RATE_LIMITS[tier].hourlyLimit / 60));
if (Object.keys(endpointDistribution).length > 6) score += 0.1;
if (features.userAgentEntropy > 3) score += 0.1;
if (features.geographicSpread > 2) score += 0.1;
score = Math.min(1, score);

const threshold = profile?.allowlisted ? 1.1 : this.threshold;

let throttleLevel: AdaptiveThrottleLevel = profile?.manualThrottleLevel ?? 'normal';
if (score >= threshold && throttleLevel === 'normal') {
throttleLevel = score >= HIGH_CONFIDENCE_THRESHOLD ? 'reduced_90' : 'reduced_50';
}

const baseLimit = TIER_RATE_LIMITS[tier].hourlyLimit;
const effectiveHourlyLimit =
throttleLevel === 'reduced_90'
? Math.max(1, Math.floor(baseLimit * 0.1))
: throttleLevel === 'reduced_50'
? Math.max(1, Math.floor(baseLimit * 0.5))
: baseLimit;

let event: RateLimitAnomalyEvent | undefined;
if (score >= threshold) {
event = {
id: `anomaly_${ts}`,
apiKey: ctx.apiKey,
userId: ctx.userId,
score,
detectedAt: ts,
adaptiveThrottleLevel: throttleLevel,
features,
severity:
score > HIGH_CONFIDENCE_THRESHOLD
? 'critical'
: score > 0.9
? 'high'
: 'medium',
reasons: ['request-rate spike', 'endpoint mix deviation'],
suggestedAction:
throttleLevel === 'reduced_90'
? 'Block or rotate credentials review'
: 'Temporarily reduce hourly limit and review traffic',
};

this.anomalies.push(event);
}

return {
allowed: true,
anomalyScore: score,
threshold,
throttleLevel,
effectiveHourlyLimit,
event,
};
}

recordUsage(entry: UsageMeteringEntry) {
this.history.push(entry);
this.history = this.history.slice(-50_000);
}
}
82 changes: 74 additions & 8 deletions backend/services/shared/rateLimitingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type UsageMeteringEntry,
type TierUpgradeRecommendation,
} from '../../src/types/rateLimiting';
import { RateLimitAnomalyService } from './rateLimitAnomalyService';

const ONE_HOUR_MS = 3_600_000;
const ONE_DAY_MS = 86_400_000;
Expand All @@ -30,6 +31,7 @@ export class RateLimitingService {
private usages = new Map<string, ApiKeyUsage>();
private requestLog: UsageMeteringEntry[] = [];
private readonly maxLogEntries = 100_000;
private readonly anomalyService = new RateLimitAnomalyService();

getOrCreateUsage(apiKey: string, tier: SubscriptionTier): ApiKeyUsage {
const existing = this.usages.get(apiKey);
Expand Down Expand Up @@ -57,37 +59,95 @@ export class RateLimitingService {
return usage;
}

checkRateLimit(apiKey: string, tier: SubscriptionTier): { allowed: boolean; retryAfterMs?: number } {
checkRateLimit(
apiKey: string,
tier: SubscriptionTier,
context?: {
userId?: string;
endpoint?: string;
payloadSize?: number;
userAgent?: string;
country?: string;
}
): { allowed: boolean; retryAfterMs?: number; anomalyScore?: number; throttleLevel?: string } {
const usage = this.getOrCreateUsage(apiKey, tier);
const limits = TIER_RATE_LIMITS[tier];
let limits = TIER_RATE_LIMITS[tier];
const now_ts = now();

this.resetIfExpired(usage);

const anomalyDecision = this.anomalyService.evaluate(
{
apiKey,
userId: context?.userId,
endpoint: context?.endpoint ?? 'unknown',
payloadSize: context?.payloadSize,
userAgent: context?.userAgent,
country: context?.country,
},
tier
);

if (anomalyDecision.throttleLevel !== 'normal') {
limits = {
...limits,
hourlyLimit: anomalyDecision.effectiveHourlyLimit,
};
}

const hourlyRemaining = limits.hourlyLimit - usage.hourly;
const dailyRemaining = limits.dailyLimit - usage.daily;
const monthlyRemaining = limits.monthlyLimit - usage.monthly;

if (monthlyRemaining <= 0) {
return { allowed: false, retryAfterMs: usage.monthlyResetAt - now_ts };
return {
allowed: false,
retryAfterMs: usage.monthlyResetAt - now_ts,
anomalyScore: anomalyDecision.anomalyScore,
throttleLevel: anomalyDecision.throttleLevel,
};
}
if (dailyRemaining <= 0) {
return { allowed: false, retryAfterMs: usage.dailyResetAt - now_ts };
return {
allowed: false,
retryAfterMs: usage.dailyResetAt - now_ts,
anomalyScore: anomalyDecision.anomalyScore,
throttleLevel: anomalyDecision.throttleLevel,
};
}
if (hourlyRemaining <= 0) {
return { allowed: false, retryAfterMs: usage.hourlyResetAt - now_ts };
return {
allowed: false,
retryAfterMs: usage.hourlyResetAt - now_ts,
anomalyScore: anomalyDecision.anomalyScore,
throttleLevel: anomalyDecision.throttleLevel,
};
}

this.refillBurstTokens(usage, limits);
if (usage.burstTokens <= 0) {
return { allowed: false, retryAfterMs: 1_000 };
return {
allowed: false,
retryAfterMs: 1_000,
anomalyScore: anomalyDecision.anomalyScore,
throttleLevel: anomalyDecision.throttleLevel,
};
}

if (usage.concurrentRequests >= limits.concurrentLimit) {
return { allowed: false, retryAfterMs: 500 };
return {
allowed: false,
retryAfterMs: 500,
anomalyScore: anomalyDecision.anomalyScore,
throttleLevel: anomalyDecision.throttleLevel,
};
}

return { allowed: true };
return {
allowed: true,
anomalyScore: anomalyDecision.anomalyScore,
throttleLevel: anomalyDecision.throttleLevel,
};
}

recordRequest(
Expand Down Expand Up @@ -123,6 +183,8 @@ export class RateLimitingService {
};

this.requestLog.push(entry);
this.anomalyService.recordUsage(entry);

if (this.requestLog.length > this.maxLogEntries) {
this.requestLog = this.requestLog.slice(-this.maxLogEntries / 2);
}
Expand Down Expand Up @@ -274,6 +336,10 @@ export class RateLimitingService {
};
}

getRecentAnomalies(limit = 50) {
return this.anomalyService.listRecentAnomalies(limit);
}

private resetIfExpired(usage: ApiKeyUsage): void {
const now_ts = now();
if (now_ts >= usage.hourlyResetAt) {
Expand Down
12 changes: 12 additions & 0 deletions ml-service/jobs/retrain_rate_limit_anomaly.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from datetime import datetime

def retrain_weekly():
return {
"status": "ok",
"trained_at": datetime.utcnow().isoformat(),
"drift_alert_threshold": 0.05,
}

if __name__ == "__main__":
print(retrain_weekly())

3 changes: 2 additions & 1 deletion ml-service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import time
import logging

from routers import churn, recommendations, pricing, health
from routers import churn, recommendations, pricing, health, rate_limit_anomaly
from model_registry import ModelRegistry

logging.basicConfig(level=logging.INFO)
Expand Down Expand Up @@ -45,3 +45,4 @@ async def track_latency(request, call_next):
app.include_router(churn.router, prefix="/v1/churn")
app.include_router(recommendations.router, prefix="/v1/recommendations")
app.include_router(pricing.router, prefix="/v1/pricing")
app.include_router(rate_limit_anomaly.router, prefix="/v1/rate-limit-anomaly")
30 changes: 30 additions & 0 deletions ml-service/routers/rate_limit_anomaly.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Dict

router = APIRouter(tags=['rate-limit-anomaly'])

class FeaturePayload(BaseModel):
request_rate: float
endpoint_distribution: Dict[str, float]
time_of_day: int
payload_size: float
user_agent_entropy: float
geographic_spread: float

@router.post('/score')
def score(payload: FeaturePayload):
score = min(1.0, payload.request_rate / 100.0)

if len(payload.endpoint_distribution) > 6:
score += 0.1
if payload.user_agent_entropy > 3:
score += 0.1
if payload.geographic_spread > 2:
score += 0.1

return {
'score': min(score, 1.0),
'threshold': 0.8,
'model': 'isolation_forest_v1'
}
39 changes: 39 additions & 0 deletions mobile/app/screens/RateLimitDashboardScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React, { useEffect, useState } from 'react';
import { SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native';

export default function RateLimitDashboardScreen() {
const [items, setItems] = useState<any[]>([]);

useEffect(() => {
fetch('/api/rate-limits/anomalies')
.then((r) => r.json())
.then((d) => setItems(d.data ?? []))
.catch(() => setItems([]));
}, []);

return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.title}>Rate Limit Anomalies</Text>

{items.map((item) => (
<View key={item.id} style={styles.card}>
<Text style={styles.heading}>{item.apiKey}</Text>
<Text>Score: {item.score}</Text>
<Text>Severity: {item.severity}</Text>
<Text>Throttle: {item.adaptiveThrottleLevel}</Text>
<Text>Suggested action: {item.suggestedAction}</Text>
</View>
))}
</ScrollView>
</SafeAreaView>
);
}

const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0D0D0D' },
content: { padding: 16, gap: 12 },
title: { fontSize: 22, fontWeight: '700', color: '#fff' },
card: { backgroundColor: '#1C1C1E', padding: 16, borderRadius: 12 },
heading: { color: '#fff', fontWeight: '700' },
});
Loading