@fluojs/throttler 1.0.0-beta.2 → 1.0.0-beta.4

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.ko.md CHANGED
@@ -69,7 +69,7 @@ class AuthController {
69
69
 
70
70
  ### Redis 저장소 사용
71
71
 
72
- 다중 인스턴스 배포 환경에서는 `RedisThrottlerStore`를 사용하여 모든 인스턴스 간에 속도 제한 상태를 공유하세요.
72
+ 다중 인스턴스 배포 환경에서는 `RedisThrottlerStore`를 사용하여 모든 인스턴스 간에 속도 제한 상태를 공유하세요. Redis 기반 윈도우는 Redis 서버 시간을 기준으로 고정되므로, 애플리케이션 노드 간 시계 오차가 있어도 하나의 공통 reset 경계를 강제합니다.
73
73
 
74
74
  ```typescript
75
75
  import { ThrottlerModule, RedisThrottlerStore } from '@fluojs/throttler';
package/README.md CHANGED
@@ -69,7 +69,7 @@ class AuthController {
69
69
 
70
70
  ### Redis Storage
71
71
 
72
- For multi-instance deployments, use `RedisThrottlerStore` to share the rate limit state across all instances.
72
+ For multi-instance deployments, use `RedisThrottlerStore` to share the rate limit state across all instances. Redis-backed windows are anchored to Redis server time, so distributed app nodes with clock skew still enforce one shared reset boundary.
73
73
 
74
74
  ```typescript
75
75
  import { ThrottlerModule, RedisThrottlerStore } from '@fluojs/throttler';
@@ -1 +1 @@
1
- {"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"AAEA,OAAO,EAA4B,KAAK,KAAK,EAAE,KAAK,YAAY,EAA0B,MAAM,cAAc,CAAC;AAY/G,OAAO,KAAK,EAAE,sBAAsB,EAAkB,MAAM,YAAY,CAAC;AA2CzE;;GAEG;AACH,qBACa,cAAe,YAAW,KAAK;IAG9B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAFpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiB;gBAEV,OAAO,EAAE,sBAAsB;IAK5D;;;;;;OAMG;IACG,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,OAAO,CAAC;CAiD3D"}
1
+ {"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../src/guard.ts"],"names":[],"mappings":"AAEA,OAAO,EAA4B,KAAK,KAAK,EAAE,KAAK,YAAY,EAA0B,MAAM,cAAc,CAAC;AAa/G,OAAO,KAAK,EAAE,sBAAsB,EAAuC,MAAM,YAAY,CAAC;AAuD9F;;GAEG;AACH,qBACa,cAAe,YAAW,KAAK;IAG9B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAFpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiB;gBAEV,OAAO,EAAE,sBAAsB;IAK5D;;;;;;OAMG;IACG,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,OAAO,CAAC;CAkD3D"}
package/dist/guard.js CHANGED
@@ -5,15 +5,16 @@ function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e =
5
5
  function _setFunctionName(e, t, n) { "symbol" == typeof t && (t = (t = t.description) ? "[" + t + "]" : ""); try { Object.defineProperty(e, "name", { configurable: !0, value: n ? n + " " + t : t }); } catch (e) {} return e; }
6
6
  function _checkInRHS(e) { if (Object(e) !== e) throw TypeError("right-hand side of 'in' should be an object, got " + (null !== e ? typeof e : "null")); return e; }
7
7
  import { Inject } from '@fluojs/core';
8
- import { metadataSymbol } from '@fluojs/core/internal';
8
+ import { getStandardMetadataBag } from '@fluojs/core/internal';
9
9
  import { TooManyRequestsException } from '@fluojs/http';
10
10
  import { resolveClientIdentity } from '@fluojs/http/internal';
11
11
  import { getClassSkipThrottleMetadata, getClassThrottleMetadata, getSkipThrottleMetadata, getThrottleMetadata, throttleRouteMetadataKey } from './decorators.js';
12
+ import { throttlerRetryAfterMsSymbol } from './store-internals.js';
12
13
  import { createMemoryThrottlerStore } from './store.js';
13
14
  import { THROTTLER_OPTIONS } from './tokens.js';
14
15
  import { validateThrottleOptions, validateThrottlerModuleOptions, validateThrottlerStoreEntry } from './validation.js';
15
16
  function getClassMetadataBag(target) {
16
- return target[metadataSymbol];
17
+ return getStandardMetadataBag(target);
17
18
  }
18
19
  function getMethodMetadataBag(controllerToken, methodName) {
19
20
  const classBag = getClassMetadataBag(controllerToken);
@@ -37,6 +38,13 @@ function buildHandlerKey(handler) {
37
38
  const version = handler.route.version ?? handler.metadata.effectiveVersion ?? 'unversioned';
38
39
  return [`method:${handler.route.method}`, `path:${encodeURIComponent(handler.route.path)}`, `version:${encodeURIComponent(version)}`, `handler:${encodeURIComponent(handler.methodName)}`].join('|');
39
40
  }
41
+ function resolveRetryAfterSeconds(entry, now) {
42
+ const retryAfterMs = entry[throttlerRetryAfterMsSymbol];
43
+ if (typeof retryAfterMs === 'number' && Number.isFinite(retryAfterMs)) {
44
+ return Math.max(1, Math.ceil(retryAfterMs / 1000));
45
+ }
46
+ return Math.max(1, Math.ceil((entry.resetAt - now) / 1000));
47
+ }
40
48
 
41
49
  /**
42
50
  * Guard that enforces module-, class-, and method-level throttling policies.
@@ -89,12 +97,13 @@ class ThrottlerGuard {
89
97
  const handlerKey = buildHandlerKey(handler);
90
98
  const storeKey = buildStoreKey(handlerKey, clientKey);
91
99
  const now = Date.now();
92
- const entry = validateThrottlerStoreEntry(await this.store.consume(storeKey, {
100
+ const rawEntry = await this.store.consume(storeKey, {
93
101
  now,
94
102
  ttlSeconds
95
- }));
103
+ });
104
+ const entry = validateThrottlerStoreEntry(rawEntry);
96
105
  if (entry.count > limit) {
97
- const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
106
+ const retryAfter = resolveRetryAfterSeconds(rawEntry, now);
98
107
  requestContext.response.setHeader('Retry-After', String(retryAfter));
99
108
  throw new TooManyRequestsException('Too Many Requests', {
100
109
  meta: {
@@ -1 +1 @@
1
- {"version":3,"file":"redis-store.d.ts","sourceRoot":"","sources":["../src/redis-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAEjC,OAAO,KAAK,EAAE,qBAAqB,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AA4C7F;;;;;;GAMG;AACH,qBAAa,mBAAoB,YAAW,cAAc;IAC5C,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,KAAK;IAE1C;;;;;;OAMG;IACG,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,qBAAqB,GAAG,OAAO,CAAC,mBAAmB,CAAC;CAWvF"}
1
+ {"version":3,"file":"redis-store.d.ts","sourceRoot":"","sources":["../src/redis-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAGjC,OAAO,KAAK,EAAE,qBAAqB,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AA8D7F;;;;;;GAMG;AACH,qBAAa,mBAAoB,YAAW,cAAc;IAC5C,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,KAAK;IAE1C;;;;;;OAMG;IACG,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,qBAAqB,GAAG,OAAO,CAAC,mBAAmB,CAAC;CAUvF"}
@@ -1,18 +1,29 @@
1
+ import { throttlerRetryAfterMsSymbol } from './store-internals.js';
1
2
  import { validateThrottlerStoreEntry } from './validation.js';
2
- const CONSUME_LUA = ["local key = KEYS[1]", "local now = tonumber(ARGV[1])", "local ttlMs = tonumber(ARGV[2])", "local raw = redis.call('GET', key)", "local count", "local resetAt", 'if not raw then', ' count = 1', ' resetAt = now + ttlMs', 'else', ' local decoded = cjson.decode(raw)', " count = tonumber(decoded['count']) or 0", " resetAt = tonumber(decoded['resetAt']) or (now + ttlMs)", ' if now >= resetAt then', ' count = 1', ' resetAt = now + ttlMs', ' else', ' count = count + 1', ' end', 'end', 'local ttlMsLeft = math.max(resetAt - now, 1)', "redis.call('SET', key, cjson.encode({ count = count, resetAt = resetAt }), 'PX', ttlMsLeft)", 'return {count, resetAt}'].join('\n');
3
+ const CONSUME_LUA = ["local key = KEYS[1]", "local ttlMs = tonumber(ARGV[1])", "local time = redis.call('TIME')", "local nowSeconds = tonumber(time[1])", "local nowMicros = tonumber(time[2])", 'local now = math.floor((nowSeconds * 1000) + (nowMicros / 1000))', "local raw = redis.call('GET', key)", "local count", "local resetAt", 'if not raw then', ' count = 1', ' resetAt = now + ttlMs', 'else', ' local decoded = cjson.decode(raw)', " count = tonumber(decoded['count']) or 0", " resetAt = tonumber(decoded['resetAt']) or (now + ttlMs)", ' if now >= resetAt then', ' count = 1', ' resetAt = now + ttlMs', ' else', ' count = count + 1', ' end', 'end', 'local ttlMsLeft = math.max(resetAt - now, 1)', "redis.call('SET', key, cjson.encode({ count = count, resetAt = resetAt }), 'PX', ttlMsLeft)", 'return {count, resetAt, ttlMsLeft}'].join('\n');
3
4
  function parseConsumeResult(result) {
4
5
  if (!Array.isArray(result) || result.length < 2) {
5
6
  throw new Error('Redis throttler consume script returned an invalid response.');
6
7
  }
7
8
  const count = Number(result[0]);
8
9
  const resetAt = Number(result[1]);
10
+ const retryAfterMs = result.length >= 3 ? Number(result[2]) : Number.NaN;
9
11
  if (!Number.isFinite(count) || !Number.isFinite(resetAt)) {
10
12
  throw new Error('Redis throttler consume script returned non-numeric counters.');
11
13
  }
12
- return validateThrottlerStoreEntry({
14
+ const entry = validateThrottlerStoreEntry({
13
15
  count,
14
16
  resetAt
15
17
  });
18
+ if (Number.isFinite(retryAfterMs)) {
19
+ Object.defineProperty(entry, throttlerRetryAfterMsSymbol, {
20
+ configurable: false,
21
+ enumerable: false,
22
+ value: retryAfterMs,
23
+ writable: false
24
+ });
25
+ }
26
+ return entry;
16
27
  }
17
28
 
18
29
  /**
@@ -35,7 +46,7 @@ export class RedisThrottlerStore {
35
46
  * @returns The updated counter value and reset timestamp for the current window.
36
47
  */
37
48
  async consume(key, input) {
38
- const result = await this.client.eval(CONSUME_LUA, 1, key, String(input.now), String(input.ttlSeconds * 1000));
49
+ const result = await this.client.eval(CONSUME_LUA, 1, key, String(input.ttlSeconds * 1000));
39
50
  return parseConsumeResult(result);
40
51
  }
41
52
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Internal store-entry symbol used to carry Redis-derived retry-after milliseconds.
3
+ */
4
+ export declare const throttlerRetryAfterMsSymbol: unique symbol;
5
+ //# sourceMappingURL=store-internals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-internals.d.ts","sourceRoot":"","sources":["../src/store-internals.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,eAAO,MAAM,2BAA2B,eAAmC,CAAC"}
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Internal store-entry symbol used to carry Redis-derived retry-after milliseconds.
3
+ */
4
+ export const throttlerRetryAfterMsSymbol = Symbol('throttler.retryAfterMs');
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "redis",
10
10
  "decorator"
11
11
  ],
12
- "version": "1.0.0-beta.2",
12
+ "version": "1.0.0-beta.4",
13
13
  "private": false,
14
14
  "license": "MIT",
15
15
  "repository": {
@@ -36,10 +36,10 @@
36
36
  "dist"
37
37
  ],
38
38
  "dependencies": {
39
- "@fluojs/core": "^1.0.0-beta.1",
40
- "@fluojs/di": "^1.0.0-beta.2",
41
- "@fluojs/http": "^1.0.0-beta.1",
42
- "@fluojs/runtime": "^1.0.0-beta.2"
39
+ "@fluojs/core": "^1.0.0-beta.2",
40
+ "@fluojs/di": "^1.0.0-beta.4",
41
+ "@fluojs/runtime": "^1.0.0-beta.4",
42
+ "@fluojs/http": "^1.0.0-beta.3"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "ioredis": "^5.0.0",