@wildix/xbees-conversations-utils 1.1.69 → 1.1.71

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.
@@ -1,12 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_OPTIONS = exports.CHANNEL_SYNC_PROXY_RESULT_METHOD_NAMES = exports.CHANNEL_SYNC_METHOD_NAMES = exports.STREAM_SYNC_METHOD_NAMES = exports.STREAM_SYNC_CHANNEL_RESULT_METHOD_NAMES = exports.STREAM_RATE_LIMIT_OPTIONS_MARKER = exports.STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX = exports.RETRY_AFTER_BUFFER_MS = exports.DEFAULT_JITTER_MS = exports.DEFAULT_RETRY_AFTER_MS = exports.DEFAULT_MAX_DELAY_MS = void 0;
3
+ exports.DEFAULT_OPTIONS = exports.CHANNEL_SYNC_PROXY_RESULT_METHOD_NAMES = exports.CHANNEL_SYNC_METHOD_NAMES = exports.STREAM_SYNC_METHOD_NAMES = exports.STREAM_SYNC_CHANNEL_RESULT_METHOD_NAMES = exports.BUDGET_DELAY_MAX_MS = exports.BUDGET_DELAY_MIN_MS = exports.BUDGET_THRESHOLD = exports.STREAM_BUDGET_COOLDOWN_KEY = exports.STREAM_RATE_LIMIT_OPTIONS_MARKER = exports.STREAM_BUDGET_COOLDOWN_KEY_PREFIX = exports.STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX = exports.RETRY_AFTER_BUFFER_MS = exports.DEFAULT_JITTER_MS = exports.DEFAULT_RETRY_AFTER_MS = exports.DEFAULT_MAX_DELAY_MS = void 0;
4
4
  exports.DEFAULT_MAX_DELAY_MS = 5000;
5
5
  exports.DEFAULT_RETRY_AFTER_MS = 10000;
6
6
  exports.DEFAULT_JITTER_MS = 150;
7
7
  exports.RETRY_AFTER_BUFFER_MS = 1000;
8
8
  exports.STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX = 'stream-rate-limit-cooldown:';
9
+ exports.STREAM_BUDGET_COOLDOWN_KEY_PREFIX = 'stream-budget-cooldown:';
9
10
  exports.STREAM_RATE_LIMIT_OPTIONS_MARKER = '__xbsRateLimitOptions';
11
+ exports.STREAM_BUDGET_COOLDOWN_KEY = 'stream-budget-global';
12
+ exports.BUDGET_THRESHOLD = 0.8;
13
+ exports.BUDGET_DELAY_MIN_MS = 30000;
14
+ exports.BUDGET_DELAY_MAX_MS = 60000;
10
15
  exports.STREAM_SYNC_CHANNEL_RESULT_METHOD_NAMES = [
11
16
  'channel',
12
17
  'getChannelById',
@@ -4,6 +4,7 @@ exports.createRateLimitedStreamProxy = void 0;
4
4
  const constants_1 = require("./constants");
5
5
  const executeWithRateLimitHandling_1 = require("./helpers/executeWithRateLimitHandling");
6
6
  const isStreamChannelLike_1 = require("./helpers/isStreamChannelLike");
7
+ const registerStreamBudgetInterceptors_1 = require("./helpers/registerStreamBudgetInterceptors");
7
8
  const splitCallArgsAndOptions_1 = require("./helpers/splitCallArgsAndOptions");
8
9
  const STREAM_SYNC_METHODS = new Set(constants_1.STREAM_SYNC_METHOD_NAMES);
9
10
  const STREAM_SYNC_CHANNEL_RESULT_METHODS = new Set(constants_1.STREAM_SYNC_CHANNEL_RESULT_METHOD_NAMES);
@@ -64,6 +65,7 @@ function wrapTarget(target, dependencies) {
64
65
  return proxy;
65
66
  }
66
67
  function createRateLimitedStreamProxy(stream, context) {
68
+ (0, registerStreamBudgetInterceptors_1.registerStreamBudgetInterceptors)(stream, context);
67
69
  return wrapTarget(stream, {
68
70
  context,
69
71
  cache: new WeakMap(),
@@ -3,10 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RedisCooldown = void 0;
4
4
  const constants_1 = require("../constants");
5
5
  class RedisCooldown {
6
- static async getRemainingMs(cooldownKey, context) {
6
+ static resolveRedisKey(cooldownKey, options) {
7
+ const keyPrefix = options?.keyPrefix ?? constants_1.STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX;
8
+ return `${keyPrefix}${cooldownKey}`;
9
+ }
10
+ static async getRemainingMs(cooldownKey, context, options) {
7
11
  const { redis, logger } = context;
8
12
  try {
9
- const ttlMs = await redis.pttl(`${constants_1.STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX}${cooldownKey}`);
13
+ const ttlMs = await redis.pttl(RedisCooldown.resolveRedisKey(cooldownKey, options));
10
14
  if (ttlMs > 0) {
11
15
  return ttlMs;
12
16
  }
@@ -19,12 +23,12 @@ class RedisCooldown {
19
23
  }
20
24
  return 0;
21
25
  }
22
- static async setRemainingMs(cooldownKey, cooldownMs, context) {
26
+ static async setRemainingMs(cooldownKey, cooldownMs, context, options) {
23
27
  if (cooldownMs <= 0) {
24
28
  return;
25
29
  }
26
30
  const { redis, logger } = context;
27
- const redisKey = `${constants_1.STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX}${cooldownKey}`;
31
+ const redisKey = RedisCooldown.resolveRedisKey(cooldownKey, options);
28
32
  try {
29
33
  await redis.eval(`
30
34
  local key = KEYS[1]
@@ -52,5 +56,17 @@ class RedisCooldown {
52
56
  });
53
57
  }
54
58
  }
59
+ static async clear(cooldownKey, context, options) {
60
+ const { redis, logger } = context;
61
+ try {
62
+ await redis.del(RedisCooldown.resolveRedisKey(cooldownKey, options));
63
+ }
64
+ catch (error) {
65
+ logger.warn('Failed to clear stream rate-limit cooldown from Redis', {
66
+ cooldownKey,
67
+ error,
68
+ });
69
+ }
70
+ }
55
71
  }
56
72
  exports.RedisCooldown = RedisCooldown;
@@ -3,16 +3,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createRateLimitExceededException = void 0;
4
4
  const xbees_conversations_client_1 = require("@wildix/xbees-conversations-client");
5
5
  const constants_1 = require("../constants");
6
+ function toPositiveSeconds(milliseconds) {
7
+ return Math.max(0, Math.ceil(milliseconds / 1000));
8
+ }
6
9
  function createRateLimitExceededException(options = {}) {
7
10
  const { rateLimit, message, operation, retryAfterMs = constants_1.DEFAULT_RETRY_AFTER_MS } = options;
8
11
  const defaultMessage = operation ? `Rate limit exceeded for ${operation}` : 'Rate limit exceeded';
12
+ const retryAfterFromResetMs = rateLimit?.reset ? rateLimit.reset - Date.now() : undefined;
13
+ const effectiveRetryAfterMs = retryAfterFromResetMs ?? retryAfterMs;
14
+ const retryAfterSeconds = toPositiveSeconds(effectiveRetryAfterMs);
15
+ const rateLimitResetSeconds = rateLimit?.reset ? toPositiveSeconds(retryAfterFromResetMs ?? 0) : retryAfterSeconds;
9
16
  return new xbees_conversations_client_1.RateLimitExceededException({
10
17
  $metadata: {},
11
18
  message: message || defaultMessage,
12
19
  rateLimitRemaining: rateLimit?.remaining || 0,
13
- retryAfter: rateLimit?.reset ? rateLimit.reset - Date.now() : retryAfterMs,
20
+ retryAfter: retryAfterSeconds,
14
21
  rateLimit: rateLimit?.limit?.toString() || 'unknown',
15
- rateLimitReset: rateLimit?.reset || Date.now() + retryAfterMs,
22
+ rateLimitReset: rateLimitResetSeconds,
16
23
  });
17
24
  }
18
25
  exports.createRateLimitExceededException = createRateLimitExceededException;
@@ -8,7 +8,8 @@ const processStreamRateLimitException_1 = require("./processStreamRateLimitExcep
8
8
  const RedisCooldown_1 = require("./RedisCooldown");
9
9
  function calculateRateLimitDelayMs(error, maxDelayMs) {
10
10
  const hasServerRetryAfter = typeof error.retryAfter === 'number' && error.retryAfter > 0;
11
- const baseDelayMs = hasServerRetryAfter ? error.retryAfter + constants_1.RETRY_AFTER_BUFFER_MS : constants_1.DEFAULT_RETRY_AFTER_MS;
11
+ const retryAfterMsFromError = hasServerRetryAfter ? Math.round(error.retryAfter * 1000) : 0;
12
+ const baseDelayMs = hasServerRetryAfter ? retryAfterMsFromError + constants_1.RETRY_AFTER_BUFFER_MS : constants_1.DEFAULT_RETRY_AFTER_MS;
12
13
  if (baseDelayMs > 0) {
13
14
  const jitterMs = Math.floor(Math.random() * constants_1.DEFAULT_JITTER_MS);
14
15
  const computedDelayMs = baseDelayMs + jitterMs;
@@ -24,6 +25,14 @@ async function executeWithRateLimitHandling(execute, operation, context, options
24
25
  const { enableCooldown, maxAttempts, maxDelayMs, maxRetryableDelayMs } = options;
25
26
  let attempt = 1;
26
27
  if (enableCooldown) {
28
+ const budgetCooldownMs = await RedisCooldown_1.RedisCooldown.getRemainingMs(constants_1.STREAM_BUDGET_COOLDOWN_KEY, { redis, logger }, { keyPrefix: constants_1.STREAM_BUDGET_COOLDOWN_KEY_PREFIX });
29
+ if (budgetCooldownMs > 0) {
30
+ logger.warn('Delaying stream request due to active budget cooldown', {
31
+ operation,
32
+ cooldown: budgetCooldownMs,
33
+ });
34
+ await (0, promises_1.setTimeout)(budgetCooldownMs);
35
+ }
27
36
  const cooldownMs = await RedisCooldown_1.RedisCooldown.getRemainingMs(operation, { redis, logger });
28
37
  if (cooldownMs > 0) {
29
38
  logger.warn('Skipping stream request due to active rate-limit cooldown', {
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.processStreamBudgetHeaders = void 0;
4
+ const constants_1 = require("../constants");
5
+ const RedisCooldown_1 = require("./RedisCooldown");
6
+ function parseHeaderNumber(value) {
7
+ if (typeof value === 'number' && Number.isFinite(value)) {
8
+ return value;
9
+ }
10
+ if (typeof value === 'string') {
11
+ const parsed = Number(value);
12
+ if (Number.isFinite(parsed)) {
13
+ return parsed;
14
+ }
15
+ }
16
+ }
17
+ function getHeaderCaseInsensitive(headers, key) {
18
+ if (!headers) {
19
+ return;
20
+ }
21
+ const direct = headers[key];
22
+ if (direct !== undefined) {
23
+ return direct;
24
+ }
25
+ const normalizedKey = key.toLowerCase();
26
+ for (const [headerKey, headerValue] of Object.entries(headers)) {
27
+ if (headerKey.toLowerCase() === normalizedKey) {
28
+ return headerValue;
29
+ }
30
+ }
31
+ }
32
+ function extractBudgetHeaders(headers) {
33
+ return {
34
+ limitMs: parseHeaderNumber(getHeaderCaseInsensitive(headers, 'x-budget-limit-ms')),
35
+ remainingMs: parseHeaderNumber(getHeaderCaseInsensitive(headers, 'x-budget-remaining-ms')),
36
+ usedMs: parseHeaderNumber(getHeaderCaseInsensitive(headers, 'x-budget-used-ms')),
37
+ };
38
+ }
39
+ function isBudgetThresholdReached(limitMs, remainingMs, usedMs) {
40
+ if (limitMs <= 0) {
41
+ return false;
42
+ }
43
+ if (typeof usedMs === 'number' && usedMs / limitMs >= constants_1.BUDGET_THRESHOLD) {
44
+ return true;
45
+ }
46
+ if (typeof remainingMs === 'number') {
47
+ const remainingThreshold = Math.ceil((1 - constants_1.BUDGET_THRESHOLD) * limitMs);
48
+ return remainingMs <= remainingThreshold;
49
+ }
50
+ return false;
51
+ }
52
+ function getBudgetDelayMs() {
53
+ return Math.round(constants_1.BUDGET_DELAY_MIN_MS + Math.random() * (constants_1.BUDGET_DELAY_MAX_MS - constants_1.BUDGET_DELAY_MIN_MS));
54
+ }
55
+ async function processStreamBudgetHeaders(headers, context) {
56
+ const { logger } = context;
57
+ const { limitMs, remainingMs, usedMs } = extractBudgetHeaders(headers);
58
+ if (limitMs === undefined && remainingMs === undefined && usedMs === undefined) {
59
+ return;
60
+ }
61
+ if (typeof limitMs !== 'number' || !Number.isFinite(limitMs)) {
62
+ return;
63
+ }
64
+ const thresholdReached = isBudgetThresholdReached(limitMs, remainingMs, usedMs);
65
+ if (thresholdReached) {
66
+ const delayMs = getBudgetDelayMs();
67
+ await RedisCooldown_1.RedisCooldown.setRemainingMs(constants_1.STREAM_BUDGET_COOLDOWN_KEY, delayMs, context, {
68
+ keyPrefix: constants_1.STREAM_BUDGET_COOLDOWN_KEY_PREFIX,
69
+ });
70
+ logger.warn('Stream budget threshold reached, enabling cooldown', {
71
+ budgetLimitMs: limitMs,
72
+ budgetRemainingMs: remainingMs,
73
+ budgetUsedMs: usedMs,
74
+ cooldownMs: delayMs,
75
+ });
76
+ return;
77
+ }
78
+ await RedisCooldown_1.RedisCooldown.clear(constants_1.STREAM_BUDGET_COOLDOWN_KEY, context, {
79
+ keyPrefix: constants_1.STREAM_BUDGET_COOLDOWN_KEY_PREFIX,
80
+ });
81
+ logger.warn('Stream budget is below threshold, disabling cooldown', {
82
+ budgetLimitMs: limitMs,
83
+ budgetRemainingMs: remainingMs,
84
+ budgetUsedMs: usedMs,
85
+ });
86
+ }
87
+ exports.processStreamBudgetHeaders = processStreamBudgetHeaders;
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerStreamBudgetInterceptors = void 0;
4
+ const processStreamBudgetHeaders_1 = require("./processStreamBudgetHeaders");
5
+ const attachedInterceptors = new WeakSet();
6
+ function hasResponseInterceptors(value) {
7
+ return (typeof value === 'object' &&
8
+ value !== null &&
9
+ typeof value.interceptors?.response?.use === 'function');
10
+ }
11
+ function resolveAxiosInstance(stream) {
12
+ if (hasResponseInterceptors(stream)) {
13
+ return stream;
14
+ }
15
+ if (typeof stream !== 'object' || stream === null) {
16
+ return;
17
+ }
18
+ const maybeStream = stream;
19
+ if (hasResponseInterceptors(maybeStream.axiosInstance)) {
20
+ return maybeStream.axiosInstance;
21
+ }
22
+ if (hasResponseInterceptors(maybeStream._client?.axiosInstance)) {
23
+ return maybeStream._client?.axiosInstance;
24
+ }
25
+ }
26
+ function registerStreamBudgetInterceptors(stream, context) {
27
+ const { logger } = context;
28
+ const axiosInstance = resolveAxiosInstance(stream);
29
+ if (!axiosInstance || !axiosInstance.interceptors?.response?.use) {
30
+ logger.warn('Unable to register stream budget interceptor: axios instance not found');
31
+ return;
32
+ }
33
+ if (attachedInterceptors.has(axiosInstance)) {
34
+ return;
35
+ }
36
+ axiosInstance.interceptors.response.use(async (response) => {
37
+ await (0, processStreamBudgetHeaders_1.processStreamBudgetHeaders)(response?.headers, context);
38
+ return response;
39
+ }, async (error) => {
40
+ await (0, processStreamBudgetHeaders_1.processStreamBudgetHeaders)(error?.response?.headers, context);
41
+ return Promise.reject(error);
42
+ });
43
+ attachedInterceptors.add(axiosInstance);
44
+ }
45
+ exports.registerStreamBudgetInterceptors = registerStreamBudgetInterceptors;
@@ -2,6 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const tslib_1 = require("tslib");
4
4
  tslib_1.__exportStar(require("./createRateLimitedStreamProxy"), exports);
5
- tslib_1.__exportStar(require("./withStreamRateLimitOptions"), exports);
6
5
  tslib_1.__exportStar(require("./helpers/createRateLimitExceededException"), exports);
7
6
  tslib_1.__exportStar(require("./helpers/processStreamRateLimitException"), exports);
7
+ tslib_1.__exportStar(require("./withStreamRateLimitOptions"), exports);
@@ -3,7 +3,12 @@ export const DEFAULT_RETRY_AFTER_MS = 10000;
3
3
  export const DEFAULT_JITTER_MS = 150;
4
4
  export const RETRY_AFTER_BUFFER_MS = 1000;
5
5
  export const STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX = 'stream-rate-limit-cooldown:';
6
+ export const STREAM_BUDGET_COOLDOWN_KEY_PREFIX = 'stream-budget-cooldown:';
6
7
  export const STREAM_RATE_LIMIT_OPTIONS_MARKER = '__xbsRateLimitOptions';
8
+ export const STREAM_BUDGET_COOLDOWN_KEY = 'stream-budget-global';
9
+ export const BUDGET_THRESHOLD = 0.8;
10
+ export const BUDGET_DELAY_MIN_MS = 30000;
11
+ export const BUDGET_DELAY_MAX_MS = 60000;
7
12
  export const STREAM_SYNC_CHANNEL_RESULT_METHOD_NAMES = [
8
13
  'channel',
9
14
  'getChannelById',
@@ -1,6 +1,7 @@
1
1
  import { CHANNEL_SYNC_METHOD_NAMES, CHANNEL_SYNC_PROXY_RESULT_METHOD_NAMES, DEFAULT_OPTIONS, STREAM_SYNC_CHANNEL_RESULT_METHOD_NAMES, STREAM_SYNC_METHOD_NAMES, } from './constants';
2
2
  import { executeWithRateLimitHandling } from './helpers/executeWithRateLimitHandling';
3
3
  import { isStreamChannelLike } from './helpers/isStreamChannelLike';
4
+ import { registerStreamBudgetInterceptors } from './helpers/registerStreamBudgetInterceptors';
4
5
  import { splitCallArgsAndOptions } from './helpers/splitCallArgsAndOptions';
5
6
  const STREAM_SYNC_METHODS = new Set(STREAM_SYNC_METHOD_NAMES);
6
7
  const STREAM_SYNC_CHANNEL_RESULT_METHODS = new Set(STREAM_SYNC_CHANNEL_RESULT_METHOD_NAMES);
@@ -61,6 +62,7 @@ function wrapTarget(target, dependencies) {
61
62
  return proxy;
62
63
  }
63
64
  export function createRateLimitedStreamProxy(stream, context) {
65
+ registerStreamBudgetInterceptors(stream, context);
64
66
  return wrapTarget(stream, {
65
67
  context,
66
68
  cache: new WeakMap(),
@@ -1,9 +1,13 @@
1
1
  import { STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX } from '../constants';
2
2
  export class RedisCooldown {
3
- static async getRemainingMs(cooldownKey, context) {
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) {
4
8
  const { redis, logger } = context;
5
9
  try {
6
- const ttlMs = await redis.pttl(`${STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX}${cooldownKey}`);
10
+ const ttlMs = await redis.pttl(RedisCooldown.resolveRedisKey(cooldownKey, options));
7
11
  if (ttlMs > 0) {
8
12
  return ttlMs;
9
13
  }
@@ -16,12 +20,12 @@ export class RedisCooldown {
16
20
  }
17
21
  return 0;
18
22
  }
19
- static async setRemainingMs(cooldownKey, cooldownMs, context) {
23
+ static async setRemainingMs(cooldownKey, cooldownMs, context, options) {
20
24
  if (cooldownMs <= 0) {
21
25
  return;
22
26
  }
23
27
  const { redis, logger } = context;
24
- const redisKey = `${STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX}${cooldownKey}`;
28
+ const redisKey = RedisCooldown.resolveRedisKey(cooldownKey, options);
25
29
  try {
26
30
  await redis.eval(`
27
31
  local key = KEYS[1]
@@ -49,4 +53,16 @@ export class RedisCooldown {
49
53
  });
50
54
  }
51
55
  }
56
+ static async clear(cooldownKey, context, options) {
57
+ const { redis, logger } = context;
58
+ try {
59
+ await redis.del(RedisCooldown.resolveRedisKey(cooldownKey, options));
60
+ }
61
+ catch (error) {
62
+ logger.warn('Failed to clear stream rate-limit cooldown from Redis', {
63
+ cooldownKey,
64
+ error,
65
+ });
66
+ }
67
+ }
52
68
  }
@@ -1,14 +1,21 @@
1
1
  import { RateLimitExceededException } from '@wildix/xbees-conversations-client';
2
2
  import { DEFAULT_RETRY_AFTER_MS } from '../constants';
3
+ function toPositiveSeconds(milliseconds) {
4
+ return Math.max(0, Math.ceil(milliseconds / 1000));
5
+ }
3
6
  export function createRateLimitExceededException(options = {}) {
4
7
  const { rateLimit, message, operation, retryAfterMs = DEFAULT_RETRY_AFTER_MS } = options;
5
8
  const defaultMessage = operation ? `Rate limit exceeded for ${operation}` : 'Rate limit exceeded';
9
+ const retryAfterFromResetMs = rateLimit?.reset ? rateLimit.reset - Date.now() : undefined;
10
+ const effectiveRetryAfterMs = retryAfterFromResetMs ?? retryAfterMs;
11
+ const retryAfterSeconds = toPositiveSeconds(effectiveRetryAfterMs);
12
+ const rateLimitResetSeconds = rateLimit?.reset ? toPositiveSeconds(retryAfterFromResetMs ?? 0) : retryAfterSeconds;
6
13
  return new RateLimitExceededException({
7
14
  $metadata: {},
8
15
  message: message || defaultMessage,
9
16
  rateLimitRemaining: rateLimit?.remaining || 0,
10
- retryAfter: rateLimit?.reset ? rateLimit.reset - Date.now() : retryAfterMs,
17
+ retryAfter: retryAfterSeconds,
11
18
  rateLimit: rateLimit?.limit?.toString() || 'unknown',
12
- rateLimitReset: rateLimit?.reset || Date.now() + retryAfterMs,
19
+ rateLimitReset: rateLimitResetSeconds,
13
20
  });
14
21
  }
@@ -1,11 +1,12 @@
1
1
  import { setTimeout as sleep } from 'node:timers/promises';
2
- import { DEFAULT_MAX_DELAY_MS, DEFAULT_JITTER_MS, DEFAULT_RETRY_AFTER_MS, RETRY_AFTER_BUFFER_MS, } from '../constants';
2
+ import { DEFAULT_JITTER_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_RETRY_AFTER_MS, RETRY_AFTER_BUFFER_MS, STREAM_BUDGET_COOLDOWN_KEY, STREAM_BUDGET_COOLDOWN_KEY_PREFIX, } from '../constants';
3
3
  import { createRateLimitExceededException } from './createRateLimitExceededException';
4
4
  import { processStreamRateLimitException } from './processStreamRateLimitException';
5
5
  import { RedisCooldown } from './RedisCooldown';
6
6
  function calculateRateLimitDelayMs(error, maxDelayMs) {
7
7
  const hasServerRetryAfter = typeof error.retryAfter === 'number' && error.retryAfter > 0;
8
- const baseDelayMs = hasServerRetryAfter ? error.retryAfter + RETRY_AFTER_BUFFER_MS : DEFAULT_RETRY_AFTER_MS;
8
+ const retryAfterMsFromError = hasServerRetryAfter ? Math.round(error.retryAfter * 1000) : 0;
9
+ const baseDelayMs = hasServerRetryAfter ? retryAfterMsFromError + RETRY_AFTER_BUFFER_MS : DEFAULT_RETRY_AFTER_MS;
9
10
  if (baseDelayMs > 0) {
10
11
  const jitterMs = Math.floor(Math.random() * DEFAULT_JITTER_MS);
11
12
  const computedDelayMs = baseDelayMs + jitterMs;
@@ -21,6 +22,14 @@ export async function executeWithRateLimitHandling(execute, operation, context,
21
22
  const { enableCooldown, maxAttempts, maxDelayMs, maxRetryableDelayMs } = options;
22
23
  let attempt = 1;
23
24
  if (enableCooldown) {
25
+ const budgetCooldownMs = await RedisCooldown.getRemainingMs(STREAM_BUDGET_COOLDOWN_KEY, { redis, logger }, { keyPrefix: STREAM_BUDGET_COOLDOWN_KEY_PREFIX });
26
+ if (budgetCooldownMs > 0) {
27
+ logger.warn('Delaying stream request due to active budget cooldown', {
28
+ operation,
29
+ cooldown: budgetCooldownMs,
30
+ });
31
+ await sleep(budgetCooldownMs);
32
+ }
24
33
  const cooldownMs = await RedisCooldown.getRemainingMs(operation, { redis, logger });
25
34
  if (cooldownMs > 0) {
26
35
  logger.warn('Skipping stream request due to active rate-limit cooldown', {
@@ -0,0 +1,83 @@
1
+ import { BUDGET_DELAY_MAX_MS, BUDGET_DELAY_MIN_MS, BUDGET_THRESHOLD, STREAM_BUDGET_COOLDOWN_KEY, STREAM_BUDGET_COOLDOWN_KEY_PREFIX, } from '../constants';
2
+ import { RedisCooldown } from './RedisCooldown';
3
+ function parseHeaderNumber(value) {
4
+ if (typeof value === 'number' && Number.isFinite(value)) {
5
+ return value;
6
+ }
7
+ if (typeof value === 'string') {
8
+ const parsed = Number(value);
9
+ if (Number.isFinite(parsed)) {
10
+ return parsed;
11
+ }
12
+ }
13
+ }
14
+ function getHeaderCaseInsensitive(headers, key) {
15
+ if (!headers) {
16
+ return;
17
+ }
18
+ const direct = headers[key];
19
+ if (direct !== undefined) {
20
+ return direct;
21
+ }
22
+ const normalizedKey = key.toLowerCase();
23
+ for (const [headerKey, headerValue] of Object.entries(headers)) {
24
+ if (headerKey.toLowerCase() === normalizedKey) {
25
+ return headerValue;
26
+ }
27
+ }
28
+ }
29
+ function extractBudgetHeaders(headers) {
30
+ return {
31
+ limitMs: parseHeaderNumber(getHeaderCaseInsensitive(headers, 'x-budget-limit-ms')),
32
+ remainingMs: parseHeaderNumber(getHeaderCaseInsensitive(headers, 'x-budget-remaining-ms')),
33
+ usedMs: parseHeaderNumber(getHeaderCaseInsensitive(headers, 'x-budget-used-ms')),
34
+ };
35
+ }
36
+ function isBudgetThresholdReached(limitMs, remainingMs, usedMs) {
37
+ if (limitMs <= 0) {
38
+ return false;
39
+ }
40
+ if (typeof usedMs === 'number' && usedMs / limitMs >= BUDGET_THRESHOLD) {
41
+ return true;
42
+ }
43
+ if (typeof remainingMs === 'number') {
44
+ const remainingThreshold = Math.ceil((1 - BUDGET_THRESHOLD) * limitMs);
45
+ return remainingMs <= remainingThreshold;
46
+ }
47
+ return false;
48
+ }
49
+ function getBudgetDelayMs() {
50
+ return Math.round(BUDGET_DELAY_MIN_MS + Math.random() * (BUDGET_DELAY_MAX_MS - BUDGET_DELAY_MIN_MS));
51
+ }
52
+ export async function processStreamBudgetHeaders(headers, context) {
53
+ const { logger } = context;
54
+ const { limitMs, remainingMs, usedMs } = extractBudgetHeaders(headers);
55
+ if (limitMs === undefined && remainingMs === undefined && usedMs === undefined) {
56
+ return;
57
+ }
58
+ if (typeof limitMs !== 'number' || !Number.isFinite(limitMs)) {
59
+ return;
60
+ }
61
+ const thresholdReached = isBudgetThresholdReached(limitMs, remainingMs, usedMs);
62
+ if (thresholdReached) {
63
+ const delayMs = getBudgetDelayMs();
64
+ await RedisCooldown.setRemainingMs(STREAM_BUDGET_COOLDOWN_KEY, delayMs, context, {
65
+ keyPrefix: STREAM_BUDGET_COOLDOWN_KEY_PREFIX,
66
+ });
67
+ logger.warn('Stream budget threshold reached, enabling cooldown', {
68
+ budgetLimitMs: limitMs,
69
+ budgetRemainingMs: remainingMs,
70
+ budgetUsedMs: usedMs,
71
+ cooldownMs: delayMs,
72
+ });
73
+ return;
74
+ }
75
+ await RedisCooldown.clear(STREAM_BUDGET_COOLDOWN_KEY, context, {
76
+ keyPrefix: STREAM_BUDGET_COOLDOWN_KEY_PREFIX,
77
+ });
78
+ logger.warn('Stream budget is below threshold, disabling cooldown', {
79
+ budgetLimitMs: limitMs,
80
+ budgetRemainingMs: remainingMs,
81
+ budgetUsedMs: usedMs,
82
+ });
83
+ }
@@ -0,0 +1,41 @@
1
+ import { processStreamBudgetHeaders } from './processStreamBudgetHeaders';
2
+ const attachedInterceptors = new WeakSet();
3
+ function hasResponseInterceptors(value) {
4
+ return (typeof value === 'object' &&
5
+ value !== null &&
6
+ typeof value.interceptors?.response?.use === 'function');
7
+ }
8
+ function resolveAxiosInstance(stream) {
9
+ if (hasResponseInterceptors(stream)) {
10
+ return stream;
11
+ }
12
+ if (typeof stream !== 'object' || stream === null) {
13
+ return;
14
+ }
15
+ const maybeStream = stream;
16
+ if (hasResponseInterceptors(maybeStream.axiosInstance)) {
17
+ return maybeStream.axiosInstance;
18
+ }
19
+ if (hasResponseInterceptors(maybeStream._client?.axiosInstance)) {
20
+ return maybeStream._client?.axiosInstance;
21
+ }
22
+ }
23
+ export function registerStreamBudgetInterceptors(stream, context) {
24
+ const { logger } = context;
25
+ const axiosInstance = resolveAxiosInstance(stream);
26
+ if (!axiosInstance || !axiosInstance.interceptors?.response?.use) {
27
+ logger.warn('Unable to register stream budget interceptor: axios instance not found');
28
+ return;
29
+ }
30
+ if (attachedInterceptors.has(axiosInstance)) {
31
+ return;
32
+ }
33
+ axiosInstance.interceptors.response.use(async (response) => {
34
+ await processStreamBudgetHeaders(response?.headers, context);
35
+ return response;
36
+ }, async (error) => {
37
+ await processStreamBudgetHeaders(error?.response?.headers, context);
38
+ return Promise.reject(error);
39
+ });
40
+ attachedInterceptors.add(axiosInstance);
41
+ }
@@ -1,4 +1,4 @@
1
1
  export * from './createRateLimitedStreamProxy';
2
- export * from './withStreamRateLimitOptions';
3
2
  export * from './helpers/createRateLimitExceededException';
4
3
  export * from './helpers/processStreamRateLimitException';
4
+ export * from './withStreamRateLimitOptions';
@@ -4,7 +4,12 @@ export declare const DEFAULT_RETRY_AFTER_MS = 10000;
4
4
  export declare const DEFAULT_JITTER_MS = 150;
5
5
  export declare const RETRY_AFTER_BUFFER_MS = 1000;
6
6
  export declare const STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX = "stream-rate-limit-cooldown:";
7
+ export declare const STREAM_BUDGET_COOLDOWN_KEY_PREFIX = "stream-budget-cooldown:";
7
8
  export declare const STREAM_RATE_LIMIT_OPTIONS_MARKER = "__xbsRateLimitOptions";
9
+ export declare const STREAM_BUDGET_COOLDOWN_KEY = "stream-budget-global";
10
+ export declare const BUDGET_THRESHOLD = 0.8;
11
+ export declare const BUDGET_DELAY_MIN_MS = 30000;
12
+ export declare const BUDGET_DELAY_MAX_MS = 60000;
8
13
  export declare const STREAM_SYNC_CHANNEL_RESULT_METHOD_NAMES: readonly ["channel", "getChannelById", "getChannelByMembers", "hydrateActiveChannels"];
9
14
  export declare const STREAM_SYNC_METHOD_NAMES: readonly ["_addChannelConfig", "_buildWSPayload", "_callClientListeners", "_deleteUserMessageReference", "_enrichAxiosOptions", "_getConnectionID", "_getToken", "_handleClientEvent", "_handleUserEvent", "_hasConnectionID", "_isUsingServerAuth", "_logApiError", "_logApiRequest", "_logApiResponse", "_muteStatus", "_normalizeDate", "_normalizeExpiration", "_sayHi", "_setUser", "_startCleaning", "_updateMemberWatcherReferences", "_updateUserMessageReferences", "_updateUserReferences", "_validateAndGetMessageId", "createAbortControllerForNextRequest", "createToken", "devToken", "dispatchEvent", "errorFromResponse", "getAuthType", "getUserAgent", "handleEvent", "handleResponse", "off", "on", "setBaseURL", "setLocalDevice", "setUserAgent", "userMuteStatus", "verifyWebhook"];
10
15
  export declare const CHANNEL_SYNC_METHOD_NAMES: readonly ["_channelURL", "countUnread", "countUnreadMentions", "getConfig", "lastMessage", "lastRead", "muteStatus", "off", "on"];
@@ -1,5 +1,7 @@
1
- import { LoggerContext, RedisContext } from '../types';
1
+ import { LoggerContext, RedisContext, RedisCooldownOptions } from '../types';
2
2
  export declare class RedisCooldown {
3
- static getRemainingMs(cooldownKey: string, context: RedisContext & LoggerContext): Promise<number>;
4
- static setRemainingMs(cooldownKey: string, cooldownMs: number, context: RedisContext & LoggerContext): Promise<void>;
3
+ private static resolveRedisKey;
4
+ static getRemainingMs(cooldownKey: string, context: RedisContext & LoggerContext, options?: RedisCooldownOptions): Promise<number>;
5
+ static setRemainingMs(cooldownKey: string, cooldownMs: number, context: RedisContext & LoggerContext, options?: RedisCooldownOptions): Promise<void>;
6
+ static clear(cooldownKey: string, context: RedisContext & LoggerContext, options?: RedisCooldownOptions): Promise<void>;
5
7
  }
@@ -0,0 +1,8 @@
1
+ import { LoggerContext, RedisContext, StreamHeaders } from '../types';
2
+ /**
3
+ * Updates app-wide Stream budget cooldown in Redis based on x-budget-* headers.
4
+ * - If budget usage reaches the threshold (>=80%), enables cooldown for 30-60s.
5
+ * - If headers indicate usage is below threshold, disables existing cooldown.
6
+ * - If headers are missing/invalid, leaves current cooldown unchanged.
7
+ */
8
+ export declare function processStreamBudgetHeaders(headers: StreamHeaders | undefined, context: LoggerContext & RedisContext): Promise<void>;
@@ -0,0 +1,2 @@
1
+ import { LoggerContext, RedisContext } from '../types';
2
+ export declare function registerStreamBudgetInterceptors(stream: unknown, context: LoggerContext & RedisContext): void;
@@ -1,4 +1,4 @@
1
1
  export * from './createRateLimitedStreamProxy';
2
- export * from './withStreamRateLimitOptions';
3
2
  export * from './helpers/createRateLimitExceededException';
4
3
  export * from './helpers/processStreamRateLimitException';
4
+ export * from './withStreamRateLimitOptions';
@@ -24,3 +24,40 @@ export type ArgsAndOptions = {
24
24
  callArgs: unknown[];
25
25
  callOptions: Partial<StreamRateLimitRetryOptions>;
26
26
  };
27
+ /** Optional Redis key namespace override for cooldown records. */
28
+ export type RedisCooldownOptions = {
29
+ keyPrefix?: string;
30
+ };
31
+ /** Generic HTTP headers shape returned by the Stream SDK transport layer. */
32
+ export type StreamHeaders = Record<string, unknown>;
33
+ /** Parsed GetStream budget headers used to manage app-wide cooldown. */
34
+ export type StreamBudgetHeaders = {
35
+ limitMs?: number;
36
+ remainingMs?: number;
37
+ usedMs?: number;
38
+ };
39
+ /** Axios response shape used by budget interceptor. */
40
+ export type StreamBudgetInterceptorResponse = {
41
+ headers?: StreamHeaders;
42
+ };
43
+ /** Axios error shape used by budget interceptor. */
44
+ export type StreamBudgetInterceptorError = {
45
+ response?: StreamBudgetInterceptorResponse;
46
+ };
47
+ /** Minimal axios response interceptor manager contract required by this package. */
48
+ export type AxiosResponseInterceptorManagerLike = {
49
+ use: (onFulfilled?: (response: StreamBudgetInterceptorResponse) => unknown | Promise<unknown>, onRejected?: (error: StreamBudgetInterceptorError) => unknown | Promise<unknown>) => unknown;
50
+ };
51
+ /** Minimal axios instance contract required to register budget interceptors. */
52
+ export type AxiosLikeWithResponseInterceptors = {
53
+ interceptors?: {
54
+ response?: AxiosResponseInterceptorManagerLike;
55
+ };
56
+ };
57
+ /** Stream client variants where axios transport can be discovered. */
58
+ export type StreamClientWithAxiosCandidates = {
59
+ axiosInstance?: unknown;
60
+ _client?: {
61
+ axiosInstance?: unknown;
62
+ };
63
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wildix/xbees-conversations-utils",
3
- "version": "1.1.69",
3
+ "version": "1.1.71",
4
4
  "description": "",
5
5
  "main": "./dist-cjs/index.js",
6
6
  "module": "./dist-es/index.js",