@stimulcross/rate-limiter 0.0.1

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.
Files changed (112) hide show
  1. package/.editorconfig +21 -0
  2. package/.github/workflows/node.yml +87 -0
  3. package/.husky/commit-msg +1 -0
  4. package/.husky/pre-commit +1 -0
  5. package/.megaignore +8 -0
  6. package/.prettierignore +3 -0
  7. package/LICENSE +21 -0
  8. package/README.md +7 -0
  9. package/commitlint.config.js +8 -0
  10. package/eslint.config.js +65 -0
  11. package/lint-staged.config.js +4 -0
  12. package/package.json +89 -0
  13. package/prettier.config.cjs +1 -0
  14. package/src/core/cancellable.ts +4 -0
  15. package/src/core/clock.ts +9 -0
  16. package/src/core/decision.ts +27 -0
  17. package/src/core/rate-limit-policy.ts +15 -0
  18. package/src/core/rate-limiter-status.ts +14 -0
  19. package/src/core/rate-limiter.ts +37 -0
  20. package/src/core/state-storage.ts +51 -0
  21. package/src/enums/rate-limit-error-code.ts +29 -0
  22. package/src/errors/custom.error.ts +14 -0
  23. package/src/errors/invalid-cost.error.ts +33 -0
  24. package/src/errors/rate-limit.error.ts +91 -0
  25. package/src/errors/rate-limiter-destroyed.error.ts +8 -0
  26. package/src/index.ts +11 -0
  27. package/src/interfaces/rate-limiter-options.ts +84 -0
  28. package/src/interfaces/rate-limiter-queue-options.ts +45 -0
  29. package/src/interfaces/rate-limiter-run-options.ts +58 -0
  30. package/src/limiters/abstract-rate-limiter.ts +206 -0
  31. package/src/limiters/composite.policy.ts +102 -0
  32. package/src/limiters/fixed-window/fixed-window.limiter.ts +121 -0
  33. package/src/limiters/fixed-window/fixed-window.options.ts +29 -0
  34. package/src/limiters/fixed-window/fixed-window.policy.ts +159 -0
  35. package/src/limiters/fixed-window/fixed-window.state.ts +10 -0
  36. package/src/limiters/fixed-window/fixed-window.status.ts +46 -0
  37. package/src/limiters/fixed-window/index.ts +4 -0
  38. package/src/limiters/generic-cell/generic-cell.limiter.ts +108 -0
  39. package/src/limiters/generic-cell/generic-cell.options.ts +23 -0
  40. package/src/limiters/generic-cell/generic-cell.policy.ts +115 -0
  41. package/src/limiters/generic-cell/generic-cell.state.ts +8 -0
  42. package/src/limiters/generic-cell/generic-cell.status.ts +54 -0
  43. package/src/limiters/generic-cell/index.ts +4 -0
  44. package/src/limiters/http-response-based/http-limit-info.extractor.ts +20 -0
  45. package/src/limiters/http-response-based/http-limit.info.ts +41 -0
  46. package/src/limiters/http-response-based/http-response-based-limiter.options.ts +18 -0
  47. package/src/limiters/http-response-based/http-response-based-limiter.state.ts +13 -0
  48. package/src/limiters/http-response-based/http-response-based-limiter.status.ts +74 -0
  49. package/src/limiters/http-response-based/http-response-based.limiter.ts +512 -0
  50. package/src/limiters/http-response-based/index.ts +6 -0
  51. package/src/limiters/leaky-bucket/index.ts +4 -0
  52. package/src/limiters/leaky-bucket/leaky-bucket.limiter.ts +105 -0
  53. package/src/limiters/leaky-bucket/leaky-bucket.options.ts +23 -0
  54. package/src/limiters/leaky-bucket/leaky-bucket.policy.ts +134 -0
  55. package/src/limiters/leaky-bucket/leaky-bucket.state.ts +9 -0
  56. package/src/limiters/leaky-bucket/leaky-bucket.status.ts +36 -0
  57. package/src/limiters/sliding-window-counter/index.ts +7 -0
  58. package/src/limiters/sliding-window-counter/sliding-window-counter.limiter.ts +76 -0
  59. package/src/limiters/sliding-window-counter/sliding-window-counter.options.ts +20 -0
  60. package/src/limiters/sliding-window-counter/sliding-window-counter.policy.ts +167 -0
  61. package/src/limiters/sliding-window-counter/sliding-window-counter.state.ts +10 -0
  62. package/src/limiters/sliding-window-counter/sliding-window-counter.status.ts +53 -0
  63. package/src/limiters/sliding-window-log/index.ts +4 -0
  64. package/src/limiters/sliding-window-log/sliding-window-log.limiter.ts +65 -0
  65. package/src/limiters/sliding-window-log/sliding-window-log.options.ts +20 -0
  66. package/src/limiters/sliding-window-log/sliding-window-log.policy.ts +166 -0
  67. package/src/limiters/sliding-window-log/sliding-window-log.state.ts +19 -0
  68. package/src/limiters/sliding-window-log/sliding-window-log.status.ts +44 -0
  69. package/src/limiters/token-bucket/index.ts +4 -0
  70. package/src/limiters/token-bucket/token-bucket.limiter.ts +110 -0
  71. package/src/limiters/token-bucket/token-bucket.options.ts +17 -0
  72. package/src/limiters/token-bucket/token-bucket.policy.ts +155 -0
  73. package/src/limiters/token-bucket/token-bucket.state.ts +10 -0
  74. package/src/limiters/token-bucket/token-bucket.status.ts +36 -0
  75. package/src/runtime/default-clock.ts +8 -0
  76. package/src/runtime/execution-tickets.ts +34 -0
  77. package/src/runtime/in-memory-state-store.ts +135 -0
  78. package/src/runtime/rate-limiter.executor.ts +286 -0
  79. package/src/runtime/semaphore.ts +31 -0
  80. package/src/runtime/task.ts +141 -0
  81. package/src/types/limit-behavior.ts +8 -0
  82. package/src/utils/generate-random-string.ts +16 -0
  83. package/src/utils/promise-with-resolvers.ts +23 -0
  84. package/src/utils/sanitize-error.ts +4 -0
  85. package/src/utils/sanitize-priority.ts +22 -0
  86. package/src/utils/validate-cost.ts +16 -0
  87. package/tests/integration/limiters/fixed-window.limiter.spec.ts +371 -0
  88. package/tests/integration/limiters/generic-cell.limiter.spec.ts +361 -0
  89. package/tests/integration/limiters/http-response-based.limiter.spec.ts +833 -0
  90. package/tests/integration/limiters/leaky-bucket.spec.ts +357 -0
  91. package/tests/integration/limiters/sliding-window-counter.limiter.spec.ts +175 -0
  92. package/tests/integration/limiters/sliding-window-log.spec.ts +185 -0
  93. package/tests/integration/limiters/token-bucket.limiter.spec.ts +363 -0
  94. package/tests/tsconfig.json +4 -0
  95. package/tests/unit/policies/composite.policy.spec.ts +244 -0
  96. package/tests/unit/policies/fixed-window.policy.spec.ts +260 -0
  97. package/tests/unit/policies/generic-cell.policy.spec.ts +178 -0
  98. package/tests/unit/policies/leaky-bucket.policy.spec.ts +215 -0
  99. package/tests/unit/policies/sliding-window-counter.policy.spec.ts +209 -0
  100. package/tests/unit/policies/sliding-window-log.policy.spec.ts +285 -0
  101. package/tests/unit/policies/token-bucket.policy.spec.ts +371 -0
  102. package/tests/unit/runtime/execution-tickets.spec.ts +121 -0
  103. package/tests/unit/runtime/in-memory-state-store.spec.ts +238 -0
  104. package/tests/unit/runtime/rate-limiter.executor.spec.ts +353 -0
  105. package/tests/unit/runtime/semaphore.spec.ts +98 -0
  106. package/tests/unit/runtime/task.spec.ts +182 -0
  107. package/tests/unit/utils/generate-random-string.spec.ts +51 -0
  108. package/tests/unit/utils/promise-with-resolvers.spec.ts +57 -0
  109. package/tests/unit/utils/sanitize-priority.spec.ts +46 -0
  110. package/tests/unit/utils/validate-cost.spec.ts +48 -0
  111. package/tsconfig.json +14 -0
  112. package/vitest.config.js +22 -0
@@ -0,0 +1,166 @@
1
+ import { Deque } from '@stimulcross/ds-deque';
2
+ import { type SlidingWindowLogEntry, type SlidingWindowLogState } from './sliding-window-log.state.js';
3
+ import { type SlidingWindowLogStatus } from './sliding-window-log.status.js';
4
+ import { type RateLimitPolicy, type RateLimitPolicyResult } from '../../core/rate-limit-policy.js';
5
+ import { validateCost } from '../../utils/validate-cost.js';
6
+
7
+ /** @internal */
8
+ export class SlidingWindowLogPolicy implements RateLimitPolicy<SlidingWindowLogState, SlidingWindowLogStatus> {
9
+ constructor(
10
+ private readonly _limit: number,
11
+ private readonly _windowMs: number,
12
+ ) {
13
+ if (!Number.isFinite(_limit) || !Number.isInteger(_limit) || _limit <= 0) {
14
+ throw new Error(`Invalid limit: ${_limit}. Must be a positive integer.`);
15
+ }
16
+
17
+ if (!Number.isFinite(_windowMs) || !Number.isInteger(_windowMs) || _windowMs <= 0) {
18
+ throw new Error(`Invalid windowMs: ${_windowMs}. Must be a positive integer.`);
19
+ }
20
+ }
21
+
22
+ public get limit(): number {
23
+ return this._limit;
24
+ }
25
+
26
+ public get windowMs(): number {
27
+ return this._windowMs;
28
+ }
29
+
30
+ public getInitialState(): SlidingWindowLogState {
31
+ return {
32
+ logs: new Deque<SlidingWindowLogEntry>(),
33
+ totalUsed: 0,
34
+ };
35
+ }
36
+
37
+ public getStatus(state: SlidingWindowLogState, now: number): SlidingWindowLogStatus {
38
+ const syncedState = this._syncState(state, now);
39
+ const { logs, totalUsed } = syncedState;
40
+
41
+ const remaining = Math.max(0, this._limit - totalUsed);
42
+
43
+ const nextAvailableAt = this._calculateAvailableAt(logs, totalUsed, 1, now);
44
+
45
+ const tail = logs.peekTail();
46
+ const resetAt = tail ? tail.ts + this._windowMs : now;
47
+
48
+ return {
49
+ limit: this._limit,
50
+ windowMs: this._windowMs,
51
+ totalUsed,
52
+ remaining,
53
+ nextAvailableAt,
54
+ resetAt,
55
+ };
56
+ }
57
+
58
+ public evaluate(
59
+ state: SlidingWindowLogState,
60
+ now: number,
61
+ cost: number,
62
+ ): RateLimitPolicyResult<SlidingWindowLogState> {
63
+ validateCost(cost, this._limit);
64
+
65
+ const syncedState = this._syncState(state, now);
66
+ const { logs, totalUsed } = syncedState;
67
+
68
+ if (totalUsed + cost <= this._limit) {
69
+ const tail = logs.peekTail();
70
+
71
+ if (tail?.ts === now) {
72
+ tail.count += cost;
73
+ } else {
74
+ logs.push({ ts: now, count: cost });
75
+ }
76
+
77
+ return {
78
+ decision: { kind: 'allow' },
79
+ nextState: { logs, totalUsed: totalUsed + cost },
80
+ };
81
+ }
82
+
83
+ const retryAt = this._calculateAvailableAt(logs, totalUsed, cost, now);
84
+
85
+ return {
86
+ decision: { kind: 'deny', retryAt },
87
+ nextState: syncedState,
88
+ };
89
+ }
90
+
91
+ public revert(state: SlidingWindowLogState, cost: number, now: number): SlidingWindowLogState {
92
+ if (cost <= 0 || state.totalUsed === 0) {
93
+ return state;
94
+ }
95
+
96
+ const syncedState = this._syncState(state, now);
97
+ const { logs } = syncedState;
98
+ let { totalUsed } = syncedState;
99
+
100
+ let remainingToRemove = cost;
101
+
102
+ while (remainingToRemove > 0 && !logs.isEmpty) {
103
+ const tail = logs.peekTail()!;
104
+
105
+ if (tail.count <= remainingToRemove) {
106
+ remainingToRemove -= tail.count;
107
+ totalUsed -= tail.count;
108
+ logs.pop();
109
+ } else {
110
+ tail.count -= remainingToRemove;
111
+ totalUsed -= remainingToRemove;
112
+ remainingToRemove = 0;
113
+ }
114
+ }
115
+
116
+ return { logs, totalUsed: Math.max(0, totalUsed) };
117
+ }
118
+
119
+ private _syncState(state: SlidingWindowLogState, now: number): SlidingWindowLogState {
120
+ const { logs } = state;
121
+ let { totalUsed } = state;
122
+ const windowStart = now - this._windowMs;
123
+
124
+ while (!logs.isEmpty) {
125
+ const head = logs.peekHead();
126
+
127
+ if (head!.ts > windowStart) {
128
+ break;
129
+ }
130
+
131
+ const removed = logs.shift();
132
+
133
+ if (removed) {
134
+ totalUsed -= removed.count;
135
+ }
136
+ }
137
+
138
+ return { logs, totalUsed: Math.max(0, totalUsed) };
139
+ }
140
+
141
+ private _calculateAvailableAt(
142
+ logs: Deque<SlidingWindowLogEntry>,
143
+ totalUsed: number,
144
+ cost: number,
145
+ now: number,
146
+ ): number {
147
+ if (totalUsed + cost <= this._limit) {
148
+ return now;
149
+ }
150
+
151
+ const targetToFree = totalUsed + cost - this._limit;
152
+ let freed = 0;
153
+ let availableAt = now;
154
+
155
+ for (const entry of logs) {
156
+ freed += entry.count;
157
+
158
+ if (freed >= targetToFree) {
159
+ availableAt = entry.ts + this._windowMs;
160
+ break;
161
+ }
162
+ }
163
+
164
+ return Math.max(now, availableAt);
165
+ }
166
+ }
@@ -0,0 +1,19 @@
1
+ import { type Deque } from '@stimulcross/ds-deque';
2
+
3
+ /**
4
+ * A log entry in the sliding window log rate limiter.
5
+ */
6
+ export interface SlidingWindowLogEntry {
7
+ ts: number;
8
+ count: number;
9
+ }
10
+
11
+ /**
12
+ * The state of the sliding window log rate limiter.
13
+ *
14
+ * When using a distributed state store, make sure it properly serializes and deserializes the state.
15
+ */
16
+ export interface SlidingWindowLogState {
17
+ logs: Deque<SlidingWindowLogEntry>;
18
+ totalUsed: number;
19
+ }
@@ -0,0 +1,44 @@
1
+ import { type RateLimiterStatus } from '../../core/rate-limiter-status.js';
2
+
3
+ /**
4
+ * The status of the Sliding Window Log rate limiter.
5
+ */
6
+ export interface SlidingWindowLogStatus extends RateLimiterStatus {
7
+ /**
8
+ * Maximum number of requests allowed within the time window.
9
+ */
10
+ limit: number;
11
+
12
+ /**
13
+ * The sliding time window duration in milliseconds.
14
+ */
15
+ windowMs: number;
16
+
17
+ /**
18
+ * Total number of requests consumed within the current sliding window.
19
+ *
20
+ * This count includes all requests that fall within the window period.
21
+ */
22
+ totalUsed: number;
23
+
24
+ /**
25
+ * Number of remaining requests available before hitting the limit.
26
+ */
27
+ remaining: number;
28
+
29
+ /**
30
+ * The timestamp (in milliseconds) when the next request slot will become available.
31
+ *
32
+ * This is based on when the oldest request in the window will expire.
33
+ */
34
+ nextAvailableAt: number;
35
+
36
+ /**
37
+ * The timestamp (in milliseconds) when the current window will fully reset.
38
+ *
39
+ * Represents when the last (most recent) request in the log will expire.
40
+ *
41
+ * After this time, all requests will have aged out of the sliding window.
42
+ */
43
+ resetAt: number;
44
+ }
@@ -0,0 +1,4 @@
1
+ export type { TokenBucketState } from './token-bucket.state.js';
2
+ export type { TokenBucketStatus } from './token-bucket.status.js';
3
+ export type { TokenBucketOptions } from './token-bucket.options.js';
4
+ export { TokenBucketLimiter } from './token-bucket.limiter.js';
@@ -0,0 +1,110 @@
1
+ import { LogLevel } from '@stimulcross/logger';
2
+ import { type TokenBucketOptions } from './token-bucket.options.js';
3
+ import { TokenBucketPolicy } from './token-bucket.policy.js';
4
+ import { type TokenBucketState } from './token-bucket.state.js';
5
+ import { type TokenBucketStatus } from './token-bucket.status.js';
6
+ import { type Decision } from '../../core/decision.js';
7
+ import { RateLimitErrorCode } from '../../enums/rate-limit-error-code.js';
8
+ import { RateLimitError } from '../../errors/rate-limit.error.js';
9
+ import { type LimitBehavior } from '../../types/limit-behavior.js';
10
+ import { AbstractRateLimiter, type ExecutionContext } from '../abstract-rate-limiter.js';
11
+
12
+ /**
13
+ * Token Bucket rate limiter.
14
+ *
15
+ * Designed primarily for client-side use to respect third-party limits or protect resources.
16
+ * While this can be used as a server-side limiter with custom distributed storage
17
+ * (e.g., Redis), it is best-effort and not recommended due to high network round-trip latency.
18
+ *
19
+ * Key features:
20
+ * - **Queueing & overflow** - optionally enqueues excess requests up to a maximum allowed overflow capacity
21
+ * - **Concurrency** - limits how many requests can be executed simultaneously
22
+ * - **Priority** - supports task priorities (with fairness and custom policy) to execute critical requests first
23
+ * - **Cancellation** - supports `AbortSignal` to safely remove pending requests from the queue
24
+ * - **Expiration** - automatically drops queued requests that wait longer than the allowed `maxWaitMs`
25
+ * - **Auto-rollback** - reverts spent quota if an enqueued task is canceled or expired
26
+ */
27
+ export class TokenBucketLimiter extends AbstractRateLimiter<TokenBucketState, TokenBucketStatus> {
28
+ private readonly _defaultLimitBehaviour: LimitBehavior;
29
+ private readonly _maxWaitMs: number | undefined;
30
+
31
+ protected override readonly _policy: TokenBucketPolicy;
32
+
33
+ constructor(options: TokenBucketOptions) {
34
+ super(options);
35
+
36
+ this._defaultLimitBehaviour = options.limitBehavior ?? 'reject';
37
+
38
+ if (options.queue?.maxWaitMs) {
39
+ this._maxWaitMs = options.queue.maxWaitMs;
40
+ }
41
+
42
+ this._policy = new TokenBucketPolicy(options.capacity, options.refillRate);
43
+ }
44
+
45
+ protected override async _runInternal<T>(fn: () => T | Promise<T>, ctx: ExecutionContext): Promise<T> {
46
+ const now = this._clock.now();
47
+ const baseTtlMs = Math.ceil(this._policy.capacity / (this._policy.refillRate / 1000));
48
+
49
+ let runAt: number;
50
+ let storeTtlMs: number;
51
+
52
+ await this._store.acquireLock?.(ctx.key);
53
+
54
+ try {
55
+ const state = (await this._store.get(ctx.key)) ?? this._policy.getInitialState();
56
+ const finalLimitBehavior = ctx.limitBehavior ?? this._defaultLimitBehaviour;
57
+
58
+ const { decision, nextState } = this._policy.evaluate(
59
+ state,
60
+ now,
61
+ ctx.cost,
62
+ finalLimitBehavior === 'enqueue',
63
+ );
64
+
65
+ if (decision.kind === 'deny') {
66
+ this._logger.debug(`[DENY] [id: ${ctx.id}, key: ${ctx.key}] - Retry: +${decision.retryAt - now}ms`);
67
+ throw new RateLimitError(RateLimitErrorCode.LimitExceeded, decision.retryAt);
68
+ }
69
+
70
+ runAt = decision.kind === 'delay' ? decision.runAt : now;
71
+ storeTtlMs = Math.max(baseTtlMs, baseTtlMs + (runAt - now));
72
+
73
+ await this._store.set(ctx.key, nextState, storeTtlMs);
74
+
75
+ this._printSuccessDebug(decision, nextState, now, ctx);
76
+ } finally {
77
+ await this._store.releaseLock?.(ctx.key);
78
+ }
79
+
80
+ const finalMaxWaitMs = ctx.maxWaitMs ?? this._maxWaitMs;
81
+ const expiresAt = finalMaxWaitMs ? now + finalMaxWaitMs : undefined;
82
+
83
+ return await this._execute(fn, runAt, storeTtlMs, ctx, expiresAt);
84
+ }
85
+
86
+ protected override _getDebugStateString(state: TokenBucketState): string {
87
+ return `tkn: ${state.tokens.toFixed(2)}/${this._policy.capacity}, deb: ${state.debt.toFixed(2)}`;
88
+ }
89
+
90
+ private _printSuccessDebug(
91
+ decision: Decision,
92
+ nextState: TokenBucketState,
93
+ now: number,
94
+ ctx: ExecutionContext,
95
+ ): void {
96
+ if (this._logger.minLevel < LogLevel.DEBUG) {
97
+ return;
98
+ }
99
+
100
+ const debugStateString = this._getDebugStateString(nextState);
101
+
102
+ if (decision.kind === 'delay') {
103
+ this._logger.debug(
104
+ `[DELAY] [id: ${ctx.id}, key: ${ctx.key}] +${decision.runAt - now}ms - ${debugStateString}`,
105
+ );
106
+ } else {
107
+ this._logger.debug(`[ALLOW] [id: ${ctx.id}, key: ${ctx.key}] - ${debugStateString}`);
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,17 @@
1
+ import { type TokenBucketState } from './token-bucket.state.js';
2
+ import { type RateLimiterOptions } from '../../interfaces/rate-limiter-options.js';
3
+
4
+ /**
5
+ * Options for the Token Bucket rate limiter.
6
+ */
7
+ export interface TokenBucketOptions extends RateLimiterOptions<TokenBucketState> {
8
+ /**
9
+ * The maximum number of tokens that can be stored in the bucket.
10
+ */
11
+ capacity: number;
12
+
13
+ /**
14
+ * The rate, in seconds, at which tokens are refilled.
15
+ */
16
+ refillRate: number;
17
+ }
@@ -0,0 +1,155 @@
1
+ import { type TokenBucketState } from './token-bucket.state.js';
2
+ import { type TokenBucketStatus } from './token-bucket.status.js';
3
+ import { type RateLimitPolicy, type RateLimitPolicyResult } from '../../core/rate-limit-policy.js';
4
+ import { validateCost } from '../../utils/validate-cost.js';
5
+
6
+ /** @internal */
7
+ export class TokenBucketPolicy implements RateLimitPolicy<TokenBucketState, TokenBucketStatus> {
8
+ constructor(
9
+ private readonly _capacity: number,
10
+ private readonly _refillRate: number,
11
+ private readonly _maxDebt: number = Number.POSITIVE_INFINITY,
12
+ ) {
13
+ if (!Number.isSafeInteger(_capacity) || _capacity <= 0) {
14
+ throw new Error(`Invalid capacity: ${_capacity}. Must be a positive integer.`);
15
+ }
16
+
17
+ if (!Number.isFinite(_refillRate) || _refillRate <= 0) {
18
+ throw new Error(`Invalid refillRate: ${_refillRate}. Must be a positive number.`);
19
+ }
20
+
21
+ if (_maxDebt < 0 || (!Number.isSafeInteger(_maxDebt) && _maxDebt !== Number.POSITIVE_INFINITY)) {
22
+ throw new Error(`Invalid maxDebt: ${_maxDebt}. Must be a non-negative integer or Infinity.`);
23
+ }
24
+ }
25
+
26
+ public get capacity(): number {
27
+ return this._capacity;
28
+ }
29
+
30
+ public get refillRate(): number {
31
+ return this._refillRate;
32
+ }
33
+
34
+ public getInitialState(): TokenBucketState {
35
+ return { tokens: this._capacity, debt: 0, lastRefill: 0 };
36
+ }
37
+
38
+ public getStatus(state: TokenBucketState, now: number): TokenBucketStatus {
39
+ const { tokens, debt } = this._syncState(state, now);
40
+
41
+ const deficitToOne = Math.max(0, debt + 1 - tokens);
42
+ const nextAvailableAt = deficitToOne === 0 ? now : now + Math.ceil((deficitToOne / this._refillRate) * 1000);
43
+
44
+ const deficitToCapacity = debt + (this._capacity - tokens);
45
+ const resetAt = deficitToCapacity === 0 ? now : now + Math.ceil((deficitToCapacity / this._refillRate) * 1000);
46
+
47
+ return {
48
+ capacity: this._capacity,
49
+ refillRate: this._refillRate,
50
+ tokens,
51
+ debt,
52
+ nextAvailableAt,
53
+ resetAt,
54
+ };
55
+ }
56
+
57
+ public evaluate(
58
+ state: TokenBucketState,
59
+ now: number,
60
+ cost: number,
61
+ shouldReserve?: boolean,
62
+ ): RateLimitPolicyResult<TokenBucketState> {
63
+ validateCost(cost, this._capacity);
64
+
65
+ const { tokens, debt, lastRefill } = this._syncState(state, now);
66
+
67
+ if (tokens >= cost) {
68
+ return {
69
+ decision: { kind: 'allow' },
70
+ nextState: { tokens: tokens - cost, debt, lastRefill },
71
+ };
72
+ }
73
+
74
+ const deficit = cost - tokens;
75
+
76
+ if (shouldReserve) {
77
+ const newDebt = debt + deficit;
78
+
79
+ if (newDebt > this._maxDebt) {
80
+ const targetDebt = this._maxDebt - deficit;
81
+ const debtToClear = Math.max(0, debt - targetDebt);
82
+ const waitMs = Math.ceil((debtToClear / this._refillRate) * 1000);
83
+ const retryAt = now + waitMs;
84
+
85
+ return this._deny(tokens, debt, lastRefill, retryAt);
86
+ }
87
+
88
+ const waitMs = Math.ceil((newDebt / this._refillRate) * 1000);
89
+ const runAt = now + waitMs;
90
+
91
+ return {
92
+ decision: { kind: 'delay', runAt },
93
+ nextState: { tokens: 0, debt: newDebt, lastRefill },
94
+ };
95
+ }
96
+
97
+ const totalNeeded = debt + deficit;
98
+ const waitMs = Math.ceil((totalNeeded / this._refillRate) * 1000);
99
+ const retryAt = now + waitMs;
100
+
101
+ return this._deny(tokens, debt, lastRefill, retryAt);
102
+ }
103
+
104
+ public revert(state: TokenBucketState, cost: number, now: number): TokenBucketState {
105
+ let { tokens, debt, lastRefill } = this._syncState(state, now);
106
+
107
+ if (debt >= cost) {
108
+ debt -= cost;
109
+ } else {
110
+ const remainder = cost - debt;
111
+ debt = 0;
112
+ tokens = Math.min(this._capacity, tokens + remainder);
113
+ }
114
+
115
+ return { tokens, debt, lastRefill };
116
+ }
117
+
118
+ private _deny(
119
+ tokens: number,
120
+ debt: number,
121
+ lastRefill: number,
122
+ retryAt: number,
123
+ ): RateLimitPolicyResult<TokenBucketState> {
124
+ return {
125
+ decision: { kind: 'deny', retryAt },
126
+ nextState: { tokens, debt, lastRefill },
127
+ };
128
+ }
129
+
130
+ private _syncState(state: TokenBucketState, now: number): TokenBucketState {
131
+ if (state.lastRefill === 0) {
132
+ return { tokens: this._capacity, debt: 0, lastRefill: now };
133
+ }
134
+
135
+ if (now <= state.lastRefill) {
136
+ return state;
137
+ }
138
+
139
+ let { tokens, debt } = state;
140
+
141
+ const elapsedMs = now - state.lastRefill;
142
+ const generatedTokens = (elapsedMs / 1000) * this._refillRate;
143
+
144
+ if (debt > 0) {
145
+ const payOff = Math.min(debt, generatedTokens);
146
+ debt -= payOff;
147
+ const remainingTokens = generatedTokens - payOff;
148
+ tokens = Math.min(this._capacity, tokens + remainingTokens);
149
+ } else {
150
+ tokens = Math.min(this._capacity, tokens + generatedTokens);
151
+ }
152
+
153
+ return { tokens, debt, lastRefill: now };
154
+ }
155
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Token Bucket rate limiter state.
3
+ *
4
+ * When using a distributed state store, make sure it properly serializes and deserializes the state.
5
+ */
6
+ export interface TokenBucketState {
7
+ tokens: number;
8
+ debt: number;
9
+ lastRefill: number;
10
+ }
@@ -0,0 +1,36 @@
1
+ import { type RateLimiterStatus } from '../../core/rate-limiter-status.js';
2
+
3
+ /**
4
+ * The status of the Token Bucket rate limiter.
5
+ */
6
+ export interface TokenBucketStatus extends RateLimiterStatus {
7
+ /**
8
+ * The maximum number of tokens that can be stored in the bucket.
9
+ */
10
+ capacity: number;
11
+
12
+ /**
13
+ * The rate, in seconds, at which tokens are refilled.
14
+ */
15
+ refillRate: number;
16
+
17
+ /**
18
+ * The number of tokens available in the bucket.
19
+ */
20
+ tokens: number;
21
+
22
+ /**
23
+ * The number of tokens that have been reserved.
24
+ */
25
+ debt: number;
26
+
27
+ /**
28
+ * The timestamp (in milliseconds) at which next token will be available for use.
29
+ */
30
+ nextAvailableAt: number;
31
+
32
+ /**
33
+ * The timestamp (in milliseconds) at which the bucket will be reset.
34
+ */
35
+ resetAt: number;
36
+ }
@@ -0,0 +1,8 @@
1
+ import { type Clock } from '../core/clock.js';
2
+
3
+ /** @internal */
4
+ export const defaultClock: Clock = {
5
+ now(): number {
6
+ return Date.now();
7
+ },
8
+ };
@@ -0,0 +1,34 @@
1
+ import { Deque } from '@stimulcross/ds-deque';
2
+
3
+ /** @internal */
4
+ export class ExecutionTickets {
5
+ private readonly _tickets = new Deque<number>();
6
+
7
+ public get size(): number {
8
+ return this._tickets.size;
9
+ }
10
+
11
+ public get isEmpty(): boolean {
12
+ return this._tickets.size === 0;
13
+ }
14
+
15
+ public add(tick: number): void {
16
+ this._tickets.push(tick);
17
+ }
18
+
19
+ public peek(): number | undefined {
20
+ return this._tickets.peekHead();
21
+ }
22
+
23
+ public consume(): number | undefined {
24
+ return this._tickets.shift();
25
+ }
26
+
27
+ public dropLast(): number | undefined {
28
+ return this._tickets.pop();
29
+ }
30
+
31
+ public clear(): void {
32
+ this._tickets.clear();
33
+ }
34
+ }