@zucker-framework/rate-limiter 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/index.d.mts +36 -1
- package/dist/index.d.ts +36 -1
- package/dist/index.js +162 -17
- package/dist/index.mjs +152 -17
- package/package.json +10 -4
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# @zucker-framework/rate-limiter
|
|
2
|
+
|
|
3
|
+
Configure `ZuckerRateLimiterModule` once at the application composition root. Feature services inject `RateLimiterService` and use named `consume`, `getRemaining`, `reset`, `penalty`, and `reward` operations. Limiter names, points, durations, key prefixes, combined quotas, and HTTP response policy belong to the consumer. `checkLimit(name, key, subjectLimit)` returns `{ allowed, remaining, resetAt }` using the same atomic counter with a per-subject cap. Configure that named limiter at the maximum supported cap and `blockDuration: 0`; window-key policy remains with the consumer, and denied requests advance the counter.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
ZuckerRateLimiterModule.forRoot({
|
|
7
|
+
globalGuard: false,
|
|
8
|
+
redis: { url: applicationRedisUrl, commandTimeoutMs: 500 },
|
|
9
|
+
operationDeadlineMs: 750,
|
|
10
|
+
circuitBreakerMs: 5000,
|
|
11
|
+
limiters: {
|
|
12
|
+
requests: { points: 30, duration: 60, blockDuration: 60, keyPrefix: 'requests:' },
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`forRootAsync` accepts the same options from a factory. Set `globalGuard: false` on its outer options when the consumer performs its own checks. `consume` keeps its existing `TooManyRequestsException` (HTTP 429) and `retryAfter` contract. Consumers may translate that exception to their established product response; connection errors are not quota responses.
|
|
18
|
+
|
|
19
|
+
`redis` creates and owns one optional `ioredis` connection, with offline queueing and command replay disabled. The `ioredis` peer is loaded only for managed Redis mode. Alternatively, `redisClient` supplies an externally owned client; the service observes and removes its own event handlers, but never connects, disconnects or reconfigures that client. These options are mutually exclusive. The owner of an external client must enforce command deadline/replay safety.
|
|
20
|
+
|
|
21
|
+
Each named limiter has a long-lived in-memory insurance limiter with the same points, duration and blocking policy. A Redis infrastructure error or operation deadline opens the circuit and uses the insurance limiter. Quota exhaustion from Redis remains a denial and is never retried against a fresh allowance. Consume, quota reads, administrative resets, penalty and reward all use bounded Redis operations. Reset clears the current backend and the insurance state. Logs include only bounded error codes, and `isDistributed()` reports current degradation.
|
|
22
|
+
|
|
23
|
+
Managed startup connection failure retains process-local limiting for that service instance. A connection that fails after successful startup can reconnect and restore distributed limiting. Shutdown is idempotent and disconnects a managed connection immediately without another Redis command. Module registration uses one service provider; its existing token alias does not create another service instance, and repeated lifecycle calls do not recreate limiters.
|
|
24
|
+
|
|
25
|
+
Insurance is intentionally per process and does not synchronize consumed points back to Redis. A timed-out distributed consume can already have reached Redis before its local insurance consume; quotas are conservative in that ambiguity. This is not a distributed safety guarantee for multiple independent server processes.
|
|
26
|
+
|
|
27
|
+
The API extension is additive. Existing named limiter keys and optional key HMAC remain unchanged. Consumers should pin the previous package version and restore their previous composition for rollback; no database migration is required. Regression source covers hanging commands, denied quotas, recovery, reset, redaction, client ownership and initialization failure. No tests or real Redis checks were run for this migration batch.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { OnModuleInit, OnModuleDestroy, HttpException, Type, DynamicModule, InjectionToken, OptionalFactoryDependency, CanActivate, ExecutionContext } from '@nestjs/common';
|
|
2
|
+
import Redis, { RedisOptions } from 'ioredis';
|
|
2
3
|
import { Reflector } from '@nestjs/core';
|
|
3
4
|
|
|
4
5
|
interface LimiterConfig {
|
|
@@ -11,8 +12,18 @@ interface LimiterConfig {
|
|
|
11
12
|
/** Key prefix for Redis storage */
|
|
12
13
|
keyPrefix?: string;
|
|
13
14
|
}
|
|
15
|
+
interface RateLimiterRedisOptions {
|
|
16
|
+
url: string;
|
|
17
|
+
commandTimeoutMs?: number;
|
|
18
|
+
createClient?: (url: string, options: RedisOptions) => Redis | Promise<Redis>;
|
|
19
|
+
}
|
|
14
20
|
interface RateLimiterModuleOptions {
|
|
21
|
+
/** Externally owned client: observed, but never disconnected by this service. */
|
|
15
22
|
redisClient?: unknown;
|
|
23
|
+
/** Internally owned Redis connection. Mutually exclusive with redisClient. */
|
|
24
|
+
redis?: RateLimiterRedisOptions;
|
|
25
|
+
operationDeadlineMs?: number;
|
|
26
|
+
circuitBreakerMs?: number;
|
|
16
27
|
/** Optional secret used to HMAC consumer keys before memory/Redis storage. */
|
|
17
28
|
keyHashSecret?: string;
|
|
18
29
|
/** Register the rate-limit guard globally. Set false when guard ordering matters. */
|
|
@@ -35,15 +46,39 @@ declare class RateLimiterService implements OnModuleInit, OnModuleDestroy {
|
|
|
35
46
|
private readonly options;
|
|
36
47
|
private readonly logger;
|
|
37
48
|
private readonly limiters;
|
|
49
|
+
private readonly insuranceLimiters;
|
|
38
50
|
private useRedis;
|
|
51
|
+
private redisClient;
|
|
52
|
+
private redisDegraded;
|
|
53
|
+
private redisRetryAt;
|
|
54
|
+
private redisResetPending;
|
|
55
|
+
private shuttingDown;
|
|
56
|
+
private initialization?;
|
|
57
|
+
private readonly onRedisError;
|
|
58
|
+
private readonly onRedisClose;
|
|
59
|
+
private readonly onRedisReady;
|
|
39
60
|
constructor(options: RateLimiterModuleOptions);
|
|
40
61
|
onModuleInit(): Promise<void>;
|
|
62
|
+
private initialize;
|
|
41
63
|
onModuleDestroy(): void;
|
|
64
|
+
private observeRedis;
|
|
65
|
+
private markRedisDegraded;
|
|
66
|
+
private markRedisRecovered;
|
|
67
|
+
private executeWithInsurance;
|
|
42
68
|
/**
|
|
43
69
|
* Consume points from a rate limiter.
|
|
44
70
|
* Throws TooManyRequestsException (429) when limit is exceeded.
|
|
45
71
|
*/
|
|
46
72
|
consume(limiterName: string, key: string, pointsToConsume?: number, errorMessage?: string): Promise<ConsumeResult>;
|
|
73
|
+
/** Atomic consumption with a per-subject cap, without allocating a limiter per subject.
|
|
74
|
+
* Configure the named limiter's points at the maximum supported cap and blockDuration: 0.
|
|
75
|
+
* Consumers own the subject/window key; rejected requests still advance the counter.
|
|
76
|
+
*/
|
|
77
|
+
checkLimit(limiterName: string, key: string, pointsLimit: number): Promise<{
|
|
78
|
+
allowed: boolean;
|
|
79
|
+
remaining: number;
|
|
80
|
+
resetAt: Date;
|
|
81
|
+
}>;
|
|
47
82
|
/**
|
|
48
83
|
* Apply a penalty by consuming extra points, extending the block time.
|
|
49
84
|
* Useful for failed login attempts, abuse detection, etc.
|
|
@@ -181,4 +216,4 @@ declare class DynamicLimiterManager {
|
|
|
181
216
|
private globToRegex;
|
|
182
217
|
}
|
|
183
218
|
|
|
184
|
-
export { type ConsumeResult, DynamicLimiterManager, type DynamicLimiterRule, type LimiterConfig, RATE_LIMIT_KEY, RateLimit, RateLimitGuard, type RateLimitOptions, type RateLimiterModuleAsyncOptions, type RateLimiterModuleOptions, type RateLimiterModuleOptionsFactory, RateLimiterService, TooManyRequestsException, ZuckerRateLimiterModule, deriveRateLimitKey };
|
|
219
|
+
export { type ConsumeResult, DynamicLimiterManager, type DynamicLimiterRule, type LimiterConfig, RATE_LIMIT_KEY, RateLimit, RateLimitGuard, type RateLimitOptions, type RateLimiterModuleAsyncOptions, type RateLimiterModuleOptions, type RateLimiterModuleOptionsFactory, type RateLimiterRedisOptions, RateLimiterService, TooManyRequestsException, ZuckerRateLimiterModule, deriveRateLimitKey };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { OnModuleInit, OnModuleDestroy, HttpException, Type, DynamicModule, InjectionToken, OptionalFactoryDependency, CanActivate, ExecutionContext } from '@nestjs/common';
|
|
2
|
+
import Redis, { RedisOptions } from 'ioredis';
|
|
2
3
|
import { Reflector } from '@nestjs/core';
|
|
3
4
|
|
|
4
5
|
interface LimiterConfig {
|
|
@@ -11,8 +12,18 @@ interface LimiterConfig {
|
|
|
11
12
|
/** Key prefix for Redis storage */
|
|
12
13
|
keyPrefix?: string;
|
|
13
14
|
}
|
|
15
|
+
interface RateLimiterRedisOptions {
|
|
16
|
+
url: string;
|
|
17
|
+
commandTimeoutMs?: number;
|
|
18
|
+
createClient?: (url: string, options: RedisOptions) => Redis | Promise<Redis>;
|
|
19
|
+
}
|
|
14
20
|
interface RateLimiterModuleOptions {
|
|
21
|
+
/** Externally owned client: observed, but never disconnected by this service. */
|
|
15
22
|
redisClient?: unknown;
|
|
23
|
+
/** Internally owned Redis connection. Mutually exclusive with redisClient. */
|
|
24
|
+
redis?: RateLimiterRedisOptions;
|
|
25
|
+
operationDeadlineMs?: number;
|
|
26
|
+
circuitBreakerMs?: number;
|
|
16
27
|
/** Optional secret used to HMAC consumer keys before memory/Redis storage. */
|
|
17
28
|
keyHashSecret?: string;
|
|
18
29
|
/** Register the rate-limit guard globally. Set false when guard ordering matters. */
|
|
@@ -35,15 +46,39 @@ declare class RateLimiterService implements OnModuleInit, OnModuleDestroy {
|
|
|
35
46
|
private readonly options;
|
|
36
47
|
private readonly logger;
|
|
37
48
|
private readonly limiters;
|
|
49
|
+
private readonly insuranceLimiters;
|
|
38
50
|
private useRedis;
|
|
51
|
+
private redisClient;
|
|
52
|
+
private redisDegraded;
|
|
53
|
+
private redisRetryAt;
|
|
54
|
+
private redisResetPending;
|
|
55
|
+
private shuttingDown;
|
|
56
|
+
private initialization?;
|
|
57
|
+
private readonly onRedisError;
|
|
58
|
+
private readonly onRedisClose;
|
|
59
|
+
private readonly onRedisReady;
|
|
39
60
|
constructor(options: RateLimiterModuleOptions);
|
|
40
61
|
onModuleInit(): Promise<void>;
|
|
62
|
+
private initialize;
|
|
41
63
|
onModuleDestroy(): void;
|
|
64
|
+
private observeRedis;
|
|
65
|
+
private markRedisDegraded;
|
|
66
|
+
private markRedisRecovered;
|
|
67
|
+
private executeWithInsurance;
|
|
42
68
|
/**
|
|
43
69
|
* Consume points from a rate limiter.
|
|
44
70
|
* Throws TooManyRequestsException (429) when limit is exceeded.
|
|
45
71
|
*/
|
|
46
72
|
consume(limiterName: string, key: string, pointsToConsume?: number, errorMessage?: string): Promise<ConsumeResult>;
|
|
73
|
+
/** Atomic consumption with a per-subject cap, without allocating a limiter per subject.
|
|
74
|
+
* Configure the named limiter's points at the maximum supported cap and blockDuration: 0.
|
|
75
|
+
* Consumers own the subject/window key; rejected requests still advance the counter.
|
|
76
|
+
*/
|
|
77
|
+
checkLimit(limiterName: string, key: string, pointsLimit: number): Promise<{
|
|
78
|
+
allowed: boolean;
|
|
79
|
+
remaining: number;
|
|
80
|
+
resetAt: Date;
|
|
81
|
+
}>;
|
|
47
82
|
/**
|
|
48
83
|
* Apply a penalty by consuming extra points, extending the block time.
|
|
49
84
|
* Useful for failed login attempts, abuse detection, etc.
|
|
@@ -181,4 +216,4 @@ declare class DynamicLimiterManager {
|
|
|
181
216
|
private globToRegex;
|
|
182
217
|
}
|
|
183
218
|
|
|
184
|
-
export { type ConsumeResult, DynamicLimiterManager, type DynamicLimiterRule, type LimiterConfig, RATE_LIMIT_KEY, RateLimit, RateLimitGuard, type RateLimitOptions, type RateLimiterModuleAsyncOptions, type RateLimiterModuleOptions, type RateLimiterModuleOptionsFactory, RateLimiterService, TooManyRequestsException, ZuckerRateLimiterModule, deriveRateLimitKey };
|
|
219
|
+
export { type ConsumeResult, DynamicLimiterManager, type DynamicLimiterRule, type LimiterConfig, RATE_LIMIT_KEY, RateLimit, RateLimitGuard, type RateLimitOptions, type RateLimiterModuleAsyncOptions, type RateLimiterModuleOptions, type RateLimiterModuleOptionsFactory, type RateLimiterRedisOptions, RateLimiterService, TooManyRequestsException, ZuckerRateLimiterModule, deriveRateLimitKey };
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
8
|
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
7
9
|
var __export = (target, all) => {
|
|
@@ -16,6 +18,14 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
16
18
|
}
|
|
17
19
|
return to;
|
|
18
20
|
};
|
|
21
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
22
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
23
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
24
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
25
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
26
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
27
|
+
mod
|
|
28
|
+
));
|
|
19
29
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
30
|
|
|
21
31
|
// src/index.ts
|
|
@@ -83,17 +93,66 @@ var RateLimiterService = class _RateLimiterService {
|
|
|
83
93
|
options;
|
|
84
94
|
logger = new import_common.Logger(_RateLimiterService.name);
|
|
85
95
|
limiters = /* @__PURE__ */ new Map();
|
|
96
|
+
insuranceLimiters = /* @__PURE__ */ new Map();
|
|
86
97
|
useRedis = false;
|
|
98
|
+
redisClient = null;
|
|
99
|
+
redisDegraded = false;
|
|
100
|
+
redisRetryAt = 0;
|
|
101
|
+
redisResetPending = false;
|
|
102
|
+
shuttingDown = false;
|
|
103
|
+
initialization;
|
|
104
|
+
onRedisError = /* @__PURE__ */ __name((error) => this.markRedisDegraded("error", error), "onRedisError");
|
|
105
|
+
onRedisClose = /* @__PURE__ */ __name(() => this.markRedisDegraded("close"), "onRedisClose");
|
|
106
|
+
onRedisReady = /* @__PURE__ */ __name(() => {
|
|
107
|
+
this.redisResetPending = false;
|
|
108
|
+
this.markRedisRecovered();
|
|
109
|
+
}, "onRedisReady");
|
|
87
110
|
constructor(options) {
|
|
88
111
|
this.options = options;
|
|
89
112
|
}
|
|
90
|
-
|
|
113
|
+
onModuleInit() {
|
|
114
|
+
if (this.shuttingDown) return Promise.resolve();
|
|
115
|
+
return this.initialization ??= this.initialize();
|
|
116
|
+
}
|
|
117
|
+
async initialize() {
|
|
118
|
+
if (this.options.redis && this.options.redisClient) {
|
|
119
|
+
throw new Error("Configure redis or redisClient, not both");
|
|
120
|
+
}
|
|
91
121
|
if (this.options.redisClient) {
|
|
122
|
+
this.redisClient = this.options.redisClient;
|
|
92
123
|
this.useRedis = true;
|
|
93
|
-
this.
|
|
124
|
+
this.observeRedis();
|
|
125
|
+
} else if (this.options.redis) {
|
|
126
|
+
try {
|
|
127
|
+
const { url, createClient, commandTimeoutMs } = this.options.redis;
|
|
128
|
+
const create = createClient ?? (async (address, options) => {
|
|
129
|
+
const { default: Redis } = await import("ioredis");
|
|
130
|
+
return new Redis(address, options);
|
|
131
|
+
});
|
|
132
|
+
this.redisClient = await create(url, {
|
|
133
|
+
maxRetriesPerRequest: 3,
|
|
134
|
+
commandTimeout: commandTimeoutMs ?? 500,
|
|
135
|
+
lazyConnect: true,
|
|
136
|
+
enableOfflineQueue: false,
|
|
137
|
+
autoResendUnfulfilledCommands: false
|
|
138
|
+
});
|
|
139
|
+
if (this.shuttingDown) {
|
|
140
|
+
this.redisClient.disconnect(false);
|
|
141
|
+
this.redisClient = null;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.observeRedis();
|
|
145
|
+
await this.redisClient.connect();
|
|
146
|
+
this.useRedis = true;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
this.markRedisDegraded("initial-connect", error);
|
|
149
|
+
this.redisClient?.disconnect(false);
|
|
150
|
+
this.redisClient = null;
|
|
151
|
+
}
|
|
94
152
|
}
|
|
153
|
+
if (this.shuttingDown) return;
|
|
95
154
|
for (const [name, config] of Object.entries(this.options.limiters)) {
|
|
96
|
-
if (config.points <= 0 || config.duration <= 0) {
|
|
155
|
+
if (!Number.isFinite(config.points) || !Number.isFinite(config.duration) || config.points <= 0 || config.duration <= 0) {
|
|
97
156
|
this.logger.warn(`\u9650\u6D41\u5668 '${name}' \u914D\u7F6E\u65E0\u6548 (points=${config.points}, duration=${config.duration})\uFF0C\u5DF2\u8DF3\u8FC7`);
|
|
98
157
|
continue;
|
|
99
158
|
}
|
|
@@ -102,34 +161,96 @@ var RateLimiterService = class _RateLimiterService {
|
|
|
102
161
|
duration: config.duration,
|
|
103
162
|
blockDuration: config.blockDuration ?? config.duration
|
|
104
163
|
};
|
|
164
|
+
const insurance = new import_rate_limiter_flexible.RateLimiterMemory(opts);
|
|
165
|
+
this.insuranceLimiters.set(name, insurance);
|
|
105
166
|
if (this.useRedis) {
|
|
106
167
|
this.limiters.set(name, new import_rate_limiter_flexible.RateLimiterRedis({
|
|
107
168
|
...opts,
|
|
108
|
-
storeClient: this.
|
|
169
|
+
storeClient: this.redisClient,
|
|
170
|
+
rejectIfRedisNotReady: true,
|
|
109
171
|
keyPrefix: config.keyPrefix ?? `rl:${name}:`
|
|
110
172
|
}));
|
|
111
173
|
} else {
|
|
112
|
-
this.limiters.set(name,
|
|
174
|
+
this.limiters.set(name, insurance);
|
|
113
175
|
}
|
|
114
176
|
}
|
|
115
177
|
this.logger.log(`RateLimiterService \u521D\u59CB\u5316\u5B8C\u6210 (${this.useRedis ? "Redis" : "Memory"}), ${this.limiters.size} \u4E2A\u9650\u6D41\u5668`);
|
|
116
178
|
}
|
|
117
179
|
onModuleDestroy() {
|
|
180
|
+
if (this.shuttingDown) return;
|
|
181
|
+
this.shuttingDown = true;
|
|
182
|
+
this.redisResetPending = false;
|
|
183
|
+
this.redisClient?.removeListener?.("error", this.onRedisError);
|
|
184
|
+
this.redisClient?.removeListener?.("close", this.onRedisClose);
|
|
185
|
+
this.redisClient?.removeListener?.("ready", this.onRedisReady);
|
|
186
|
+
if (this.options.redis) this.redisClient?.disconnect(false);
|
|
187
|
+
this.redisClient = null;
|
|
188
|
+
this.useRedis = false;
|
|
118
189
|
this.limiters.clear();
|
|
119
|
-
this.
|
|
190
|
+
this.insuranceLimiters.clear();
|
|
191
|
+
}
|
|
192
|
+
observeRedis() {
|
|
193
|
+
this.redisClient?.on?.("error", this.onRedisError);
|
|
194
|
+
this.redisClient?.on?.("close", this.onRedisClose);
|
|
195
|
+
this.redisClient?.on?.("ready", this.onRedisReady);
|
|
196
|
+
}
|
|
197
|
+
markRedisDegraded(event, error) {
|
|
198
|
+
if (this.shuttingDown) return;
|
|
199
|
+
this.redisRetryAt = Date.now() + (this.options.circuitBreakerMs ?? 5e3);
|
|
200
|
+
if (this.redisDegraded) return;
|
|
201
|
+
this.redisDegraded = true;
|
|
202
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" && /^[A-Za-z0-9_-]{1,32}$/.test(error.code) ? error.code : "unknown";
|
|
203
|
+
this.logger.warn(`Redis rate limiter degraded; using bounded in-process insurance limiter (event=${event}, code=${code})`);
|
|
204
|
+
}
|
|
205
|
+
markRedisRecovered() {
|
|
206
|
+
if (this.shuttingDown || !this.redisDegraded) return;
|
|
207
|
+
this.redisDegraded = false;
|
|
208
|
+
this.redisRetryAt = 0;
|
|
209
|
+
this.logger.log("Redis rate limiter recovered; distributed limits restored");
|
|
210
|
+
}
|
|
211
|
+
async executeWithInsurance(name, operation) {
|
|
212
|
+
const primary = this.getLimiter(name);
|
|
213
|
+
const insurance = this.insuranceLimiters.get(name);
|
|
214
|
+
if (!this.useRedis || primary === insurance || this.redisDegraded && Date.now() < this.redisRetryAt) {
|
|
215
|
+
return operation(insurance);
|
|
216
|
+
}
|
|
217
|
+
let timeout;
|
|
218
|
+
const deadline = new Promise((_, reject) => {
|
|
219
|
+
timeout = setTimeout(() => reject(Object.assign(new Error("Redis operation timed out"), {
|
|
220
|
+
code: "ETIMEDOUT"
|
|
221
|
+
})), this.options.operationDeadlineMs ?? 750);
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
const result = await Promise.race([
|
|
225
|
+
operation(primary),
|
|
226
|
+
deadline
|
|
227
|
+
]);
|
|
228
|
+
this.markRedisRecovered();
|
|
229
|
+
return result;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (!(error instanceof Error)) throw error;
|
|
232
|
+
this.markRedisDegraded("command", error);
|
|
233
|
+
if (this.options.redis && this.redisClient && !this.shuttingDown && !this.redisResetPending) {
|
|
234
|
+
this.redisResetPending = true;
|
|
235
|
+
this.redisClient.disconnect(true);
|
|
236
|
+
}
|
|
237
|
+
return operation(insurance);
|
|
238
|
+
} finally {
|
|
239
|
+
if (timeout) clearTimeout(timeout);
|
|
240
|
+
}
|
|
120
241
|
}
|
|
121
242
|
/**
|
|
122
243
|
* Consume points from a rate limiter.
|
|
123
244
|
* Throws TooManyRequestsException (429) when limit is exceeded.
|
|
124
245
|
*/
|
|
125
246
|
async consume(limiterName, key, pointsToConsume = 1, errorMessage = "\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5") {
|
|
126
|
-
|
|
247
|
+
this.getLimiter(limiterName);
|
|
127
248
|
const storageKey = this.toStorageKey(key);
|
|
128
249
|
if (pointsToConsume <= 0 || !Number.isFinite(pointsToConsume)) {
|
|
129
250
|
throw new import_core.BusinessException(`\u6D88\u8D39\u70B9\u6570\u65E0\u6548: ${pointsToConsume}`, "INVALID_POINTS", 400);
|
|
130
251
|
}
|
|
131
252
|
try {
|
|
132
|
-
const res = await limiter.consume(storageKey, pointsToConsume);
|
|
253
|
+
const res = await this.executeWithInsurance(limiterName, (limiter) => limiter.consume(storageKey, pointsToConsume));
|
|
133
254
|
return {
|
|
134
255
|
remaining: res.remainingPoints,
|
|
135
256
|
msBeforeNext: res.msBeforeNext,
|
|
@@ -145,25 +266,48 @@ var RateLimiterService = class _RateLimiterService {
|
|
|
145
266
|
throw error;
|
|
146
267
|
}
|
|
147
268
|
}
|
|
269
|
+
/** Atomic consumption with a per-subject cap, without allocating a limiter per subject.
|
|
270
|
+
* Configure the named limiter's points at the maximum supported cap and blockDuration: 0.
|
|
271
|
+
* Consumers own the subject/window key; rejected requests still advance the counter.
|
|
272
|
+
*/
|
|
273
|
+
async checkLimit(limiterName, key, pointsLimit) {
|
|
274
|
+
const configured = this.options.limiters[limiterName];
|
|
275
|
+
this.getLimiter(limiterName);
|
|
276
|
+
if (!Number.isFinite(pointsLimit) || pointsLimit <= 0 || pointsLimit > configured.points) {
|
|
277
|
+
throw new import_core.BusinessException("Invalid subject rate limit", "INVALID_POINTS", 400);
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const result = await this.consume(limiterName, key);
|
|
281
|
+
return {
|
|
282
|
+
allowed: result.consumedPoints <= pointsLimit,
|
|
283
|
+
remaining: Math.max(0, pointsLimit - result.consumedPoints),
|
|
284
|
+
resetAt: new Date(Date.now() + result.msBeforeNext)
|
|
285
|
+
};
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (!(error instanceof TooManyRequestsException)) throw error;
|
|
288
|
+
return {
|
|
289
|
+
allowed: false,
|
|
290
|
+
remaining: 0,
|
|
291
|
+
resetAt: new Date(Date.now() + error.retryAfter * 1e3)
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
148
295
|
/**
|
|
149
296
|
* Apply a penalty by consuming extra points, extending the block time.
|
|
150
297
|
* Useful for failed login attempts, abuse detection, etc.
|
|
151
298
|
*/
|
|
152
299
|
async penalty(limiterName, key, points = 1) {
|
|
153
|
-
|
|
154
|
-
await limiter.penalty(this.toStorageKey(key), points);
|
|
300
|
+
await this.executeWithInsurance(limiterName, (limiter) => limiter.penalty(this.toStorageKey(key), points));
|
|
155
301
|
this.logger.log(`\u9650\u6D41\u60E9\u7F5A: limiter=${limiterName}, points=${points}`);
|
|
156
302
|
}
|
|
157
303
|
/**
|
|
158
304
|
* Reward by reducing consumed points (opposite of penalty).
|
|
159
305
|
*/
|
|
160
306
|
async reward(limiterName, key, points = 1) {
|
|
161
|
-
|
|
162
|
-
await limiter.reward(this.toStorageKey(key), points);
|
|
307
|
+
await this.executeWithInsurance(limiterName, (limiter) => limiter.reward(this.toStorageKey(key), points));
|
|
163
308
|
}
|
|
164
309
|
async getRemaining(limiterName, key) {
|
|
165
|
-
const
|
|
166
|
-
const res = await limiter.get(this.toStorageKey(key));
|
|
310
|
+
const res = await this.executeWithInsurance(limiterName, (limiter) => limiter.get(this.toStorageKey(key)));
|
|
167
311
|
if (!res) {
|
|
168
312
|
const config = this.options.limiters[limiterName];
|
|
169
313
|
return {
|
|
@@ -177,8 +321,9 @@ var RateLimiterService = class _RateLimiterService {
|
|
|
177
321
|
};
|
|
178
322
|
}
|
|
179
323
|
async reset(limiterName, key) {
|
|
180
|
-
const
|
|
181
|
-
await limiter.delete(
|
|
324
|
+
const storageKey = this.toStorageKey(key);
|
|
325
|
+
await this.executeWithInsurance(limiterName, (limiter) => limiter.delete(storageKey));
|
|
326
|
+
await this.insuranceLimiters.get(limiterName).delete(storageKey);
|
|
182
327
|
}
|
|
183
328
|
/**
|
|
184
329
|
* Convenience method for public API rate limiting.
|
|
@@ -215,7 +360,7 @@ var RateLimiterService = class _RateLimiterService {
|
|
|
215
360
|
];
|
|
216
361
|
}
|
|
217
362
|
isDistributed() {
|
|
218
|
-
return this.useRedis;
|
|
363
|
+
return this.useRedis && !this.redisDegraded && !this.shuttingDown;
|
|
219
364
|
}
|
|
220
365
|
/** Get a limiter by name, throwing if not found */
|
|
221
366
|
getLimiter(limiterName) {
|
package/dist/index.mjs
CHANGED
|
@@ -52,17 +52,66 @@ var RateLimiterService = class _RateLimiterService {
|
|
|
52
52
|
options;
|
|
53
53
|
logger = new Logger(_RateLimiterService.name);
|
|
54
54
|
limiters = /* @__PURE__ */ new Map();
|
|
55
|
+
insuranceLimiters = /* @__PURE__ */ new Map();
|
|
55
56
|
useRedis = false;
|
|
57
|
+
redisClient = null;
|
|
58
|
+
redisDegraded = false;
|
|
59
|
+
redisRetryAt = 0;
|
|
60
|
+
redisResetPending = false;
|
|
61
|
+
shuttingDown = false;
|
|
62
|
+
initialization;
|
|
63
|
+
onRedisError = /* @__PURE__ */ __name((error) => this.markRedisDegraded("error", error), "onRedisError");
|
|
64
|
+
onRedisClose = /* @__PURE__ */ __name(() => this.markRedisDegraded("close"), "onRedisClose");
|
|
65
|
+
onRedisReady = /* @__PURE__ */ __name(() => {
|
|
66
|
+
this.redisResetPending = false;
|
|
67
|
+
this.markRedisRecovered();
|
|
68
|
+
}, "onRedisReady");
|
|
56
69
|
constructor(options) {
|
|
57
70
|
this.options = options;
|
|
58
71
|
}
|
|
59
|
-
|
|
72
|
+
onModuleInit() {
|
|
73
|
+
if (this.shuttingDown) return Promise.resolve();
|
|
74
|
+
return this.initialization ??= this.initialize();
|
|
75
|
+
}
|
|
76
|
+
async initialize() {
|
|
77
|
+
if (this.options.redis && this.options.redisClient) {
|
|
78
|
+
throw new Error("Configure redis or redisClient, not both");
|
|
79
|
+
}
|
|
60
80
|
if (this.options.redisClient) {
|
|
81
|
+
this.redisClient = this.options.redisClient;
|
|
61
82
|
this.useRedis = true;
|
|
62
|
-
this.
|
|
83
|
+
this.observeRedis();
|
|
84
|
+
} else if (this.options.redis) {
|
|
85
|
+
try {
|
|
86
|
+
const { url, createClient, commandTimeoutMs } = this.options.redis;
|
|
87
|
+
const create = createClient ?? (async (address, options) => {
|
|
88
|
+
const { default: Redis } = await import("ioredis");
|
|
89
|
+
return new Redis(address, options);
|
|
90
|
+
});
|
|
91
|
+
this.redisClient = await create(url, {
|
|
92
|
+
maxRetriesPerRequest: 3,
|
|
93
|
+
commandTimeout: commandTimeoutMs ?? 500,
|
|
94
|
+
lazyConnect: true,
|
|
95
|
+
enableOfflineQueue: false,
|
|
96
|
+
autoResendUnfulfilledCommands: false
|
|
97
|
+
});
|
|
98
|
+
if (this.shuttingDown) {
|
|
99
|
+
this.redisClient.disconnect(false);
|
|
100
|
+
this.redisClient = null;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.observeRedis();
|
|
104
|
+
await this.redisClient.connect();
|
|
105
|
+
this.useRedis = true;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
this.markRedisDegraded("initial-connect", error);
|
|
108
|
+
this.redisClient?.disconnect(false);
|
|
109
|
+
this.redisClient = null;
|
|
110
|
+
}
|
|
63
111
|
}
|
|
112
|
+
if (this.shuttingDown) return;
|
|
64
113
|
for (const [name, config] of Object.entries(this.options.limiters)) {
|
|
65
|
-
if (config.points <= 0 || config.duration <= 0) {
|
|
114
|
+
if (!Number.isFinite(config.points) || !Number.isFinite(config.duration) || config.points <= 0 || config.duration <= 0) {
|
|
66
115
|
this.logger.warn(`\u9650\u6D41\u5668 '${name}' \u914D\u7F6E\u65E0\u6548 (points=${config.points}, duration=${config.duration})\uFF0C\u5DF2\u8DF3\u8FC7`);
|
|
67
116
|
continue;
|
|
68
117
|
}
|
|
@@ -71,34 +120,96 @@ var RateLimiterService = class _RateLimiterService {
|
|
|
71
120
|
duration: config.duration,
|
|
72
121
|
blockDuration: config.blockDuration ?? config.duration
|
|
73
122
|
};
|
|
123
|
+
const insurance = new RateLimiterMemory(opts);
|
|
124
|
+
this.insuranceLimiters.set(name, insurance);
|
|
74
125
|
if (this.useRedis) {
|
|
75
126
|
this.limiters.set(name, new RateLimiterRedis({
|
|
76
127
|
...opts,
|
|
77
|
-
storeClient: this.
|
|
128
|
+
storeClient: this.redisClient,
|
|
129
|
+
rejectIfRedisNotReady: true,
|
|
78
130
|
keyPrefix: config.keyPrefix ?? `rl:${name}:`
|
|
79
131
|
}));
|
|
80
132
|
} else {
|
|
81
|
-
this.limiters.set(name,
|
|
133
|
+
this.limiters.set(name, insurance);
|
|
82
134
|
}
|
|
83
135
|
}
|
|
84
136
|
this.logger.log(`RateLimiterService \u521D\u59CB\u5316\u5B8C\u6210 (${this.useRedis ? "Redis" : "Memory"}), ${this.limiters.size} \u4E2A\u9650\u6D41\u5668`);
|
|
85
137
|
}
|
|
86
138
|
onModuleDestroy() {
|
|
139
|
+
if (this.shuttingDown) return;
|
|
140
|
+
this.shuttingDown = true;
|
|
141
|
+
this.redisResetPending = false;
|
|
142
|
+
this.redisClient?.removeListener?.("error", this.onRedisError);
|
|
143
|
+
this.redisClient?.removeListener?.("close", this.onRedisClose);
|
|
144
|
+
this.redisClient?.removeListener?.("ready", this.onRedisReady);
|
|
145
|
+
if (this.options.redis) this.redisClient?.disconnect(false);
|
|
146
|
+
this.redisClient = null;
|
|
147
|
+
this.useRedis = false;
|
|
87
148
|
this.limiters.clear();
|
|
88
|
-
this.
|
|
149
|
+
this.insuranceLimiters.clear();
|
|
150
|
+
}
|
|
151
|
+
observeRedis() {
|
|
152
|
+
this.redisClient?.on?.("error", this.onRedisError);
|
|
153
|
+
this.redisClient?.on?.("close", this.onRedisClose);
|
|
154
|
+
this.redisClient?.on?.("ready", this.onRedisReady);
|
|
155
|
+
}
|
|
156
|
+
markRedisDegraded(event, error) {
|
|
157
|
+
if (this.shuttingDown) return;
|
|
158
|
+
this.redisRetryAt = Date.now() + (this.options.circuitBreakerMs ?? 5e3);
|
|
159
|
+
if (this.redisDegraded) return;
|
|
160
|
+
this.redisDegraded = true;
|
|
161
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" && /^[A-Za-z0-9_-]{1,32}$/.test(error.code) ? error.code : "unknown";
|
|
162
|
+
this.logger.warn(`Redis rate limiter degraded; using bounded in-process insurance limiter (event=${event}, code=${code})`);
|
|
163
|
+
}
|
|
164
|
+
markRedisRecovered() {
|
|
165
|
+
if (this.shuttingDown || !this.redisDegraded) return;
|
|
166
|
+
this.redisDegraded = false;
|
|
167
|
+
this.redisRetryAt = 0;
|
|
168
|
+
this.logger.log("Redis rate limiter recovered; distributed limits restored");
|
|
169
|
+
}
|
|
170
|
+
async executeWithInsurance(name, operation) {
|
|
171
|
+
const primary = this.getLimiter(name);
|
|
172
|
+
const insurance = this.insuranceLimiters.get(name);
|
|
173
|
+
if (!this.useRedis || primary === insurance || this.redisDegraded && Date.now() < this.redisRetryAt) {
|
|
174
|
+
return operation(insurance);
|
|
175
|
+
}
|
|
176
|
+
let timeout;
|
|
177
|
+
const deadline = new Promise((_, reject) => {
|
|
178
|
+
timeout = setTimeout(() => reject(Object.assign(new Error("Redis operation timed out"), {
|
|
179
|
+
code: "ETIMEDOUT"
|
|
180
|
+
})), this.options.operationDeadlineMs ?? 750);
|
|
181
|
+
});
|
|
182
|
+
try {
|
|
183
|
+
const result = await Promise.race([
|
|
184
|
+
operation(primary),
|
|
185
|
+
deadline
|
|
186
|
+
]);
|
|
187
|
+
this.markRedisRecovered();
|
|
188
|
+
return result;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (!(error instanceof Error)) throw error;
|
|
191
|
+
this.markRedisDegraded("command", error);
|
|
192
|
+
if (this.options.redis && this.redisClient && !this.shuttingDown && !this.redisResetPending) {
|
|
193
|
+
this.redisResetPending = true;
|
|
194
|
+
this.redisClient.disconnect(true);
|
|
195
|
+
}
|
|
196
|
+
return operation(insurance);
|
|
197
|
+
} finally {
|
|
198
|
+
if (timeout) clearTimeout(timeout);
|
|
199
|
+
}
|
|
89
200
|
}
|
|
90
201
|
/**
|
|
91
202
|
* Consume points from a rate limiter.
|
|
92
203
|
* Throws TooManyRequestsException (429) when limit is exceeded.
|
|
93
204
|
*/
|
|
94
205
|
async consume(limiterName, key, pointsToConsume = 1, errorMessage = "\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5") {
|
|
95
|
-
|
|
206
|
+
this.getLimiter(limiterName);
|
|
96
207
|
const storageKey = this.toStorageKey(key);
|
|
97
208
|
if (pointsToConsume <= 0 || !Number.isFinite(pointsToConsume)) {
|
|
98
209
|
throw new BusinessException(`\u6D88\u8D39\u70B9\u6570\u65E0\u6548: ${pointsToConsume}`, "INVALID_POINTS", 400);
|
|
99
210
|
}
|
|
100
211
|
try {
|
|
101
|
-
const res = await limiter.consume(storageKey, pointsToConsume);
|
|
212
|
+
const res = await this.executeWithInsurance(limiterName, (limiter) => limiter.consume(storageKey, pointsToConsume));
|
|
102
213
|
return {
|
|
103
214
|
remaining: res.remainingPoints,
|
|
104
215
|
msBeforeNext: res.msBeforeNext,
|
|
@@ -114,25 +225,48 @@ var RateLimiterService = class _RateLimiterService {
|
|
|
114
225
|
throw error;
|
|
115
226
|
}
|
|
116
227
|
}
|
|
228
|
+
/** Atomic consumption with a per-subject cap, without allocating a limiter per subject.
|
|
229
|
+
* Configure the named limiter's points at the maximum supported cap and blockDuration: 0.
|
|
230
|
+
* Consumers own the subject/window key; rejected requests still advance the counter.
|
|
231
|
+
*/
|
|
232
|
+
async checkLimit(limiterName, key, pointsLimit) {
|
|
233
|
+
const configured = this.options.limiters[limiterName];
|
|
234
|
+
this.getLimiter(limiterName);
|
|
235
|
+
if (!Number.isFinite(pointsLimit) || pointsLimit <= 0 || pointsLimit > configured.points) {
|
|
236
|
+
throw new BusinessException("Invalid subject rate limit", "INVALID_POINTS", 400);
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const result = await this.consume(limiterName, key);
|
|
240
|
+
return {
|
|
241
|
+
allowed: result.consumedPoints <= pointsLimit,
|
|
242
|
+
remaining: Math.max(0, pointsLimit - result.consumedPoints),
|
|
243
|
+
resetAt: new Date(Date.now() + result.msBeforeNext)
|
|
244
|
+
};
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (!(error instanceof TooManyRequestsException)) throw error;
|
|
247
|
+
return {
|
|
248
|
+
allowed: false,
|
|
249
|
+
remaining: 0,
|
|
250
|
+
resetAt: new Date(Date.now() + error.retryAfter * 1e3)
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
117
254
|
/**
|
|
118
255
|
* Apply a penalty by consuming extra points, extending the block time.
|
|
119
256
|
* Useful for failed login attempts, abuse detection, etc.
|
|
120
257
|
*/
|
|
121
258
|
async penalty(limiterName, key, points = 1) {
|
|
122
|
-
|
|
123
|
-
await limiter.penalty(this.toStorageKey(key), points);
|
|
259
|
+
await this.executeWithInsurance(limiterName, (limiter) => limiter.penalty(this.toStorageKey(key), points));
|
|
124
260
|
this.logger.log(`\u9650\u6D41\u60E9\u7F5A: limiter=${limiterName}, points=${points}`);
|
|
125
261
|
}
|
|
126
262
|
/**
|
|
127
263
|
* Reward by reducing consumed points (opposite of penalty).
|
|
128
264
|
*/
|
|
129
265
|
async reward(limiterName, key, points = 1) {
|
|
130
|
-
|
|
131
|
-
await limiter.reward(this.toStorageKey(key), points);
|
|
266
|
+
await this.executeWithInsurance(limiterName, (limiter) => limiter.reward(this.toStorageKey(key), points));
|
|
132
267
|
}
|
|
133
268
|
async getRemaining(limiterName, key) {
|
|
134
|
-
const
|
|
135
|
-
const res = await limiter.get(this.toStorageKey(key));
|
|
269
|
+
const res = await this.executeWithInsurance(limiterName, (limiter) => limiter.get(this.toStorageKey(key)));
|
|
136
270
|
if (!res) {
|
|
137
271
|
const config = this.options.limiters[limiterName];
|
|
138
272
|
return {
|
|
@@ -146,8 +280,9 @@ var RateLimiterService = class _RateLimiterService {
|
|
|
146
280
|
};
|
|
147
281
|
}
|
|
148
282
|
async reset(limiterName, key) {
|
|
149
|
-
const
|
|
150
|
-
await limiter.delete(
|
|
283
|
+
const storageKey = this.toStorageKey(key);
|
|
284
|
+
await this.executeWithInsurance(limiterName, (limiter) => limiter.delete(storageKey));
|
|
285
|
+
await this.insuranceLimiters.get(limiterName).delete(storageKey);
|
|
151
286
|
}
|
|
152
287
|
/**
|
|
153
288
|
* Convenience method for public API rate limiting.
|
|
@@ -184,7 +319,7 @@ var RateLimiterService = class _RateLimiterService {
|
|
|
184
319
|
];
|
|
185
320
|
}
|
|
186
321
|
isDistributed() {
|
|
187
|
-
return this.useRedis;
|
|
322
|
+
return this.useRedis && !this.redisDegraded && !this.shuttingDown;
|
|
188
323
|
}
|
|
189
324
|
/** Get a limiter by name, throwing if not found */
|
|
190
325
|
getLimiter(limiterName) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zucker-framework/rate-limiter",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"module": "dist/index.mjs",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -11,12 +11,18 @@
|
|
|
11
11
|
"THIRD_PARTY_NOTICES"
|
|
12
12
|
],
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@zucker-framework/core": "1.0.
|
|
14
|
+
"@zucker-framework/core": "1.0.2",
|
|
15
15
|
"rate-limiter-flexible": "^11.2.0"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
|
18
18
|
"@nestjs/common": "^12.0.0",
|
|
19
|
-
"@nestjs/core": "^12.0.0"
|
|
19
|
+
"@nestjs/core": "^12.0.0",
|
|
20
|
+
"ioredis": "^6.0.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependenciesMeta": {
|
|
23
|
+
"ioredis": {
|
|
24
|
+
"optional": true
|
|
25
|
+
}
|
|
20
26
|
},
|
|
21
27
|
"devDependencies": {
|
|
22
28
|
"@types/node": "^20.19.43"
|
|
@@ -33,7 +39,7 @@
|
|
|
33
39
|
"registry": "https://registry.npmjs.org/"
|
|
34
40
|
},
|
|
35
41
|
"scripts": {
|
|
36
|
-
"build": "tsup src/index.ts --format cjs,esm --dts --external @nestjs/common --external @nestjs/core --external @zucker-framework/core --external rate-limiter-flexible",
|
|
42
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --external @nestjs/common --external @nestjs/core --external @zucker-framework/core --external rate-limiter-flexible --external ioredis",
|
|
37
43
|
"clean": "rm -rf dist",
|
|
38
44
|
"lint": "eslint src/"
|
|
39
45
|
}
|