@wildix/xbees-conversations-utils 1.1.55 → 1.1.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.js +1 -0
- package/dist-cjs/rateLimits/RedisCooldown.js +56 -0
- package/dist-cjs/rateLimits/constants.js +22 -0
- package/dist-cjs/rateLimits/createRateLimitedStreamProxy.js +99 -0
- package/dist-cjs/rateLimits/exception/createRateLimitExceededException.js +18 -0
- package/dist-cjs/rateLimits/exception/processStreamRateLimitException.js +31 -0
- package/dist-cjs/rateLimits/executeStreamRequestWithRateLimitHandling.js +75 -0
- package/dist-cjs/rateLimits/index.js +4 -0
- package/dist-cjs/rateLimits/types.js +2 -0
- package/dist-es/index.js +1 -0
- package/dist-es/rateLimits/RedisCooldown.js +52 -0
- package/dist-es/rateLimits/constants.js +19 -0
- package/dist-es/rateLimits/createRateLimitedStreamProxy.js +94 -0
- package/dist-es/rateLimits/exception/createRateLimitExceededException.js +14 -0
- package/dist-es/rateLimits/exception/processStreamRateLimitException.js +27 -0
- package/dist-es/rateLimits/executeStreamRequestWithRateLimitHandling.js +71 -0
- package/dist-es/rateLimits/index.js +1 -0
- package/dist-es/rateLimits/types.js +1 -0
- package/dist-types/index.d.ts +1 -0
- package/dist-types/rateLimits/RedisCooldown.d.ts +5 -0
- package/dist-types/rateLimits/constants.d.ts +10 -0
- package/dist-types/rateLimits/createRateLimitedStreamProxy.d.ts +15 -0
- package/dist-types/rateLimits/exception/createRateLimitExceededException.d.ts +10 -0
- package/dist-types/rateLimits/exception/processStreamRateLimitException.d.ts +3 -0
- package/dist-types/rateLimits/executeStreamRequestWithRateLimitHandling.d.ts +2 -0
- package/dist-types/rateLimits/index.d.ts +1 -0
- package/dist-types/rateLimits/types.d.ts +17 -0
- package/package.json +4 -2
package/dist-cjs/index.js
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RedisCooldown = void 0;
|
|
4
|
+
const constants_1 = require("./constants");
|
|
5
|
+
class RedisCooldown {
|
|
6
|
+
static async getRemainingMs(cooldownKey, context) {
|
|
7
|
+
const { redis, logger } = context;
|
|
8
|
+
try {
|
|
9
|
+
const ttlMs = await redis.pttl(`${constants_1.STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX}${cooldownKey}`);
|
|
10
|
+
if (ttlMs > 0) {
|
|
11
|
+
return ttlMs;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
logger.warn('Failed to read stream rate-limit cooldown from Redis', {
|
|
16
|
+
cooldownKey,
|
|
17
|
+
error,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
static async setRemainingMs(cooldownKey, cooldownMs, context) {
|
|
23
|
+
if (cooldownMs <= 0) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const { redis, logger } = context;
|
|
27
|
+
const redisKey = `${constants_1.STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX}${cooldownKey}`;
|
|
28
|
+
try {
|
|
29
|
+
await redis.eval(`
|
|
30
|
+
local key = KEYS[1]
|
|
31
|
+
local new_ttl = tonumber(ARGV[1])
|
|
32
|
+
local current_ttl = redis.call('PTTL', key)
|
|
33
|
+
|
|
34
|
+
if current_ttl == -2 then
|
|
35
|
+
redis.call('SET', key, '1', 'PX', new_ttl)
|
|
36
|
+
return 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if current_ttl == -1 or current_ttl < new_ttl then
|
|
40
|
+
redis.call('PEXPIRE', key, new_ttl)
|
|
41
|
+
return 2
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
return 0
|
|
45
|
+
`, 1, redisKey, cooldownMs.toString());
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
logger.warn('Failed to write stream rate-limit cooldown to Redis', {
|
|
49
|
+
cooldownKey,
|
|
50
|
+
cooldownMs,
|
|
51
|
+
error,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
exports.RedisCooldown = RedisCooldown;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_OPTS_3_ATTEMPTS_WITH_COOLDOWN = exports.DEFAULT_OPTS_1_ATTEMPT_NO_COOLDOWN = exports.DEFAULT_RETRY_AFTER_MS = exports.STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX = exports.RETRY_AFTER_BUFFER_MS = exports.DEFAULT_JITTER_MS = exports.DEFAULT_MAX_RETRYABLE_DELAY_MS = exports.DEFAULT_MAX_DELAY_MS = exports.DEFAULT_FIXED_RATE_LIMIT_DELAY_MS = void 0;
|
|
4
|
+
exports.DEFAULT_FIXED_RATE_LIMIT_DELAY_MS = 15000;
|
|
5
|
+
exports.DEFAULT_MAX_DELAY_MS = 15000;
|
|
6
|
+
exports.DEFAULT_MAX_RETRYABLE_DELAY_MS = 5000;
|
|
7
|
+
exports.DEFAULT_JITTER_MS = 150;
|
|
8
|
+
exports.RETRY_AFTER_BUFFER_MS = 1000;
|
|
9
|
+
exports.STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX = 'stream-rate-limit-cooldown:';
|
|
10
|
+
exports.DEFAULT_RETRY_AFTER_MS = 10000;
|
|
11
|
+
exports.DEFAULT_OPTS_1_ATTEMPT_NO_COOLDOWN = {
|
|
12
|
+
maxAttempts: 1,
|
|
13
|
+
enableCooldown: false,
|
|
14
|
+
maxDelayMs: exports.DEFAULT_MAX_DELAY_MS,
|
|
15
|
+
maxRetryableDelayMs: exports.DEFAULT_MAX_RETRYABLE_DELAY_MS,
|
|
16
|
+
};
|
|
17
|
+
exports.DEFAULT_OPTS_3_ATTEMPTS_WITH_COOLDOWN = {
|
|
18
|
+
maxAttempts: 3,
|
|
19
|
+
enableCooldown: true,
|
|
20
|
+
maxDelayMs: exports.DEFAULT_MAX_DELAY_MS,
|
|
21
|
+
maxRetryableDelayMs: exports.DEFAULT_MAX_RETRYABLE_DELAY_MS,
|
|
22
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRateLimitedStreamProxy = exports.withStreamRateLimitOptions = void 0;
|
|
4
|
+
const constants_1 = require("./constants");
|
|
5
|
+
const executeStreamRequestWithRateLimitHandling_1 = require("./executeStreamRequestWithRateLimitHandling");
|
|
6
|
+
const STREAM_RATE_LIMIT_OPTIONS_MARKER = '__xbsRateLimitOptions';
|
|
7
|
+
const STREAM_RATE_LIMIT_PROXY_MARKER = Symbol('xbsStreamRateLimitProxy');
|
|
8
|
+
const OPERATION_DEFAULT_OPTIONS = {
|
|
9
|
+
queryChannels: constants_1.DEFAULT_OPTS_3_ATTEMPTS_WITH_COOLDOWN,
|
|
10
|
+
};
|
|
11
|
+
function isObject(value) {
|
|
12
|
+
return typeof value === 'object' && value !== null;
|
|
13
|
+
}
|
|
14
|
+
function hasFunctionProperty(value, key) {
|
|
15
|
+
return key in value && typeof value[key] === 'function';
|
|
16
|
+
}
|
|
17
|
+
function isStreamChannelLike(value) {
|
|
18
|
+
if (!isObject(value)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return (hasFunctionProperty(value, 'sendMessage') &&
|
|
22
|
+
hasFunctionProperty(value, 'queryMembers') &&
|
|
23
|
+
hasFunctionProperty(value, 'updatePartial'));
|
|
24
|
+
}
|
|
25
|
+
function isStreamRateLimitOptionsToken(value) {
|
|
26
|
+
return isObject(value) && STREAM_RATE_LIMIT_OPTIONS_MARKER in value;
|
|
27
|
+
}
|
|
28
|
+
function splitCallArgsAndOptions(args) {
|
|
29
|
+
if (args.length === 0) {
|
|
30
|
+
return { callArgs: args, callOptions: {} };
|
|
31
|
+
}
|
|
32
|
+
const lastArg = args[args.length - 1];
|
|
33
|
+
if (isStreamRateLimitOptionsToken(lastArg)) {
|
|
34
|
+
return {
|
|
35
|
+
callArgs: args.slice(0, -1),
|
|
36
|
+
callOptions: lastArg[STREAM_RATE_LIMIT_OPTIONS_MARKER],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return { callArgs: args, callOptions: {} };
|
|
40
|
+
}
|
|
41
|
+
function withStreamRateLimitOptions(options) {
|
|
42
|
+
return {
|
|
43
|
+
[STREAM_RATE_LIMIT_OPTIONS_MARKER]: options,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
exports.withStreamRateLimitOptions = withStreamRateLimitOptions;
|
|
47
|
+
function createRateLimitedProxy(target, context, cache) {
|
|
48
|
+
const cached = cache.get(target);
|
|
49
|
+
if (cached) {
|
|
50
|
+
return cached;
|
|
51
|
+
}
|
|
52
|
+
const wrapResult = (result) => {
|
|
53
|
+
if (Array.isArray(result)) {
|
|
54
|
+
return result.map((item) => wrapResult(item));
|
|
55
|
+
}
|
|
56
|
+
if (!isStreamChannelLike(result)) {
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
const cachedResult = cache.get(result);
|
|
60
|
+
if (cachedResult) {
|
|
61
|
+
return cachedResult;
|
|
62
|
+
}
|
|
63
|
+
return createRateLimitedProxy(result, context, cache);
|
|
64
|
+
};
|
|
65
|
+
const proxy = new Proxy(target, {
|
|
66
|
+
get: (originalTarget, property, receiver) => {
|
|
67
|
+
if (property === STREAM_RATE_LIMIT_PROXY_MARKER) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
const value = Reflect.get(originalTarget, property, receiver);
|
|
71
|
+
if (typeof property !== 'string' || typeof value !== 'function') {
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
if (property === 'channel') {
|
|
75
|
+
return (...args) => {
|
|
76
|
+
const channel = Reflect.apply(value, originalTarget, args);
|
|
77
|
+
return wrapResult(channel);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return async (...args) => {
|
|
81
|
+
const { callArgs, callOptions } = splitCallArgsAndOptions(args);
|
|
82
|
+
const operation = property;
|
|
83
|
+
const defaultOptions = OPERATION_DEFAULT_OPTIONS[operation] || {};
|
|
84
|
+
const result = await (0, executeStreamRequestWithRateLimitHandling_1.executeStreamRequestWithRateLimitHandling)(() => Reflect.apply(value, originalTarget, callArgs), operation, context, {
|
|
85
|
+
...defaultOptions,
|
|
86
|
+
...callOptions,
|
|
87
|
+
});
|
|
88
|
+
return wrapResult(result);
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
cache.set(target, proxy);
|
|
93
|
+
return proxy;
|
|
94
|
+
}
|
|
95
|
+
function createRateLimitedStreamProxy(stream, context) {
|
|
96
|
+
const cache = new WeakMap();
|
|
97
|
+
return createRateLimitedProxy(stream, context, cache);
|
|
98
|
+
}
|
|
99
|
+
exports.createRateLimitedStreamProxy = createRateLimitedStreamProxy;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRateLimitExceededException = void 0;
|
|
4
|
+
const xbees_conversations_client_1 = require("@wildix/xbees-conversations-client");
|
|
5
|
+
const constants_1 = require("../constants");
|
|
6
|
+
function createRateLimitExceededException(options = {}) {
|
|
7
|
+
const { rateLimit, message, operation, retryAfterMs = constants_1.DEFAULT_RETRY_AFTER_MS } = options;
|
|
8
|
+
const defaultMessage = operation ? `Rate limit exceeded for ${operation}` : 'Rate limit exceeded';
|
|
9
|
+
return new xbees_conversations_client_1.RateLimitExceededException({
|
|
10
|
+
$metadata: {},
|
|
11
|
+
message: message || defaultMessage,
|
|
12
|
+
rateLimitRemaining: rateLimit?.remaining || 0,
|
|
13
|
+
retryAfter: rateLimit?.reset ? rateLimit.reset - Date.now() : retryAfterMs,
|
|
14
|
+
rateLimit: rateLimit?.limit?.toString() || 'unknown',
|
|
15
|
+
rateLimitReset: rateLimit?.reset || Date.now() + retryAfterMs,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
exports.createRateLimitExceededException = createRateLimitExceededException;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.processStreamRateLimitException = void 0;
|
|
4
|
+
const createRateLimitExceededException_1 = require("./createRateLimitExceededException");
|
|
5
|
+
function isStreamException(error) {
|
|
6
|
+
return (typeof error === 'object' &&
|
|
7
|
+
error !== null &&
|
|
8
|
+
'metadata' in error &&
|
|
9
|
+
typeof error.metadata === 'object' &&
|
|
10
|
+
error.metadata !== null &&
|
|
11
|
+
'responseCode' in error.metadata);
|
|
12
|
+
}
|
|
13
|
+
function isStreamAPIException(error) {
|
|
14
|
+
return typeof error === 'object' && !!error.code;
|
|
15
|
+
}
|
|
16
|
+
function processStreamRateLimitException(error, logger) {
|
|
17
|
+
if (isStreamException(error) && error.metadata?.responseCode === 429) {
|
|
18
|
+
const rateLimit = error.metadata?.rateLimit;
|
|
19
|
+
logger.warn('Stream rate limit exceeded [StreamException]', { rateLimit });
|
|
20
|
+
return (0, createRateLimitExceededException_1.createRateLimitExceededException)({ rateLimit, message: error.message || 'Rate limit exceeded' });
|
|
21
|
+
}
|
|
22
|
+
if (isStreamAPIException(error) && (error.code === 429 || error.StatusCode === 429)) {
|
|
23
|
+
logger.warn('Stream rate limit exceeded [StreamAPIException]', { code: error.code, StatusCode: error.StatusCode });
|
|
24
|
+
return (0, createRateLimitExceededException_1.createRateLimitExceededException)({ message: error.message || 'Rate limit exceeded' });
|
|
25
|
+
}
|
|
26
|
+
if (typeof error === 'object' && error !== null && 'status' in error && error.status === 429) {
|
|
27
|
+
logger.warn('Stream rate limit exceeded [error.status === 429]');
|
|
28
|
+
return (0, createRateLimitExceededException_1.createRateLimitExceededException)({ message: 'Rate limit exceeded' });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
exports.processStreamRateLimitException = processStreamRateLimitException;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executeStreamRequestWithRateLimitHandling = void 0;
|
|
4
|
+
const promises_1 = require("node:timers/promises");
|
|
5
|
+
const constants_1 = require("./constants");
|
|
6
|
+
const createRateLimitExceededException_1 = require("./exception/createRateLimitExceededException");
|
|
7
|
+
const processStreamRateLimitException_1 = require("./exception/processStreamRateLimitException");
|
|
8
|
+
const RedisCooldown_1 = require("./RedisCooldown");
|
|
9
|
+
function calculateRateLimitDelayMs(error, maxDelayMs) {
|
|
10
|
+
const retryAfterMs = typeof error.retryAfter === 'number' ? error.retryAfter + constants_1.RETRY_AFTER_BUFFER_MS : constants_1.DEFAULT_RETRY_AFTER_MS;
|
|
11
|
+
if (retryAfterMs > 0) {
|
|
12
|
+
const jitterMs = Math.floor(Math.random() * constants_1.DEFAULT_JITTER_MS);
|
|
13
|
+
return Math.min(maxDelayMs, retryAfterMs + jitterMs);
|
|
14
|
+
}
|
|
15
|
+
return Math.min(maxDelayMs, constants_1.DEFAULT_FIXED_RATE_LIMIT_DELAY_MS);
|
|
16
|
+
}
|
|
17
|
+
async function executeStreamRequestWithRateLimitHandling(execute, operation, context, options = {}) {
|
|
18
|
+
const { logger, redis } = context;
|
|
19
|
+
const { enableCooldown, maxAttempts, maxDelayMs, maxRetryableDelayMs } = {
|
|
20
|
+
...constants_1.DEFAULT_OPTS_1_ATTEMPT_NO_COOLDOWN,
|
|
21
|
+
...options,
|
|
22
|
+
};
|
|
23
|
+
let attempt = 1;
|
|
24
|
+
if (enableCooldown) {
|
|
25
|
+
const cooldownMs = await RedisCooldown_1.RedisCooldown.getRemainingMs(operation, { redis, logger });
|
|
26
|
+
if (cooldownMs > 0) {
|
|
27
|
+
logger.warn('Skipping stream request due to active rate-limit cooldown', {
|
|
28
|
+
operation,
|
|
29
|
+
cooldown: cooldownMs,
|
|
30
|
+
});
|
|
31
|
+
throw (0, createRateLimitExceededException_1.createRateLimitExceededException)({ operation, retryAfterMs: cooldownMs });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (;;) {
|
|
35
|
+
try {
|
|
36
|
+
return await execute();
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
const rateLimitError = (0, processStreamRateLimitException_1.processStreamRateLimitException)(error, logger);
|
|
40
|
+
if (!rateLimitError) {
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
const retryAfterMs = calculateRateLimitDelayMs(rateLimitError, maxDelayMs);
|
|
44
|
+
if (enableCooldown) {
|
|
45
|
+
await RedisCooldown_1.RedisCooldown.setRemainingMs(operation, retryAfterMs, { redis, logger });
|
|
46
|
+
}
|
|
47
|
+
if (attempt < maxAttempts && retryAfterMs <= maxRetryableDelayMs) {
|
|
48
|
+
logger.warn('Retrying stream request after rate limit', {
|
|
49
|
+
operation,
|
|
50
|
+
attempt,
|
|
51
|
+
maxAttempts,
|
|
52
|
+
retryAfterMs,
|
|
53
|
+
rateLimit: rateLimitError.rateLimit,
|
|
54
|
+
rateLimitRemaining: rateLimitError.rateLimitRemaining,
|
|
55
|
+
rateLimitReset: rateLimitError.rateLimitReset,
|
|
56
|
+
});
|
|
57
|
+
await (0, promises_1.setTimeout)(retryAfterMs);
|
|
58
|
+
attempt++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
logger.warn('Propagating stream rate limit w/o retry', {
|
|
62
|
+
operation,
|
|
63
|
+
attempt,
|
|
64
|
+
maxAttempts,
|
|
65
|
+
retryAfterMs,
|
|
66
|
+
maxRetryableDelayMs,
|
|
67
|
+
rateLimit: rateLimitError.rateLimit,
|
|
68
|
+
rateLimitRemaining: rateLimitError.rateLimitRemaining,
|
|
69
|
+
rateLimitReset: rateLimitError.rateLimitReset,
|
|
70
|
+
});
|
|
71
|
+
throw rateLimitError;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
exports.executeStreamRequestWithRateLimitHandling = executeStreamRequestWithRateLimitHandling;
|
package/dist-es/index.js
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX } from './constants';
|
|
2
|
+
export class RedisCooldown {
|
|
3
|
+
static async getRemainingMs(cooldownKey, context) {
|
|
4
|
+
const { redis, logger } = context;
|
|
5
|
+
try {
|
|
6
|
+
const ttlMs = await redis.pttl(`${STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX}${cooldownKey}`);
|
|
7
|
+
if (ttlMs > 0) {
|
|
8
|
+
return ttlMs;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
logger.warn('Failed to read stream rate-limit cooldown from Redis', {
|
|
13
|
+
cooldownKey,
|
|
14
|
+
error,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
static async setRemainingMs(cooldownKey, cooldownMs, context) {
|
|
20
|
+
if (cooldownMs <= 0) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const { redis, logger } = context;
|
|
24
|
+
const redisKey = `${STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX}${cooldownKey}`;
|
|
25
|
+
try {
|
|
26
|
+
await redis.eval(`
|
|
27
|
+
local key = KEYS[1]
|
|
28
|
+
local new_ttl = tonumber(ARGV[1])
|
|
29
|
+
local current_ttl = redis.call('PTTL', key)
|
|
30
|
+
|
|
31
|
+
if current_ttl == -2 then
|
|
32
|
+
redis.call('SET', key, '1', 'PX', new_ttl)
|
|
33
|
+
return 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if current_ttl == -1 or current_ttl < new_ttl then
|
|
37
|
+
redis.call('PEXPIRE', key, new_ttl)
|
|
38
|
+
return 2
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
return 0
|
|
42
|
+
`, 1, redisKey, cooldownMs.toString());
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
logger.warn('Failed to write stream rate-limit cooldown to Redis', {
|
|
46
|
+
cooldownKey,
|
|
47
|
+
cooldownMs,
|
|
48
|
+
error,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const DEFAULT_FIXED_RATE_LIMIT_DELAY_MS = 15000;
|
|
2
|
+
export const DEFAULT_MAX_DELAY_MS = 15000;
|
|
3
|
+
export const DEFAULT_MAX_RETRYABLE_DELAY_MS = 5000;
|
|
4
|
+
export const DEFAULT_JITTER_MS = 150;
|
|
5
|
+
export const RETRY_AFTER_BUFFER_MS = 1000;
|
|
6
|
+
export const STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX = 'stream-rate-limit-cooldown:';
|
|
7
|
+
export const DEFAULT_RETRY_AFTER_MS = 10000;
|
|
8
|
+
export const DEFAULT_OPTS_1_ATTEMPT_NO_COOLDOWN = {
|
|
9
|
+
maxAttempts: 1,
|
|
10
|
+
enableCooldown: false,
|
|
11
|
+
maxDelayMs: DEFAULT_MAX_DELAY_MS,
|
|
12
|
+
maxRetryableDelayMs: DEFAULT_MAX_RETRYABLE_DELAY_MS,
|
|
13
|
+
};
|
|
14
|
+
export const DEFAULT_OPTS_3_ATTEMPTS_WITH_COOLDOWN = {
|
|
15
|
+
maxAttempts: 3,
|
|
16
|
+
enableCooldown: true,
|
|
17
|
+
maxDelayMs: DEFAULT_MAX_DELAY_MS,
|
|
18
|
+
maxRetryableDelayMs: DEFAULT_MAX_RETRYABLE_DELAY_MS,
|
|
19
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { DEFAULT_OPTS_3_ATTEMPTS_WITH_COOLDOWN } from './constants';
|
|
2
|
+
import { executeStreamRequestWithRateLimitHandling } from './executeStreamRequestWithRateLimitHandling';
|
|
3
|
+
const STREAM_RATE_LIMIT_OPTIONS_MARKER = '__xbsRateLimitOptions';
|
|
4
|
+
const STREAM_RATE_LIMIT_PROXY_MARKER = Symbol('xbsStreamRateLimitProxy');
|
|
5
|
+
const OPERATION_DEFAULT_OPTIONS = {
|
|
6
|
+
queryChannels: DEFAULT_OPTS_3_ATTEMPTS_WITH_COOLDOWN,
|
|
7
|
+
};
|
|
8
|
+
function isObject(value) {
|
|
9
|
+
return typeof value === 'object' && value !== null;
|
|
10
|
+
}
|
|
11
|
+
function hasFunctionProperty(value, key) {
|
|
12
|
+
return key in value && typeof value[key] === 'function';
|
|
13
|
+
}
|
|
14
|
+
function isStreamChannelLike(value) {
|
|
15
|
+
if (!isObject(value)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return (hasFunctionProperty(value, 'sendMessage') &&
|
|
19
|
+
hasFunctionProperty(value, 'queryMembers') &&
|
|
20
|
+
hasFunctionProperty(value, 'updatePartial'));
|
|
21
|
+
}
|
|
22
|
+
function isStreamRateLimitOptionsToken(value) {
|
|
23
|
+
return isObject(value) && STREAM_RATE_LIMIT_OPTIONS_MARKER in value;
|
|
24
|
+
}
|
|
25
|
+
function splitCallArgsAndOptions(args) {
|
|
26
|
+
if (args.length === 0) {
|
|
27
|
+
return { callArgs: args, callOptions: {} };
|
|
28
|
+
}
|
|
29
|
+
const lastArg = args[args.length - 1];
|
|
30
|
+
if (isStreamRateLimitOptionsToken(lastArg)) {
|
|
31
|
+
return {
|
|
32
|
+
callArgs: args.slice(0, -1),
|
|
33
|
+
callOptions: lastArg[STREAM_RATE_LIMIT_OPTIONS_MARKER],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return { callArgs: args, callOptions: {} };
|
|
37
|
+
}
|
|
38
|
+
export function withStreamRateLimitOptions(options) {
|
|
39
|
+
return {
|
|
40
|
+
[STREAM_RATE_LIMIT_OPTIONS_MARKER]: options,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function createRateLimitedProxy(target, context, cache) {
|
|
44
|
+
const cached = cache.get(target);
|
|
45
|
+
if (cached) {
|
|
46
|
+
return cached;
|
|
47
|
+
}
|
|
48
|
+
const wrapResult = (result) => {
|
|
49
|
+
if (Array.isArray(result)) {
|
|
50
|
+
return result.map((item) => wrapResult(item));
|
|
51
|
+
}
|
|
52
|
+
if (!isStreamChannelLike(result)) {
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
const cachedResult = cache.get(result);
|
|
56
|
+
if (cachedResult) {
|
|
57
|
+
return cachedResult;
|
|
58
|
+
}
|
|
59
|
+
return createRateLimitedProxy(result, context, cache);
|
|
60
|
+
};
|
|
61
|
+
const proxy = new Proxy(target, {
|
|
62
|
+
get: (originalTarget, property, receiver) => {
|
|
63
|
+
if (property === STREAM_RATE_LIMIT_PROXY_MARKER) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
const value = Reflect.get(originalTarget, property, receiver);
|
|
67
|
+
if (typeof property !== 'string' || typeof value !== 'function') {
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
if (property === 'channel') {
|
|
71
|
+
return (...args) => {
|
|
72
|
+
const channel = Reflect.apply(value, originalTarget, args);
|
|
73
|
+
return wrapResult(channel);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return async (...args) => {
|
|
77
|
+
const { callArgs, callOptions } = splitCallArgsAndOptions(args);
|
|
78
|
+
const operation = property;
|
|
79
|
+
const defaultOptions = OPERATION_DEFAULT_OPTIONS[operation] || {};
|
|
80
|
+
const result = await executeStreamRequestWithRateLimitHandling(() => Reflect.apply(value, originalTarget, callArgs), operation, context, {
|
|
81
|
+
...defaultOptions,
|
|
82
|
+
...callOptions,
|
|
83
|
+
});
|
|
84
|
+
return wrapResult(result);
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
cache.set(target, proxy);
|
|
89
|
+
return proxy;
|
|
90
|
+
}
|
|
91
|
+
export function createRateLimitedStreamProxy(stream, context) {
|
|
92
|
+
const cache = new WeakMap();
|
|
93
|
+
return createRateLimitedProxy(stream, context, cache);
|
|
94
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { RateLimitExceededException } from '@wildix/xbees-conversations-client';
|
|
2
|
+
import { DEFAULT_RETRY_AFTER_MS } from '../constants';
|
|
3
|
+
export function createRateLimitExceededException(options = {}) {
|
|
4
|
+
const { rateLimit, message, operation, retryAfterMs = DEFAULT_RETRY_AFTER_MS } = options;
|
|
5
|
+
const defaultMessage = operation ? `Rate limit exceeded for ${operation}` : 'Rate limit exceeded';
|
|
6
|
+
return new RateLimitExceededException({
|
|
7
|
+
$metadata: {},
|
|
8
|
+
message: message || defaultMessage,
|
|
9
|
+
rateLimitRemaining: rateLimit?.remaining || 0,
|
|
10
|
+
retryAfter: rateLimit?.reset ? rateLimit.reset - Date.now() : retryAfterMs,
|
|
11
|
+
rateLimit: rateLimit?.limit?.toString() || 'unknown',
|
|
12
|
+
rateLimitReset: rateLimit?.reset || Date.now() + retryAfterMs,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createRateLimitExceededException } from './createRateLimitExceededException';
|
|
2
|
+
function isStreamException(error) {
|
|
3
|
+
return (typeof error === 'object' &&
|
|
4
|
+
error !== null &&
|
|
5
|
+
'metadata' in error &&
|
|
6
|
+
typeof error.metadata === 'object' &&
|
|
7
|
+
error.metadata !== null &&
|
|
8
|
+
'responseCode' in error.metadata);
|
|
9
|
+
}
|
|
10
|
+
function isStreamAPIException(error) {
|
|
11
|
+
return typeof error === 'object' && !!error.code;
|
|
12
|
+
}
|
|
13
|
+
export function processStreamRateLimitException(error, logger) {
|
|
14
|
+
if (isStreamException(error) && error.metadata?.responseCode === 429) {
|
|
15
|
+
const rateLimit = error.metadata?.rateLimit;
|
|
16
|
+
logger.warn('Stream rate limit exceeded [StreamException]', { rateLimit });
|
|
17
|
+
return createRateLimitExceededException({ rateLimit, message: error.message || 'Rate limit exceeded' });
|
|
18
|
+
}
|
|
19
|
+
if (isStreamAPIException(error) && (error.code === 429 || error.StatusCode === 429)) {
|
|
20
|
+
logger.warn('Stream rate limit exceeded [StreamAPIException]', { code: error.code, StatusCode: error.StatusCode });
|
|
21
|
+
return createRateLimitExceededException({ message: error.message || 'Rate limit exceeded' });
|
|
22
|
+
}
|
|
23
|
+
if (typeof error === 'object' && error !== null && 'status' in error && error.status === 429) {
|
|
24
|
+
logger.warn('Stream rate limit exceeded [error.status === 429]');
|
|
25
|
+
return createRateLimitExceededException({ message: 'Rate limit exceeded' });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
import { DEFAULT_FIXED_RATE_LIMIT_DELAY_MS, DEFAULT_JITTER_MS, DEFAULT_OPTS_1_ATTEMPT_NO_COOLDOWN, DEFAULT_RETRY_AFTER_MS, RETRY_AFTER_BUFFER_MS, } from './constants';
|
|
3
|
+
import { createRateLimitExceededException } from './exception/createRateLimitExceededException';
|
|
4
|
+
import { processStreamRateLimitException } from './exception/processStreamRateLimitException';
|
|
5
|
+
import { RedisCooldown } from './RedisCooldown';
|
|
6
|
+
function calculateRateLimitDelayMs(error, maxDelayMs) {
|
|
7
|
+
const retryAfterMs = typeof error.retryAfter === 'number' ? error.retryAfter + RETRY_AFTER_BUFFER_MS : DEFAULT_RETRY_AFTER_MS;
|
|
8
|
+
if (retryAfterMs > 0) {
|
|
9
|
+
const jitterMs = Math.floor(Math.random() * DEFAULT_JITTER_MS);
|
|
10
|
+
return Math.min(maxDelayMs, retryAfterMs + jitterMs);
|
|
11
|
+
}
|
|
12
|
+
return Math.min(maxDelayMs, DEFAULT_FIXED_RATE_LIMIT_DELAY_MS);
|
|
13
|
+
}
|
|
14
|
+
export async function executeStreamRequestWithRateLimitHandling(execute, operation, context, options = {}) {
|
|
15
|
+
const { logger, redis } = context;
|
|
16
|
+
const { enableCooldown, maxAttempts, maxDelayMs, maxRetryableDelayMs } = {
|
|
17
|
+
...DEFAULT_OPTS_1_ATTEMPT_NO_COOLDOWN,
|
|
18
|
+
...options,
|
|
19
|
+
};
|
|
20
|
+
let attempt = 1;
|
|
21
|
+
if (enableCooldown) {
|
|
22
|
+
const cooldownMs = await RedisCooldown.getRemainingMs(operation, { redis, logger });
|
|
23
|
+
if (cooldownMs > 0) {
|
|
24
|
+
logger.warn('Skipping stream request due to active rate-limit cooldown', {
|
|
25
|
+
operation,
|
|
26
|
+
cooldown: cooldownMs,
|
|
27
|
+
});
|
|
28
|
+
throw createRateLimitExceededException({ operation, retryAfterMs: cooldownMs });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (;;) {
|
|
32
|
+
try {
|
|
33
|
+
return await execute();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
const rateLimitError = processStreamRateLimitException(error, logger);
|
|
37
|
+
if (!rateLimitError) {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
const retryAfterMs = calculateRateLimitDelayMs(rateLimitError, maxDelayMs);
|
|
41
|
+
if (enableCooldown) {
|
|
42
|
+
await RedisCooldown.setRemainingMs(operation, retryAfterMs, { redis, logger });
|
|
43
|
+
}
|
|
44
|
+
if (attempt < maxAttempts && retryAfterMs <= maxRetryableDelayMs) {
|
|
45
|
+
logger.warn('Retrying stream request after rate limit', {
|
|
46
|
+
operation,
|
|
47
|
+
attempt,
|
|
48
|
+
maxAttempts,
|
|
49
|
+
retryAfterMs,
|
|
50
|
+
rateLimit: rateLimitError.rateLimit,
|
|
51
|
+
rateLimitRemaining: rateLimitError.rateLimitRemaining,
|
|
52
|
+
rateLimitReset: rateLimitError.rateLimitReset,
|
|
53
|
+
});
|
|
54
|
+
await sleep(retryAfterMs);
|
|
55
|
+
attempt++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
logger.warn('Propagating stream rate limit w/o retry', {
|
|
59
|
+
operation,
|
|
60
|
+
attempt,
|
|
61
|
+
maxAttempts,
|
|
62
|
+
retryAfterMs,
|
|
63
|
+
maxRetryableDelayMs,
|
|
64
|
+
rateLimit: rateLimitError.rateLimit,
|
|
65
|
+
rateLimitRemaining: rateLimitError.rateLimitRemaining,
|
|
66
|
+
rateLimitReset: rateLimitError.rateLimitReset,
|
|
67
|
+
});
|
|
68
|
+
throw rateLimitError;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './createRateLimitedStreamProxy';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist-types/index.d.ts
CHANGED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { LoggerContext, RedisContext } from "./types";
|
|
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>;
|
|
5
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { StreamRateLimitRetryOptions } from './types';
|
|
2
|
+
export declare const DEFAULT_FIXED_RATE_LIMIT_DELAY_MS = 15000;
|
|
3
|
+
export declare const DEFAULT_MAX_DELAY_MS = 15000;
|
|
4
|
+
export declare const DEFAULT_MAX_RETRYABLE_DELAY_MS = 5000;
|
|
5
|
+
export declare const DEFAULT_JITTER_MS = 150;
|
|
6
|
+
export declare const RETRY_AFTER_BUFFER_MS = 1000;
|
|
7
|
+
export declare const STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX = "stream-rate-limit-cooldown:";
|
|
8
|
+
export declare const DEFAULT_RETRY_AFTER_MS = 10000;
|
|
9
|
+
export declare const DEFAULT_OPTS_1_ATTEMPT_NO_COOLDOWN: StreamRateLimitRetryOptions;
|
|
10
|
+
export declare const DEFAULT_OPTS_3_ATTEMPTS_WITH_COOLDOWN: StreamRateLimitRetryOptions;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { StreamChat } from 'stream-chat';
|
|
2
|
+
import { LoggerContext, RedisContext } from './types';
|
|
3
|
+
import { StreamRateLimitRetryOptions } from './types';
|
|
4
|
+
import { StreamType } from "../stream";
|
|
5
|
+
declare const STREAM_RATE_LIMIT_OPTIONS_MARKER = "__xbsRateLimitOptions";
|
|
6
|
+
type StreamRateLimitOptionsToken = {
|
|
7
|
+
[STREAM_RATE_LIMIT_OPTIONS_MARKER]: Partial<StreamRateLimitRetryOptions>;
|
|
8
|
+
};
|
|
9
|
+
type MethodWithRateLimitOptions<T> = T extends (...args: infer Args) => infer Result ? T & ((...args: [...Args, StreamRateLimitOptionsToken?]) => Result) : T;
|
|
10
|
+
export type RateLimited<T extends object> = {
|
|
11
|
+
[Key in keyof T]: MethodWithRateLimitOptions<T[Key]>;
|
|
12
|
+
};
|
|
13
|
+
export declare function withStreamRateLimitOptions(options: Partial<StreamRateLimitRetryOptions>): StreamRateLimitOptionsToken;
|
|
14
|
+
export declare function createRateLimitedStreamProxy(stream: StreamChat<StreamType>, context: LoggerContext & RedisContext): RateLimited<StreamChat<StreamType>>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { RateLimitExceededException } from '@wildix/xbees-conversations-client';
|
|
2
|
+
import { RateLimitsInfo } from 'stream-chat';
|
|
3
|
+
type Options = {
|
|
4
|
+
rateLimit?: RateLimitsInfo;
|
|
5
|
+
message?: string;
|
|
6
|
+
operation?: string;
|
|
7
|
+
retryAfterMs?: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function createRateLimitExceededException(options?: Options): RateLimitExceededException;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { LoggerContext, RedisContext, StreamOperationType, StreamRateLimitRetryOptions } from './types';
|
|
2
|
+
export declare function executeStreamRequestWithRateLimitHandling<T>(execute: () => Promise<T>, operation: StreamOperationType, context: LoggerContext & RedisContext, options?: Partial<StreamRateLimitRetryOptions>): Promise<T>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './createRateLimitedStreamProxy';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { StreamChat } from 'stream-chat';
|
|
2
|
+
import { StreamChannel, StreamType } from "../stream";
|
|
3
|
+
import Redis from 'ioredis';
|
|
4
|
+
import { Logger } from "@aws-lambda-powertools/logger";
|
|
5
|
+
export type StreamOperationType = keyof StreamChat<StreamType> | keyof StreamChannel;
|
|
6
|
+
export interface StreamRateLimitRetryOptions {
|
|
7
|
+
maxAttempts: number;
|
|
8
|
+
maxDelayMs: number;
|
|
9
|
+
maxRetryableDelayMs: number;
|
|
10
|
+
enableCooldown: boolean;
|
|
11
|
+
}
|
|
12
|
+
export type RedisContext = {
|
|
13
|
+
redis: Redis;
|
|
14
|
+
};
|
|
15
|
+
export type LoggerContext = {
|
|
16
|
+
logger: Logger;
|
|
17
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wildix/xbees-conversations-utils",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.56",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./dist-cjs/index.js",
|
|
6
6
|
"module": "./dist-es/index.js",
|
|
@@ -21,8 +21,10 @@
|
|
|
21
21
|
},
|
|
22
22
|
"license": "Apache-2.0",
|
|
23
23
|
"dependencies": {
|
|
24
|
+
"@aws-lambda-powertools/logger": "^2.31.0",
|
|
24
25
|
"@wildix/xbees-conversations-client": "1.2.5",
|
|
25
|
-
"
|
|
26
|
+
"ioredis": "^5.10.0",
|
|
27
|
+
"stream-chat": "8.12.1",
|
|
26
28
|
"tslib": "^2.5.0"
|
|
27
29
|
},
|
|
28
30
|
"devDependencies": {
|