# Throttle Library Reusable token-bucket throttle middleware for Express. Lives in `@humand-packages/common` and is framework-agnostic in terms of infrastructure — the caller injects Redis, logger, and metrics. ## API ### `parseRules(yamlString: string): { rules: ThrottleRule[]; errors: string[] }` Parses a YAML string into throttle rules. Invalid individual entries are discarded with a descriptive error; valid entries are kept. Returns both the parsed rules and any errors encountered. ### `compileRules(rules: ThrottleRule[]): CompiledRule[]` Pre-compiles glob patterns into RegExp and prepares bucket key templates. Call once at startup, pass the result to the middleware factory. ### `createThrottleMiddleware(opts: ThrottleOptions): RequestHandler` Returns an Express middleware that applies token-bucket throttling. ```typescript interface ThrottleOptions { redis: Redis; // ioredis instance loader: ThrottleRuleLoader; // supplies CompiledRule[] on middleware build requestSignature: (req: Request) => string[]; // returns one or more signatures per request logger: LoggerPort; // from @humand-packages/common metrics: MetricRecorderPort; // from @humand-packages/common metricPrefix?: string; // default: 'throttle' — used as `${prefix}.allowed`, etc. baseMetricTags?: Record; // tags merged into every metric (e.g. `{ nodeType: 'APP' }`) shadowMode?: boolean; // default: false — log rejections but let requests through failOpen?: boolean; // default: true — let requests through on Redis errors clock?: () => number; // default: Date.now — injectable for testing } ``` **Multi-signature support.** `requestSignature` returns an array; a common pattern is to emit a long form (`instanceId:userId:path`) and a short form (`instanceId:userId`) so rules can target either granularity without needing a trailing `:*`. `matchRule` tries signatures shortest-first, so a rule like `*:*` with `bucketKey: "user:{0}:{1}"` always binds to the 2-segment form and produces one bucket per user, not per path. ### `ThrottleRuleLoader` (interface) ```typescript interface ThrottleRuleLoader { load(): CompiledRule[]; } ``` Abstraction for loading rules from any source. Implementations handle reading, parsing, validation, and compilation. ### `FileThrottleRuleLoader` Built-in implementation that loads rules from a local YAML file. ```typescript const loader = new FileThrottleRuleLoader('/path/to/rules.yaml', logger); const rules = loader.load(); // CompiledRule[] ``` Behavior: - Reads file synchronously at call time. - Validates each entry with zod; discards invalid entries and logs warnings. - Warns if no catch-all rule is found. - Returns `[]` if the file is missing or has no valid rules (logs error/warn). ### Helper functions (also exported) | Function | Purpose | |---|---| | `globToRegex(pattern)` | Converts a simplified glob pattern (`*` only) into a RegExp with capture groups | | `matchRule(signatures, rules)` | Sorts the given signatures shortest-first, then returns the first matching rule and the resolved bucket key, or `null` | | `resolveBucketKey(template, match)` | Substitutes `{0}`, `{1}`, ... in a template with regex match groups | ## Rule format Rules are defined as a YAML array. Each rule configures a token bucket: | Field | Type | Required | Description | |---|---|---|---| | `pattern` | string | yes | Glob pattern (`*` = wildcard capture group) | | `burst` | int >= 0 | yes | Token bucket capacity — max requests allowed in a burst | | `refill` | int >= 0 | yes | Token bucket refill rate — tokens added per second | | `bucketKey` | string | no | Redis key template with `{N}` placeholders | Validation rules (enforced by zod): - `burst >= 0`, `refill >= 0`. - `refill = 0` is only valid when `burst = 0` (a hard block). Any other combination is rejected at parse time to avoid dividing by zero in the Lua script. Rules are evaluated in order — **first match wins**. The library does not sort or prioritize by specificity; rule order in the YAML is authoritative. ## Usage example ```typescript import { createThrottleMiddleware, FileThrottleRuleLoader, LoggerPort, MetricRecorderPort, } from '@humand-packages/common'; import Redis from 'ioredis'; const logger: LoggerPort = /* your logger */; const metrics: MetricRecorderPort = /* your metrics recorder */; const redis = new Redis({ host: 'localhost', db: 3 }); const loader = new FileThrottleRuleLoader('./config/throttle-rules.yaml', logger); app.use(createThrottleMiddleware({ redis, loader, requestSignature: (req) => { const instanceId = req.headers['x-instance-id'] ?? 'unknown'; const userId = req.headers['x-user-id'] ?? 'unknown'; // Emit both long (path-specific) and short (user-level) signatures; // matchRule will try the shortest applicable one first. return [ `${instanceId}:${userId}:${req.path}`, `${instanceId}:${userId}`, ]; }, logger, metrics, metricPrefix: 'myapp.throttle', // emits myapp.throttle.allowed, .rejected, ... shadowMode: true, // log but don't block failOpen: true, // let through on Redis errors })); ``` ## Token bucket algorithm Implemented as an atomic Lua script executed via `ioredis` `EVAL`. A single Redis round-trip per request, no race conditions. Each bucket stores two values in a Redis hash: - `tokens`: current token count - `last_refill`: timestamp of the last refill On each request: 1. Calculate elapsed time since `last_refill`. 2. Add `elapsed * refill_rate` tokens (capped at `burst`). 3. If tokens >= 1, consume 1 and allow. Otherwise, reject. The hash key TTL is set to `ceil(burst / refill) + 1` seconds, so idle buckets auto-expire. Special case: `burst=0, refill=0` is a hard-block handled entirely in TypeScript (fast-path, no Redis call). ## Metrics All metric names are built by prefixing `opts.metricPrefix` (default `'throttle'`). The four metrics emitted are: | Metric | Per-call tags | When | |------------------------|------------------------|-----------------------------------| | `{prefix}.allowed` | `rule` | Request allowed | | `{prefix}.rejected` | `rule`, `shadow` | Rejected (or shadow-rejected) | | `{prefix}.no_match` | — | No rule matched | | `{prefix}.redis_error` | — | Redis error (fail-open path) | `baseMetricTags` (if provided) are merged into every emitted metric — typically used for cross-cutting dimensions like `nodeType`. The `shadow` tag is `"true"` or `"false"` and flags whether the rejection was real or only logged. ## Response headers Requests matching a rule get IETF draft-7 headers: - `RateLimit-Limit`: burst capacity - `RateLimit-Remaining`: tokens left after this request - `RateLimit-Reset`: seconds until the bucket is full again (not emitted on hard-block fast-path) Rejected requests (429) additionally get: - `Retry-After`: seconds until at least 1 token is available. Set to a fixed large value (86400) for hard-block rules since there is no retry that would succeed until the configuration changes. ## Extending with custom loaders Implement `ThrottleRuleLoader` to load rules from any source: ```typescript import { ThrottleRuleLoader, CompiledRule, parseRules, compileRules } from '@humand-packages/common'; class SSMThrottleRuleLoader implements ThrottleRuleLoader { constructor(private parameterName: string, private ssmClient: SSMClient) {} load(): CompiledRule[] { const yamlString = /* fetch from SSM */; const { rules, errors } = parseRules(yamlString); // handle errors... return compileRules(rules); } } ``` ## Dependencies - `ioredis` — Redis client (already a dependency of common) - `yaml` — YAML parser - `zod` — Schema validation