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
29 changes: 28 additions & 1 deletion backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,27 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
return;
}

// -----------------------------------------------------------------
// Public tier configs GET /rate-limits/tiers
// -----------------------------------------------------------------
if (pathname === '/rate-limits/tiers' && method === 'GET') {
sendJson(res, 200, {
algorithm: 'token_bucket',
tiers: {
free: rateLimitingService.getPublicTierLimits(
rateLimitingService.getRateLimitTier(SubscriptionTier.FREE),
),
pro: rateLimitingService.getPublicTierLimits(
rateLimitingService.getRateLimitTier(SubscriptionTier.PREMIUM),
),
enterprise: rateLimitingService.getPublicTierLimits(
rateLimitingService.getRateLimitTier(SubscriptionTier.ENTERPRISE),
),
},
});
return;
}

// -----------------------------------------------------------------
// Rate-limit status GET /rate-limits/status?apiKey=...&tier=...
// -----------------------------------------------------------------
Expand All @@ -236,7 +257,11 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
return;
}
const status = rateLimitingService.getRateLimitStatus(apiKey, tier);
sendJson(res, 200, status);
sendJson(res, 200, {
...status,
rateLimitTier: rateLimitingService.getRateLimitTier(tier),
algorithm: 'token_bucket',
});
return;
}

Expand Down Expand Up @@ -281,6 +306,7 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
monthlyLimit?: number;
burstLimit?: number;
concurrentLimit?: number;
refillRatePerSecond?: number;
};
};
if (!body.apiKey) {
Expand Down Expand Up @@ -365,6 +391,7 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
console.info(`[Server] Plans → /plans`);
console.info(`[Server] Metrics → GET /metrics/plan-cache`);
console.info(`[Server] RateLimit → GET /rate-limits/analytics`);
console.info(`[Server] RateLimit → GET /rate-limits/tiers`);
console.info(`[Server] RateLimit → GET /rate-limits/status?apiKey=...`);
console.info(`[Server] RateLimit → POST /rate-limits/bypass`);
console.info(`[Server] RateLimit → POST /rate-limits/config`);
Expand Down
38 changes: 34 additions & 4 deletions backend/services/shared/__tests__/rateLimiting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import {
type RateLimitResponse,
} from '../rateLimitMiddleware';
import { SubscriptionTier } from '../../../../src/types/subscription';
import { TIER_RATE_LIMITS } from '../../../../src/types/rateLimiting';
import {
TIER_RATE_LIMITS,
RateLimitTier,
mapSubscriptionToRateLimitTier,
RATE_LIMIT_TIER_CONFIG,
} from '../../../../src/types/rateLimiting';

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -118,12 +123,13 @@ describe('RateLimitingService', () => {

it('blocks when burst tokens are exhausted', () => {
const tier = SubscriptionTier.FREE;
const usage = service.getOrCreateUsage('key1', tier);
usage.burstTokens = 0;
service.setBurstTokens('key1', 0, tier);

const result = service.checkRateLimit('key1', tier);
expect(result.allowed).toBe(false);
expect(result.retryAfterMs).toBe(1_000);
// ~1s at FREE refill rate (1 token/s); allow tiny elapsed-time drift
expect(result.retryAfterMs).toBeGreaterThan(0);
expect(result.retryAfterMs).toBeLessThanOrEqual(1_000);
});

it('blocks when concurrency limit is exceeded', () => {
Expand Down Expand Up @@ -309,6 +315,30 @@ describe('RateLimitingService', () => {
expect(status.remaining.hourly).toBe(TIER_RATE_LIMITS[tier].hourlyLimit - 2);
});
});

// -------------------------------------------------------------------------
describe('free/pro/enterprise rate limit tiers', () => {
it('maps subscription tiers to free/pro/enterprise', () => {
expect(mapSubscriptionToRateLimitTier(SubscriptionTier.FREE)).toBe(RateLimitTier.FREE);
expect(mapSubscriptionToRateLimitTier(SubscriptionTier.BASIC)).toBe(RateLimitTier.PRO);
expect(mapSubscriptionToRateLimitTier(SubscriptionTier.PREMIUM)).toBe(RateLimitTier.PRO);
expect(mapSubscriptionToRateLimitTier(SubscriptionTier.ENTERPRISE)).toBe(
RateLimitTier.ENTERPRISE,
);
});

it('exposes public tier configs via the service', () => {
expect(service.getRateLimitTier(SubscriptionTier.PREMIUM)).toBe(RateLimitTier.PRO);
const pro = service.getPublicTierLimits(RateLimitTier.PRO);
expect(pro.hourlyLimit).toBe(RATE_LIMIT_TIER_CONFIG[RateLimitTier.PRO].hourlyLimit);
expect(pro.refillRatePerSecond).toBeGreaterThan(0);
});

it('includes refillRatePerSecond in effective limits', () => {
const limits = service.getEffectiveLimits('key1', SubscriptionTier.FREE);
expect(limits.refillRatePerSecond).toBe(TIER_RATE_LIMITS[SubscriptionTier.FREE].refillRatePerSecond);
});
});
});

// ---------------------------------------------------------------------------
Expand Down
83 changes: 83 additions & 0 deletions backend/services/shared/__tests__/tokenBucket.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Tests for the TokenBucket rate limiter.
*
* Run with:
* npx jest backend/services/shared/__tests__/tokenBucket.test.ts
*/

import { TokenBucket, refillRateFromHourlyLimit } from '../tokenBucket';

describe('TokenBucket', () => {
it('starts full by default', () => {
const bucket = new TokenBucket({ capacity: 10, refillRatePerSecond: 1 });
expect(bucket.getRemaining()).toBe(10);
expect(bucket.getCapacity()).toBe(10);
});

it('consumes tokens when available', () => {
const bucket = new TokenBucket({ capacity: 5, refillRatePerSecond: 1 });
const result = bucket.tryConsume(2);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(3);
expect(result.retryAfterMs).toBe(0);
});

it('rejects when empty and reports retryAfterMs', () => {
const bucket = new TokenBucket(
{ capacity: 2, refillRatePerSecond: 1 },
{ initialTokens: 0 },
);
const result = bucket.tryConsume(1);
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
expect(result.retryAfterMs).toBe(1_000);
});

it('refills continuously based on elapsed time', () => {
let now = 1_000_000;
const bucket = new TokenBucket(
{ capacity: 10, refillRatePerSecond: 2 },
{ now: () => now, initialTokens: 0 },
);

now += 2_500; // 2.5s * 2 tokens/s = 5 tokens
expect(bucket.getRemaining()).toBe(5);

const result = bucket.tryConsume(5);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(0);
});

it('never exceeds capacity when refilling', () => {
let now = 0;
const bucket = new TokenBucket(
{ capacity: 3, refillRatePerSecond: 100 },
{ now: () => now, initialTokens: 0 },
);
now += 10_000;
expect(bucket.getRemaining()).toBe(3);
});

it('reconfigure updates capacity and clamps tokens', () => {
const bucket = new TokenBucket({ capacity: 10, refillRatePerSecond: 1 });
bucket.reconfigure({ capacity: 4 });
expect(bucket.getCapacity()).toBe(4);
expect(bucket.getRemaining()).toBe(4);
});

it('throws on invalid config', () => {
expect(() => new TokenBucket({ capacity: 0, refillRatePerSecond: 1 })).toThrow();
expect(() => new TokenBucket({ capacity: 5, refillRatePerSecond: 0 })).toThrow();
});
});

describe('refillRateFromHourlyLimit', () => {
it('derives tokens/sec from hourly limit', () => {
expect(refillRateFromHourlyLimit(3_600)).toBe(1);
expect(refillRateFromHourlyLimit(100)).toBeCloseTo(100 / 3_600);
});

it('floors at a small epsilon', () => {
expect(refillRateFromHourlyLimit(0)).toBe(0.001);
});
});
2 changes: 2 additions & 0 deletions backend/services/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export type {
RateLimitResponse,
RateLimitMiddlewareOptions,
} from './rateLimitMiddleware';
export { TokenBucket, refillRateFromHourlyLimit } from './tokenBucket';
export type { TokenBucketConfig, TokenBucketSnapshot, ConsumeResult } from './tokenBucket';
export { apiClient } from './apiClient';
export {
ok,
Expand Down
12 changes: 9 additions & 3 deletions backend/services/shared/rateLimitMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
* Rate limiting middleware for SubTrackr API.
*
* Supports:
* - Per-API-key rate limiting (hourly / daily / monthly / burst / concurrent)
* - Token-bucket burst limiting with continuous refill
* - Per-API-key rate limiting (hourly / daily / monthly / concurrent)
* - Per-user rate limiting (aggregate across all keys for a user)
* - Standard rate-limit response headers (X-RateLimit-*)
* - Tier-based limits (free / pro / enterprise)
* - Bypass list for trusted clients (service accounts, internal health checks)
* - Configurable limits that can be overridden per-key
*
Expand All @@ -16,7 +18,7 @@

import { SubscriptionTier } from '../../src/types/subscription';
import { RateLimitingService } from './rateLimitingService';
import { TIER_RATE_LIMITS } from '../../src/types/rateLimiting';
import { TIER_RATE_LIMITS, mapSubscriptionToRateLimitTier } from '../../src/types/rateLimiting';

// ---------------------------------------------------------------------------
// Minimal request / response types — structural, no Express dep required
Expand Down Expand Up @@ -254,6 +256,7 @@ export function createRateLimitMiddleware(options: RateLimitMiddlewareOptions) {

const tier = tierFn(apiKey ?? identifier, userId);
const limits = TIER_RATE_LIMITS[tier];
const publicTier = mapSubscriptionToRateLimitTier(tier);

// -----------------------------------------------------------------------
// Per-API-key check
Expand All @@ -265,7 +268,10 @@ export function createRateLimitMiddleware(options: RateLimitMiddlewareOptions) {
res.setHeader(RATE_LIMIT_HEADERS.LIMIT, limits.hourlyLimit);
res.setHeader(RATE_LIMIT_HEADERS.REMAINING, status.remaining.hourly);
res.setHeader(RATE_LIMIT_HEADERS.RESET, Math.ceil(status.resetAt.hourly / 1_000));
res.setHeader(RATE_LIMIT_HEADERS.POLICY, `${tier};hourly=${limits.hourlyLimit};daily=${limits.dailyLimit}`);
res.setHeader(
RATE_LIMIT_HEADERS.POLICY,
`${publicTier};hourly=${limits.hourlyLimit};daily=${limits.dailyLimit};burst=${limits.burstLimit};refill=${limits.refillRatePerSecond}/s`,
);

if (!check.allowed && !softMode) {
sendRateLimitExceeded(
Expand Down
Loading