@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,159 @@
1
+ import { type FixedWindowState } from './fixed-window.state.js';
2
+ import { type FixedWindowStatus } from './fixed-window.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 FixedWindowPolicy implements RateLimitPolicy<FixedWindowState, FixedWindowStatus> {
8
+ constructor(
9
+ private readonly _limit: number,
10
+ private readonly _windowMs: number,
11
+ private readonly _maxReserved: number = Number.POSITIVE_INFINITY,
12
+ ) {
13
+ if (!Number.isSafeInteger(_limit) || _limit <= 0) {
14
+ throw new Error(`Invalid limit: ${_limit}. Must be a positive integer.`);
15
+ }
16
+
17
+ if (!Number.isSafeInteger(_windowMs) || _windowMs <= 0) {
18
+ throw new Error(`Invalid windowMs: ${_windowMs}. Must be a positive integer.`);
19
+ }
20
+
21
+ if (_maxReserved < 0 || (_maxReserved !== Number.POSITIVE_INFINITY && !Number.isSafeInteger(_maxReserved))) {
22
+ throw new Error(`Invalid maxReserved: ${_maxReserved}. Must be a positive integer or Infinity.`);
23
+ }
24
+ }
25
+
26
+ public get limit(): number {
27
+ return this._limit;
28
+ }
29
+
30
+ public get windowMs(): number {
31
+ return this._windowMs;
32
+ }
33
+
34
+ public getInitialState(): FixedWindowState {
35
+ return { windowStart: 0, used: 0, reserved: 0 };
36
+ }
37
+
38
+ public getStatus(state: FixedWindowState, now: number): FixedWindowStatus {
39
+ const { windowStart, used, reserved } = this._syncState(state, now);
40
+ const windowEnd = windowStart + this._windowMs;
41
+
42
+ let nextAvailableAt: number;
43
+
44
+ if (used < this._limit) {
45
+ nextAvailableAt = windowStart;
46
+ } else {
47
+ const blockedWindows = Math.floor((used + reserved) / this._limit);
48
+ nextAvailableAt = windowStart + blockedWindows * this._windowMs;
49
+ }
50
+
51
+ const windowsToClear = Math.ceil((used + reserved) / this._limit);
52
+ const resetAt = windowStart + Math.max(1, windowsToClear) * this._windowMs;
53
+
54
+ return {
55
+ windowStart,
56
+ windowEnd,
57
+ limit: this._limit,
58
+ used,
59
+ reserved,
60
+ remaining: Math.max(0, this._limit - used),
61
+ nextAvailableAt,
62
+ resetAt,
63
+ };
64
+ }
65
+
66
+ public evaluate(
67
+ state: FixedWindowState,
68
+ now: number,
69
+ cost: number,
70
+ shouldReserve?: boolean,
71
+ ): RateLimitPolicyResult<FixedWindowState> {
72
+ validateCost(cost, this._limit);
73
+
74
+ const { windowStart, used, reserved } = this._syncState(state, now);
75
+ const currentTotal = used + cost;
76
+
77
+ if (reserved === 0 && currentTotal <= this._limit) {
78
+ return {
79
+ decision: { kind: 'allow' },
80
+ nextState: { windowStart, used: currentTotal, reserved: 0 },
81
+ };
82
+ }
83
+
84
+ if (shouldReserve) {
85
+ if (reserved + cost > this._maxReserved) {
86
+ const deficit = reserved + cost - this._maxReserved;
87
+ const windowsToWait = Math.ceil(deficit / this._limit);
88
+ const retryAt = windowStart + Math.max(1, windowsToWait) * this._windowMs;
89
+
90
+ return this._deny(windowStart, used, reserved, retryAt);
91
+ }
92
+
93
+ const totalItems = used + reserved + cost;
94
+ const windowIndex = Math.floor((totalItems - 1) / this._limit);
95
+ const runAt = windowStart + windowIndex * this._windowMs;
96
+
97
+ return {
98
+ decision: { kind: 'delay', runAt },
99
+ nextState: {
100
+ windowStart,
101
+ used,
102
+ reserved: reserved + cost,
103
+ },
104
+ };
105
+ }
106
+
107
+ const windowsToWait = Math.ceil((used + reserved + cost - this._limit) / this._limit);
108
+ const retryAt = windowStart + Math.max(1, windowsToWait) * this._windowMs;
109
+
110
+ return this._deny(windowStart, used, reserved, retryAt);
111
+ }
112
+
113
+ public revert(state: FixedWindowState, cost: number, now: number): FixedWindowState {
114
+ let { used, reserved, windowStart } = this._syncState(state, now);
115
+
116
+ if (reserved >= cost) {
117
+ reserved -= cost;
118
+ } else {
119
+ const remainder = cost - reserved;
120
+ reserved = 0;
121
+ used = Math.max(0, used - remainder);
122
+ }
123
+
124
+ return { windowStart, used, reserved };
125
+ }
126
+
127
+ private _deny(
128
+ start: number,
129
+ used: number,
130
+ reserved: number,
131
+ retryAt: number,
132
+ ): RateLimitPolicyResult<FixedWindowState> {
133
+ return {
134
+ decision: { kind: 'deny', retryAt },
135
+ nextState: { windowStart: start, used, reserved },
136
+ };
137
+ }
138
+
139
+ private _syncState(state: FixedWindowState, now: number): FixedWindowState {
140
+ const currentWindowStart = Math.max(Math.floor(now / this._windowMs) * this._windowMs, state.windowStart);
141
+
142
+ if (currentWindowStart <= state.windowStart) {
143
+ return state;
144
+ }
145
+
146
+ let { reserved } = state;
147
+ const windowsPassed = Math.floor((currentWindowStart - state.windowStart) / this._windowMs);
148
+ const burnableWindows = windowsPassed - 1;
149
+
150
+ if (burnableWindows > 0 && reserved > 0) {
151
+ reserved = Math.max(0, reserved - burnableWindows * this._limit);
152
+ }
153
+
154
+ const used = Math.min(reserved, this._limit);
155
+ reserved = Math.max(0, reserved - used);
156
+
157
+ return { windowStart: currentWindowStart, used, reserved };
158
+ }
159
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Fixed Window rate limiter state.
3
+ *
4
+ * When using a distributed state store, make sure it properly serializes and deserializes the state.
5
+ */
6
+ export interface FixedWindowState {
7
+ windowStart: number;
8
+ used: number;
9
+ reserved: number;
10
+ }
@@ -0,0 +1,46 @@
1
+ import { type RateLimiterStatus } from '../../core/rate-limiter-status.js';
2
+
3
+ /**
4
+ * The status of the Fixed Window Limiter.
5
+ */
6
+ export interface FixedWindowStatus extends RateLimiterStatus {
7
+ /**
8
+ * The start of the current window.
9
+ */
10
+ readonly windowStart: number;
11
+
12
+ /**
13
+ * The end of the current window.
14
+ */
15
+ readonly windowEnd: number;
16
+
17
+ /**
18
+ * The maximum number of tokens can be used in the window.
19
+ */
20
+ readonly limit: number;
21
+
22
+ /**
23
+ * The number of tokens that have been used in the current window.
24
+ */
25
+ readonly used: number;
26
+
27
+ /**
28
+ * The number of tokens that are reserved for future windows.
29
+ */
30
+ readonly reserved: number;
31
+
32
+ /**
33
+ * The number of tokens that can be used in the current window.
34
+ */
35
+ readonly remaining: number;
36
+
37
+ /**
38
+ * The timestamp (in milliseconds) when the next token will be available for use.
39
+ */
40
+ readonly nextAvailableAt: number;
41
+
42
+ /**
43
+ * The timestamp (in milliseconds) when the limit will be reset (all tokens will be available for use again).
44
+ */
45
+ readonly resetAt: number;
46
+ }
@@ -0,0 +1,4 @@
1
+ export type { FixedWindowState } from './fixed-window.state.js';
2
+ export type { FixedWindowStatus } from './fixed-window.status.js';
3
+ export type { FixedWindowLimitOptions, FixedWindowOptions } from './fixed-window.options.js';
4
+ export { FixedWindowLimiter } from './fixed-window.limiter.js';
@@ -0,0 +1,108 @@
1
+ import { LogLevel } from '@stimulcross/logger';
2
+ import { GenericCellPolicy } from './generic-cell.policy.js';
3
+ import { AbstractRateLimiter, type ExecutionContext } from '../abstract-rate-limiter.js';
4
+ import { type GenericCellOptions } from './generic-cell.options.js';
5
+ import { type GenericCellState } from './generic-cell.state.js';
6
+ import { type GenericCellStatus } from './generic-cell.status.js';
7
+ import { type Decision } from '../../core/decision.js';
8
+ import { RateLimitErrorCode } from '../../enums/rate-limit-error-code.js';
9
+ import { RateLimitError } from '../../errors/rate-limit.error.js';
10
+ import { type LimitBehavior } from '../../types/limit-behavior.js';
11
+
12
+ /**
13
+ * Generic Cell (GCRA) 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 GenericCellLimiter extends AbstractRateLimiter<GenericCellState, GenericCellStatus> {
28
+ private readonly _defaultLimitBehaviour: LimitBehavior;
29
+ private readonly _defaultMaxWaitMs: number | undefined;
30
+
31
+ protected override readonly _policy: GenericCellPolicy;
32
+
33
+ constructor(options: GenericCellOptions) {
34
+ super(options);
35
+
36
+ this._defaultLimitBehaviour = options.limitBehavior ?? 'reject';
37
+
38
+ if (options.queue?.maxWaitMs) {
39
+ this._defaultMaxWaitMs = options.queue.maxWaitMs;
40
+ }
41
+
42
+ this._policy = new GenericCellPolicy(options.intervalMs, options.burst);
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: number = Math.ceil(this._policy.burst * this._policy.intervalMs);
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, runAt - now + baseTtlMs);
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._defaultMaxWaitMs;
81
+ const expiresAt = finalMaxWaitMs ? now + finalMaxWaitMs : undefined;
82
+
83
+ return await this._execute<T>(fn, runAt, storeTtlMs, ctx, expiresAt);
84
+ }
85
+
86
+ protected override _getDebugStateString(state: GenericCellState): string {
87
+ return String(state.tat);
88
+ }
89
+
90
+ private _printSuccessDebug(
91
+ decision: Decision,
92
+ nextState: GenericCellState,
93
+ now: number,
94
+ ctx: ExecutionContext,
95
+ ): void {
96
+ if (this._logger.minLevel < LogLevel.DEBUG) {
97
+ return;
98
+ }
99
+
100
+ if (decision.kind === 'delay') {
101
+ this._logger.debug(
102
+ `[DELAY] [id: ${ctx.id}, key: ${ctx.key}] +${decision.runAt - now}ms - tat: ${nextState.tat}`,
103
+ );
104
+ } else {
105
+ this._logger.debug(`[ALLOW] [id: ${ctx.id}, key: ${ctx.key}] - tat: ${nextState.tat}`);
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,23 @@
1
+ import { type GenericCellState } from './generic-cell.state.js';
2
+ import { type RateLimiterOptions } from '../../interfaces/rate-limiter-options.js';
3
+
4
+ /**
5
+ * Options for the Generic Cell Rate Algorithm (GCRA) limiter.
6
+ */
7
+ export interface GenericCellOptions extends RateLimiterOptions<GenericCellState> {
8
+ /**
9
+ * The minimum interval between requests (in milliseconds).
10
+ *
11
+ * This defines the emission interval.
12
+ * For example, if you want to allow 10 requests per second, set this to 100ms (1000ms / 10).
13
+ */
14
+ intervalMs: number;
15
+
16
+ /**
17
+ * The maximum burst size.
18
+ *
19
+ * This defines how many requests can be made immediately in quick succession before rate limiting kicks in.
20
+ * The burst capacity allows temporary spikes in traffic.
21
+ */
22
+ burst: number;
23
+ }
@@ -0,0 +1,115 @@
1
+ import { type GenericCellState } from './generic-cell.state.js';
2
+ import { type GenericCellStatus } from './generic-cell.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 GenericCellPolicy implements RateLimitPolicy<GenericCellState, GenericCellStatus> {
8
+ constructor(
9
+ private readonly _intervalMs: number,
10
+ private readonly _burst: number,
11
+ private readonly _maxDelayMs: number = Number.POSITIVE_INFINITY,
12
+ ) {
13
+ if (!Number.isFinite(_intervalMs) || !Number.isSafeInteger(_intervalMs) || _intervalMs <= 0) {
14
+ throw new Error(`Invalid intervalMs: ${_intervalMs}. Must be a positive integer.`);
15
+ }
16
+
17
+ if (!Number.isFinite(_burst) || !Number.isSafeInteger(_burst) || _burst <= 0) {
18
+ throw new Error(`Invalid burst: ${_burst}. Must be a positive integer.`);
19
+ }
20
+
21
+ if (_maxDelayMs < 0 || (!Number.isSafeInteger(_maxDelayMs) && _maxDelayMs !== Number.POSITIVE_INFINITY)) {
22
+ throw new Error(`Invalid maxDelayMs: ${_maxDelayMs}. Must be a non-negative integer or Infinity.`);
23
+ }
24
+ }
25
+
26
+ public get burst(): number {
27
+ return this._burst;
28
+ }
29
+
30
+ public get intervalMs(): number {
31
+ return this._intervalMs;
32
+ }
33
+
34
+ public getInitialState(): GenericCellState {
35
+ return { tat: 0 };
36
+ }
37
+
38
+ public getStatus(state: GenericCellState, now: number): GenericCellStatus {
39
+ const tat = Math.max(state.tat, now);
40
+ const burstOffset = this._burst * this._intervalMs;
41
+
42
+ const availableTimeDebt = burstOffset + now - tat;
43
+ const remaining = Math.max(0, Math.floor(availableTimeDebt / this._intervalMs));
44
+
45
+ const nextAvailableAt = Math.max(now, tat + this._intervalMs - burstOffset);
46
+
47
+ const resetAt = tat;
48
+
49
+ return {
50
+ intervalMs: this._intervalMs,
51
+ burst: this._burst,
52
+ tat,
53
+ remaining,
54
+ nextAvailableAt,
55
+ resetAt,
56
+ };
57
+ }
58
+
59
+ public evaluate(
60
+ state: GenericCellState,
61
+ now: number,
62
+ cost: number,
63
+ shouldReserve?: boolean,
64
+ ): RateLimitPolicyResult<GenericCellState> {
65
+ const absoluteMax = shouldReserve ? this._burst + Math.floor(this._maxDelayMs / this._intervalMs) : this._burst;
66
+
67
+ validateCost(cost, absoluteMax);
68
+
69
+ const tat = Math.max(state.tat, now);
70
+ const costInterval = cost * this._intervalMs;
71
+ const burstOffset = this._burst * this._intervalMs;
72
+
73
+ const newTat = tat + costInterval;
74
+
75
+ const minNow = newTat - burstOffset;
76
+
77
+ if (now >= minNow) {
78
+ return {
79
+ decision: { kind: 'allow' },
80
+ nextState: { tat: newTat },
81
+ };
82
+ }
83
+
84
+ if (shouldReserve) {
85
+ const waitMs = minNow - now;
86
+
87
+ if (waitMs > this._maxDelayMs) {
88
+ const retryAt = minNow - this._maxDelayMs;
89
+ return this._deny(state.tat, retryAt);
90
+ }
91
+
92
+ const runAt = minNow;
93
+ return {
94
+ decision: { kind: 'delay', runAt },
95
+ nextState: { tat: newTat },
96
+ };
97
+ }
98
+
99
+ return this._deny(state.tat, minNow);
100
+ }
101
+
102
+ public revert(state: GenericCellState, cost: number): GenericCellState {
103
+ const costInterval = cost * this._intervalMs;
104
+ return {
105
+ tat: Math.max(0, state.tat - costInterval),
106
+ };
107
+ }
108
+
109
+ private _deny(tat: number, retryAt: number): RateLimitPolicyResult<GenericCellState> {
110
+ return {
111
+ decision: { kind: 'deny', retryAt },
112
+ nextState: { tat },
113
+ };
114
+ }
115
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Generic Cell rate limiter state.
3
+ *
4
+ * When using a distributed state store, make sure it properly serializes and deserializes the state.
5
+ */
6
+ export interface GenericCellState {
7
+ tat: number;
8
+ }
@@ -0,0 +1,54 @@
1
+ import { type RateLimiterStatus } from '../../core/rate-limiter-status.js';
2
+
3
+ /**
4
+ * The status of the Generic Cell rate limiter.
5
+ */
6
+ export interface GenericCellStatus extends RateLimiterStatus {
7
+ /**
8
+ * The minimum interval between tokens (in milliseconds).
9
+ *
10
+ * It represents the emission interval - the time period between
11
+ * successive token emissions at the steady-state rate.
12
+ */
13
+ readonly intervalMs: number;
14
+
15
+ /**
16
+ * The maximum burst size (maximum number of tokens that can be accumulated).
17
+ *
18
+ * It represents the burst capacity - the maximum number of requests
19
+ * that can be made instantaneously when the bucket is full.
20
+ */
21
+ readonly burst: number;
22
+
23
+ /**
24
+ * Theoretical Arrival Time (TAT) - the virtual time when the bucket will be empty.
25
+ *
26
+ * TAT represents the earliest time at which a future request could be allowed.
27
+ *
28
+ * Value is in milliseconds.
29
+ */
30
+ readonly tat: number;
31
+
32
+ /**
33
+ * The number of tokens that can be used immediately.
34
+ *
35
+ * Calculated as the time allowance available between now and TAT, divided by
36
+ * the emission interval.
37
+ */
38
+ readonly remaining: number;
39
+
40
+ /**
41
+ * The timestamp (in milliseconds) when the next token will be available for use.
42
+ *
43
+ * This is the earliest time when a request can be accepted if no tokens are currently available.
44
+ */
45
+ readonly nextAvailableAt: number;
46
+
47
+ /**
48
+ * The timestamp when all tokens will be available for use again.
49
+ *
50
+ * It is when the TAT returns to the current time, meaning the bucket has fully recovered to its
51
+ * maximum burst capacity.
52
+ */
53
+ readonly resetAt: number;
54
+ }
@@ -0,0 +1,4 @@
1
+ export type { GenericCellState } from './generic-cell.state.js';
2
+ export type { GenericCellStatus } from './generic-cell.status.js';
3
+ export type { GenericCellOptions } from './generic-cell.options.js';
4
+ export { GenericCellLimiter } from './generic-cell.limiter.js';
@@ -0,0 +1,20 @@
1
+ import { type HttpLimitInfo } from './http-limit.info.js';
2
+
3
+ /**
4
+ * A function that extracts limit information from response headers.
5
+ *
6
+ * @param res The response object, for example, native `Response` object from the `fetch` API.
7
+ * @param err The error object, if any.
8
+ * @param now The current timestamp in milliseconds gotten from the internal clock.
9
+ * If you need the current timestamp, prefer this value over `Date.now()`.
10
+ *
11
+ * @returns {@link HttpLimitInfo} or `null` if not possible to extract or no limit info.
12
+ *
13
+ * @template TResponse The type of the response object.
14
+ * @template TError The type of the error object.
15
+ */
16
+ export type HttpLimitInfoExtractor<TResponse, TError extends Error = Error> = (
17
+ res: TResponse | null,
18
+ err: TError | null,
19
+ now: number,
20
+ ) => HttpLimitInfo | null;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Rate limit information extracted from the HTTP response headers.
3
+ */
4
+ export interface HttpLimitInfo {
5
+ /**
6
+ * The maximum number of requests allowed within the time window.
7
+ *
8
+ * It usually corresponds to `RateLimit-Limit` or `X-RateLimit-Limit` headers.
9
+ */
10
+ limit: number;
11
+
12
+ /**
13
+ * The number of requests remaining in the current time window.
14
+ *
15
+ * It usually corresponds to `RateLimit-Remaining` or `X-RateLimit-Remaining` headers.
16
+ */
17
+ remaining: number;
18
+
19
+ /**
20
+ * The timestamp (in milliseconds) when the current time window resets.
21
+ *
22
+ * It usually corresponds to `RateLimit-Reset`, `X-RateLimit-Reset`, or `Retry-After` headers.
23
+ *
24
+ * **WARNING:** these headers can be either delta (usually in seconds) or UNIX epoche
25
+ * timestamp (usually in seconds) depending on the target API specs.
26
+ *
27
+ *The library expects a UNIX epoche timestamp in milliseconds. Refer to the API docs to
28
+ * determine the header format and properly convert it to UNIX epoche timestamp in milliseconds.
29
+ *
30
+ * For example,
31
+ * - delta in seconds: `Date.now() + delta * 1000`
32
+ * - UNIX timestamp in seconds: `timestamp * 1000`
33
+ *
34
+ */
35
+ resetAt?: number | null;
36
+
37
+ /**
38
+ * HTTP status code of the response.
39
+ */
40
+ statusCode: number;
41
+ }
@@ -0,0 +1,18 @@
1
+ import { type HttpLimitInfoExtractor } from './http-limit-info.extractor.js';
2
+ import { type HttpResponseBasedLimiterState } from './http-response-based-limiter.state.js';
3
+ import { type RateLimiterOptions } from '../../interfaces/rate-limiter-options.js';
4
+
5
+ /**
6
+ * Options for the HTTP Response Based rate limiter.
7
+ */
8
+ export interface HttpResponseBasedLimiterOptions<TResponse> extends RateLimiterOptions<HttpResponseBasedLimiterState> {
9
+ /**
10
+ * Function that extracts limit information from the HTTP response.
11
+ */
12
+ limitInfoExtractor: HttpLimitInfoExtractor<TResponse>;
13
+
14
+ /**
15
+ * The fallback reset delay in milliseconds.
16
+ */
17
+ fallbackResetDelayMs?: number;
18
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The state of the HTTP Response Based rate limiter.
3
+ *
4
+ * When using a distributed state store, make sure it properly serializes and deserializes the state.
5
+ */
6
+ export interface HttpResponseBasedLimiterState {
7
+ isProbing: boolean;
8
+ isUnlimited: boolean;
9
+ lastKnownLimit: number | null;
10
+ lastKnownRemaining: number | null;
11
+ lastKnownResetAt: number | null;
12
+ lastSyncedAt: number | null;
13
+ }