Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b0b2823
docs(ratelimit): define runtime activation contract
OnlineChef Aug 2, 2026
0a77ee0
feat(ratelimit): define complete runtime config defaults
OnlineChef Aug 2, 2026
fcbc9ba
feat(ratelimit): export runtime config contract
OnlineChef Aug 2, 2026
605592e
test(ratelimit): cover complete runtime config defaults
OnlineChef Aug 2, 2026
be44cb7
feat(ratelimit): add inert runtime admission coordinator
OnlineChef Aug 2, 2026
37f0dea
fix(ratelimit): align admission coordinator with concurrency contract
OnlineChef Aug 2, 2026
1ea5eb7
test(ratelimit): cover inert admission coordinator
OnlineChef Aug 2, 2026
0311f4b
fix(ratelimit): narrow WebSocket reservation unions in tests
OnlineChef Aug 2, 2026
e7c3d03
feat(ratelimit): add inert authenticated-principal boundary
OnlineChef Aug 2, 2026
775507c
test(ratelimit): cover accepted auth principal boundary
OnlineChef Aug 2, 2026
2a79f44
feat(ratelimit): add inert API-specific 429 renderers
OnlineChef Aug 2, 2026
1c39da8
fix(ratelimit): rely on canonical 429 error classification
OnlineChef Aug 2, 2026
00f5552
test(ratelimit): cover API-specific 429 envelopes
OnlineChef Aug 2, 2026
b3287be
fix(ratelimit): share live sideband route recognition
OnlineChef Aug 2, 2026
96e084c
test(ratelimit): cover canonical live sideband routes
OnlineChef Aug 2, 2026
3e07591
feat(ratelimit): add strict canonical config subschema
OnlineChef Aug 2, 2026
0e24ede
feat(ratelimit): export canonical config schema
OnlineChef Aug 2, 2026
c4c286d
test(ratelimit): cover strict canonical config schema
OnlineChef Aug 2, 2026
b40ad8f
chore: sync rate-limit wiring with current dev
OnlineChef Aug 2, 2026
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
109 changes: 109 additions & 0 deletions src/ratelimit/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { ConcurrencyLimits } from "./concurrency";
import type { RateLimitPolicy, RateLimitSurface } from "./token-bucket";

/** Canonical bounded surface order shared by config, runtime admission, metrics, and docs. */
export const RATE_LIMIT_SURFACES = Object.freeze([
"management",
"responses-http",
"responses-websocket",
"chat-completions",
"claude-messages",
"images",
"search",
"live",
"model-discovery",
] as const satisfies readonly RateLimitSurface[]);

export interface RateLimitWebSocketConfig extends ConcurrencyLimits {
maxTrackedPrincipals: number;
}

/**
* Persisted/user-facing shape after canonical schema validation.
*
* This module deliberately does not parse unknown input. `src/config.ts` remains the single
* Zod validation boundary; the resolver below only completes an already-validated value with
* fixed defaults for runtime use.
*/
export interface RateLimitConfigInput {
enabled?: boolean;
bypassLoopback?: boolean;
maxBuckets?: number;
staleAfterMs?: number;
policies?: Partial<Record<RateLimitSurface, RateLimitPolicy>>;
webSocket?: Partial<RateLimitWebSocketConfig>;
}

export interface ResolvedRateLimitConfig {
enabled: true;
bypassLoopback: boolean;
maxBuckets: number;
staleAfterMs: number;
policies: Readonly<Record<RateLimitSurface, Readonly<RateLimitPolicy>>>;
webSocket: Readonly<RateLimitWebSocketConfig>;
}

export const DEFAULT_RATE_LIMIT_MAX_BUCKETS = 10_000;
export const DEFAULT_RATE_LIMIT_STALE_AFTER_MS = 10 * 60_000;

/**
* Complete process-local defaults. Rate limiting remains disabled unless `enabled: true` is
* present, but once enabled no supported surface silently stays unlimited.
*/
export const DEFAULT_RATE_LIMIT_POLICIES: Readonly<Record<RateLimitSurface, Readonly<RateLimitPolicy>>> = Object.freeze({
management: Object.freeze({ requestsPerMinute: 240, burst: 60 }),
"responses-http": Object.freeze({ requestsPerMinute: 120, burst: 20 }),
"responses-websocket": Object.freeze({ requestsPerMinute: 60, burst: 10 }),
"chat-completions": Object.freeze({ requestsPerMinute: 120, burst: 20 }),
"claude-messages": Object.freeze({ requestsPerMinute: 120, burst: 20 }),
images: Object.freeze({ requestsPerMinute: 20, burst: 4 }),
search: Object.freeze({ requestsPerMinute: 60, burst: 10 }),
live: Object.freeze({ requestsPerMinute: 60, burst: 10 }),
"model-discovery": Object.freeze({ requestsPerMinute: 240, burst: 60 }),
});

export const DEFAULT_RATE_LIMIT_WEBSOCKET: Readonly<RateLimitWebSocketConfig> = Object.freeze({
perPrincipal: 4,
global: 64,
maxTrackedPrincipals: 10_000,
});

function resolvedPolicies(
overrides: RateLimitConfigInput["policies"],
): Readonly<Record<RateLimitSurface, Readonly<RateLimitPolicy>>> {
return Object.freeze({
management: Object.freeze({ ...DEFAULT_RATE_LIMIT_POLICIES.management, ...overrides?.management }),
"responses-http": Object.freeze({ ...DEFAULT_RATE_LIMIT_POLICIES["responses-http"], ...overrides?.["responses-http"] }),
"responses-websocket": Object.freeze({ ...DEFAULT_RATE_LIMIT_POLICIES["responses-websocket"], ...overrides?.["responses-websocket"] }),
"chat-completions": Object.freeze({ ...DEFAULT_RATE_LIMIT_POLICIES["chat-completions"], ...overrides?.["chat-completions"] }),
"claude-messages": Object.freeze({ ...DEFAULT_RATE_LIMIT_POLICIES["claude-messages"], ...overrides?.["claude-messages"] }),
images: Object.freeze({ ...DEFAULT_RATE_LIMIT_POLICIES.images, ...overrides?.images }),
search: Object.freeze({ ...DEFAULT_RATE_LIMIT_POLICIES.search, ...overrides?.search }),
live: Object.freeze({ ...DEFAULT_RATE_LIMIT_POLICIES.live, ...overrides?.live }),
"model-discovery": Object.freeze({ ...DEFAULT_RATE_LIMIT_POLICIES["model-discovery"], ...overrides?.["model-discovery"] }),
});
}

/**
* Complete an already-schema-validated config for one process lifetime.
*
* Absent/disabled input returns null so existing runtime behavior remains byte-compatible. The
* returned value is detached and deeply frozen: callers cannot mutate defaults, persisted config,
* or another server instance through shared nested objects.
*/
export function resolveValidatedRateLimitConfig(
config: Readonly<RateLimitConfigInput> | undefined,
): Readonly<ResolvedRateLimitConfig> | null {
if (config?.enabled !== true) return null;
return Object.freeze({
enabled: true as const,
bypassLoopback: config.bypassLoopback === true,
maxBuckets: config.maxBuckets ?? DEFAULT_RATE_LIMIT_MAX_BUCKETS,
staleAfterMs: config.staleAfterMs ?? DEFAULT_RATE_LIMIT_STALE_AFTER_MS,
policies: resolvedPolicies(config.policies),
webSocket: Object.freeze({
...DEFAULT_RATE_LIMIT_WEBSOCKET,
...config.webSocket,
}),
});
}
22 changes: 22 additions & 0 deletions src/ratelimit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,25 @@ export {
type ConcurrencyReservation,
type ConcurrencyStats,
} from "./concurrency";
export {
DEFAULT_RATE_LIMIT_MAX_BUCKETS,
DEFAULT_RATE_LIMIT_POLICIES,
DEFAULT_RATE_LIMIT_STALE_AFTER_MS,
DEFAULT_RATE_LIMIT_WEBSOCKET,
RATE_LIMIT_SURFACES,
resolveValidatedRateLimitConfig,
type RateLimitConfigInput,
type RateLimitWebSocketConfig,
type ResolvedRateLimitConfig,
} from "./config";
export {
MAX_RATE_LIMIT_BUCKETS,
MAX_RATE_LIMIT_BURST,
MAX_RATE_LIMIT_REQUESTS_PER_MINUTE,
MAX_RATE_LIMIT_STALE_AFTER_MS,
MAX_RATE_LIMIT_TRACKED_PRINCIPALS,
MAX_RATE_LIMIT_WEBSOCKET_CONCURRENCY,
MIN_RATE_LIMIT_STALE_AFTER_MS,
rateLimitConfigSchema,
rateLimitPolicySchema,
} from "./schema";
61 changes: 61 additions & 0 deletions src/ratelimit/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as z from "zod/v4";
import type { RateLimitConfigInput } from "./config";

export const MAX_RATE_LIMIT_REQUESTS_PER_MINUTE = 1_000_000;
export const MAX_RATE_LIMIT_BURST = 100_000;
export const MAX_RATE_LIMIT_BUCKETS = 1_000_000;
export const MIN_RATE_LIMIT_STALE_AFTER_MS = 1_000;
export const MAX_RATE_LIMIT_STALE_AFTER_MS = 24 * 60 * 60_000;
export const MAX_RATE_LIMIT_WEBSOCKET_CONCURRENCY = 100_000;
export const MAX_RATE_LIMIT_TRACKED_PRINCIPALS = 1_000_000;

export const rateLimitPolicySchema = z.object({
requestsPerMinute: z.number().finite().positive().max(MAX_RATE_LIMIT_REQUESTS_PER_MINUTE),
burst: z.number().int().positive().max(MAX_RATE_LIMIT_BURST),
}).strict();

const rateLimitPoliciesSchema = z.object({
management: rateLimitPolicySchema.optional(),
"responses-http": rateLimitPolicySchema.optional(),
"responses-websocket": rateLimitPolicySchema.optional(),
"chat-completions": rateLimitPolicySchema.optional(),
"claude-messages": rateLimitPolicySchema.optional(),
images: rateLimitPolicySchema.optional(),
search: rateLimitPolicySchema.optional(),
live: rateLimitPolicySchema.optional(),
"model-discovery": rateLimitPolicySchema.optional(),
}).strict();

const rateLimitWebSocketSchema = z.object({
perPrincipal: z.number().int().positive().max(MAX_RATE_LIMIT_WEBSOCKET_CONCURRENCY).optional(),
global: z.number().int().positive().max(MAX_RATE_LIMIT_WEBSOCKET_CONCURRENCY).optional(),
maxTrackedPrincipals: z.number().int().positive().max(MAX_RATE_LIMIT_TRACKED_PRINCIPALS).optional(),
}).strict().superRefine((value, ctx) => {
if (value.perPrincipal !== undefined && value.global !== undefined && value.perPrincipal > value.global) {
ctx.addIssue({
code: "custom",
path: ["perPrincipal"],
message: "perPrincipal must not exceed global",
});
}
});

/**
* Canonical persisted `rateLimit` subschema.
*
* The root config intentionally remains passthrough for historic compatibility, so this nested
* object must be strict: unknown surfaces or runtime-only fields are rejected instead of silently
* surviving load/save. Complete fixed defaults are applied later by resolveValidatedRateLimitConfig.
*/
export const rateLimitConfigSchema: z.ZodType<RateLimitConfigInput> = z.object({
enabled: z.boolean().optional(),
bypassLoopback: z.boolean().optional(),
maxBuckets: z.number().int().positive().max(MAX_RATE_LIMIT_BUCKETS).optional(),
staleAfterMs: z.number()
.int()
.min(MIN_RATE_LIMIT_STALE_AFTER_MS)
.max(MAX_RATE_LIMIT_STALE_AFTER_MS)
.optional(),
policies: rateLimitPoliciesSchema.optional(),
webSocket: rateLimitWebSocketSchema.optional(),
}).strict();
180 changes: 180 additions & 0 deletions src/server/rate-limit-admission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import {
TokenBucketLimiter,
WebSocketConcurrencyLimiter,
type ConcurrencyReservation,
type ConcurrencyStats,
type RateLimitDecision,
type RateLimitPrincipal,
type RateLimitStatsRow,
type RateLimitSurface,
type ResolvedRateLimitConfig,
} from "../ratelimit";
import { parseLiveSidebandTarget } from "./live";

export interface RateLimitRouteInput {
method: string;
pathname: string;
searchParams?: URLSearchParams;
webSocket?: boolean;
}

export type RuntimeAdmissionDecision =
| { outcome: "disabled" | "bypassed"; surface: RateLimitSurface }
| { outcome: "allowed" | "denied"; surface: RateLimitSurface; decision: RateLimitDecision };

export type RuntimeWebSocketReservation =
| {
outcome: "disabled" | "bypassed";
accepted: true;
principalCount: 0;
globalCount: 0;
release: () => void;
}
| ({ outcome: "allowed" | "denied" } & ConcurrencyReservation);

export interface RuntimeAdmissionSnapshot {
rateLimits: readonly RateLimitStatsRow[];
buckets: Readonly<{ principals: number; overflowSurfaces: number }>;
webSocket: Readonly<{
globalCount: number;
trackedPrincipals: number;
stats: Readonly<ConcurrencyStats>;
}>;
}

const NOOP_RELEASE = (): void => {};

function isSupportedLiveWebSocketRoute(input: Readonly<RateLimitRouteInput>): boolean {
if (input.method.toUpperCase() !== "GET") return false;
try {
return parseLiveSidebandTarget(
input.pathname,
input.searchParams ?? new URLSearchParams(),
) !== null;
} catch {
// Malformed percent-encoding or another invalid route remains unclassified. The live
// dispatcher applies the same parser later; admission must never broaden support.
return false;
}
}

/** Exact stable route-to-surface mapping. Unknown routes are intentionally not charged. */
export function rateLimitSurfaceForRequest(input: Readonly<RateLimitRouteInput>): RateLimitSurface | null {
const method = input.method.toUpperCase();
const path = input.pathname;

if (path === "/metrics" || path === "/api" || path.startsWith("/api/")) return "management";
if (method === "GET" && path === "/v1/models") return "model-discovery";

if (input.webSocket === true) {
if (method === "GET" && path === "/v1/responses") return "responses-websocket";
if (isSupportedLiveWebSocketRoute(input)) return "live";
return null;
}

if (method !== "POST") return null;
if (path === "/v1/responses" || path === "/v1/responses/compact") return "responses-http";
if (path === "/v1/chat/completions") return "chat-completions";
if (path === "/v1/messages" || path === "/v1/messages/count_tokens") return "claude-messages";
if (path === "/v1/images/generations" || path === "/v1/images/edits") return "images";
if (path === "/v1/alpha/search") return "search";
if (path === "/v1/live" || path === "/v1/realtime/calls") return "live";
return null;
}

export function rateLimitHeaders(decision: Readonly<RateLimitDecision>): Readonly<Record<string, string>> {
const headers: Record<string, string> = {
"X-RateLimit-Limit": String(Math.max(0, Math.trunc(decision.limit))),
"X-RateLimit-Remaining": String(Math.max(0, Math.trunc(decision.remaining))),
"X-RateLimit-Reset": String(Math.max(0, Math.trunc(decision.resetAfterSeconds))),
};
if (!decision.allowed) {
headers["Retry-After"] = String(Math.max(1, Math.trunc(decision.retryAfterSeconds)));
}
return Object.freeze(headers);
}

/**
* One process-local admission boundary. It owns all mutable limiter state but receives only an
* opaque principal and fixed route surface; raw credentials, addresses, origins, and request
* metadata are outside this contract.
*/
export class RuntimeRateLimitAdmission {
private readonly tokenBuckets: TokenBucketLimiter | null;
private readonly webSockets: WebSocketConcurrencyLimiter | null;

constructor(private readonly config: Readonly<ResolvedRateLimitConfig> | null) {
this.tokenBuckets = config
? new TokenBucketLimiter({ maxBuckets: config.maxBuckets, staleAfterMs: config.staleAfterMs })
: null;
this.webSockets = config
? new WebSocketConcurrencyLimiter({ maxTrackedPrincipals: config.webSocket.maxTrackedPrincipals })
: null;
}

admit(
surface: RateLimitSurface,
principal: RateLimitPrincipal,
options: Readonly<{ isLoopback: boolean }> = { isLoopback: false },
): RuntimeAdmissionDecision {
if (!this.config || !this.tokenBuckets) return { outcome: "disabled", surface };
if (this.config.bypassLoopback && options.isLoopback) return { outcome: "bypassed", surface };
const decision = this.tokenBuckets.consume(surface, principal, this.config.policies[surface]);
return {
outcome: decision.allowed ? "allowed" : "denied",
surface,
decision,
};
}

reserveWebSocket(
principal: RateLimitPrincipal,
options: Readonly<{ isLoopback: boolean }> = { isLoopback: false },
): RuntimeWebSocketReservation {
if (!this.config || !this.webSockets) {
return {
outcome: "disabled",
accepted: true,
principalCount: 0,
globalCount: 0,
release: NOOP_RELEASE,
};
}
if (this.config.bypassLoopback && options.isLoopback) {
return {
outcome: "bypassed",
accepted: true,
principalCount: 0,
globalCount: 0,
release: NOOP_RELEASE,
};
}
const reservation = this.webSockets.reserve(principal, this.config.webSocket);
return {
outcome: reservation.accepted ? "allowed" : "denied",
...reservation,
};
}

snapshotForTests(): Readonly<RuntimeAdmissionSnapshot> {
return Object.freeze({
rateLimits: this.tokenBuckets?.statsSnapshot() ?? Object.freeze([]),
buckets: this.tokenBuckets?.bucketCounts() ?? Object.freeze({ principals: 0, overflowSurfaces: 0 }),
webSocket: this.webSockets?.snapshot() ?? Object.freeze({
globalCount: 0,
trackedPrincipals: 0,
stats: Object.freeze({
accepted: 0,
deniedGlobal: 0,
deniedPrincipal: 0,
deniedPrincipalCapacity: 0,
}),
}),
});
}

resetForTests(): void {
this.tokenBuckets?.reset();
this.webSockets?.reset();
}
}
Loading
Loading