@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 +1 -1
- package/README.md +1 -1
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +14 -5
- package/dist/redis-store.d.ts.map +1 -1
- package/dist/redis-store.js +14 -3
- package/dist/store-internals.d.ts +5 -0
- package/dist/store-internals.d.ts.map +1 -0
- package/dist/store-internals.js +4 -0
- package/package.json +5 -5
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';
|
package/dist/guard.d.ts.map
CHANGED
|
@@ -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;
|
|
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 {
|
|
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
|
|
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
|
|
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 =
|
|
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;
|
|
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"}
|
package/dist/redis-store.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"store-internals.d.ts","sourceRoot":"","sources":["../src/store-internals.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,eAAO,MAAM,2BAA2B,eAAmC,CAAC"}
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"redis",
|
|
10
10
|
"decorator"
|
|
11
11
|
],
|
|
12
|
-
"version": "1.0.0-beta.
|
|
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.
|
|
40
|
-
"@fluojs/di": "^1.0.0-beta.
|
|
41
|
-
"@fluojs/
|
|
42
|
-
"@fluojs/
|
|
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",
|