diff --git a/backend/services/shared/rateLimitAnomalyService.ts b/backend/services/shared/rateLimitAnomalyService.ts new file mode 100644 index 00000000..79d5eafe --- /dev/null +++ b/backend/services/shared/rateLimitAnomalyService.ts @@ -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(); + 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 = {}; + const geos = new Set(); + 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); + } +} diff --git a/backend/services/shared/rateLimitingService.ts b/backend/services/shared/rateLimitingService.ts index 378723b2..cd9eccb0 100644 --- a/backend/services/shared/rateLimitingService.ts +++ b/backend/services/shared/rateLimitingService.ts @@ -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; @@ -30,6 +31,7 @@ export class RateLimitingService { private usages = new Map(); 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); @@ -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( @@ -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); } @@ -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) { diff --git a/ml-service/jobs/retrain_rate_limit_anomaly.py b/ml-service/jobs/retrain_rate_limit_anomaly.py new file mode 100644 index 00000000..1dba060d --- /dev/null +++ b/ml-service/jobs/retrain_rate_limit_anomaly.py @@ -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()) + \ No newline at end of file diff --git a/ml-service/main.py b/ml-service/main.py index 9e30a132..3e17a002 100644 --- a/ml-service/main.py +++ b/ml-service/main.py @@ -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) @@ -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") diff --git a/ml-service/routers/rate_limit_anomaly.py b/ml-service/routers/rate_limit_anomaly.py new file mode 100644 index 00000000..e9f11e98 --- /dev/null +++ b/ml-service/routers/rate_limit_anomaly.py @@ -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' + } diff --git a/mobile/app/screens/RateLimitDashboardScreen.tsx b/mobile/app/screens/RateLimitDashboardScreen.tsx new file mode 100644 index 00000000..f00a9967 --- /dev/null +++ b/mobile/app/screens/RateLimitDashboardScreen.tsx @@ -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([]); + + useEffect(() => { + fetch('/api/rate-limits/anomalies') + .then((r) => r.json()) + .then((d) => setItems(d.data ?? [])) + .catch(() => setItems([])); + }, []); + + return ( + + + Rate Limit Anomalies + + {items.map((item) => ( + + {item.apiKey} + Score: {item.score} + Severity: {item.severity} + Throttle: {item.adaptiveThrottleLevel} + Suggested action: {item.suggestedAction} + + ))} + + + ); +} + +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' }, +}); diff --git a/src/types/rateLimitAnomaly.ts b/src/types/rateLimitAnomaly.ts new file mode 100644 index 00000000..2289442e --- /dev/null +++ b/src/types/rateLimitAnomaly.ts @@ -0,0 +1,41 @@ +export type AdaptiveThrottleLevel = 'normal' | 'reduced_50' | 'reduced_90'; + +export interface RateLimitBehaviorFeatures { + requestRatePerMinute: number; + endpointDistribution: Record; + timeOfDayBucket: number; + payloadSizeAvg: number; + userAgentEntropy: number; + geographicSpread: number; +} + +export interface RateLimitAnomalyEvent { + id: string; + apiKey: string; + userId?: string; + score: number; + severity: 'low' | 'medium' | 'high' | 'critical'; + detectedAt: number; + adaptiveThrottleLevel: AdaptiveThrottleLevel; + features: RateLimitBehaviorFeatures; + reasons: string[]; + suggestedAction: string; +} + +export interface AdaptiveRateLimitDecision { + allowed: boolean; + anomalyScore: number; + threshold: number; + throttleLevel: AdaptiveThrottleLevel; + effectiveHourlyLimit: number; + retryAfterMs?: number; + event?: RateLimitAnomalyEvent; +} + +export interface BehavioralProfile { + apiKey: string; + userId?: string; + allowlisted?: boolean; + manualThrottleLevel?: AdaptiveThrottleLevel; + seasonalProfile?: string; +}