@teneo-protocol/sdk 1.0.0
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/.dockerignore +14 -0
- package/.env.test.example +14 -0
- package/.eslintrc.json +26 -0
- package/.github/workflows/claude-code-review.yml +78 -0
- package/.github/workflows/claude-reviewer.yml +64 -0
- package/.github/workflows/publish-npm.yml +38 -0
- package/.github/workflows/push-to-main.yml +23 -0
- package/.node-version +1 -0
- package/.prettierrc +11 -0
- package/Dockerfile +25 -0
- package/LICENCE +661 -0
- package/README.md +709 -0
- package/dist/constants.d.ts +42 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +45 -0
- package/dist/constants.js.map +1 -0
- package/dist/core/websocket-client.d.ts +261 -0
- package/dist/core/websocket-client.d.ts.map +1 -0
- package/dist/core/websocket-client.js +875 -0
- package/dist/core/websocket-client.js.map +1 -0
- package/dist/formatters/response-formatter.d.ts +354 -0
- package/dist/formatters/response-formatter.d.ts.map +1 -0
- package/dist/formatters/response-formatter.js +575 -0
- package/dist/formatters/response-formatter.js.map +1 -0
- package/dist/handlers/message-handler-registry.d.ts +155 -0
- package/dist/handlers/message-handler-registry.d.ts.map +1 -0
- package/dist/handlers/message-handler-registry.js +216 -0
- package/dist/handlers/message-handler-registry.js.map +1 -0
- package/dist/handlers/message-handlers/agent-selected-handler.d.ts +112 -0
- package/dist/handlers/message-handlers/agent-selected-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/agent-selected-handler.js +40 -0
- package/dist/handlers/message-handlers/agent-selected-handler.js.map +1 -0
- package/dist/handlers/message-handlers/agents-list-handler.d.ts +14 -0
- package/dist/handlers/message-handlers/agents-list-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/agents-list-handler.js +25 -0
- package/dist/handlers/message-handlers/agents-list-handler.js.map +1 -0
- package/dist/handlers/message-handlers/auth-error-handler.d.ts +71 -0
- package/dist/handlers/message-handlers/auth-error-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/auth-error-handler.js +30 -0
- package/dist/handlers/message-handlers/auth-error-handler.js.map +1 -0
- package/dist/handlers/message-handlers/auth-message-handler.d.ts +18 -0
- package/dist/handlers/message-handlers/auth-message-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/auth-message-handler.js +60 -0
- package/dist/handlers/message-handlers/auth-message-handler.js.map +1 -0
- package/dist/handlers/message-handlers/auth-required-handler.d.ts +76 -0
- package/dist/handlers/message-handlers/auth-required-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/auth-required-handler.js +23 -0
- package/dist/handlers/message-handlers/auth-required-handler.js.map +1 -0
- package/dist/handlers/message-handlers/auth-success-handler.d.ts +18 -0
- package/dist/handlers/message-handlers/auth-success-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/auth-success-handler.js +51 -0
- package/dist/handlers/message-handlers/auth-success-handler.js.map +1 -0
- package/dist/handlers/message-handlers/base-handler.d.ts +55 -0
- package/dist/handlers/message-handlers/base-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/base-handler.js +83 -0
- package/dist/handlers/message-handlers/base-handler.js.map +1 -0
- package/dist/handlers/message-handlers/challenge-handler.d.ts +73 -0
- package/dist/handlers/message-handlers/challenge-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/challenge-handler.js +47 -0
- package/dist/handlers/message-handlers/challenge-handler.js.map +1 -0
- package/dist/handlers/message-handlers/error-message-handler.d.ts +76 -0
- package/dist/handlers/message-handlers/error-message-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/error-message-handler.js +29 -0
- package/dist/handlers/message-handlers/error-message-handler.js.map +1 -0
- package/dist/handlers/message-handlers/index.d.ts +28 -0
- package/dist/handlers/message-handlers/index.d.ts.map +1 -0
- package/dist/handlers/message-handlers/index.js +100 -0
- package/dist/handlers/message-handlers/index.js.map +1 -0
- package/dist/handlers/message-handlers/list-rooms-response-handler.d.ts +122 -0
- package/dist/handlers/message-handlers/list-rooms-response-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/list-rooms-response-handler.js +30 -0
- package/dist/handlers/message-handlers/list-rooms-response-handler.js.map +1 -0
- package/dist/handlers/message-handlers/ping-pong-handler.d.ts +104 -0
- package/dist/handlers/message-handlers/ping-pong-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/ping-pong-handler.js +36 -0
- package/dist/handlers/message-handlers/ping-pong-handler.js.map +1 -0
- package/dist/handlers/message-handlers/regular-message-handler.d.ts +56 -0
- package/dist/handlers/message-handlers/regular-message-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/regular-message-handler.js +59 -0
- package/dist/handlers/message-handlers/regular-message-handler.js.map +1 -0
- package/dist/handlers/message-handlers/subscribe-response-handler.d.ts +81 -0
- package/dist/handlers/message-handlers/subscribe-response-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/subscribe-response-handler.js +48 -0
- package/dist/handlers/message-handlers/subscribe-response-handler.js.map +1 -0
- package/dist/handlers/message-handlers/task-response-handler.d.ts +14 -0
- package/dist/handlers/message-handlers/task-response-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/task-response-handler.js +44 -0
- package/dist/handlers/message-handlers/task-response-handler.js.map +1 -0
- package/dist/handlers/message-handlers/types.d.ts +51 -0
- package/dist/handlers/message-handlers/types.d.ts.map +1 -0
- package/dist/handlers/message-handlers/types.js +7 -0
- package/dist/handlers/message-handlers/types.js.map +1 -0
- package/dist/handlers/message-handlers/unsubscribe-response-handler.d.ts +81 -0
- package/dist/handlers/message-handlers/unsubscribe-response-handler.d.ts.map +1 -0
- package/dist/handlers/message-handlers/unsubscribe-response-handler.js +48 -0
- package/dist/handlers/message-handlers/unsubscribe-response-handler.js.map +1 -0
- package/dist/handlers/webhook-handler.d.ts +202 -0
- package/dist/handlers/webhook-handler.d.ts.map +1 -0
- package/dist/handlers/webhook-handler.js +511 -0
- package/dist/handlers/webhook-handler.js.map +1 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +217 -0
- package/dist/index.js.map +1 -0
- package/dist/managers/agent-registry.d.ts +173 -0
- package/dist/managers/agent-registry.d.ts.map +1 -0
- package/dist/managers/agent-registry.js +310 -0
- package/dist/managers/agent-registry.js.map +1 -0
- package/dist/managers/connection-manager.d.ts +134 -0
- package/dist/managers/connection-manager.d.ts.map +1 -0
- package/dist/managers/connection-manager.js +176 -0
- package/dist/managers/connection-manager.js.map +1 -0
- package/dist/managers/index.d.ts +9 -0
- package/dist/managers/index.d.ts.map +1 -0
- package/dist/managers/index.js +16 -0
- package/dist/managers/index.js.map +1 -0
- package/dist/managers/message-router.d.ts +112 -0
- package/dist/managers/message-router.d.ts.map +1 -0
- package/dist/managers/message-router.js +260 -0
- package/dist/managers/message-router.js.map +1 -0
- package/dist/managers/room-manager.d.ts +165 -0
- package/dist/managers/room-manager.d.ts.map +1 -0
- package/dist/managers/room-manager.js +227 -0
- package/dist/managers/room-manager.js.map +1 -0
- package/dist/teneo-sdk.d.ts +703 -0
- package/dist/teneo-sdk.d.ts.map +1 -0
- package/dist/teneo-sdk.js +907 -0
- package/dist/teneo-sdk.js.map +1 -0
- package/dist/types/config.d.ts +1047 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +720 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/error-codes.d.ts +29 -0
- package/dist/types/error-codes.d.ts.map +1 -0
- package/dist/types/error-codes.js +41 -0
- package/dist/types/error-codes.js.map +1 -0
- package/dist/types/events.d.ts +616 -0
- package/dist/types/events.d.ts.map +1 -0
- package/dist/types/events.js +261 -0
- package/dist/types/events.js.map +1 -0
- package/dist/types/health.d.ts +40 -0
- package/dist/types/health.d.ts.map +1 -0
- package/dist/types/health.js +6 -0
- package/dist/types/health.js.map +1 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +123 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/messages.d.ts +3734 -0
- package/dist/types/messages.d.ts.map +1 -0
- package/dist/types/messages.js +482 -0
- package/dist/types/messages.js.map +1 -0
- package/dist/types/validation.d.ts +81 -0
- package/dist/types/validation.d.ts.map +1 -0
- package/dist/types/validation.js +115 -0
- package/dist/types/validation.js.map +1 -0
- package/dist/utils/bounded-queue.d.ts +127 -0
- package/dist/utils/bounded-queue.d.ts.map +1 -0
- package/dist/utils/bounded-queue.js +181 -0
- package/dist/utils/bounded-queue.js.map +1 -0
- package/dist/utils/circuit-breaker.d.ts +141 -0
- package/dist/utils/circuit-breaker.d.ts.map +1 -0
- package/dist/utils/circuit-breaker.js +215 -0
- package/dist/utils/circuit-breaker.js.map +1 -0
- package/dist/utils/deduplication-cache.d.ts +110 -0
- package/dist/utils/deduplication-cache.d.ts.map +1 -0
- package/dist/utils/deduplication-cache.js +177 -0
- package/dist/utils/deduplication-cache.js.map +1 -0
- package/dist/utils/event-waiter.d.ts +101 -0
- package/dist/utils/event-waiter.d.ts.map +1 -0
- package/dist/utils/event-waiter.js +118 -0
- package/dist/utils/event-waiter.js.map +1 -0
- package/dist/utils/index.d.ts +51 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +72 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/logger.d.ts +22 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +91 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +122 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +190 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- package/dist/utils/retry-policy.d.ts +191 -0
- package/dist/utils/retry-policy.d.ts.map +1 -0
- package/dist/utils/retry-policy.js +225 -0
- package/dist/utils/retry-policy.js.map +1 -0
- package/dist/utils/secure-private-key.d.ts +113 -0
- package/dist/utils/secure-private-key.d.ts.map +1 -0
- package/dist/utils/secure-private-key.js +188 -0
- package/dist/utils/secure-private-key.js.map +1 -0
- package/dist/utils/signature-verifier.d.ts +143 -0
- package/dist/utils/signature-verifier.d.ts.map +1 -0
- package/dist/utils/signature-verifier.js +238 -0
- package/dist/utils/signature-verifier.js.map +1 -0
- package/dist/utils/ssrf-validator.d.ts +36 -0
- package/dist/utils/ssrf-validator.d.ts.map +1 -0
- package/dist/utils/ssrf-validator.js +195 -0
- package/dist/utils/ssrf-validator.js.map +1 -0
- package/examples/.env.example +17 -0
- package/examples/basic-usage.ts +211 -0
- package/examples/production-dashboard/.env.example +153 -0
- package/examples/production-dashboard/package.json +39 -0
- package/examples/production-dashboard/public/dashboard.html +642 -0
- package/examples/production-dashboard/server.ts +753 -0
- package/examples/webhook-integration.ts +239 -0
- package/examples/x-influencer-battle-redesign.html +1065 -0
- package/examples/x-influencer-battle-server.ts +217 -0
- package/examples/x-influencer-battle.html +787 -0
- package/package.json +65 -0
- package/src/constants.ts +43 -0
- package/src/core/websocket-client.test.ts +512 -0
- package/src/core/websocket-client.ts +1056 -0
- package/src/formatters/response-formatter.test.ts +571 -0
- package/src/formatters/response-formatter.ts +677 -0
- package/src/handlers/message-handler-registry.ts +239 -0
- package/src/handlers/message-handlers/agent-selected-handler.ts +40 -0
- package/src/handlers/message-handlers/agents-list-handler.ts +26 -0
- package/src/handlers/message-handlers/auth-error-handler.ts +31 -0
- package/src/handlers/message-handlers/auth-message-handler.ts +66 -0
- package/src/handlers/message-handlers/auth-required-handler.ts +23 -0
- package/src/handlers/message-handlers/auth-success-handler.ts +57 -0
- package/src/handlers/message-handlers/base-handler.ts +101 -0
- package/src/handlers/message-handlers/challenge-handler.ts +57 -0
- package/src/handlers/message-handlers/error-message-handler.ts +27 -0
- package/src/handlers/message-handlers/index.ts +77 -0
- package/src/handlers/message-handlers/list-rooms-response-handler.ts +28 -0
- package/src/handlers/message-handlers/ping-pong-handler.ts +30 -0
- package/src/handlers/message-handlers/regular-message-handler.ts +65 -0
- package/src/handlers/message-handlers/subscribe-response-handler.ts +47 -0
- package/src/handlers/message-handlers/task-response-handler.ts +45 -0
- package/src/handlers/message-handlers/types.ts +77 -0
- package/src/handlers/message-handlers/unsubscribe-response-handler.ts +47 -0
- package/src/handlers/webhook-handler.test.ts +789 -0
- package/src/handlers/webhook-handler.ts +576 -0
- package/src/index.ts +269 -0
- package/src/managers/agent-registry.test.ts +466 -0
- package/src/managers/agent-registry.ts +347 -0
- package/src/managers/connection-manager.ts +195 -0
- package/src/managers/index.ts +9 -0
- package/src/managers/message-router.ts +349 -0
- package/src/managers/room-manager.ts +248 -0
- package/src/teneo-sdk.ts +1022 -0
- package/src/types/config.test.ts +325 -0
- package/src/types/config.ts +799 -0
- package/src/types/error-codes.ts +44 -0
- package/src/types/events.test.ts +302 -0
- package/src/types/events.ts +382 -0
- package/src/types/health.ts +46 -0
- package/src/types/index.ts +199 -0
- package/src/types/messages.test.ts +660 -0
- package/src/types/messages.ts +570 -0
- package/src/types/validation.ts +123 -0
- package/src/utils/bounded-queue.test.ts +356 -0
- package/src/utils/bounded-queue.ts +205 -0
- package/src/utils/circuit-breaker.test.ts +394 -0
- package/src/utils/circuit-breaker.ts +262 -0
- package/src/utils/deduplication-cache.test.ts +380 -0
- package/src/utils/deduplication-cache.ts +198 -0
- package/src/utils/event-waiter.test.ts +381 -0
- package/src/utils/event-waiter.ts +172 -0
- package/src/utils/index.ts +74 -0
- package/src/utils/logger.ts +87 -0
- package/src/utils/rate-limiter.test.ts +341 -0
- package/src/utils/rate-limiter.ts +211 -0
- package/src/utils/retry-policy.test.ts +558 -0
- package/src/utils/retry-policy.ts +272 -0
- package/src/utils/secure-private-key.test.ts +356 -0
- package/src/utils/secure-private-key.ts +205 -0
- package/src/utils/signature-verifier.test.ts +464 -0
- package/src/utils/signature-verifier.ts +298 -0
- package/src/utils/ssrf-validator.test.ts +372 -0
- package/src/utils/ssrf-validator.ts +224 -0
- package/tests/integration/real-server.test.ts +740 -0
- package/tests/integration/websocket.test.ts +381 -0
- package/tests/integration-setup.ts +16 -0
- package/tests/setup.ts +34 -0
- package/tsconfig.json +32 -0
- package/vitest.config.ts +42 -0
- package/vitest.integration.config.ts +23 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Token Bucket Rate Limiter
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
6
|
+
import { TokenBucketRateLimiter, RateLimitError } from "./rate-limiter";
|
|
7
|
+
|
|
8
|
+
describe("TokenBucketRateLimiter", () => {
|
|
9
|
+
describe("constructor", () => {
|
|
10
|
+
it("should create a rate limiter with valid parameters", () => {
|
|
11
|
+
const limiter = new TokenBucketRateLimiter(10, 20);
|
|
12
|
+
const config = limiter.getConfig();
|
|
13
|
+
|
|
14
|
+
expect(config.tokensPerSecond).toBe(10);
|
|
15
|
+
expect(config.maxBurst).toBe(20);
|
|
16
|
+
expect(limiter.getAvailableTokens()).toBe(20); // Starts full
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should throw error if tokensPerSecond is less than 1", () => {
|
|
20
|
+
expect(() => new TokenBucketRateLimiter(0, 10)).toThrow(
|
|
21
|
+
"tokensPerSecond must be at least 1"
|
|
22
|
+
);
|
|
23
|
+
expect(() => new TokenBucketRateLimiter(-1, 10)).toThrow(
|
|
24
|
+
"tokensPerSecond must be at least 1"
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should throw error if maxBurst is less than 1", () => {
|
|
29
|
+
expect(() => new TokenBucketRateLimiter(10, 0)).toThrow(
|
|
30
|
+
"maxBurst must be at least 1"
|
|
31
|
+
);
|
|
32
|
+
expect(() => new TokenBucketRateLimiter(10, -1)).toThrow(
|
|
33
|
+
"maxBurst must be at least 1"
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("tryConsume", () => {
|
|
39
|
+
it("should consume tokens successfully when available", () => {
|
|
40
|
+
const limiter = new TokenBucketRateLimiter(10, 5);
|
|
41
|
+
|
|
42
|
+
expect(limiter.tryConsume()).toBe(true);
|
|
43
|
+
expect(limiter.getAvailableTokens()).toBeCloseTo(4, 0);
|
|
44
|
+
|
|
45
|
+
expect(limiter.tryConsume()).toBe(true);
|
|
46
|
+
expect(limiter.getAvailableTokens()).toBeCloseTo(3, 0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should return false when no tokens available", () => {
|
|
50
|
+
const limiter = new TokenBucketRateLimiter(10, 2);
|
|
51
|
+
|
|
52
|
+
// Consume all tokens
|
|
53
|
+
expect(limiter.tryConsume()).toBe(true);
|
|
54
|
+
expect(limiter.tryConsume()).toBe(true);
|
|
55
|
+
|
|
56
|
+
// Should fail now
|
|
57
|
+
expect(limiter.tryConsume()).toBe(false);
|
|
58
|
+
expect(limiter.tryConsume()).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should allow burst up to maxBurst", () => {
|
|
62
|
+
const limiter = new TokenBucketRateLimiter(10, 5);
|
|
63
|
+
|
|
64
|
+
// Should allow 5 rapid operations
|
|
65
|
+
for (let i = 0; i < 5; i++) {
|
|
66
|
+
expect(limiter.tryConsume()).toBe(true);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 6th should fail
|
|
70
|
+
expect(limiter.tryConsume()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("token refill", () => {
|
|
75
|
+
it("should refill tokens over time", async () => {
|
|
76
|
+
// 10 tokens/sec = 1 token per 100ms
|
|
77
|
+
const limiter = new TokenBucketRateLimiter(10, 5);
|
|
78
|
+
|
|
79
|
+
// Consume all tokens
|
|
80
|
+
for (let i = 0; i < 5; i++) {
|
|
81
|
+
limiter.tryConsume();
|
|
82
|
+
}
|
|
83
|
+
expect(limiter.getAvailableTokens()).toBeCloseTo(0, 0);
|
|
84
|
+
|
|
85
|
+
// Wait 250ms (should refill ~2.5 tokens)
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
87
|
+
|
|
88
|
+
const tokens = limiter.getAvailableTokens();
|
|
89
|
+
expect(tokens).toBeGreaterThan(2);
|
|
90
|
+
expect(tokens).toBeLessThan(3);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should cap refill at maxBurst", async () => {
|
|
94
|
+
const limiter = new TokenBucketRateLimiter(100, 5); // Fast refill
|
|
95
|
+
|
|
96
|
+
// Wait long enough to refill way past maxBurst
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
98
|
+
|
|
99
|
+
expect(limiter.getAvailableTokens()).toBe(5); // Capped at maxBurst
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should refill continuously at specified rate", async () => {
|
|
103
|
+
// 20 tokens/sec = 1 token per 50ms
|
|
104
|
+
const limiter = new TokenBucketRateLimiter(20, 10);
|
|
105
|
+
|
|
106
|
+
// Consume all
|
|
107
|
+
for (let i = 0; i < 10; i++) {
|
|
108
|
+
limiter.tryConsume();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Wait and check refill multiple times
|
|
112
|
+
await new Promise((resolve) => setTimeout(resolve, 100)); // ~2 tokens
|
|
113
|
+
expect(limiter.getAvailableTokens()).toBeGreaterThan(1.5);
|
|
114
|
+
expect(limiter.getAvailableTokens()).toBeLessThan(2.5);
|
|
115
|
+
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 100)); // ~4 tokens total
|
|
117
|
+
expect(limiter.getAvailableTokens()).toBeGreaterThan(3.5);
|
|
118
|
+
expect(limiter.getAvailableTokens()).toBeLessThan(4.5);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("consume (blocking)", () => {
|
|
123
|
+
it("should consume immediately when token available", async () => {
|
|
124
|
+
const limiter = new TokenBucketRateLimiter(10, 5);
|
|
125
|
+
|
|
126
|
+
const start = Date.now();
|
|
127
|
+
await limiter.consume();
|
|
128
|
+
const elapsed = Date.now() - start;
|
|
129
|
+
|
|
130
|
+
expect(elapsed).toBeLessThan(50); // Should be immediate
|
|
131
|
+
expect(limiter.getAvailableTokens()).toBeCloseTo(4, 0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should wait for token when none available", async () => {
|
|
135
|
+
// 10 tokens/sec = 100ms per token
|
|
136
|
+
const limiter = new TokenBucketRateLimiter(10, 2);
|
|
137
|
+
|
|
138
|
+
// Consume all
|
|
139
|
+
limiter.tryConsume();
|
|
140
|
+
limiter.tryConsume();
|
|
141
|
+
|
|
142
|
+
const start = Date.now();
|
|
143
|
+
await limiter.consume(); // Should wait ~100ms for refill
|
|
144
|
+
const elapsed = Date.now() - start;
|
|
145
|
+
|
|
146
|
+
expect(elapsed).toBeGreaterThan(80); // Allow some timing variance
|
|
147
|
+
expect(elapsed).toBeLessThan(200);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should throw RateLimitError when timeout exceeded", async () => {
|
|
151
|
+
const limiter = new TokenBucketRateLimiter(10, 1);
|
|
152
|
+
|
|
153
|
+
// Consume token
|
|
154
|
+
limiter.tryConsume();
|
|
155
|
+
|
|
156
|
+
// Try to consume with very short timeout (token needs 100ms to refill)
|
|
157
|
+
await expect(limiter.consume(10)).rejects.toThrow(RateLimitError);
|
|
158
|
+
|
|
159
|
+
// Reset and try again to verify error message
|
|
160
|
+
limiter.reset();
|
|
161
|
+
limiter.tryConsume();
|
|
162
|
+
await expect(limiter.consume(10)).rejects.toThrow(
|
|
163
|
+
/Rate limit timeout/
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should succeed within timeout if token becomes available", async () => {
|
|
168
|
+
// 10 tokens/sec = 100ms per token
|
|
169
|
+
const limiter = new TokenBucketRateLimiter(10, 1);
|
|
170
|
+
|
|
171
|
+
limiter.tryConsume();
|
|
172
|
+
|
|
173
|
+
// Wait with generous timeout
|
|
174
|
+
await expect(limiter.consume(500)).resolves.toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("reset", () => {
|
|
179
|
+
it("should reset tokens to full capacity", () => {
|
|
180
|
+
const limiter = new TokenBucketRateLimiter(10, 5);
|
|
181
|
+
|
|
182
|
+
// Consume some tokens
|
|
183
|
+
limiter.tryConsume();
|
|
184
|
+
limiter.tryConsume();
|
|
185
|
+
limiter.tryConsume();
|
|
186
|
+
expect(limiter.getAvailableTokens()).toBeCloseTo(2, 0);
|
|
187
|
+
|
|
188
|
+
// Reset
|
|
189
|
+
limiter.reset();
|
|
190
|
+
expect(limiter.getAvailableTokens()).toBe(5);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("should reset refill timer", async () => {
|
|
194
|
+
const limiter = new TokenBucketRateLimiter(10, 5);
|
|
195
|
+
|
|
196
|
+
// Consume all
|
|
197
|
+
for (let i = 0; i < 5; i++) {
|
|
198
|
+
limiter.tryConsume();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Wait a bit
|
|
202
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
203
|
+
|
|
204
|
+
// Reset should clear any pending refill
|
|
205
|
+
limiter.reset();
|
|
206
|
+
expect(limiter.getAvailableTokens()).toBe(5);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("getAvailableTokens", () => {
|
|
211
|
+
it("should return current token count", () => {
|
|
212
|
+
const limiter = new TokenBucketRateLimiter(10, 10);
|
|
213
|
+
|
|
214
|
+
expect(limiter.getAvailableTokens()).toBe(10);
|
|
215
|
+
|
|
216
|
+
limiter.tryConsume();
|
|
217
|
+
expect(limiter.getAvailableTokens()).toBeCloseTo(9, 0);
|
|
218
|
+
|
|
219
|
+
limiter.tryConsume();
|
|
220
|
+
expect(limiter.getAvailableTokens()).toBeCloseTo(8, 0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("should trigger refill before returning", async () => {
|
|
224
|
+
const limiter = new TokenBucketRateLimiter(10, 5);
|
|
225
|
+
|
|
226
|
+
// Consume all
|
|
227
|
+
for (let i = 0; i < 5; i++) {
|
|
228
|
+
limiter.tryConsume();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Wait for refill
|
|
232
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
233
|
+
|
|
234
|
+
// getAvailableTokens should show refilled amount
|
|
235
|
+
const tokens = limiter.getAvailableTokens();
|
|
236
|
+
expect(tokens).toBeGreaterThan(1);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("getConfig", () => {
|
|
241
|
+
it("should return configuration", () => {
|
|
242
|
+
const limiter = new TokenBucketRateLimiter(15, 30);
|
|
243
|
+
const config = limiter.getConfig();
|
|
244
|
+
|
|
245
|
+
expect(config).toEqual({
|
|
246
|
+
tokensPerSecond: 15,
|
|
247
|
+
maxBurst: 30
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("edge cases", () => {
|
|
253
|
+
it("should handle very high rates", () => {
|
|
254
|
+
const limiter = new TokenBucketRateLimiter(1000, 1000);
|
|
255
|
+
|
|
256
|
+
// Should allow 1000 rapid operations
|
|
257
|
+
for (let i = 0; i < 1000; i++) {
|
|
258
|
+
expect(limiter.tryConsume()).toBe(true);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
expect(limiter.tryConsume()).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("should handle rate of 1 token per second", async () => {
|
|
265
|
+
const limiter = new TokenBucketRateLimiter(1, 2);
|
|
266
|
+
|
|
267
|
+
limiter.tryConsume();
|
|
268
|
+
limiter.tryConsume();
|
|
269
|
+
|
|
270
|
+
// Should take ~1 second for next token
|
|
271
|
+
const start = Date.now();
|
|
272
|
+
await limiter.consume();
|
|
273
|
+
const elapsed = Date.now() - start;
|
|
274
|
+
|
|
275
|
+
expect(elapsed).toBeGreaterThan(900);
|
|
276
|
+
expect(elapsed).toBeLessThan(1200);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("should handle burst = 1 (no burst)", () => {
|
|
280
|
+
const limiter = new TokenBucketRateLimiter(10, 1);
|
|
281
|
+
|
|
282
|
+
expect(limiter.tryConsume()).toBe(true);
|
|
283
|
+
expect(limiter.tryConsume()).toBe(false); // Immediate rate limit
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("should be reusable after rate limit", async () => {
|
|
287
|
+
const limiter = new TokenBucketRateLimiter(10, 2);
|
|
288
|
+
|
|
289
|
+
// Hit rate limit
|
|
290
|
+
limiter.tryConsume();
|
|
291
|
+
limiter.tryConsume();
|
|
292
|
+
expect(limiter.tryConsume()).toBe(false);
|
|
293
|
+
|
|
294
|
+
// Wait for refill
|
|
295
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
296
|
+
|
|
297
|
+
// Should work again
|
|
298
|
+
expect(limiter.tryConsume()).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("concurrent operations", () => {
|
|
303
|
+
it("should handle multiple consume calls correctly", async () => {
|
|
304
|
+
const limiter = new TokenBucketRateLimiter(10, 3);
|
|
305
|
+
|
|
306
|
+
// Consume all tokens
|
|
307
|
+
limiter.tryConsume();
|
|
308
|
+
limiter.tryConsume();
|
|
309
|
+
limiter.tryConsume();
|
|
310
|
+
|
|
311
|
+
// Start multiple blocking consume calls
|
|
312
|
+
const promises = [
|
|
313
|
+
limiter.consume(1000),
|
|
314
|
+
limiter.consume(1000),
|
|
315
|
+
limiter.consume(1000)
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
// All should eventually succeed as tokens refill
|
|
319
|
+
await expect(Promise.all(promises)).resolves.toBeDefined();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("RateLimitError", () => {
|
|
325
|
+
it("should be instanceof Error", () => {
|
|
326
|
+
const error = new RateLimitError("Test");
|
|
327
|
+
expect(error).toBeInstanceOf(Error);
|
|
328
|
+
expect(error).toBeInstanceOf(RateLimitError);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("should have correct name", () => {
|
|
332
|
+
const error = new RateLimitError("Test");
|
|
333
|
+
expect(error.name).toBe("RateLimitError");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("should preserve error message", () => {
|
|
337
|
+
const message = "Custom rate limit message";
|
|
338
|
+
const error = new RateLimitError(message);
|
|
339
|
+
expect(error.message).toBe(message);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Bucket Rate Limiter
|
|
3
|
+
* Prevents message flooding and ensures fair resource usage
|
|
4
|
+
*
|
|
5
|
+
* Uses the token bucket algorithm: tokens are added at a constant rate,
|
|
6
|
+
* and each operation consumes one token. When no tokens are available,
|
|
7
|
+
* operations must wait until tokens are replenished.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Error thrown when rate limit is exceeded with tryConsume()
|
|
12
|
+
*/
|
|
13
|
+
export class RateLimitError extends Error {
|
|
14
|
+
constructor(message: string) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'RateLimitError';
|
|
17
|
+
|
|
18
|
+
if (Error.captureStackTrace) {
|
|
19
|
+
Error.captureStackTrace(this, RateLimitError);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Token bucket rate limiter for controlling operation frequency
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* // Allow 10 messages per second with burst of 20
|
|
30
|
+
* const limiter = new TokenBucketRateLimiter(10, 20);
|
|
31
|
+
*
|
|
32
|
+
* // Non-blocking check
|
|
33
|
+
* if (limiter.tryConsume()) {
|
|
34
|
+
* await sendMessage(msg);
|
|
35
|
+
* } else {
|
|
36
|
+
* console.log('Rate limit exceeded');
|
|
37
|
+
* }
|
|
38
|
+
*
|
|
39
|
+
* // Blocking wait (auto-waits for token)
|
|
40
|
+
* await limiter.consume();
|
|
41
|
+
* await sendMessage(msg);
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export class TokenBucketRateLimiter {
|
|
45
|
+
private tokens: number;
|
|
46
|
+
private lastRefill: number;
|
|
47
|
+
private readonly refillInterval: number; // ms per token
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Creates a new token bucket rate limiter
|
|
51
|
+
*
|
|
52
|
+
* @param tokensPerSecond - Rate at which tokens are added (operations per second)
|
|
53
|
+
* @param maxBurst - Maximum tokens that can accumulate (burst capacity)
|
|
54
|
+
* @throws {Error} If tokensPerSecond or maxBurst is less than 1
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* // 10 ops/sec, burst up to 20
|
|
59
|
+
* const limiter = new TokenBucketRateLimiter(10, 20);
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
constructor(
|
|
63
|
+
private readonly tokensPerSecond: number,
|
|
64
|
+
private readonly maxBurst: number
|
|
65
|
+
) {
|
|
66
|
+
if (tokensPerSecond < 1) {
|
|
67
|
+
throw new Error('TokenBucketRateLimiter tokensPerSecond must be at least 1');
|
|
68
|
+
}
|
|
69
|
+
if (maxBurst < 1) {
|
|
70
|
+
throw new Error('TokenBucketRateLimiter maxBurst must be at least 1');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.tokens = maxBurst; // Start with full bucket
|
|
74
|
+
this.lastRefill = Date.now();
|
|
75
|
+
this.refillInterval = 1000 / tokensPerSecond; // ms between tokens
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Try to consume one token without blocking
|
|
80
|
+
*
|
|
81
|
+
* @returns true if token was consumed, false if no tokens available
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```typescript
|
|
85
|
+
* if (limiter.tryConsume()) {
|
|
86
|
+
* // Proceed with operation
|
|
87
|
+
* } else {
|
|
88
|
+
* // Rate limited - handle accordingly
|
|
89
|
+
* }
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
public tryConsume(): boolean {
|
|
93
|
+
this.refill();
|
|
94
|
+
|
|
95
|
+
if (this.tokens >= 1) {
|
|
96
|
+
this.tokens -= 1;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Consume one token, waiting if necessary until a token is available
|
|
105
|
+
*
|
|
106
|
+
* @param timeout - Optional max wait time in ms (default: no timeout)
|
|
107
|
+
* @returns Promise that resolves when token is consumed
|
|
108
|
+
* @throws {RateLimitError} If timeout is exceeded
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```typescript
|
|
112
|
+
* // Wait indefinitely for token
|
|
113
|
+
* await limiter.consume();
|
|
114
|
+
*
|
|
115
|
+
* // Wait max 5 seconds
|
|
116
|
+
* try {
|
|
117
|
+
* await limiter.consume(5000);
|
|
118
|
+
* } catch (error) {
|
|
119
|
+
* console.log('Timeout waiting for rate limit');
|
|
120
|
+
* }
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
public async consume(timeout?: number): Promise<void> {
|
|
124
|
+
const startTime = Date.now();
|
|
125
|
+
|
|
126
|
+
while (true) {
|
|
127
|
+
if (this.tryConsume()) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check timeout
|
|
132
|
+
const elapsed = Date.now() - startTime;
|
|
133
|
+
if (timeout !== undefined && elapsed >= timeout) {
|
|
134
|
+
throw new RateLimitError(
|
|
135
|
+
`Rate limit timeout: no token available after ${timeout}ms`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Calculate wait time until next token
|
|
140
|
+
// If timeout is specified, don't wait longer than remaining timeout
|
|
141
|
+
const baseWaitTime = Math.min(this.refillInterval, 100);
|
|
142
|
+
const waitTime = timeout !== undefined
|
|
143
|
+
? Math.min(baseWaitTime, timeout - elapsed)
|
|
144
|
+
: baseWaitTime;
|
|
145
|
+
|
|
146
|
+
// If waitTime is very small or negative, check timeout immediately
|
|
147
|
+
if (waitTime <= 0) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await this.sleep(waitTime);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get the number of tokens currently available
|
|
157
|
+
* Refills tokens before returning count
|
|
158
|
+
*
|
|
159
|
+
* @returns Number of tokens available (may be fractional before consumption)
|
|
160
|
+
*/
|
|
161
|
+
public getAvailableTokens(): number {
|
|
162
|
+
this.refill();
|
|
163
|
+
return this.tokens;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get rate limiter configuration
|
|
168
|
+
*
|
|
169
|
+
* @returns Configuration object with rate and burst capacity
|
|
170
|
+
*/
|
|
171
|
+
public getConfig(): { tokensPerSecond: number; maxBurst: number } {
|
|
172
|
+
return {
|
|
173
|
+
tokensPerSecond: this.tokensPerSecond,
|
|
174
|
+
maxBurst: this.maxBurst
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reset the rate limiter to full capacity
|
|
180
|
+
* Useful for testing or manual reset scenarios
|
|
181
|
+
*/
|
|
182
|
+
public reset(): void {
|
|
183
|
+
this.tokens = this.maxBurst;
|
|
184
|
+
this.lastRefill = Date.now();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Refill tokens based on elapsed time since last refill
|
|
189
|
+
* Called automatically before token consumption
|
|
190
|
+
*/
|
|
191
|
+
private refill(): void {
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
const elapsed = now - this.lastRefill;
|
|
194
|
+
|
|
195
|
+
if (elapsed > 0) {
|
|
196
|
+
// Calculate tokens to add based on elapsed time
|
|
197
|
+
const tokensToAdd = elapsed / this.refillInterval;
|
|
198
|
+
|
|
199
|
+
// Add tokens, capped at maxBurst
|
|
200
|
+
this.tokens = Math.min(this.tokens + tokensToAdd, this.maxBurst);
|
|
201
|
+
this.lastRefill = now;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Sleep helper for async waiting
|
|
207
|
+
*/
|
|
208
|
+
private sleep(ms: number): Promise<void> {
|
|
209
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
210
|
+
}
|
|
211
|
+
}
|