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
70 changes: 70 additions & 0 deletions docs/tiered-rate-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Tiered Rate Limits

> **Issue:** #389 — Tiered rate-limit policies driven by API key plan tier.

## Overview

API keys can now carry a **plan tier** (`free`, `pro`, or `enterprise`) that
determines the per-key rate-limit ceiling. This replaces the previous
flat-rate approach where every key shared the same `maxRequests` value.

## Default Tier Policies

| Tier | Max Requests / min | Window |
|------------- |-------------------:|--------|
| `free` | 100 | 60 s |
| `pro` | 500 | 60 s |
| `enterprise` | 5 000 | 60 s |

When a key has **no tier** (or the tier is not recognised), the limiter falls
back to the constructor-level defaults (typically the `free` ceiling) and emits
a `console.warn`.

## Database

Migration **0007** adds a `plan_tier` column to `api_keys`:

```sql
ALTER TABLE api_keys
ADD COLUMN plan_tier VARCHAR(20) NOT NULL DEFAULT 'free'
CHECK (plan_tier IN ('free', 'pro', 'enterprise'));
```

Rollback: `ALTER TABLE api_keys DROP COLUMN plan_tier;`

## How It Works

1. **Gateway middleware** (`gatewayApiKeyAuth.ts`) queries `ak.plan_tier` and
maps it to `apiKeyRecord.tier`. It also sets `res.locals.apiKeyTier` for
downstream handlers.

2. **Proxy & gateway routes** pass the tier into `rateLimiter.check(apiKey, tier)`.

3. **`StoreBackedRateLimiter.resolvePolicy(tier)`** looks up the
`TierPolicy` for the given tier. If the tier is unknown it logs a
warning and falls back to the constructor defaults.

## Custom Overrides

Pass a partial `tierPolicies` map when constructing a limiter to override
individual tiers:

```typescript
import { createRateLimiter } from './services/rateLimiter.js';

const limiter = createRateLimiter(100, 60_000, {
free: { maxRequests: 50, windowMs: 60_000 }, // tighter free tier
});
```

Or via `RateLimiterConfig.tierPolicies` when using `createConfiguredRateLimiter`.

## Testing

```bash
# Run all rate-limiter tests (existing + tiered)
npm test -- rateLimiter

# Run only the tiered suite
npm test -- rateLimiter.tiered.test.ts
```
1 change: 1 addition & 0 deletions migrations/0007_add_plan_tier_to_api_keys.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE api_keys DROP COLUMN plan_tier;
1 change: 1 addition & 0 deletions migrations/0007_add_plan_tier_to_api_keys.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE api_keys ADD COLUMN plan_tier VARCHAR(20) NOT NULL DEFAULT 'free' CHECK (plan_tier IN ('free', 'pro', 'enterprise'));
9 changes: 9 additions & 0 deletions src/middleware/gatewayApiKeyAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface GatewayApiKeyRecord {
rateLimitPerMinute?: number | null;
createdAt?: Date | string;
lastUsedAt?: Date | string | null;
tier?: string;
}

export interface GatewayAuthCandidate<
Expand Down Expand Up @@ -58,6 +59,7 @@ export interface InMemoryGatewayApiKey {
developerId: string;
apiId: string;
revoked?: boolean;
tier?: string;
}

export interface GatewayAuthQueryable {
Expand All @@ -75,6 +77,7 @@ export interface DatabaseGatewayApiKeyRow {
rate_limit_per_minute: number | null;
created_at: string | Date | null;
last_used_at: string | Date | null;
plan_tier: string | null;
user: Record<string, unknown> | null;
vault: Record<string, unknown> | null;
}
Expand Down Expand Up @@ -222,6 +225,9 @@ export function createGatewayApiKeyAuthMiddleware<
req.api = resolvedContext.api as Record<string, unknown>;
req.endpoint = resolvedContext.endpoint as Record<string, unknown>;

res.locals = res.locals || {};
res.locals.apiKeyTier = matchedCandidate.apiKeyRecord.tier;

next();
};
}
Expand Down Expand Up @@ -249,6 +255,7 @@ export function createMapBackedGatewayApiKeyAuthMiddleware<
prefix: rawKey.slice(0, API_KEY_PREFIX_LENGTH),
keyHash: sha256Hex(rawKey),
revoked: record.revoked ?? false,
tier: record.tier,
},
user: { id: record.developerId },
vault: null,
Expand Down Expand Up @@ -287,6 +294,7 @@ export function createDatabaseGatewayApiKeyAuthMiddleware<
ak.rate_limit_per_minute,
ak.created_at,
ak.last_used_at,
ak.plan_tier,
row_to_json(u) AS "user",
row_to_json(v) AS vault
FROM api_keys ak
Expand Down Expand Up @@ -318,6 +326,7 @@ export function createDatabaseGatewayApiKeyAuthMiddleware<
rateLimitPerMinute: row.rate_limit_per_minute,
createdAt: row.created_at ?? undefined,
lastUsedAt: row.last_used_at ?? undefined,
tier: row.plan_tier ?? undefined,
},
user: row.user ?? {},
vault: row.vault,
Expand Down
2 changes: 1 addition & 1 deletion src/routes/proxyRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function createProxyRouter(deps: ProxyDeps): Router {
}

// 3. Rate-limit check
const rateResult = await rateLimiter.check(apiKeyHeader);
const rateResult = await rateLimiter.check(apiKeyHeader, res.locals.apiKeyTier as string | undefined);
if (!rateResult.allowed) {
const retryAfterSec = Math.ceil((rateResult.retryAfterMs ?? 1000) / 1000);
res.set('Retry-After', String(retryAfterSec));
Expand Down
160 changes: 160 additions & 0 deletions src/services/rateLimiter.tiered.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Tiered rate-limit policy tests (issue #389).
*
* Verifies that StoreBackedRateLimiter / InMemoryRateLimiter correctly
* resolve per-tier ceilings (free/pro/enterprise), honour custom overrides,
* and fall back to the default policy for unknown tiers.
*/

import {
InMemoryRateLimiter,
DEFAULT_TIER_POLICIES,
type PlanTier,
type TierPolicy,
} from './rateLimiter.js';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Rapidly consume `n` tokens for a given key. */
async function consumeTokens(
limiter: InMemoryRateLimiter,
apiKey: string,
n: number,
tier?: string,
) {
for (let i = 0; i < n; i++) {
await limiter.check(apiKey, tier);
}
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('Tiered rate limiting', () => {
const DEFAULT_MAX = 10; // low ceiling for faster tests
const DEFAULT_WINDOW = 60_000;

// ── Default tier ceilings ──────────────────────────────────────────────────

it('uses the free-tier ceiling when tier="free"', async () => {
const limiter = new InMemoryRateLimiter(DEFAULT_MAX, DEFAULT_WINDOW);
const freeMax = DEFAULT_TIER_POLICIES.free.maxRequests;

await consumeTokens(limiter, 'key-free', freeMax, 'free');

const result = await limiter.check('key-free', 'free');
expect(result.allowed).toBe(false);
expect(result.retryAfterMs).toBeGreaterThan(0);
});

it('uses the pro-tier ceiling when tier="pro"', async () => {
const limiter = new InMemoryRateLimiter(DEFAULT_MAX, DEFAULT_WINDOW);
const proMax = DEFAULT_TIER_POLICIES.pro.maxRequests;

// Exhaust free ceiling should still leave room in pro
await consumeTokens(limiter, 'key-pro', DEFAULT_TIER_POLICIES.free.maxRequests, 'pro');

const result = await limiter.check('key-pro', 'pro');
expect(result.allowed).toBe(true);
});

it('uses the enterprise-tier ceiling when tier="enterprise"', async () => {
const limiter = new InMemoryRateLimiter(DEFAULT_MAX, DEFAULT_WINDOW);
const enterpriseMax = DEFAULT_TIER_POLICIES.enterprise.maxRequests;

// Pro ceiling reached, but enterprise still has room
await consumeTokens(limiter, 'key-ent', DEFAULT_TIER_POLICIES.pro.maxRequests, 'enterprise');

const result = await limiter.check('key-ent', 'enterprise');
expect(result.allowed).toBe(true);
});

// ── Default fallback (no tier) ─────────────────────────────────────────────

it('falls back to the constructor default when no tier is provided', async () => {
const limiter = new InMemoryRateLimiter(DEFAULT_MAX, DEFAULT_WINDOW);

await consumeTokens(limiter, 'key-none', DEFAULT_MAX);

const result = await limiter.check('key-none');
expect(result.allowed).toBe(false);
expect(result.retryAfterMs).toBeGreaterThan(0);
});

it('falls back to the constructor default for an unknown tier', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});

const limiter = new InMemoryRateLimiter(DEFAULT_MAX, DEFAULT_WINDOW);

await consumeTokens(limiter, 'key-unknown', DEFAULT_MAX, 'platinum');

const result = await limiter.check('key-unknown', 'platinum');
expect(result.allowed).toBe(false);

// A warning must have been emitted
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Unknown tier "platinum"'),
);

warnSpy.mockRestore();
});

// ── Custom overrides ───────────────────────────────────────────────────────

it('honours custom tier policy overrides', async () => {
const customPolicies: Partial<Record<PlanTier, TierPolicy>> = {
free: { maxRequests: 5, windowMs: 60_000 },
};

const limiter = new InMemoryRateLimiter(DEFAULT_MAX, DEFAULT_WINDOW, customPolicies);

await consumeTokens(limiter, 'key-custom', 5, 'free');

const result = await limiter.check('key-custom', 'free');
expect(result.allowed).toBe(false);
});

it('preserves non-overridden tiers when custom policies are provided', async () => {
const customPolicies: Partial<Record<PlanTier, TierPolicy>> = {
free: { maxRequests: 5, windowMs: 60_000 },
};

const limiter = new InMemoryRateLimiter(DEFAULT_MAX, DEFAULT_WINDOW, customPolicies);

// Pro should still use the default 500
await consumeTokens(limiter, 'key-pro-default', 100, 'pro');

const result = await limiter.check('key-pro-default', 'pro');
expect(result.allowed).toBe(true);
});

// ── Allowed response shape ─────────────────────────────────────────────────

it('returns the correct shape on allowed requests', async () => {
const limiter = new InMemoryRateLimiter(DEFAULT_MAX, DEFAULT_WINDOW);

const result = await limiter.check('key-shape', 'free');

expect(result.allowed).toBe(true);
// retryAfterMs should be 0 or absent when allowed
expect(result.retryAfterMs ?? 0).toBe(0);
});

// ── Buckets are per-key, not per-tier ──────────────────────────────────────

it('tracks rate-limit buckets per API key, not per tier', async () => {
const limiter = new InMemoryRateLimiter(DEFAULT_MAX, DEFAULT_WINDOW);

// Exhaust key-a under free
await consumeTokens(limiter, 'key-a', DEFAULT_TIER_POLICIES.free.maxRequests, 'free');
const resultA = await limiter.check('key-a', 'free');
expect(resultA.allowed).toBe(false);

// key-b under the same tier should still have tokens
const resultB = await limiter.check('key-b', 'free');
expect(resultB.allowed).toBe(true);
});
});
Loading
Loading