From 5280bb9f0e18ad56a917010661da161b36445c95 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 00:20:18 +0000 Subject: [PATCH] feat(rate-limit): implement token bucket algorithm with free/pro/enterprise tiers --- backend/server.ts | 29 +++- .../shared/__tests__/rateLimiting.test.ts | 38 ++++- .../shared/__tests__/tokenBucket.test.ts | 83 +++++++++++ backend/services/shared/index.ts | 2 + .../services/shared/rateLimitMiddleware.ts | 12 +- .../services/shared/rateLimitingService.ts | 115 +++++++++++++-- backend/services/shared/tokenBucket.ts | 134 ++++++++++++++++++ developer-portal/docs/rate-limiting.md | 80 +++++++++++ developer-portal/pages/DocumentationPage.tsx | 17 ++- docs/rate-limiting.md | 52 ++++--- src/types/rateLimiting.ts | 78 ++++++++++ 11 files changed, 596 insertions(+), 44 deletions(-) create mode 100644 backend/services/shared/__tests__/tokenBucket.test.ts create mode 100644 backend/services/shared/tokenBucket.ts create mode 100644 developer-portal/docs/rate-limiting.md diff --git a/backend/server.ts b/backend/server.ts index 522687f0..820d10b8 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -225,6 +225,27 @@ export async function startServer(options: StartServerOptions = {}): Promise { 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', () => { @@ -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); + }); + }); }); // --------------------------------------------------------------------------- diff --git a/backend/services/shared/__tests__/tokenBucket.test.ts b/backend/services/shared/__tests__/tokenBucket.test.ts new file mode 100644 index 00000000..60ea7177 --- /dev/null +++ b/backend/services/shared/__tests__/tokenBucket.test.ts @@ -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); + }); +}); diff --git a/backend/services/shared/index.ts b/backend/services/shared/index.ts index b94ed9d1..997afd28 100644 --- a/backend/services/shared/index.ts +++ b/backend/services/shared/index.ts @@ -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, diff --git a/backend/services/shared/rateLimitMiddleware.ts b/backend/services/shared/rateLimitMiddleware.ts index ae688e3c..9649586a 100644 --- a/backend/services/shared/rateLimitMiddleware.ts +++ b/backend/services/shared/rateLimitMiddleware.ts @@ -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 * @@ -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 @@ -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 @@ -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( diff --git a/backend/services/shared/rateLimitingService.ts b/backend/services/shared/rateLimitingService.ts index 6c70032e..3e2ab1b6 100644 --- a/backend/services/shared/rateLimitingService.ts +++ b/backend/services/shared/rateLimitingService.ts @@ -4,6 +4,9 @@ import { SOFT_LIMIT_WARNINGS, TIER_UPGRADE_THRESHOLDS, getNextTier, + mapSubscriptionToRateLimitTier, + getRateLimitTierConfig, + RateLimitTier, type ApiKeyUsage, type RateLimitExceededError, type SoftLimitWarning, @@ -12,6 +15,7 @@ import { type UsageMeteringEntry, type TierUpgradeRecommendation, } from '../../src/types/rateLimiting'; +import { TokenBucket } from './tokenBucket'; const ONE_HOUR_MS = 3_600_000; const ONE_DAY_MS = 86_400_000; @@ -49,12 +53,15 @@ export interface CustomLimits { monthlyLimit?: number; burstLimit?: number; concurrentLimit?: number; + refillRatePerSecond?: number; } export class RateLimitingService { private usages = new Map(); /** Separate tracking bucket for per-user aggregated usage */ private userUsages = new Map(); + /** Token buckets keyed by API key (or IP / user fallback identifier). */ + private buckets = new Map(); private requestLog: UsageMeteringEntry[] = []; private readonly maxLogEntries = 100_000; @@ -109,10 +116,30 @@ export class RateLimitingService { setCustomLimits(apiKey: string, limits: CustomLimits): void { this.customLimits.set(apiKey, limits); + const bucket = this.buckets.get(apiKey); + if (bucket) { + const effective = this.getEffectiveLimits( + apiKey, + this.usages.get(apiKey)?.tier ?? SubscriptionTier.FREE, + ); + bucket.reconfigure({ + capacity: effective.burstLimit, + refillRatePerSecond: effective.refillRatePerSecond, + }); + } } clearCustomLimits(apiKey: string): void { this.customLimits.delete(apiKey); + const usage = this.usages.get(apiKey); + const bucket = this.buckets.get(apiKey); + if (bucket && usage) { + const effective = this.getEffectiveLimits(apiKey, usage.tier); + bucket.reconfigure({ + capacity: effective.burstLimit, + refillRatePerSecond: effective.refillRatePerSecond, + }); + } } getEffectiveLimits(apiKey: string, tier: SubscriptionTier): TierRateLimit { @@ -127,9 +154,67 @@ export class RateLimitingService { monthlyLimit: custom.monthlyLimit ?? tierLimits.monthlyLimit, burstLimit: custom.burstLimit ?? tierLimits.burstLimit, concurrentLimit: custom.concurrentLimit ?? tierLimits.concurrentLimit, + refillRatePerSecond: custom.refillRatePerSecond ?? tierLimits.refillRatePerSecond, }; } + /** Resolve the public free/pro/enterprise tier for a subscription tier. */ + getRateLimitTier(tier: SubscriptionTier): RateLimitTier { + return mapSubscriptionToRateLimitTier(tier); + } + + getPublicTierLimits(tier: RateLimitTier) { + return getRateLimitTierConfig(tier); + } + + // ------------------------------------------------------------------------- + // Token bucket helpers + // ------------------------------------------------------------------------- + + private getOrCreateBucket(apiKey: string, limits: TierRateLimit): TokenBucket { + let bucket = this.buckets.get(apiKey); + if (!bucket) { + bucket = new TokenBucket({ + capacity: limits.burstLimit, + refillRatePerSecond: limits.refillRatePerSecond, + }); + this.buckets.set(apiKey, bucket); + } + return bucket; + } + + /** + * Refill the token bucket and mirror remaining tokens onto ApiKeyUsage. + * The TokenBucket is the source of truth for burst capacity. + */ + private syncAndRefillBucket( + apiKey: string, + usage: ApiKeyUsage, + limits: TierRateLimit, + ): TokenBucket { + const bucket = this.getOrCreateBucket(apiKey, limits); + bucket.reconfigure({ + capacity: limits.burstLimit, + refillRatePerSecond: limits.refillRatePerSecond, + }); + bucket.refill(); + usage.burstTokens = bucket.getRemaining(); + usage.lastBurstRefill = now(); + return bucket; + } + + /** + * Set burst tokens for a key (admin / tests). Updates the token bucket. + */ + setBurstTokens(apiKey: string, tokens: number, tier: SubscriptionTier = SubscriptionTier.FREE): void { + const usage = this.getOrCreateUsage(apiKey, tier); + const limits = this.getEffectiveLimits(apiKey, tier); + const bucket = this.getOrCreateBucket(apiKey, limits); + bucket.setTokens(tokens); + usage.burstTokens = Math.floor(Math.max(0, tokens)); + usage.lastBurstRefill = now(); + } + // ------------------------------------------------------------------------- // Core: per-API-key usage // ------------------------------------------------------------------------- @@ -158,6 +243,7 @@ export class RateLimitingService { }; this.usages.set(apiKey, usage); + this.getOrCreateBucket(apiKey, limits); return usage; } @@ -184,9 +270,13 @@ export class RateLimitingService { return { allowed: false, retryAfterMs: usage.hourlyResetAt - now_ts }; } - this.refillBurstTokens(usage, limits); - if (usage.burstTokens <= 0) { - return { allowed: false, retryAfterMs: 1_000 }; + // Token-bucket burst check (peek — does not consume) + const bucket = this.syncAndRefillBucket(apiKey, usage, limits); + const available = bucket.snapshot().tokens; + if (available < 1) { + const deficit = 1 - available; + const retryAfterMs = Math.ceil((deficit / limits.refillRatePerSecond) * 1_000); + return { allowed: false, retryAfterMs }; } if (usage.concurrentRequests >= limits.concurrentLimit) { return { allowed: false, retryAfterMs: 500 }; @@ -211,7 +301,13 @@ export class RateLimitingService { usage.daily += 1; usage.monthly += 1; usage.lastRequestAt = now(); - usage.burstTokens = Math.max(0, usage.burstTokens - 1); + + // Consume one token from the token bucket + const bucket = this.syncAndRefillBucket(apiKey, usage, limits); + const consumed = bucket.tryConsume(1); + usage.burstTokens = Math.max(0, Math.floor(consumed.remaining)); + usage.lastBurstRefill = now(); + usage.concurrentRequests += 1; setTimeout(() => { @@ -400,6 +496,7 @@ export class RateLimitingService { const usage = this.getOrCreateUsage(apiKey, tier); this.resetIfExpired(usage); const limits = this.getEffectiveLimits(apiKey, tier); + this.syncAndRefillBucket(apiKey, usage, limits); return { limits, @@ -602,16 +699,6 @@ export class RateLimitingService { usage.monthlyResetAt = computeResetTime(ONE_MONTH_MS); } } - - private refillBurstTokens(usage: ApiKeyUsage, limits: TierRateLimit): void { - const now_ts = now(); - const elapsed = now_ts - usage.lastBurstRefill; - const tokensToAdd = Math.floor(elapsed / 1_000); - if (tokensToAdd > 0) { - usage.burstTokens = Math.min(limits.burstLimit, usage.burstTokens + tokensToAdd); - usage.lastBurstRefill = now_ts; - } - } } export const rateLimitingService = new RateLimitingService(); diff --git a/backend/services/shared/tokenBucket.ts b/backend/services/shared/tokenBucket.ts new file mode 100644 index 00000000..b9bf1dbe --- /dev/null +++ b/backend/services/shared/tokenBucket.ts @@ -0,0 +1,134 @@ +/** + * Token bucket rate limiter. + * + * Tokens refill continuously at `refillRatePerSecond` up to `capacity`. + * Each request consumes one token (configurable). When the bucket is empty + * the caller receives `allowed: false` and a `retryAfterMs` hint. + */ + +export interface TokenBucketConfig { + /** Maximum tokens the bucket can hold (burst capacity). */ + capacity: number; + /** Tokens added per second (sustained rate). Must be > 0. */ + refillRatePerSecond: number; +} + +export interface TokenBucketSnapshot { + tokens: number; + capacity: number; + refillRatePerSecond: number; + lastRefillAt: number; +} + +export interface ConsumeResult { + allowed: boolean; + /** Tokens remaining after the attempt (may be fractional). */ + remaining: number; + /** Milliseconds until at least `cost` tokens are available (0 when allowed). */ + retryAfterMs: number; +} + +export class TokenBucket { + private tokens: number; + private lastRefillAt: number; + private capacity: number; + private refillRatePerSecond: number; + private readonly now: () => number; + + constructor(config: TokenBucketConfig, options: { now?: () => number; initialTokens?: number } = {}) { + if (config.capacity <= 0) { + throw new Error('TokenBucket capacity must be > 0'); + } + if (config.refillRatePerSecond <= 0) { + throw new Error('TokenBucket refillRatePerSecond must be > 0'); + } + + this.capacity = config.capacity; + this.refillRatePerSecond = config.refillRatePerSecond; + this.now = options.now ?? Date.now; + this.tokens = options.initialTokens ?? config.capacity; + this.lastRefillAt = this.now(); + } + + /** Refill tokens based on elapsed time since last refill. */ + refill(): number { + const ts = this.now(); + const elapsedMs = ts - this.lastRefillAt; + if (elapsedMs > 0) { + const tokensToAdd = (elapsedMs / 1_000) * this.refillRatePerSecond; + this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd); + this.lastRefillAt = ts; + } + return this.tokens; + } + + /** + * Attempt to consume `cost` tokens. + * Returns whether the request is allowed and when to retry if not. + */ + tryConsume(cost = 1): ConsumeResult { + this.refill(); + + if (this.tokens >= cost) { + this.tokens -= cost; + return { allowed: true, remaining: this.tokens, retryAfterMs: 0 }; + } + + const deficit = cost - this.tokens; + const retryAfterMs = Math.ceil((deficit / this.refillRatePerSecond) * 1_000); + return { allowed: false, remaining: this.tokens, retryAfterMs }; + } + + /** Tokens available after a refill (floored for header display). */ + getRemaining(): number { + return Math.floor(this.refill()); + } + + getCapacity(): number { + return this.capacity; + } + + getRefillRatePerSecond(): number { + return this.refillRatePerSecond; + } + + /** Replace capacity / refill rate while clamping current tokens. */ + reconfigure(config: Partial): void { + this.refill(); + if (config.capacity !== undefined) { + if (config.capacity <= 0) throw new Error('TokenBucket capacity must be > 0'); + this.capacity = config.capacity; + this.tokens = Math.min(this.tokens, config.capacity); + } + if (config.refillRatePerSecond !== undefined) { + if (config.refillRatePerSecond <= 0) { + throw new Error('TokenBucket refillRatePerSecond must be > 0'); + } + this.refillRatePerSecond = config.refillRatePerSecond; + } + } + + /** Force-set token count (used by tests and migration from legacy state). */ + setTokens(tokens: number): void { + this.tokens = Math.max(0, Math.min(this.capacity, tokens)); + this.lastRefillAt = this.now(); + } + + snapshot(): TokenBucketSnapshot { + this.refill(); + return { + tokens: this.tokens, + capacity: this.capacity, + refillRatePerSecond: this.refillRatePerSecond, + lastRefillAt: this.lastRefillAt, + }; + } +} + +/** + * Derive a sustained refill rate (tokens/sec) from an hourly request limit. + * Floors at a small epsilon so empty buckets always recover. + */ +export function refillRateFromHourlyLimit(hourlyLimit: number): number { + return Math.max(hourlyLimit / 3_600, 0.001); +} diff --git a/developer-portal/docs/rate-limiting.md b/developer-portal/docs/rate-limiting.md new file mode 100644 index 00000000..7cde98cc --- /dev/null +++ b/developer-portal/docs/rate-limiting.md @@ -0,0 +1,80 @@ +# Rate Limiting (Developer Portal) + +SubTrackr enforces API rate limits with a **token bucket** algorithm and tier-based quotas (**free** / **pro** / **enterprise**). + +## Quick reference + +| Tier | Hourly | Daily | Burst | Refill rate | +|------|-------:|------:|------:|------------:| +| Free | 100 | 500 | 20 | 1 token/s | +| Pro | 1,000 | 10,000 | 100 | 5 tokens/s | +| Enterprise | 10,000 | 100,000 | 500 | 20 tokens/s | + +Subscription mapping: `free` → free, `basic`/`premium` → pro, `enterprise` → enterprise. + +## Response headers + +Every non-bypassed response includes: + +- `X-RateLimit-Limit` — hourly limit +- `X-RateLimit-Remaining` — remaining hourly quota +- `X-RateLimit-Reset` — Unix timestamp (seconds) when the hourly window resets +- `X-RateLimit-Policy` — e.g. `pro;hourly=1000;daily=10000` +- `Retry-After` — present on `429` responses (seconds) + +## 429 Too Many Requests + +```json +{ + "status": 429, + "error": "rate_limit_exceeded", + "message": "Rate limit exceeded. Retry after 12 seconds.", + "retryAfter": 12, + "limit": 100, + "remaining": 0, + "resetAt": 1722038400000 +} +``` + +Wait for `Retry-After` (add jitter) before retrying. + +## Trusted-client bypass + +Internal service accounts can skip rate limiting: + +```http +POST /rate-limits/bypass +Content-Type: application/json + +{ "type": "key", "value": "sk_internal_...", "action": "add" } +``` + +Configure the same from **Developer Portal → API Keys → Rate Limits → Bypass**. + +## Configuration UI + +**Developer Portal → API Keys → Configure → Rate Limits** lets you: + +- View live remaining quota (token bucket + windows) +- Override hourly / daily / monthly / burst / concurrent limits per key +- Toggle bypass for trusted keys + +## Analytics dashboard + +**Developer Portal → Rate Limits** shows: + +- Overall hit rate and 429 counts +- Throttle rate by tier +- Top throttled API keys and endpoints + +API: `GET /rate-limits/analytics` + +## How the token bucket works + +1. Each key starts with `burstLimit` tokens. +2. Tokens refill continuously at the tier refill rate (not a fixed window). +3. Each request costs one token. +4. When tokens are exhausted, the API returns `429` until refill catches up. +5. Hourly / daily / monthly counters remain as hard caps above the bucket. + +Full reference: [`docs/rate-limiting.md`](../../docs/rate-limiting.md). diff --git a/developer-portal/pages/DocumentationPage.tsx b/developer-portal/pages/DocumentationPage.tsx index 2d412b07..4e0b870f 100644 --- a/developer-portal/pages/DocumentationPage.tsx +++ b/developer-portal/pages/DocumentationPage.tsx @@ -49,13 +49,20 @@ Always use the sandbox URL during development and testing.`, id: 'rate-limits', title: 'Rate Limits', icon: '⚡', - content: `Free tier: 30 requests/minute, 5,000/day -Pro tier: 120 requests/minute, 50,000/day -Enterprise: 300 requests/minute, 200,000/day + content: `SubTrackr uses a token-bucket algorithm with free / pro / enterprise tiers. -Rate limit headers are included in every response: +Free: 100 requests/hour, burst 20 (1 token/s) +Pro: 1,000 requests/hour, burst 100 (5 tokens/s) +Enterprise: 10,000 requests/hour, burst 500 (20 tokens/s) + +Rate limit headers on every response: +- X-RateLimit-Limit - X-RateLimit-Remaining -- X-RateLimit-Reset`, +- X-RateLimit-Reset +- X-RateLimit-Policy + +HTTP 429 is returned when limits are exceeded (Retry-After included). +See docs/rate-limiting.md for bypass, analytics, and custom limits.`, }, { id: 'errors', diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md index 3d2d044b..f3049c0e 100644 --- a/docs/rate-limiting.md +++ b/docs/rate-limiting.md @@ -23,26 +23,37 @@ SubTrackr's API enforces rate limits to ensure fair usage and platform stability ## Overview -Rate limiting protects the SubTrackr API from abusive traffic patterns while guaranteeing capacity for all users. Limits operate on three complementary windows: +Rate limiting protects the SubTrackr API from abusive traffic patterns while guaranteeing capacity for all users. The primary algorithm is a **token bucket**: -| Window | Description | -|--------|-------------| -| **Hourly** | Rolling 60-minute window | -| **Daily** | Rolling 24-hour window | -| **Monthly** | Rolling 30-day window | +| Mechanism | Description | +|-----------|-------------| +| **Token bucket** | Continuous refill at a tier-specific rate up to burst capacity | +| **Hourly / daily / monthly caps** | Hard quota ceilings on top of the bucket | +| **Concurrency limit** | Caps simultaneous in-flight requests per key | -In addition, a **burst token bucket** smooths short-lived traffic spikes, and a **concurrency limit** caps simultaneous in-flight requests per key. +Public client tiers are **free**, **pro**, and **enterprise** (subscription `basic`/`premium` both map to **pro**). --- ## Rate Limit Tiers -| Tier | Hourly | Daily | Monthly | Burst | Concurrent | -|------|-------:|------:|--------:|------:|-----------:| -| **Free** | 100 | 500 | 10,000 | 20 | 2 | -| **Basic** | 500 | 2,500 | 50,000 | 50 | 5 | -| **Premium** | 1,000 | 10,000 | 200,000 | 100 | 10 | -| **Enterprise** | 10,000 | 100,000 | 2,000,000 | 500 | 50 | +### Public tiers (free / pro / enterprise) + +| Tier | Hourly | Daily | Monthly | Burst | Refill (tokens/s) | Concurrent | +|------|-------:|------:|--------:|------:|------------------:|-----------:| +| **Free** | 100 | 500 | 10,000 | 20 | 1 | 2 | +| **Pro** | 1,000 | 10,000 | 200,000 | 100 | 5 | 10 | +| **Enterprise** | 10,000 | 100,000 | 2,000,000 | 500 | 20 | 50 | + +### Subscription tier mapping + +| Subscription | Rate-limit tier | +|--------------|-----------------| +| `free` | free | +| `basic`, `premium` | pro | +| `enterprise` | enterprise | + +Fine-grained per-subscription defaults (including `basic`) remain available via `TIER_RATE_LIMITS` for billing alignment. > **Note:** Tier limits are defaults. Per-key custom limits and bypass overrides take precedence. See [Custom Limits per API Key](#custom-limits-per-api-key). @@ -50,13 +61,19 @@ In addition, a **burst token bucket** smooths short-lived traffic spikes, and a ## How Rate Limits Work -### Token bucket (burst) +### Token bucket (primary) + +Each API key has a token bucket with: + +- **Capacity** = burst limit for the tier +- **Refill rate** = tier-specific tokens per second (continuous, not fixed-window) +- **Cost** = 1 token per request -Each API key has a token bucket refilled at **1 token per second** up to the burst limit. A request consumes one token. If the bucket is empty the request is rejected even if the hourly window has remaining capacity. +A request is allowed only when at least one token is available. If the bucket is empty the API returns `429` with `Retry-After` derived from the deficit and refill rate. Short traffic spikes are absorbed up to burst capacity while the sustained rate stays bounded by refill. -### Window counters +### Window counters (hard caps) -Three independent sliding-window counters track requests per hour, per day, and per month. The most restrictive limit that has been exhausted determines the `Retry-After` value. +Independent hourly, daily, and monthly counters enforce absolute quotas. Exhausting any window rejects the request even if the token bucket still has tokens. The most restrictive exhausted window determines `Retry-After`. ### Concurrency @@ -412,6 +429,7 @@ Set custom rate limits for a specific API key. | `limits.monthlyLimit` | number | No | Override monthly request limit | | `limits.burstLimit` | number | No | Override burst token bucket size | | `limits.concurrentLimit` | number | No | Override max concurrent requests | +| `limits.refillRatePerSecond` | number | No | Override token-bucket refill rate | --- diff --git a/src/types/rateLimiting.ts b/src/types/rateLimiting.ts index 3d0449f0..be63798a 100644 --- a/src/types/rateLimiting.ts +++ b/src/types/rateLimiting.ts @@ -1,5 +1,15 @@ import { SubscriptionTier } from './subscription'; +/** + * Public rate-limit tiers exposed to clients and the developer portal. + * Maps onto SubscriptionTier via {@link mapSubscriptionToRateLimitTier}. + */ +export enum RateLimitTier { + FREE = 'free', + PRO = 'pro', + ENTERPRISE = 'enterprise', +} + export interface TierRateLimit { tier: SubscriptionTier; hourlyLimit: number; @@ -7,6 +17,18 @@ export interface TierRateLimit { monthlyLimit: number; burstLimit: number; concurrentLimit: number; + /** Sustained token-bucket refill rate (tokens per second). */ + refillRatePerSecond: number; +} + +export interface RateLimitTierConfig { + tier: RateLimitTier; + hourlyLimit: number; + dailyLimit: number; + monthlyLimit: number; + burstLimit: number; + concurrentLimit: number; + refillRatePerSecond: number; } export interface ApiKeyUsage { @@ -83,6 +105,8 @@ export const TIER_RATE_LIMITS: Record = { monthlyLimit: 10_000, burstLimit: 20, concurrentLimit: 2, + // 1 token/sec burst recovery (classic token bucket for short spikes) + refillRatePerSecond: 1, }, [SubscriptionTier.BASIC]: { tier: SubscriptionTier.BASIC, @@ -91,6 +115,7 @@ export const TIER_RATE_LIMITS: Record = { monthlyLimit: 50_000, burstLimit: 50, concurrentLimit: 5, + refillRatePerSecond: 2, }, [SubscriptionTier.PREMIUM]: { tier: SubscriptionTier.PREMIUM, @@ -99,6 +124,7 @@ export const TIER_RATE_LIMITS: Record = { monthlyLimit: 200_000, burstLimit: 100, concurrentLimit: 10, + refillRatePerSecond: 5, }, [SubscriptionTier.ENTERPRISE]: { tier: SubscriptionTier.ENTERPRISE, @@ -107,9 +133,61 @@ export const TIER_RATE_LIMITS: Record = { monthlyLimit: 2_000_000, burstLimit: 500, concurrentLimit: 50, + refillRatePerSecond: 20, }, }; +/** + * Client-facing free / pro / enterprise rate-limit tiers. + * BASIC and PREMIUM subscription tiers both map to PRO. + */ +export const RATE_LIMIT_TIER_CONFIG: Record = { + [RateLimitTier.FREE]: { + tier: RateLimitTier.FREE, + hourlyLimit: 100, + dailyLimit: 500, + monthlyLimit: 10_000, + burstLimit: 20, + concurrentLimit: 2, + refillRatePerSecond: 1, + }, + [RateLimitTier.PRO]: { + tier: RateLimitTier.PRO, + hourlyLimit: 1_000, + dailyLimit: 10_000, + monthlyLimit: 200_000, + burstLimit: 100, + concurrentLimit: 10, + refillRatePerSecond: 5, + }, + [RateLimitTier.ENTERPRISE]: { + tier: RateLimitTier.ENTERPRISE, + hourlyLimit: 10_000, + dailyLimit: 100_000, + monthlyLimit: 2_000_000, + burstLimit: 500, + concurrentLimit: 50, + refillRatePerSecond: 20, + }, +}; + +export function mapSubscriptionToRateLimitTier(tier: SubscriptionTier): RateLimitTier { + switch (tier) { + case SubscriptionTier.ENTERPRISE: + return RateLimitTier.ENTERPRISE; + case SubscriptionTier.BASIC: + case SubscriptionTier.PREMIUM: + return RateLimitTier.PRO; + case SubscriptionTier.FREE: + default: + return RateLimitTier.FREE; + } +} + +export function getRateLimitTierConfig(tier: RateLimitTier): RateLimitTierConfig { + return RATE_LIMIT_TIER_CONFIG[tier]; +} + export const SOFT_LIMIT_WARNINGS = [0.8, 0.95] as const; export const TIER_UPGRADE_THRESHOLDS: Record<