@wildix/xbees-conversations-utils 1.2.1 → 1.2.3

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 (74) hide show
  1. package/dist-cjs/normalization.js +1 -1
  2. package/dist-cjs/rateLimits/RedisCooldown.js +66 -0
  3. package/dist-cjs/rateLimits/constants.js +43 -0
  4. package/dist-cjs/rateLimits/createRateLimitedStreamProxy.js +92 -0
  5. package/dist-cjs/rateLimits/exception/createRateLimitExceededException.js +21 -0
  6. package/dist-cjs/rateLimits/exception/processStreamRateLimitException.js +47 -0
  7. package/dist-cjs/rateLimits/executeStreamRequestWithRateLimitHandling.js +94 -0
  8. package/dist-cjs/rateLimits/generatedMethodNames.js +320 -0
  9. package/dist-cjs/rateLimits/helpers/RedisCooldown.js +174 -0
  10. package/dist-cjs/rateLimits/helpers/createRateLimitExceededException.js +32 -0
  11. package/dist-cjs/rateLimits/helpers/executeWithRateLimitHandling.js +144 -0
  12. package/dist-cjs/rateLimits/helpers/headerUtils.js +40 -0
  13. package/dist-cjs/rateLimits/helpers/isStreamChannelLike.js +23 -0
  14. package/dist-cjs/rateLimits/helpers/processStreamBudgetHeaders.js +93 -0
  15. package/dist-cjs/rateLimits/helpers/processStreamRateLimitException.js +63 -0
  16. package/dist-cjs/rateLimits/helpers/processStreamRateLimitHeaders.js +50 -0
  17. package/dist-cjs/rateLimits/helpers/rateLimitSnapshot.js +31 -0
  18. package/dist-cjs/rateLimits/helpers/registerStreamBudgetInterceptors.js +48 -0
  19. package/dist-cjs/rateLimits/helpers/registerStreamInterceptors.js +58 -0
  20. package/dist-cjs/rateLimits/helpers/splitCallArgsAndOptions.js +21 -0
  21. package/dist-cjs/rateLimits/index.js +7 -0
  22. package/dist-cjs/rateLimits/types.js +3 -0
  23. package/dist-cjs/rateLimits/withStreamRateLimitOptions.js +10 -0
  24. package/dist-cjs/streamRateLimitHandlingProxy/helpers/executeWithRateLimitHandling.js +5 -4
  25. package/dist-cjs/streamRateLimitHandlingProxy/helpers/sleep.js +6 -0
  26. package/dist-es/normalization.js +1 -1
  27. package/dist-es/rateLimits/RedisCooldown.js +62 -0
  28. package/dist-es/rateLimits/constants.js +33 -0
  29. package/dist-es/rateLimits/createRateLimitedStreamProxy.js +88 -0
  30. package/dist-es/rateLimits/exception/createRateLimitExceededException.js +17 -0
  31. package/dist-es/rateLimits/exception/processStreamRateLimitException.js +37 -0
  32. package/dist-es/rateLimits/executeStreamRequestWithRateLimitHandling.js +93 -0
  33. package/dist-es/rateLimits/generatedMethodNames.js +317 -0
  34. package/dist-es/rateLimits/helpers/RedisCooldown.js +170 -0
  35. package/dist-es/rateLimits/helpers/createRateLimitExceededException.js +28 -0
  36. package/dist-es/rateLimits/helpers/executeWithRateLimitHandling.js +140 -0
  37. package/dist-es/rateLimits/helpers/headerUtils.js +34 -0
  38. package/dist-es/rateLimits/helpers/isStreamChannelLike.js +19 -0
  39. package/dist-es/rateLimits/helpers/processStreamBudgetHeaders.js +89 -0
  40. package/dist-es/rateLimits/helpers/processStreamRateLimitException.js +59 -0
  41. package/dist-es/rateLimits/helpers/processStreamRateLimitHeaders.js +46 -0
  42. package/dist-es/rateLimits/helpers/rateLimitSnapshot.js +26 -0
  43. package/dist-es/rateLimits/helpers/registerStreamBudgetInterceptors.js +44 -0
  44. package/dist-es/rateLimits/helpers/registerStreamInterceptors.js +54 -0
  45. package/dist-es/rateLimits/helpers/splitCallArgsAndOptions.js +17 -0
  46. package/dist-es/rateLimits/index.js +4 -0
  47. package/dist-es/rateLimits/types.js +1 -0
  48. package/dist-es/rateLimits/withStreamRateLimitOptions.js +6 -0
  49. package/dist-es/streamRateLimitHandlingProxy/helpers/executeWithRateLimitHandling.js +1 -1
  50. package/dist-es/streamRateLimitHandlingProxy/helpers/sleep.js +4 -0
  51. package/dist-types/rateLimits/RedisCooldown.d.ts +7 -0
  52. package/dist-types/rateLimits/constants.d.ts +29 -0
  53. package/dist-types/rateLimits/createRateLimitedStreamProxy.d.ts +11 -0
  54. package/dist-types/rateLimits/exception/createRateLimitExceededException.d.ts +12 -0
  55. package/dist-types/rateLimits/exception/processStreamRateLimitException.d.ts +8 -0
  56. package/dist-types/rateLimits/executeStreamRequestWithRateLimitHandling.d.ts +8 -0
  57. package/dist-types/rateLimits/generatedMethodNames.d.ts +11 -0
  58. package/dist-types/rateLimits/helpers/RedisCooldown.d.ts +11 -0
  59. package/dist-types/rateLimits/helpers/createRateLimitExceededException.d.ts +10 -0
  60. package/dist-types/rateLimits/helpers/executeWithRateLimitHandling.d.ts +2 -0
  61. package/dist-types/rateLimits/helpers/headerUtils.d.ts +4 -0
  62. package/dist-types/rateLimits/helpers/isStreamChannelLike.d.ts +6 -0
  63. package/dist-types/rateLimits/helpers/processStreamBudgetHeaders.d.ts +9 -0
  64. package/dist-types/rateLimits/helpers/processStreamRateLimitException.d.ts +3 -0
  65. package/dist-types/rateLimits/helpers/processStreamRateLimitHeaders.d.ts +6 -0
  66. package/dist-types/rateLimits/helpers/rateLimitSnapshot.d.ts +3 -0
  67. package/dist-types/rateLimits/helpers/registerStreamBudgetInterceptors.d.ts +2 -0
  68. package/dist-types/rateLimits/helpers/registerStreamInterceptors.d.ts +2 -0
  69. package/dist-types/rateLimits/helpers/splitCallArgsAndOptions.d.ts +6 -0
  70. package/dist-types/rateLimits/index.d.ts +4 -0
  71. package/dist-types/rateLimits/types.d.ts +68 -0
  72. package/dist-types/rateLimits/withStreamRateLimitOptions.d.ts +6 -0
  73. package/dist-types/streamRateLimitHandlingProxy/helpers/sleep.d.ts +2 -0
  74. package/package.json +1 -1
@@ -0,0 +1,170 @@
1
+ import { STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX } from '../constants';
2
+ export class RedisCooldown {
3
+ static resolveRedisKey(cooldownKey, options) {
4
+ const keyPrefix = options?.keyPrefix ?? STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX;
5
+ return `${keyPrefix}${cooldownKey}`;
6
+ }
7
+ static async getRemainingMs(cooldownKey, context, options) {
8
+ const { redis, logger } = context;
9
+ try {
10
+ const ttlMs = await redis.pttl(RedisCooldown.resolveRedisKey(cooldownKey, options));
11
+ if (ttlMs > 0) {
12
+ return ttlMs;
13
+ }
14
+ }
15
+ catch (error) {
16
+ logger.warn('Failed to read stream rate-limit cooldown from Redis', {
17
+ cooldownKey,
18
+ error,
19
+ });
20
+ }
21
+ return 0;
22
+ }
23
+ static async setRemainingMs(cooldownKey, cooldownMs, context, options) {
24
+ if (cooldownMs <= 0) {
25
+ return;
26
+ }
27
+ const { redis, logger } = context;
28
+ const redisKey = RedisCooldown.resolveRedisKey(cooldownKey, options);
29
+ try {
30
+ await redis.eval(`
31
+ local key = KEYS[1]
32
+ local new_ttl = tonumber(ARGV[1])
33
+ local current_ttl = redis.call('PTTL', key)
34
+
35
+ if current_ttl == -2 then
36
+ redis.call('SET', key, '1', 'PX', new_ttl)
37
+ return 1
38
+ end
39
+
40
+ if current_ttl == -1 or current_ttl < new_ttl then
41
+ redis.call('PEXPIRE', key, new_ttl)
42
+ return 2
43
+ end
44
+
45
+ return 0
46
+ `, 1, redisKey, cooldownMs.toString());
47
+ }
48
+ catch (error) {
49
+ logger.warn('Failed to write stream rate-limit cooldown to Redis', {
50
+ cooldownKey,
51
+ cooldownMs,
52
+ error,
53
+ });
54
+ }
55
+ }
56
+ static async setRemainingMsExact(cooldownKey, cooldownMs, context, options) {
57
+ if (cooldownMs <= 0) {
58
+ return;
59
+ }
60
+ const { redis, logger } = context;
61
+ try {
62
+ await redis.set(RedisCooldown.resolveRedisKey(cooldownKey, options), '1', 'PX', cooldownMs);
63
+ }
64
+ catch (error) {
65
+ logger.warn('Failed to write exact stream rate-limit cooldown to Redis', {
66
+ cooldownKey,
67
+ cooldownMs,
68
+ error,
69
+ });
70
+ }
71
+ }
72
+ static async getValue(cooldownKey, context, options) {
73
+ const { redis, logger } = context;
74
+ try {
75
+ const value = await redis.get(RedisCooldown.resolveRedisKey(cooldownKey, options));
76
+ if (typeof value === 'string' && value.length > 0) {
77
+ return value;
78
+ }
79
+ }
80
+ catch (error) {
81
+ logger.warn('Failed to read stream rate-limit metadata from Redis', {
82
+ cooldownKey,
83
+ error,
84
+ });
85
+ }
86
+ }
87
+ static async setValueWithRemainingMs(cooldownKey, value, cooldownMs, context, options) {
88
+ if (value.length === 0 || cooldownMs <= 0) {
89
+ return;
90
+ }
91
+ const { redis, logger } = context;
92
+ try {
93
+ await redis.eval(`
94
+ local key = KEYS[1]
95
+ local value = ARGV[1]
96
+ local new_ttl = tonumber(ARGV[2])
97
+ local current_ttl = redis.call('PTTL', key)
98
+
99
+ if current_ttl == -2 then
100
+ redis.call('SET', key, value, 'PX', new_ttl)
101
+ return 1
102
+ end
103
+
104
+ if current_ttl == -1 or current_ttl < new_ttl then
105
+ redis.call('SET', key, value, 'PX', new_ttl)
106
+ return 2
107
+ end
108
+
109
+ redis.call('SET', key, value, 'KEEPTTL')
110
+ return 0
111
+ `, 1, RedisCooldown.resolveRedisKey(cooldownKey, options), value, cooldownMs.toString());
112
+ }
113
+ catch (error) {
114
+ logger.warn('Failed to write stream rate-limit metadata to Redis', {
115
+ cooldownKey,
116
+ cooldownMs,
117
+ error,
118
+ });
119
+ }
120
+ }
121
+ static async setRemainingMsWithValue(cooldownKey, valueKey, value, cooldownMs, context, options) {
122
+ if (value.length === 0 || cooldownMs <= 0) {
123
+ return;
124
+ }
125
+ const { redis, logger } = context;
126
+ const cooldownRedisKey = RedisCooldown.resolveRedisKey(cooldownKey, options);
127
+ const valueRedisKey = RedisCooldown.resolveRedisKey(valueKey, options);
128
+ try {
129
+ await redis.eval(`
130
+ local cooldown_key = KEYS[1]
131
+ local value_key = KEYS[2]
132
+ local value = ARGV[1]
133
+ local new_ttl = tonumber(ARGV[2])
134
+ local current_ttl = redis.call('PTTL', cooldown_key)
135
+ local final_ttl = new_ttl
136
+
137
+ if current_ttl == -2 then
138
+ redis.call('SET', cooldown_key, '1', 'PX', new_ttl)
139
+ elseif current_ttl == -1 or current_ttl < new_ttl then
140
+ redis.call('PEXPIRE', cooldown_key, new_ttl)
141
+ else
142
+ final_ttl = current_ttl
143
+ end
144
+
145
+ redis.call('SET', value_key, value, 'PX', final_ttl)
146
+ return final_ttl
147
+ `, 2, cooldownRedisKey, valueRedisKey, value, cooldownMs.toString());
148
+ }
149
+ catch (error) {
150
+ logger.warn('Failed to write stream rate-limit cooldown and metadata to Redis', {
151
+ cooldownKey,
152
+ valueKey,
153
+ cooldownMs,
154
+ error,
155
+ });
156
+ }
157
+ }
158
+ static async clear(cooldownKey, context, options) {
159
+ const { redis, logger } = context;
160
+ try {
161
+ await redis.del(RedisCooldown.resolveRedisKey(cooldownKey, options));
162
+ }
163
+ catch (error) {
164
+ logger.warn('Failed to clear stream rate-limit cooldown from Redis', {
165
+ cooldownKey,
166
+ error,
167
+ });
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,28 @@
1
+ import { RateLimitExceededException } from '@wildix/xbees-conversations-client';
2
+ import { DEFAULT_RETRY_AFTER_MS } from '../constants';
3
+ const RETRY_AFTER_HEADER_PADDING_MS = 3000;
4
+ function toPositiveMilliseconds(milliseconds) {
5
+ return Math.max(0, Math.ceil(milliseconds));
6
+ }
7
+ function toEpochSecondsFromNow(delayMs) {
8
+ return Math.ceil((Date.now() + toPositiveMilliseconds(delayMs)) / 1000);
9
+ }
10
+ export function createRateLimitExceededException(options = {}) {
11
+ const { rateLimit, message, operation, retryAfterMs = DEFAULT_RETRY_AFTER_MS } = options;
12
+ const defaultMessage = operation ? `Rate limit exceeded for ${operation}` : 'Rate limit exceeded';
13
+ const retryAfterFromResetMs = rateLimit?.reset ? rateLimit.reset - Date.now() : undefined;
14
+ const effectiveRetryAfterMs = retryAfterFromResetMs ?? retryAfterMs;
15
+ const retryAfterMilliseconds = toPositiveMilliseconds(effectiveRetryAfterMs);
16
+ const rateLimitResetMilliseconds = rateLimit?.reset
17
+ ? toPositiveMilliseconds(retryAfterFromResetMs ?? 0)
18
+ : retryAfterMilliseconds;
19
+ const retryAfterEpochSeconds = toEpochSecondsFromNow(effectiveRetryAfterMs + RETRY_AFTER_HEADER_PADDING_MS);
20
+ return new RateLimitExceededException({
21
+ $metadata: {},
22
+ message: message || defaultMessage,
23
+ rateLimitRemaining: rateLimit?.remaining || 0,
24
+ retryAfter: retryAfterEpochSeconds,
25
+ rateLimit: rateLimit?.limit?.toString() || 'unknown',
26
+ rateLimitReset: rateLimitResetMilliseconds,
27
+ });
28
+ }
@@ -0,0 +1,140 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { BUDGET_COOLDOWN_SLEEP_CHUNK_MS, BUDGET_DELAY_MAX_MS, DEFAULT_JITTER_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_RETRY_AFTER_MS, RETRY_AFTER_BUFFER_MS, STREAM_BUDGET_COOLDOWN_KEY, STREAM_BUDGET_COOLDOWN_KEY_PREFIX, STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX, STREAM_RATE_LIMIT_METADATA_KEY_SUFFIX, STREAM_RATE_LIMIT_SOFT_COOLDOWN_KEY, } from '../constants';
3
+ import { createRateLimitExceededException } from './createRateLimitExceededException';
4
+ import { processStreamRateLimitException } from './processStreamRateLimitException';
5
+ import { parseRateLimitSnapshot, serializeRateLimitSnapshot } from './rateLimitSnapshot';
6
+ import { RedisCooldown } from './RedisCooldown';
7
+ function calculateRateLimitDelayMs(error, maxDelayMs) {
8
+ const retryAfterMs = typeof error.rateLimitReset === 'number' && error.rateLimitReset > 0 ? error.rateLimitReset : 0;
9
+ const hasServerRetryAfter = retryAfterMs > 0;
10
+ const baseDelayMs = hasServerRetryAfter ? retryAfterMs + RETRY_AFTER_BUFFER_MS : DEFAULT_RETRY_AFTER_MS;
11
+ if (baseDelayMs > 0) {
12
+ const jitterMs = Math.floor(Math.random() * DEFAULT_JITTER_MS);
13
+ const computedDelayMs = baseDelayMs + jitterMs;
14
+ if (hasServerRetryAfter) {
15
+ return computedDelayMs;
16
+ }
17
+ return Math.min(maxDelayMs, computedDelayMs);
18
+ }
19
+ return Math.min(maxDelayMs, DEFAULT_MAX_DELAY_MS);
20
+ }
21
+ function getRateLimitMetadataKey(operation) {
22
+ return `${operation}${STREAM_RATE_LIMIT_METADATA_KEY_SUFFIX}`;
23
+ }
24
+ function toCooldownRateLimit(snapshot, cooldownMs) {
25
+ return {
26
+ limit: snapshot.limit,
27
+ remaining: snapshot.remaining,
28
+ reset: Math.max(snapshot.reset, Date.now() + cooldownMs),
29
+ };
30
+ }
31
+ function toRateLimitSnapshot(error) {
32
+ const limit = Number(error.rateLimit);
33
+ const remaining = error.rateLimitRemaining;
34
+ const resetMs = error.rateLimitReset;
35
+ if (!Number.isFinite(limit) ||
36
+ typeof remaining !== 'number' ||
37
+ !Number.isFinite(remaining) ||
38
+ typeof resetMs !== 'number' ||
39
+ !Number.isFinite(resetMs) ||
40
+ resetMs <= 0) {
41
+ return;
42
+ }
43
+ return {
44
+ limit,
45
+ remaining,
46
+ reset: Date.now() + resetMs,
47
+ };
48
+ }
49
+ export async function executeWithRateLimitHandling(execute, operation, context, options) {
50
+ const { logger, redis } = context;
51
+ const { enableCooldown, maxAttempts, maxDelayMs, maxRetryableDelayMs } = options;
52
+ let attempt = 1;
53
+ if (enableCooldown) {
54
+ const cooldownMs = await RedisCooldown.getRemainingMs(operation, { redis, logger });
55
+ if (cooldownMs > 0) {
56
+ const rateLimitSnapshot = parseRateLimitSnapshot(await RedisCooldown.getValue(getRateLimitMetadataKey(operation), { redis, logger }));
57
+ logger.warn('Skipping stream request due to active rate-limit cooldown', {
58
+ operation,
59
+ cooldown: cooldownMs,
60
+ rateLimitSnapshot,
61
+ });
62
+ throw createRateLimitExceededException({
63
+ operation,
64
+ retryAfterMs: cooldownMs,
65
+ rateLimit: rateLimitSnapshot ? toCooldownRateLimit(rateLimitSnapshot, cooldownMs) : undefined,
66
+ });
67
+ }
68
+ const softCooldownMs = await RedisCooldown.getRemainingMs(STREAM_RATE_LIMIT_SOFT_COOLDOWN_KEY, { redis, logger }, { keyPrefix: STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX });
69
+ if (softCooldownMs > 0) {
70
+ logger.debug('Delaying stream request due to active soft rate-limit cooldown', {
71
+ operation,
72
+ cooldown: softCooldownMs,
73
+ });
74
+ await sleep(softCooldownMs);
75
+ }
76
+ const budgetCooldownMs = await RedisCooldown.getRemainingMs(STREAM_BUDGET_COOLDOWN_KEY, { redis, logger }, { keyPrefix: STREAM_BUDGET_COOLDOWN_KEY_PREFIX });
77
+ if (budgetCooldownMs > 0) {
78
+ logger.warn('Delaying stream request due to active budget cooldown', {
79
+ operation,
80
+ cooldown: budgetCooldownMs,
81
+ });
82
+ let totalBudgetWaitMs = 0;
83
+ for (;;) {
84
+ const waitMs = await RedisCooldown.getRemainingMs(STREAM_BUDGET_COOLDOWN_KEY, { redis, logger }, { keyPrefix: STREAM_BUDGET_COOLDOWN_KEY_PREFIX });
85
+ if (waitMs <= 0 || totalBudgetWaitMs >= BUDGET_DELAY_MAX_MS) {
86
+ break;
87
+ }
88
+ const chunkMs = Math.min(waitMs, BUDGET_COOLDOWN_SLEEP_CHUNK_MS);
89
+ await sleep(chunkMs);
90
+ totalBudgetWaitMs += chunkMs;
91
+ }
92
+ }
93
+ }
94
+ for (;;) {
95
+ try {
96
+ return await execute();
97
+ }
98
+ catch (error) {
99
+ const rateLimitError = processStreamRateLimitException(error, logger);
100
+ if (!rateLimitError) {
101
+ throw error;
102
+ }
103
+ const retryAfterMs = calculateRateLimitDelayMs(rateLimitError, maxDelayMs);
104
+ if (enableCooldown) {
105
+ const rateLimitSnapshot = toRateLimitSnapshot(rateLimitError);
106
+ if (rateLimitSnapshot) {
107
+ await RedisCooldown.setRemainingMsWithValue(operation, getRateLimitMetadataKey(operation), serializeRateLimitSnapshot(rateLimitSnapshot), retryAfterMs, { redis, logger });
108
+ }
109
+ else {
110
+ await RedisCooldown.setRemainingMs(operation, retryAfterMs, { redis, logger });
111
+ }
112
+ }
113
+ if (attempt < maxAttempts && retryAfterMs <= maxRetryableDelayMs) {
114
+ logger.warn('Retrying stream request after rate limit', {
115
+ operation,
116
+ attempt,
117
+ maxAttempts,
118
+ retryAfterMs,
119
+ rateLimit: rateLimitError.rateLimit,
120
+ rateLimitRemaining: rateLimitError.rateLimitRemaining,
121
+ rateLimitReset: rateLimitError.rateLimitReset,
122
+ });
123
+ await sleep(retryAfterMs);
124
+ attempt++;
125
+ continue;
126
+ }
127
+ logger.warn('Propagating stream rate limit w/o retry', {
128
+ operation,
129
+ attempt,
130
+ maxAttempts,
131
+ retryAfterMs,
132
+ maxRetryableDelayMs,
133
+ rateLimit: rateLimitError.rateLimit,
134
+ rateLimitRemaining: rateLimitError.rateLimitRemaining,
135
+ rateLimitReset: rateLimitError.rateLimitReset,
136
+ });
137
+ throw rateLimitError;
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,34 @@
1
+ export function getHeaderCaseInsensitive(headers, key) {
2
+ if (!headers) {
3
+ return;
4
+ }
5
+ const direct = headers[key];
6
+ if (direct !== undefined) {
7
+ return direct;
8
+ }
9
+ const normalizedKey = key.toLowerCase();
10
+ for (const [headerKey, headerValue] of Object.entries(headers)) {
11
+ if (headerKey.toLowerCase() === normalizedKey) {
12
+ return headerValue;
13
+ }
14
+ }
15
+ }
16
+ export function parseHeaderNumber(headers, key) {
17
+ const raw = getHeaderCaseInsensitive(headers, key);
18
+ if (typeof raw === 'number' && Number.isFinite(raw)) {
19
+ return raw;
20
+ }
21
+ if (typeof raw === 'string') {
22
+ const parsed = Number(raw);
23
+ if (Number.isFinite(parsed)) {
24
+ return parsed;
25
+ }
26
+ }
27
+ }
28
+ export function normalizeResetToEpochMs(reset) {
29
+ if (reset < 10000)
30
+ return Date.now() + Math.round(reset * 1000);
31
+ if (reset < 10000000000)
32
+ return Math.round(reset * 1000);
33
+ return Math.round(reset);
34
+ }
@@ -0,0 +1,19 @@
1
+ import { Channel } from 'stream-chat';
2
+ function hasFunctionProperty(value, key) {
3
+ return key in value && typeof value[key] === 'function';
4
+ }
5
+ function hasStringProperty(value, key) {
6
+ return key in value && typeof value[key] === 'string';
7
+ }
8
+ export function isStreamChannelLike(value) {
9
+ if (typeof value !== 'object' || value === null) {
10
+ return false;
11
+ }
12
+ if (value instanceof Channel) {
13
+ return true;
14
+ }
15
+ return (hasStringProperty(value, 'cid') &&
16
+ hasFunctionProperty(value, 'sendMessage') &&
17
+ hasFunctionProperty(value, 'queryMembers') &&
18
+ hasFunctionProperty(value, 'updatePartial'));
19
+ }
@@ -0,0 +1,89 @@
1
+ import { BUDGET_CLEAR_USAGE_THRESHOLD, BUDGET_DELAY_MAX_MS, BUDGET_DELAY_MIN_MS, BUDGET_MINIMAL_DELAY_MAX_MS, BUDGET_MINIMAL_DELAY_MIN_MS, BUDGET_RELAXED_DELAY_MAX_MS, BUDGET_RELAXED_DELAY_MIN_MS, BUDGET_RELAXED_USAGE_THRESHOLD, BUDGET_THRESHOLD, STREAM_BUDGET_COOLDOWN_KEY, STREAM_BUDGET_COOLDOWN_KEY_PREFIX, } from '../constants';
2
+ import { parseHeaderNumber } from './headerUtils';
3
+ import { RedisCooldown } from './RedisCooldown';
4
+ function extractBudgetHeaders(headers) {
5
+ return {
6
+ limitMs: parseHeaderNumber(headers ?? {}, 'x-budget-limit-ms'),
7
+ remainingMs: parseHeaderNumber(headers ?? {}, 'x-budget-remaining-ms'),
8
+ usedMs: parseHeaderNumber(headers ?? {}, 'x-budget-used-ms'),
9
+ };
10
+ }
11
+ function isBudgetThresholdReached(limitMs, remainingMs, usedMs) {
12
+ if (limitMs <= 0) {
13
+ return false;
14
+ }
15
+ if (typeof usedMs === 'number' && usedMs / limitMs >= BUDGET_THRESHOLD) {
16
+ return true;
17
+ }
18
+ if (typeof remainingMs === 'number') {
19
+ const remainingThreshold = Math.ceil((1 - BUDGET_THRESHOLD) * limitMs);
20
+ return remainingMs <= remainingThreshold;
21
+ }
22
+ return false;
23
+ }
24
+ function getRandomDelayInRange(minMs, maxMs) {
25
+ return Math.round(minMs + Math.random() * (maxMs - minMs));
26
+ }
27
+ function getBudgetUsage(limitMs, remainingMs, usedMs) {
28
+ if (limitMs <= 0) {
29
+ return 0;
30
+ }
31
+ if (typeof usedMs === 'number' && Number.isFinite(usedMs) && usedMs >= 0) {
32
+ return usedMs / limitMs;
33
+ }
34
+ if (typeof remainingMs === 'number' && Number.isFinite(remainingMs)) {
35
+ return Math.max(0, 1 - remainingMs / limitMs);
36
+ }
37
+ return 0;
38
+ }
39
+ export async function processStreamBudgetHeaders(headers, context) {
40
+ const { logger } = context;
41
+ const { limitMs, remainingMs, usedMs } = extractBudgetHeaders(headers);
42
+ if (limitMs === undefined && remainingMs === undefined && usedMs === undefined) {
43
+ return;
44
+ }
45
+ if (typeof limitMs !== 'number' || !Number.isFinite(limitMs)) {
46
+ return;
47
+ }
48
+ const usage = getBudgetUsage(limitMs, remainingMs, usedMs);
49
+ const thresholdReached = isBudgetThresholdReached(limitMs, remainingMs, usedMs);
50
+ if (thresholdReached) {
51
+ const delayMs = getRandomDelayInRange(BUDGET_DELAY_MIN_MS, BUDGET_DELAY_MAX_MS);
52
+ await RedisCooldown.setRemainingMsExact(STREAM_BUDGET_COOLDOWN_KEY, delayMs, context, {
53
+ keyPrefix: STREAM_BUDGET_COOLDOWN_KEY_PREFIX,
54
+ });
55
+ logger.warn('Stream budget threshold reached, enabling cooldown', {
56
+ budgetLimitMs: limitMs,
57
+ budgetRemainingMs: remainingMs,
58
+ budgetUsedMs: usedMs,
59
+ budgetUsage: usage,
60
+ cooldownMs: delayMs,
61
+ });
62
+ return;
63
+ }
64
+ if (usage < BUDGET_CLEAR_USAGE_THRESHOLD) {
65
+ await RedisCooldown.clear(STREAM_BUDGET_COOLDOWN_KEY, context, {
66
+ keyPrefix: STREAM_BUDGET_COOLDOWN_KEY_PREFIX,
67
+ });
68
+ logger.info('Stream budget is low, clearing cooldown', {
69
+ budgetLimitMs: limitMs,
70
+ budgetRemainingMs: remainingMs,
71
+ budgetUsedMs: usedMs,
72
+ budgetUsage: usage,
73
+ });
74
+ return;
75
+ }
76
+ const delayMs = usage >= BUDGET_RELAXED_USAGE_THRESHOLD
77
+ ? getRandomDelayInRange(BUDGET_RELAXED_DELAY_MIN_MS, BUDGET_RELAXED_DELAY_MAX_MS)
78
+ : getRandomDelayInRange(BUDGET_MINIMAL_DELAY_MIN_MS, BUDGET_MINIMAL_DELAY_MAX_MS);
79
+ await RedisCooldown.setRemainingMsExact(STREAM_BUDGET_COOLDOWN_KEY, delayMs, context, {
80
+ keyPrefix: STREAM_BUDGET_COOLDOWN_KEY_PREFIX,
81
+ });
82
+ logger.info('Stream budget is below threshold, reducing cooldown', {
83
+ budgetLimitMs: limitMs,
84
+ budgetRemainingMs: remainingMs,
85
+ budgetUsedMs: usedMs,
86
+ budgetUsage: usage,
87
+ cooldownMs: delayMs,
88
+ });
89
+ }
@@ -0,0 +1,59 @@
1
+ import { createRateLimitExceededException } from './createRateLimitExceededException';
2
+ import { getHeaderCaseInsensitive, normalizeResetToEpochMs, parseHeaderNumber } from './headerUtils';
3
+ function parseRetryAfterMs(headers) {
4
+ const retryAfter = getHeaderCaseInsensitive(headers, 'retry-after');
5
+ if (typeof retryAfter === 'number' && Number.isFinite(retryAfter) && retryAfter >= 0) {
6
+ return Math.round(retryAfter * 1000);
7
+ }
8
+ if (typeof retryAfter !== 'string') {
9
+ return;
10
+ }
11
+ const asSeconds = Number(retryAfter);
12
+ if (Number.isFinite(asSeconds) && asSeconds >= 0) {
13
+ return Math.round(asSeconds * 1000);
14
+ }
15
+ const asDateMs = Date.parse(retryAfter);
16
+ if (!Number.isNaN(asDateMs)) {
17
+ return Math.max(0, asDateMs - Date.now());
18
+ }
19
+ }
20
+ function getRateLimitFromHeaders(headers) {
21
+ const limit = parseHeaderNumber(headers, 'x-ratelimit-limit');
22
+ const remaining = parseHeaderNumber(headers, 'x-ratelimit-remaining');
23
+ const resetRaw = parseHeaderNumber(headers, 'x-ratelimit-reset');
24
+ if (limit === undefined || remaining === undefined || resetRaw === undefined) {
25
+ return;
26
+ }
27
+ return {
28
+ limit,
29
+ remaining,
30
+ reset: normalizeResetToEpochMs(resetRaw),
31
+ };
32
+ }
33
+ function isStreamHttpRateLimitError(error) {
34
+ return (typeof error === 'object' &&
35
+ error !== null &&
36
+ (('status' in error && error.status === 429) ||
37
+ ('response' in error &&
38
+ typeof error.response === 'object' &&
39
+ error.response !== null &&
40
+ error.response?.status === 429)));
41
+ }
42
+ export function processStreamRateLimitException(error, logger) {
43
+ if (isStreamHttpRateLimitError(error)) {
44
+ const headers = error.response?.headers ?? {};
45
+ const rateLimit = getRateLimitFromHeaders(headers);
46
+ const retryAfterMs = parseRetryAfterMs(headers);
47
+ const message = error.response?.data?.message || error.message || 'Rate limit exceeded';
48
+ logger.warn('Stream rate limit exceeded [HTTP 429]', {
49
+ status: error.status ?? error.response?.status,
50
+ retryAfterMs,
51
+ rateLimit,
52
+ });
53
+ return createRateLimitExceededException({
54
+ rateLimit,
55
+ retryAfterMs,
56
+ message,
57
+ });
58
+ }
59
+ }
@@ -0,0 +1,46 @@
1
+ import { SOFT_THROTTLE_DELAY_HIGH_MS, SOFT_THROTTLE_DELAY_LOW_MS, SOFT_THROTTLE_DELAY_MEDIUM_MS, SOFT_THROTTLE_USAGE_HIGH, SOFT_THROTTLE_USAGE_LOW, SOFT_THROTTLE_USAGE_MEDIUM, STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX, STREAM_RATE_LIMIT_SOFT_COOLDOWN_KEY, } from '../constants';
2
+ import { parseHeaderNumber } from './headerUtils';
3
+ import { RedisCooldown } from './RedisCooldown';
4
+ function computeSoftThrottleDelayMs(limit, remaining) {
5
+ if (limit <= 0) {
6
+ return 0;
7
+ }
8
+ const usage = 1 - remaining / limit;
9
+ if (usage >= SOFT_THROTTLE_USAGE_HIGH) {
10
+ return SOFT_THROTTLE_DELAY_HIGH_MS;
11
+ }
12
+ if (usage >= SOFT_THROTTLE_USAGE_MEDIUM) {
13
+ return SOFT_THROTTLE_DELAY_MEDIUM_MS;
14
+ }
15
+ if (usage >= SOFT_THROTTLE_USAGE_LOW) {
16
+ return SOFT_THROTTLE_DELAY_LOW_MS;
17
+ }
18
+ return 0;
19
+ }
20
+ export async function processStreamRateLimitHeaders(headers, context) {
21
+ const { logger } = context;
22
+ const limit = parseHeaderNumber(headers ?? {}, 'x-ratelimit-limit');
23
+ const remaining = parseHeaderNumber(headers ?? {}, 'x-ratelimit-remaining');
24
+ if (limit === undefined || remaining === undefined) {
25
+ return;
26
+ }
27
+ if (limit <= 0 || remaining < 0) {
28
+ return;
29
+ }
30
+ const delayMs = computeSoftThrottleDelayMs(limit, remaining);
31
+ if (delayMs > 0) {
32
+ await RedisCooldown.setRemainingMs(STREAM_RATE_LIMIT_SOFT_COOLDOWN_KEY, delayMs, context, {
33
+ keyPrefix: STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX,
34
+ });
35
+ logger.debug('Enabling soft stream rate-limit cooldown', {
36
+ rateLimit: limit,
37
+ rateLimitRemaining: remaining,
38
+ cooldownMs: delayMs,
39
+ });
40
+ return;
41
+ }
42
+ await RedisCooldown.clear(STREAM_RATE_LIMIT_SOFT_COOLDOWN_KEY, context, {
43
+ keyPrefix: STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX,
44
+ });
45
+ logger.debug('Clearing soft stream rate-limit cooldown');
46
+ }
@@ -0,0 +1,26 @@
1
+ const RATE_LIMIT_SNAPSHOT_PARTS_COUNT = 3;
2
+ export function serializeRateLimitSnapshot(snapshot) {
3
+ return `${snapshot.limit},${snapshot.remaining},${snapshot.reset}`;
4
+ }
5
+ export function parseRateLimitSnapshot(snapshot) {
6
+ if (typeof snapshot !== 'string' || snapshot.length === 0) {
7
+ return;
8
+ }
9
+ const parts = snapshot.split(',');
10
+ if (parts.length !== RATE_LIMIT_SNAPSHOT_PARTS_COUNT) {
11
+ return;
12
+ }
13
+ const [limitRaw, remainingRaw, resetRaw] = parts;
14
+ const limit = Number(limitRaw);
15
+ const remaining = Number(remainingRaw);
16
+ const reset = Number(resetRaw);
17
+ if (!Number.isFinite(limit) ||
18
+ !Number.isFinite(remaining) ||
19
+ !Number.isFinite(reset) ||
20
+ limit < 0 ||
21
+ remaining < 0 ||
22
+ reset <= 0) {
23
+ return;
24
+ }
25
+ return { limit, remaining, reset };
26
+ }
@@ -0,0 +1,44 @@
1
+ import { processStreamBudgetHeaders } from './processStreamBudgetHeaders';
2
+ import { processStreamRateLimitHeaders } from './processStreamRateLimitHeaders';
3
+ const attachedInterceptors = new WeakSet();
4
+ function hasResponseInterceptors(value) {
5
+ return (typeof value === 'object' &&
6
+ value !== null &&
7
+ typeof value.interceptors?.response?.use === 'function');
8
+ }
9
+ function resolveAxiosInstance(stream) {
10
+ if (hasResponseInterceptors(stream)) {
11
+ return stream;
12
+ }
13
+ if (typeof stream !== 'object' || stream === null) {
14
+ return;
15
+ }
16
+ const maybeStream = stream;
17
+ if (hasResponseInterceptors(maybeStream.axiosInstance)) {
18
+ return maybeStream.axiosInstance;
19
+ }
20
+ if (hasResponseInterceptors(maybeStream._client?.axiosInstance)) {
21
+ return maybeStream._client?.axiosInstance;
22
+ }
23
+ }
24
+ export function registerStreamBudgetInterceptors(stream, context) {
25
+ const { logger } = context;
26
+ const axiosInstance = resolveAxiosInstance(stream);
27
+ if (!axiosInstance || !axiosInstance.interceptors?.response?.use) {
28
+ logger.warn('Unable to register stream budget interceptor: axios instance not found');
29
+ return;
30
+ }
31
+ if (attachedInterceptors.has(axiosInstance)) {
32
+ return;
33
+ }
34
+ axiosInstance.interceptors.response.use(async (response) => {
35
+ await processStreamBudgetHeaders(response?.headers, context);
36
+ await processStreamRateLimitHeaders(response?.headers, context);
37
+ return response;
38
+ }, async (error) => {
39
+ await processStreamBudgetHeaders(error?.response?.headers, context);
40
+ await processStreamRateLimitHeaders(error?.response?.headers, context);
41
+ return Promise.reject(error);
42
+ });
43
+ attachedInterceptors.add(axiosInstance);
44
+ }