Skip to content
Merged
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
20 changes: 20 additions & 0 deletions backend/src/config/rateLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@
*/

import { UserTier, TierRateLimitConfig } from '../types/userTier';
import { EndpointType } from '../../shared/types';

/**
* Multiplier applied to tier rate limits based on endpoint type.
* READ endpoints are more generous (3x the tier limit).
* WRITE endpoints use the base tier limit (1x).
*/
export const ENDPOINT_TYPE_MULTIPLIERS: Record<EndpointType, number> = {
[EndpointType.READ]: 3,
[EndpointType.WRITE]: 1,
};

/**
* Get the rate limit multiplier for a given endpoint type.
* Defaults to WRITE (1x) if not specified (stricter default).
*/
export function getEndpointTypeMultiplier(endpointType?: EndpointType): number {
if (!endpointType) return 1;
return ENDPOINT_TYPE_MULTIPLIERS[endpointType] ?? 1;
}

export const TIER_RATE_LIMITS: Record<UserTier, TierRateLimitConfig> = {
[UserTier.ANONYMOUS]: {
Expand Down
64 changes: 49 additions & 15 deletions backend/src/middleware/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
*/

import { Request, Response, NextFunction } from 'express';
import { UserTier, TierRateLimitStatus } from '../types/userTier';
import { getRateLimitForTier } from '../config/rateLimits';
import { UserTier, TierRateLimitStatus, EndpointType } from '../types/userTier';
import { getRateLimitForTier, getEndpointTypeMultiplier } from '../config/rateLimits';
import { userTierService } from '../services/userTierService';
import logger from '../utils/logger';
import { getPublisher, isRedisEnabled } from '../utils/redis';
Expand All @@ -19,6 +19,12 @@ interface WindowEntry {
queueCount: number;
}

/** Options for the rate limiter middleware */
export interface RateLimiterMiddlewareOptions {
/** Explicit endpoint type override. If omitted, auto-detected from HTTP method. */
endpointType?: EndpointType;
}

export class TieredRateLimiter {
private windows: Map<string, WindowEntry> = new Map();
private cleanupInterval: NodeJS.Timeout;
Expand All @@ -35,15 +41,22 @@ export class TieredRateLimiter {

/**
* Check (and consume) one request slot for a user.
* @param userId - The user identifier
* @param endpointType - Optional endpoint type for differentiated rate limits
*/
async checkLimit(userId: string): Promise<TierRateLimitStatus> {
async checkLimit(userId: string, endpointType?: EndpointType): Promise<TierRateLimitStatus> {
const tier = userTierService.getUserTier(userId);
const config = getRateLimitForTier(tier);
const now = Date.now();
const windowStart = now - config.windowMs;

// Apply endpoint-type multiplier for differentiated rate limits
const multiplier = getEndpointTypeMultiplier(endpointType);
const effectiveMaxRequests = Math.floor(config.maxRequests * multiplier);
const effectiveConfig = { ...config, maxRequests: effectiveMaxRequests };

if (this.redisEnabled) {
return this.checkLimitRedis(userId, tier, config, now);
return this.checkLimitRedis(userId, tier, effectiveConfig, now);
}

let entry = this.windows.get(userId);
Expand All @@ -55,7 +68,7 @@ export class TieredRateLimiter {
// Slide the window
entry.timestamps = entry.timestamps.filter((t) => t > windowStart);

const remaining = config.maxRequests - entry.timestamps.length;
const remaining = effectiveMaxRequests - entry.timestamps.length;
const resetTime = new Date(
entry.timestamps.length > 0
? entry.timestamps[0] + config.windowMs
Expand All @@ -70,7 +83,7 @@ export class TieredRateLimiter {
remainingRequests: remaining - 1,
resetTime: resetTime.toISOString(),
queued: false,
limit: config.maxRequests,
limit: effectiveMaxRequests,
};
}

Expand All @@ -84,7 +97,7 @@ export class TieredRateLimiter {
resetTime: resetTime.toISOString(),
queued: true,
queuePosition: entry.queueCount,
limit: config.maxRequests,
limit: effectiveMaxRequests,
};
}

Expand All @@ -95,20 +108,27 @@ export class TieredRateLimiter {
remainingRequests: 0,
resetTime: resetTime.toISOString(),
queued: false,
limit: config.maxRequests,
limit: effectiveMaxRequests,
};
}

/**
* Read-only status check (does NOT consume a request slot).
* @param userId - The user identifier
* @param endpointType - Optional endpoint type for differentiated rate limits
*/
async getStatus(userId: string): Promise<TierRateLimitStatus> {
async getStatus(userId: string, endpointType?: EndpointType): Promise<TierRateLimitStatus> {
const tier = userTierService.getUserTier(userId);
const config = getRateLimitForTier(tier);
const now = Date.now();

// Apply endpoint-type multiplier for differentiated rate limits
const multiplier = getEndpointTypeMultiplier(endpointType);
const effectiveMaxRequests = Math.floor(config.maxRequests * multiplier);
const effectiveConfig = { ...config, maxRequests: effectiveMaxRequests };

if (this.redisEnabled) {
return this.getStatusRedis(userId, tier, config, now);
return this.getStatusRedis(userId, tier, effectiveConfig, now);
}

const windowStart = now - config.windowMs;
Expand All @@ -117,7 +137,7 @@ export class TieredRateLimiter {
const timestamps = entry
? entry.timestamps.filter((t) => t > windowStart)
: [];
const remaining = Math.max(0, config.maxRequests - timestamps.length);
const remaining = Math.max(0, effectiveMaxRequests - timestamps.length);
const resetTime = new Date(
timestamps.length > 0
? timestamps[0] + config.windowMs
Expand All @@ -130,31 +150,44 @@ export class TieredRateLimiter {
remainingRequests: remaining,
resetTime: resetTime.toISOString(),
queued: false,
limit: config.maxRequests,
limit: effectiveMaxRequests,
};
}

// ── Express middleware factory ─────────────────────────────

middleware() {
/**
* Create Express middleware for rate limiting.
* @param options - Optional configuration
* @param options.endpointType - Explicit endpoint type. If omitted, auto-detected
* from HTTP method: GET/HEAD/OPTIONS → READ, everything else → WRITE.
*/
middleware(options?: RateLimiterMiddlewareOptions) {
return async (req: Request, res: Response, next: NextFunction) => {
if (process.env.NODE_ENV === 'test') {
return next();
}

try {
// Auto-detect endpoint type from HTTP method if not explicitly set
const endpointType = options?.endpointType
?? ((req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS')
? EndpointType.READ
: EndpointType.WRITE);

const userId =
(req.headers['x-user-id'] as string) || req.ip || 'unknown';
const status = await this.checkLimit(userId);
const status = await this.checkLimit(userId, endpointType);
const resetAtMs = Date.parse(status.resetTime);

res.set('X-RateLimit-Limit', String(status.limit));
res.set('X-RateLimit-Remaining', String(status.remainingRequests));
res.set('X-RateLimit-Reset', String(Math.ceil(resetAtMs / 1000)));
res.set('X-RateLimit-Tier', status.tier);
res.set('X-RateLimit-Type', endpointType);

if (!status.allowed && !status.queued) {
logger.warn('Rate limit exceeded', { userId, tier: status.tier });
logger.warn('Rate limit exceeded', { userId, tier: status.tier, endpointType });
return res.status(429).json({
error: 'Rate limit exceeded',
tier: status.tier,
Expand All @@ -168,6 +201,7 @@ export class TieredRateLimiter {
userId,
tier: status.tier,
position: status.queuePosition,
endpointType,
});
return res.status(202).json({
message: 'Request queued',
Expand Down
8 changes: 6 additions & 2 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,12 @@ app.use((req: Request, _res: Response, next: NextFunction) => {

app.use(metricsCollector.middleware());
app.use(versioningMiddleware);
app.use('/api/v1/payment', tieredRateLimiter.middleware());
app.use('/api/v2/payment', tieredRateLimiter.middleware());

// ── Rate limiting for ALL API endpoints ──────────────────────
// Auto-detects endpoint type from HTTP method:
// GET / HEAD / OPTIONS → READ (3x tier limit)
// POST / PUT / DELETE / PATCH → WRITE (1x tier limit)
app.use('/api', tieredRateLimiter.middleware());

// Versioned routes
app.use('/api/v1/monitoring', monitoringRoutes);
Expand Down
3 changes: 2 additions & 1 deletion backend/src/types/userTier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ export {
UserTier,
TierRateLimitConfig,
UserTierInfo,
TierRateLimitStatus
TierRateLimitStatus,
EndpointType
} from '../../shared/types';

// Backend-specific utility functions for user tier management
Expand Down
6 changes: 6 additions & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ export interface RateLimitStatus {
error?: string;
}

// Endpoint Type for tiered rate limiting
export enum EndpointType {
READ = 'read',
WRITE = 'write',
}

// User Tier Types
export enum UserTier {
ANONYMOUS = 'anonymous',
Expand Down
Loading