Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user