Skip to content
Merged
154 changes: 154 additions & 0 deletions src/ratelimit/concurrency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type { RateLimitPrincipal } from "./principal";

export interface ConcurrencyLimits {
perPrincipal: number;
global: number;
}

export interface ConcurrencyLimiterOptions {
maxTrackedPrincipals?: number;
}

export type ConcurrencyDenyReason = "global_limit" | "principal_limit" | "principal_capacity";

export type ConcurrencyReservation =
| {
accepted: true;
principalCount: number;
globalCount: number;
release(): void;
}
| {
accepted: false;
reason: ConcurrencyDenyReason;
principalCount: number;
globalCount: number;
retryAfterSeconds: number;
};

export interface ConcurrencyStats {
accepted: number;
deniedGlobal: number;
deniedPrincipal: number;
deniedPrincipalCapacity: number;
}

function validateLimit(value: number, name: string): number {
if (!Number.isInteger(value) || value < 1) throw new RangeError(`${name} must be a positive integer`);
return value;
}

function increment(value: number): number {
return value >= Number.MAX_SAFE_INTEGER ? Number.MAX_SAFE_INTEGER : value + 1;
}

/**
* Synchronous reservation gate for long-lived WebSocket connections.
*
* The caller reserves before completing the handshake, releases immediately when upgrade fails,
* and keeps the returned idempotent release function on the socket for all close/error paths.
*/
export class WebSocketConcurrencyLimiter {
private readonly counts = new Map<string, number>();
private globalCount = 0;
private generation = 0;
private readonly maxTrackedPrincipals: number;
private stats: ConcurrencyStats = {
accepted: 0,
deniedGlobal: 0,
deniedPrincipal: 0,
deniedPrincipalCapacity: 0,
};

constructor(options: ConcurrencyLimiterOptions = {}) {
this.maxTrackedPrincipals = validateLimit(
options.maxTrackedPrincipals ?? 10_000,
"maxTrackedPrincipals",
);
}

reserve(principal: RateLimitPrincipal, limitsInput: ConcurrencyLimits): ConcurrencyReservation {
const limits = {
perPrincipal: validateLimit(limitsInput.perPrincipal, "perPrincipal"),
global: validateLimit(limitsInput.global, "global"),
};
const currentPrincipal = this.counts.get(principal.fingerprint) ?? 0;

if (this.globalCount >= limits.global) {
this.stats.deniedGlobal = increment(this.stats.deniedGlobal);
return Object.freeze({
accepted: false,
reason: "global_limit",
principalCount: currentPrincipal,
globalCount: this.globalCount,
retryAfterSeconds: 1,
});
}
if (currentPrincipal >= limits.perPrincipal) {
this.stats.deniedPrincipal = increment(this.stats.deniedPrincipal);
return Object.freeze({
accepted: false,
reason: "principal_limit",
principalCount: currentPrincipal,
globalCount: this.globalCount,
retryAfterSeconds: 1,
});
}
if (currentPrincipal === 0 && this.counts.size >= this.maxTrackedPrincipals) {
this.stats.deniedPrincipalCapacity = increment(this.stats.deniedPrincipalCapacity);
return Object.freeze({
accepted: false,
reason: "principal_capacity",
principalCount: 0,
globalCount: this.globalCount,
retryAfterSeconds: 1,
});
}

const nextPrincipal = currentPrincipal + 1;
this.counts.set(principal.fingerprint, nextPrincipal);
this.globalCount += 1;
this.stats.accepted = increment(this.stats.accepted);
const generation = this.generation;
let released = false;

return Object.freeze({
accepted: true,
principalCount: nextPrincipal,
globalCount: this.globalCount,
release: () => {
if (released) return;
released = true;
if (generation !== this.generation) return;
const current = this.counts.get(principal.fingerprint) ?? 0;
if (current <= 1) this.counts.delete(principal.fingerprint);
else this.counts.set(principal.fingerprint, current - 1);
this.globalCount = Math.max(0, this.globalCount - 1);
},
});
}

snapshot(): Readonly<{
globalCount: number;
trackedPrincipals: number;
stats: Readonly<ConcurrencyStats>;
}> {
return Object.freeze({
globalCount: this.globalCount,
trackedPrincipals: this.counts.size,
stats: Object.freeze({ ...this.stats }),
});
}

reset(): void {
this.generation += 1;
this.counts.clear();
this.globalCount = 0;
this.stats = {
accepted: 0,
deniedGlobal: 0,
deniedPrincipal: 0,
deniedPrincipalCapacity: 0,
};
}
}
23 changes: 23 additions & 0 deletions src/ratelimit/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export {
PrincipalFingerprinter,
rateLimitFingerprinter,
type RateLimitPrincipal,
type RateLimitPrincipalKind,
} from "./principal";
export {
TokenBucketLimiter,
validateRateLimitPolicy,
type RateLimitDecision,
type RateLimitPolicy,
type RateLimitStatsRow,
type RateLimitSurface,
type TokenBucketLimiterOptions,
} from "./token-bucket";
export {
WebSocketConcurrencyLimiter,
type ConcurrencyDenyReason,
type ConcurrencyLimiterOptions,
type ConcurrencyLimits,
type ConcurrencyReservation,
type ConcurrencyStats,
} from "./concurrency";
101 changes: 101 additions & 0 deletions src/ratelimit/principal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { createHmac, randomBytes } from "node:crypto";

export type RateLimitPrincipalKind =
| "admission-key"
| "management"
| "remote-address"
| "anonymous";

const principalBrand: unique symbol = Symbol("opencodex.rate-limit-principal");

/**
* Opaque process-local principal. The private symbol prevents callers from passing a raw API key,
* account identifier, or arbitrary string where a keyed fingerprint is required.
*/
export interface RateLimitPrincipal {
readonly kind: RateLimitPrincipalKind;
/** Keyed, non-reversible process-local identity. Never persist or log in full. */
readonly fingerprint: string;
readonly [principalBrand]: true;
}

const DOMAIN = "opencodex/rate-limit/principal/v1";
const MIN_SECRET_BYTES = 32;
const MAX_PRINCIPAL_BYTES = 16 * 1024;

function byteLength(value: string): number {
return Buffer.byteLength(value, "utf8");
}

function validateSecret(secret: Uint8Array): Buffer {
if (secret.byteLength < MIN_SECRET_BYTES) {
throw new RangeError(`rate-limit fingerprint secret must be at least ${MIN_SECRET_BYTES} bytes`);
}
return Buffer.from(secret);
}

function validatePrincipalValue(value: string): string {
if (!value) throw new Error("rate-limit principal value must not be empty");
if (byteLength(value) > MAX_PRINCIPAL_BYTES) {
throw new RangeError(`rate-limit principal exceeds ${MAX_PRINCIPAL_BYTES} bytes`);
}
return value;
}

function createPrincipal(kind: RateLimitPrincipalKind, digest: string): RateLimitPrincipal {
return Object.freeze({
kind,
fingerprint: `${kind}:${digest}`,
[principalBrand]: true as const,
});
}

/**
* HMAC-based principal identity for in-memory rate-limit keys.
*
* The secret is process-local by default. Restarting or rotating it intentionally starts
* fresh buckets. Domain separation includes both the subsystem tag and principal kind, so
* identities cannot be correlated with fingerprints from another purpose or credential class.
*/
export class PrincipalFingerprinter {
private readonly secret: Buffer;

constructor(secret: Uint8Array = randomBytes(MIN_SECRET_BYTES)) {
this.secret = validateSecret(secret);
}

fingerprint(kind: Exclude<RateLimitPrincipalKind, "anonymous">, value: string): RateLimitPrincipal {
const normalized = validatePrincipalValue(value);
const digest = createHmac("sha256", this.secret)
.update(DOMAIN)
.update("\0")
.update(kind)
.update("\0")
.update(normalized)
.digest("base64url");
return createPrincipal(kind, digest);
}

admissionKey(value: string): RateLimitPrincipal {
return this.fingerprint("admission-key", value);
}

management(value: string): RateLimitPrincipal {
return this.fingerprint("management", value);
}

remoteAddress(value: string): RateLimitPrincipal {
return this.fingerprint("remote-address", value);
}

anonymous(): RateLimitPrincipal {
const digest = createHmac("sha256", this.secret)
.update(DOMAIN)
.update("\0anonymous\0shared")
.digest("base64url");
return createPrincipal("anonymous", digest);
}
}

/** Process-wide fingerprinter. Its secret is never exported. */
export const rateLimitFingerprinter = new PrincipalFingerprinter();
Loading
Loading