Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
# `ThrottlingMiddleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Token-bucket rate limiting backed by `@suaveplan/rate-limiter` for the network chain. Three independent layers — global, per-`senderId`, per-`MessageType` — combine conservatively: a message is allowed only when every applicable bucket has tokens. When any bucket refuses, the middleware either throws `THROTTLED` (default) so the manager's error policy decides, or returns `null` for a clean chain drop.
## Features
- Three configurable layers, each with its own `capacity` (burst) and `refillRate` (sustained tokens / sec).
- Per-peer buckets are created lazily on first sight so the cost is `O(active peers)` rather than `O(known peers)`.
- `onExceed: "throw"` / `"drop"` lets callers choose between loud-failure semantics (informational headers, retry policy) and silent suppression (audit-only deployments).
- Injectable clock — every internal `TokenBucket` shares the manager's clock so deterministic tests can pin the schedule exactly.
- Lives in HIGH band by default (`priority: 150`) so throttling runs after security/validation but before application-level transformation.
## Usage: Basic Example
```ts
import {
ThrottlingMiddleware,
MiddlewareManager,
} from "@suaveplan/network-plugin-middleware";
const manager = new MiddlewareManager();
manager.register(
new ThrottlingMiddleware({
global: { capacity: 10, refillRate: 5 },
})
);
await manager.dispatch(envelope, "outgoing");
```
## Usage: Advanced Example
```ts
import { ThrottlingMiddleware } from "@suaveplan/network-plugin-middleware";
let now = 0;
const throttler = new ThrottlingMiddleware({
id: "tiered-throttle",
priority: 160,
onExceed: "drop",
clock: () => now,
global: { capacity: 1000, refillRate: 500 },
perPeer: { capacity: 100, refillRate: 50 },
perType: {
"chunk:upload": { capacity: 20, refillRate: 5 },
"registry:add": { capacity: 5, refillRate: 1 },
},
});
```
## API Reference
- `class ThrottlingMiddleware implements Middleware`
- `constructor(config?: ThrottlingMiddlewareConfig)`.
- `apply(envelope, context): MessageEnvelope | null` — `null` only when `onExceed: "drop"`.
- `interface ThrottleLimit` — `{ refillRate, capacity }`.
- `interface ThrottlingMiddlewareConfig` — `{ id?, priority?, direction?, global?, perPeer?, perType?, onExceed?, clock? }`.
## Implementation Notes
- All three layers must allow the message — this is intentional: the most-restrictive bucket wins. Combining limits any other way (e.g. "any bucket allows") would let a misbehaving peer chew through the global budget unchecked.
- Per-peer buckets are not garbage-collected. For long-running processes with high peer churn, attach an external `peer:disconnected` listener and call `forget(senderId)` from a custom subclass that exposes the peer-bucket map; the default behaviour optimises for the common case where the peer set is bounded.
- `THROTTLED` carries `retryAfterSec` so callers (the dispatcher, a custom error responder) can communicate `Retry-After` semantics back to the sender.
- `onExceed: "drop"` returns `null` from `apply`, which the manager surfaces as `ChainResult.dropped: true` with `droppedBy: "throttler"` — observability gets the throttle event even when the failure is intentionally silent.

View File

@@ -0,0 +1,121 @@
import type { MessageEnvelope } from "@suaveplan/network";
import { TokenBucket } from "@suaveplan/rate-limiter";
import { NETWORK_MW_CODES, NetworkMwError } from "../error-codes.js";
import type {
Middleware,
MiddlewareContext,
} from "../types/middleware.types.js";
export interface ThrottleLimit {
/** Sustained tokens per second. */
readonly refillRate: number;
/** Bucket size — burst allowance. */
readonly capacity: number;
}
export interface ThrottlingMiddlewareConfig {
readonly id?: string;
readonly priority?: number;
readonly direction?: MiddlewareContext["direction"];
/** Limit applied to every message regardless of peer / type. */
readonly global?: ThrottleLimit;
/** Per-`senderId` limit; the same limit applies to each peer independently. */
readonly perPeer?: ThrottleLimit;
/** Per-`MessageType` limit map. */
readonly perType?: Readonly<Record<string, ThrottleLimit>>;
/** Action when any bucket refuses a token. Defaults to `"throw"`. */
readonly onExceed?: "throw" | "drop";
/** Injectable clock — wired into every TokenBucket for determinism. */
readonly clock?: () => number;
}
/**
* Token-bucket rate limiting via `@suaveplan/rate-limiter`. Three
* independent layers — global, per-peer, per-type — combine
* conservatively: a message is allowed only if every applicable
* bucket has tokens. When any bucket refuses, the middleware either
* throws `THROTTLED` (default) or returns `null` (chain drop).
*/
export class ThrottlingMiddleware implements Middleware {
public readonly id: string;
public readonly priority: number;
public readonly direction: MiddlewareContext["direction"] | undefined;
public enabled = true;
private readonly globalBucket: TokenBucket | null;
private readonly perPeerLimit: ThrottleLimit | undefined;
private readonly perPeerBuckets = new Map<string, TokenBucket>();
private readonly perTypeBuckets: Map<string, TokenBucket>;
private readonly onExceed: "throw" | "drop";
private readonly clock: () => number;
public constructor(config: ThrottlingMiddlewareConfig = {}) {
this.id = config.id ?? "throttler";
this.priority = config.priority ?? 150;
this.direction = config.direction;
this.onExceed = config.onExceed ?? "throw";
this.clock = config.clock ?? (() => Date.now());
this.globalBucket =
config.global !== undefined ? this.makeBucket(config.global) : null;
this.perPeerLimit = config.perPeer;
this.perTypeBuckets = new Map();
for (const [type, limit] of Object.entries(config.perType ?? {})) {
this.perTypeBuckets.set(type, this.makeBucket(limit));
}
}
public apply(
envelope: MessageEnvelope,
_context: MiddlewareContext
): MessageEnvelope | null {
const buckets = this.bucketsFor(envelope);
for (const bucket of buckets) {
const result = bucket.consume(1);
if (!result.allowed) {
// `retryAfter` is always set on `allowed: false` results
// by `@suaveplan/rate-limiter`'s `TokenBucket.consume`.
return this.refuse(envelope, result.retryAfter as number);
}
}
return envelope;
}
private bucketsFor(envelope: MessageEnvelope): TokenBucket[] {
const out: TokenBucket[] = [];
if (this.globalBucket !== null) out.push(this.globalBucket);
if (this.perPeerLimit !== undefined) {
let bucket = this.perPeerBuckets.get(envelope.senderId);
if (bucket === undefined) {
bucket = this.makeBucket(this.perPeerLimit);
this.perPeerBuckets.set(envelope.senderId, bucket);
}
out.push(bucket);
}
const typeBucket = this.perTypeBuckets.get(envelope.type);
if (typeBucket !== undefined) out.push(typeBucket);
return out;
}
private refuse(envelope: MessageEnvelope, retryAfterSec: number): null {
if (this.onExceed === "drop") return null;
throw new NetworkMwError(
NETWORK_MW_CODES.THROTTLED,
"throttling.exceeded",
{
context: {
messageId: envelope.id,
messageType: envelope.type,
senderId: envelope.senderId,
retryAfterSec,
},
}
);
}
private makeBucket(limit: ThrottleLimit): TokenBucket {
return new TokenBucket({
capacity: limit.capacity,
refillRate: limit.refillRate,
clock: this.clock,
});
}
}

View File

@@ -0,0 +1,117 @@
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_MW_CODES, type NetworkMwError } from "../error-codes.js";
import { ThrottlingMiddleware } from "./throttling-middleware.js";
const env = (overrides: Partial<Parameters<typeof createEnvelope>[0]> = {}) =>
createEnvelope({
type: "test",
senderId: "peer-1",
payload: {},
...overrides,
});
const ctx = {
direction: "outgoing" as const,
state: {} as Record<string, unknown>,
};
describe("ThrottlingMiddleware: global / per-peer / per-type / burst", () => {
it("throws THROTTLED past the global capacity", () => {
const now = 0;
const t = new ThrottlingMiddleware({
global: { capacity: 2, refillRate: 1 },
clock: () => now,
});
t.apply(env(), ctx);
t.apply(env(), ctx);
try {
t.apply(env(), ctx);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.THROTTLED
);
}
});
it("onExceed: drop returns null instead of throwing", () => {
const now = 0;
const t = new ThrottlingMiddleware({
global: { capacity: 1, refillRate: 1 },
clock: () => now,
onExceed: "drop",
});
t.apply(env(), ctx);
expect(t.apply(env(), ctx)).toBeNull();
});
it("per-peer buckets are independent across senderIds", () => {
const now = 0;
const t = new ThrottlingMiddleware({
perPeer: { capacity: 1, refillRate: 1 },
clock: () => now,
});
t.apply(env({ senderId: "peer-A" }), ctx);
t.apply(env({ senderId: "peer-B" }), ctx);
try {
t.apply(env({ senderId: "peer-A" }), ctx);
expect.fail("should have thrown");
} catch {
// expected
}
});
it("per-type bucket only triggers on matching messages", () => {
const now = 0;
const t = new ThrottlingMiddleware({
perType: { "rare:type": { capacity: 1, refillRate: 1 } },
clock: () => now,
});
t.apply(env({ type: "common", senderId: "peer-1" }), ctx);
t.apply(env({ type: "common", senderId: "peer-1" }), ctx);
t.apply(env({ type: "common", senderId: "peer-1" }), ctx);
t.apply(env({ type: "rare:type", senderId: "peer-1" }), ctx);
try {
t.apply(env({ type: "rare:type", senderId: "peer-1" }), ctx);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkMwError).code).toBe(
NETWORK_MW_CODES.THROTTLED
);
}
});
it("burst capacity is honoured up to the bucket size", () => {
const now = 0;
const t = new ThrottlingMiddleware({
global: { capacity: 5, refillRate: 1 },
clock: () => now,
});
for (let i = 0; i < 5; i += 1) {
expect(t.apply(env(), ctx)).not.toBeNull();
}
try {
t.apply(env(), ctx);
expect.fail("should have thrown");
} catch {
// expected
}
});
it("no-op when no limits configured", () => {
const t = new ThrottlingMiddleware();
for (let i = 0; i < 100; i += 1) {
expect(t.apply(env(), ctx)).not.toBeNull();
}
});
it("uses Date.now as the default clock", () => {
// Construct without an injected clock so the default arrow is
// wired into every TokenBucket and exercised on apply.
const t = new ThrottlingMiddleware({
global: { capacity: 5, refillRate: 1 },
});
expect(t.apply(env(), ctx)).not.toBeNull();
});
});