@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 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
- async onModuleInit() {
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.logger.log("RateLimiterService \u4F7F\u7528 Redis \u5206\u5E03\u5F0F\u9650\u6D41");
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.options.redisClient,
169
+ storeClient: this.redisClient,
170
+ rejectIfRedisNotReady: true,
109
171
  keyPrefix: config.keyPrefix ?? `rl:${name}:`
110
172
  }));
111
173
  } else {
112
- this.limiters.set(name, new import_rate_limiter_flexible.RateLimiterMemory(opts));
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.logger.log("RateLimiterService \u5DF2\u6E05\u7406");
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
- const limiter = this.getLimiter(limiterName);
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
- const limiter = this.getLimiter(limiterName);
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
- const limiter = this.getLimiter(limiterName);
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 limiter = this.getLimiter(limiterName);
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 limiter = this.getLimiter(limiterName);
181
- await limiter.delete(this.toStorageKey(key));
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
- async onModuleInit() {
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.logger.log("RateLimiterService \u4F7F\u7528 Redis \u5206\u5E03\u5F0F\u9650\u6D41");
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.options.redisClient,
128
+ storeClient: this.redisClient,
129
+ rejectIfRedisNotReady: true,
78
130
  keyPrefix: config.keyPrefix ?? `rl:${name}:`
79
131
  }));
80
132
  } else {
81
- this.limiters.set(name, new RateLimiterMemory(opts));
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.logger.log("RateLimiterService \u5DF2\u6E05\u7406");
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
- const limiter = this.getLimiter(limiterName);
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
- const limiter = this.getLimiter(limiterName);
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
- const limiter = this.getLimiter(limiterName);
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 limiter = this.getLimiter(limiterName);
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 limiter = this.getLimiter(limiterName);
150
- await limiter.delete(this.toStorageKey(key));
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.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.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
  }