@wildix/xbees-conversations-utils 1.1.77 → 1.1.79
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/rateLimits/constants.js +2 -1
- package/dist-cjs/rateLimits/helpers/RedisCooldown.js +49 -0
- package/dist-cjs/rateLimits/helpers/executeWithRateLimitHandling.js +40 -1
- package/dist-cjs/rateLimits/helpers/rateLimitSnapshot.js +31 -0
- package/dist-es/rateLimits/constants.js +1 -0
- package/dist-es/rateLimits/helpers/RedisCooldown.js +49 -0
- package/dist-es/rateLimits/helpers/executeWithRateLimitHandling.js +41 -2
- package/dist-es/rateLimits/helpers/rateLimitSnapshot.js +26 -0
- package/dist-types/rateLimits/constants.d.ts +1 -0
- package/dist-types/rateLimits/helpers/RedisCooldown.d.ts +2 -0
- package/dist-types/rateLimits/helpers/rateLimitSnapshot.d.ts +3 -0
- package/dist-types/rateLimits/types.d.ts +5 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DEFAULT_OPTIONS = exports.SOFT_THROTTLE_DELAY_HIGH_MS = exports.SOFT_THROTTLE_DELAY_MEDIUM_MS = exports.SOFT_THROTTLE_DELAY_LOW_MS = exports.SOFT_THROTTLE_USAGE_HIGH = exports.SOFT_THROTTLE_USAGE_MEDIUM = exports.SOFT_THROTTLE_USAGE_LOW = exports.BUDGET_COOLDOWN_SLEEP_CHUNK_MS = exports.BUDGET_MINIMAL_DELAY_MAX_MS = exports.BUDGET_MINIMAL_DELAY_MIN_MS = exports.BUDGET_RELAXED_DELAY_MAX_MS = exports.BUDGET_RELAXED_DELAY_MIN_MS = exports.BUDGET_RELAXED_USAGE_THRESHOLD = exports.BUDGET_CLEAR_USAGE_THRESHOLD = exports.BUDGET_DELAY_MAX_MS = exports.BUDGET_DELAY_MIN_MS = exports.BUDGET_THRESHOLD = exports.STREAM_BUDGET_COOLDOWN_KEY = exports.STREAM_RATE_LIMIT_SOFT_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 = exports.STREAM_SYNC_METHOD_NAMES = exports.STREAM_SYNC_CHANNEL_RESULT_METHOD_NAMES = exports.STREAM_METHOD_NAMES = exports.STREAM_CHANNEL_RESULT_METHOD_NAMES = exports.CHANNEL_SYNC_PROXY_RESULT_METHOD_NAMES = exports.CHANNEL_SYNC_METHOD_NAMES = exports.CHANNEL_METHOD_NAMES = void 0;
|
|
3
|
+
exports.DEFAULT_OPTIONS = exports.SOFT_THROTTLE_DELAY_HIGH_MS = exports.SOFT_THROTTLE_DELAY_MEDIUM_MS = exports.SOFT_THROTTLE_DELAY_LOW_MS = exports.SOFT_THROTTLE_USAGE_HIGH = exports.SOFT_THROTTLE_USAGE_MEDIUM = exports.SOFT_THROTTLE_USAGE_LOW = exports.BUDGET_COOLDOWN_SLEEP_CHUNK_MS = exports.BUDGET_MINIMAL_DELAY_MAX_MS = exports.BUDGET_MINIMAL_DELAY_MIN_MS = exports.BUDGET_RELAXED_DELAY_MAX_MS = exports.BUDGET_RELAXED_DELAY_MIN_MS = exports.BUDGET_RELAXED_USAGE_THRESHOLD = exports.BUDGET_CLEAR_USAGE_THRESHOLD = exports.BUDGET_DELAY_MAX_MS = exports.BUDGET_DELAY_MIN_MS = exports.BUDGET_THRESHOLD = exports.STREAM_BUDGET_COOLDOWN_KEY = exports.STREAM_RATE_LIMIT_METADATA_KEY_SUFFIX = exports.STREAM_RATE_LIMIT_SOFT_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 = exports.STREAM_SYNC_METHOD_NAMES = exports.STREAM_SYNC_CHANNEL_RESULT_METHOD_NAMES = exports.STREAM_METHOD_NAMES = exports.STREAM_CHANNEL_RESULT_METHOD_NAMES = exports.CHANNEL_SYNC_PROXY_RESULT_METHOD_NAMES = exports.CHANNEL_SYNC_METHOD_NAMES = exports.CHANNEL_METHOD_NAMES = void 0;
|
|
4
4
|
var generatedMethodNames_1 = require("./generatedMethodNames");
|
|
5
5
|
Object.defineProperty(exports, "CHANNEL_METHOD_NAMES", { enumerable: true, get: function () { return generatedMethodNames_1.CHANNEL_METHOD_NAMES; } });
|
|
6
6
|
Object.defineProperty(exports, "CHANNEL_SYNC_METHOD_NAMES", { enumerable: true, get: function () { return generatedMethodNames_1.CHANNEL_SYNC_METHOD_NAMES; } });
|
|
@@ -17,6 +17,7 @@ exports.STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX = 'stream-rate-limit-cooldown:';
|
|
|
17
17
|
exports.STREAM_BUDGET_COOLDOWN_KEY_PREFIX = 'stream-budget-cooldown:';
|
|
18
18
|
exports.STREAM_RATE_LIMIT_OPTIONS_MARKER = '__xbsRateLimitOptions';
|
|
19
19
|
exports.STREAM_RATE_LIMIT_SOFT_COOLDOWN_KEY = 'stream-rate-limit-soft-global';
|
|
20
|
+
exports.STREAM_RATE_LIMIT_METADATA_KEY_SUFFIX = ':meta';
|
|
20
21
|
exports.STREAM_BUDGET_COOLDOWN_KEY = 'stream-budget-global';
|
|
21
22
|
exports.BUDGET_THRESHOLD = 0.8;
|
|
22
23
|
exports.BUDGET_DELAY_MIN_MS = 30000;
|
|
@@ -72,6 +72,55 @@ class RedisCooldown {
|
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
static async getValue(cooldownKey, context, options) {
|
|
76
|
+
const { redis, logger } = context;
|
|
77
|
+
try {
|
|
78
|
+
const value = await redis.get(RedisCooldown.resolveRedisKey(cooldownKey, options));
|
|
79
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
logger.warn('Failed to read stream rate-limit metadata from Redis', {
|
|
85
|
+
cooldownKey,
|
|
86
|
+
error,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
static async setValueWithRemainingMs(cooldownKey, value, cooldownMs, context, options) {
|
|
91
|
+
if (value.length === 0 || cooldownMs <= 0) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const { redis, logger } = context;
|
|
95
|
+
try {
|
|
96
|
+
await redis.eval(`
|
|
97
|
+
local key = KEYS[1]
|
|
98
|
+
local value = ARGV[1]
|
|
99
|
+
local new_ttl = tonumber(ARGV[2])
|
|
100
|
+
local current_ttl = redis.call('PTTL', key)
|
|
101
|
+
|
|
102
|
+
if current_ttl == -2 then
|
|
103
|
+
redis.call('SET', key, value, 'PX', new_ttl)
|
|
104
|
+
return 1
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if current_ttl == -1 or current_ttl < new_ttl then
|
|
108
|
+
redis.call('SET', key, value, 'PX', new_ttl)
|
|
109
|
+
return 2
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
redis.call('SET', key, value, 'KEEPTTL')
|
|
113
|
+
return 0
|
|
114
|
+
`, 1, RedisCooldown.resolveRedisKey(cooldownKey, options), value, cooldownMs.toString());
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
logger.warn('Failed to write stream rate-limit metadata to Redis', {
|
|
118
|
+
cooldownKey,
|
|
119
|
+
cooldownMs,
|
|
120
|
+
error,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
75
124
|
static async clear(cooldownKey, context, options) {
|
|
76
125
|
const { redis, logger } = context;
|
|
77
126
|
try {
|
|
@@ -4,6 +4,7 @@ exports.executeWithRateLimitHandling = void 0;
|
|
|
4
4
|
const promises_1 = require("node:timers/promises");
|
|
5
5
|
const constants_1 = require("../constants");
|
|
6
6
|
const createRateLimitExceededException_1 = require("./createRateLimitExceededException");
|
|
7
|
+
const rateLimitSnapshot_1 = require("./rateLimitSnapshot");
|
|
7
8
|
const processStreamRateLimitException_1 = require("./processStreamRateLimitException");
|
|
8
9
|
const RedisCooldown_1 = require("./RedisCooldown");
|
|
9
10
|
function calculateRateLimitDelayMs(error, maxDelayMs) {
|
|
@@ -20,6 +21,34 @@ function calculateRateLimitDelayMs(error, maxDelayMs) {
|
|
|
20
21
|
}
|
|
21
22
|
return Math.min(maxDelayMs, constants_1.DEFAULT_MAX_DELAY_MS);
|
|
22
23
|
}
|
|
24
|
+
function getRateLimitMetadataKey(operation) {
|
|
25
|
+
return `${operation}${constants_1.STREAM_RATE_LIMIT_METADATA_KEY_SUFFIX}`;
|
|
26
|
+
}
|
|
27
|
+
function toCooldownRateLimit(snapshot, cooldownMs) {
|
|
28
|
+
return {
|
|
29
|
+
limit: snapshot.limit,
|
|
30
|
+
remaining: snapshot.remaining,
|
|
31
|
+
reset: Math.max(snapshot.reset, Date.now() + cooldownMs),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function toRateLimitSnapshot(error) {
|
|
35
|
+
const limit = Number(error.rateLimit);
|
|
36
|
+
const remaining = error.rateLimitRemaining;
|
|
37
|
+
const resetMs = error.rateLimitReset;
|
|
38
|
+
if (!Number.isFinite(limit) ||
|
|
39
|
+
typeof remaining !== 'number' ||
|
|
40
|
+
!Number.isFinite(remaining) ||
|
|
41
|
+
typeof resetMs !== 'number' ||
|
|
42
|
+
!Number.isFinite(resetMs) ||
|
|
43
|
+
resetMs <= 0) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
limit,
|
|
48
|
+
remaining,
|
|
49
|
+
reset: Date.now() + resetMs,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
23
52
|
async function executeWithRateLimitHandling(execute, operation, context, options) {
|
|
24
53
|
const { logger, redis } = context;
|
|
25
54
|
const { enableCooldown, maxAttempts, maxDelayMs, maxRetryableDelayMs } = options;
|
|
@@ -27,11 +56,17 @@ async function executeWithRateLimitHandling(execute, operation, context, options
|
|
|
27
56
|
if (enableCooldown) {
|
|
28
57
|
const cooldownMs = await RedisCooldown_1.RedisCooldown.getRemainingMs(operation, { redis, logger });
|
|
29
58
|
if (cooldownMs > 0) {
|
|
59
|
+
const rateLimitSnapshot = (0, rateLimitSnapshot_1.parseRateLimitSnapshot)(await RedisCooldown_1.RedisCooldown.getValue(getRateLimitMetadataKey(operation), { redis, logger }));
|
|
30
60
|
logger.warn('Skipping stream request due to active rate-limit cooldown', {
|
|
31
61
|
operation,
|
|
32
62
|
cooldown: cooldownMs,
|
|
63
|
+
rateLimitSnapshot,
|
|
64
|
+
});
|
|
65
|
+
throw (0, createRateLimitExceededException_1.createRateLimitExceededException)({
|
|
66
|
+
operation,
|
|
67
|
+
retryAfterMs: cooldownMs,
|
|
68
|
+
rateLimit: rateLimitSnapshot ? toCooldownRateLimit(rateLimitSnapshot, cooldownMs) : undefined,
|
|
33
69
|
});
|
|
34
|
-
throw (0, createRateLimitExceededException_1.createRateLimitExceededException)({ operation, retryAfterMs: cooldownMs });
|
|
35
70
|
}
|
|
36
71
|
const softCooldownMs = await RedisCooldown_1.RedisCooldown.getRemainingMs(constants_1.STREAM_RATE_LIMIT_SOFT_COOLDOWN_KEY, { redis, logger }, { keyPrefix: constants_1.STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX });
|
|
37
72
|
if (softCooldownMs > 0) {
|
|
@@ -70,7 +105,11 @@ async function executeWithRateLimitHandling(execute, operation, context, options
|
|
|
70
105
|
}
|
|
71
106
|
const retryAfterMs = calculateRateLimitDelayMs(rateLimitError, maxDelayMs);
|
|
72
107
|
if (enableCooldown) {
|
|
108
|
+
const rateLimitSnapshot = toRateLimitSnapshot(rateLimitError);
|
|
73
109
|
await RedisCooldown_1.RedisCooldown.setRemainingMs(operation, retryAfterMs, { redis, logger });
|
|
110
|
+
if (rateLimitSnapshot) {
|
|
111
|
+
await RedisCooldown_1.RedisCooldown.setValueWithRemainingMs(getRateLimitMetadataKey(operation), (0, rateLimitSnapshot_1.serializeRateLimitSnapshot)(rateLimitSnapshot), retryAfterMs, { redis, logger });
|
|
112
|
+
}
|
|
74
113
|
}
|
|
75
114
|
if (attempt < maxAttempts && retryAfterMs <= maxRetryableDelayMs) {
|
|
76
115
|
logger.warn('Retrying stream request after rate limit', {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseRateLimitSnapshot = exports.serializeRateLimitSnapshot = void 0;
|
|
4
|
+
const RATE_LIMIT_SNAPSHOT_PARTS_COUNT = 3;
|
|
5
|
+
function serializeRateLimitSnapshot(snapshot) {
|
|
6
|
+
return `${snapshot.limit},${snapshot.remaining},${snapshot.reset}`;
|
|
7
|
+
}
|
|
8
|
+
exports.serializeRateLimitSnapshot = serializeRateLimitSnapshot;
|
|
9
|
+
function parseRateLimitSnapshot(snapshot) {
|
|
10
|
+
if (typeof snapshot !== 'string' || snapshot.length === 0) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const parts = snapshot.split(',');
|
|
14
|
+
if (parts.length !== RATE_LIMIT_SNAPSHOT_PARTS_COUNT) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const [limitRaw, remainingRaw, resetRaw] = parts;
|
|
18
|
+
const limit = Number(limitRaw);
|
|
19
|
+
const remaining = Number(remainingRaw);
|
|
20
|
+
const reset = Number(resetRaw);
|
|
21
|
+
if (!Number.isFinite(limit) ||
|
|
22
|
+
!Number.isFinite(remaining) ||
|
|
23
|
+
!Number.isFinite(reset) ||
|
|
24
|
+
limit < 0 ||
|
|
25
|
+
remaining < 0 ||
|
|
26
|
+
reset <= 0) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
return { limit, remaining, reset };
|
|
30
|
+
}
|
|
31
|
+
exports.parseRateLimitSnapshot = parseRateLimitSnapshot;
|
|
@@ -7,6 +7,7 @@ export const STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX = 'stream-rate-limit-cooldown
|
|
|
7
7
|
export const STREAM_BUDGET_COOLDOWN_KEY_PREFIX = 'stream-budget-cooldown:';
|
|
8
8
|
export const STREAM_RATE_LIMIT_OPTIONS_MARKER = '__xbsRateLimitOptions';
|
|
9
9
|
export const STREAM_RATE_LIMIT_SOFT_COOLDOWN_KEY = 'stream-rate-limit-soft-global';
|
|
10
|
+
export const STREAM_RATE_LIMIT_METADATA_KEY_SUFFIX = ':meta';
|
|
10
11
|
export const STREAM_BUDGET_COOLDOWN_KEY = 'stream-budget-global';
|
|
11
12
|
export const BUDGET_THRESHOLD = 0.8;
|
|
12
13
|
export const BUDGET_DELAY_MIN_MS = 30000;
|
|
@@ -69,6 +69,55 @@ export class RedisCooldown {
|
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
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
|
+
}
|
|
72
121
|
static async clear(cooldownKey, context, options) {
|
|
73
122
|
const { redis, logger } = context;
|
|
74
123
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
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_SOFT_COOLDOWN_KEY, } from '../constants';
|
|
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
3
|
import { createRateLimitExceededException } from './createRateLimitExceededException';
|
|
4
|
+
import { parseRateLimitSnapshot, serializeRateLimitSnapshot } from './rateLimitSnapshot';
|
|
4
5
|
import { processStreamRateLimitException } from './processStreamRateLimitException';
|
|
5
6
|
import { RedisCooldown } from './RedisCooldown';
|
|
6
7
|
function calculateRateLimitDelayMs(error, maxDelayMs) {
|
|
@@ -17,6 +18,34 @@ function calculateRateLimitDelayMs(error, maxDelayMs) {
|
|
|
17
18
|
}
|
|
18
19
|
return Math.min(maxDelayMs, DEFAULT_MAX_DELAY_MS);
|
|
19
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
|
+
}
|
|
20
49
|
export async function executeWithRateLimitHandling(execute, operation, context, options) {
|
|
21
50
|
const { logger, redis } = context;
|
|
22
51
|
const { enableCooldown, maxAttempts, maxDelayMs, maxRetryableDelayMs } = options;
|
|
@@ -24,11 +53,17 @@ export async function executeWithRateLimitHandling(execute, operation, context,
|
|
|
24
53
|
if (enableCooldown) {
|
|
25
54
|
const cooldownMs = await RedisCooldown.getRemainingMs(operation, { redis, logger });
|
|
26
55
|
if (cooldownMs > 0) {
|
|
56
|
+
const rateLimitSnapshot = parseRateLimitSnapshot(await RedisCooldown.getValue(getRateLimitMetadataKey(operation), { redis, logger }));
|
|
27
57
|
logger.warn('Skipping stream request due to active rate-limit cooldown', {
|
|
28
58
|
operation,
|
|
29
59
|
cooldown: cooldownMs,
|
|
60
|
+
rateLimitSnapshot,
|
|
61
|
+
});
|
|
62
|
+
throw createRateLimitExceededException({
|
|
63
|
+
operation,
|
|
64
|
+
retryAfterMs: cooldownMs,
|
|
65
|
+
rateLimit: rateLimitSnapshot ? toCooldownRateLimit(rateLimitSnapshot, cooldownMs) : undefined,
|
|
30
66
|
});
|
|
31
|
-
throw createRateLimitExceededException({ operation, retryAfterMs: cooldownMs });
|
|
32
67
|
}
|
|
33
68
|
const softCooldownMs = await RedisCooldown.getRemainingMs(STREAM_RATE_LIMIT_SOFT_COOLDOWN_KEY, { redis, logger }, { keyPrefix: STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX });
|
|
34
69
|
if (softCooldownMs > 0) {
|
|
@@ -67,7 +102,11 @@ export async function executeWithRateLimitHandling(execute, operation, context,
|
|
|
67
102
|
}
|
|
68
103
|
const retryAfterMs = calculateRateLimitDelayMs(rateLimitError, maxDelayMs);
|
|
69
104
|
if (enableCooldown) {
|
|
105
|
+
const rateLimitSnapshot = toRateLimitSnapshot(rateLimitError);
|
|
70
106
|
await RedisCooldown.setRemainingMs(operation, retryAfterMs, { redis, logger });
|
|
107
|
+
if (rateLimitSnapshot) {
|
|
108
|
+
await RedisCooldown.setValueWithRemainingMs(getRateLimitMetadataKey(operation), serializeRateLimitSnapshot(rateLimitSnapshot), retryAfterMs, { redis, logger });
|
|
109
|
+
}
|
|
71
110
|
}
|
|
72
111
|
if (attempt < maxAttempts && retryAfterMs <= maxRetryableDelayMs) {
|
|
73
112
|
logger.warn('Retrying stream request after rate limit', {
|
|
@@ -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
|
+
}
|
|
@@ -8,6 +8,7 @@ export declare const STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX = "stream-rate-limit-
|
|
|
8
8
|
export declare const STREAM_BUDGET_COOLDOWN_KEY_PREFIX = "stream-budget-cooldown:";
|
|
9
9
|
export declare const STREAM_RATE_LIMIT_OPTIONS_MARKER = "__xbsRateLimitOptions";
|
|
10
10
|
export declare const STREAM_RATE_LIMIT_SOFT_COOLDOWN_KEY = "stream-rate-limit-soft-global";
|
|
11
|
+
export declare const STREAM_RATE_LIMIT_METADATA_KEY_SUFFIX = ":meta";
|
|
11
12
|
export declare const STREAM_BUDGET_COOLDOWN_KEY = "stream-budget-global";
|
|
12
13
|
export declare const BUDGET_THRESHOLD = 0.8;
|
|
13
14
|
export declare const BUDGET_DELAY_MIN_MS = 30000;
|
|
@@ -4,5 +4,7 @@ export declare class RedisCooldown {
|
|
|
4
4
|
static getRemainingMs(cooldownKey: string, context: RedisContext & LoggerContext, options?: RedisCooldownOptions): Promise<number>;
|
|
5
5
|
static setRemainingMs(cooldownKey: string, cooldownMs: number, context: RedisContext & LoggerContext, options?: RedisCooldownOptions): Promise<void>;
|
|
6
6
|
static setRemainingMsExact(cooldownKey: string, cooldownMs: number, context: RedisContext & LoggerContext, options?: RedisCooldownOptions): Promise<void>;
|
|
7
|
+
static getValue(cooldownKey: string, context: RedisContext & LoggerContext, options?: RedisCooldownOptions): Promise<string | undefined>;
|
|
8
|
+
static setValueWithRemainingMs(cooldownKey: string, value: string, cooldownMs: number, context: RedisContext & LoggerContext, options?: RedisCooldownOptions): Promise<void>;
|
|
7
9
|
static clear(cooldownKey: string, context: RedisContext & LoggerContext, options?: RedisCooldownOptions): Promise<void>;
|
|
8
10
|
}
|
|
@@ -36,6 +36,11 @@ export type StreamBudgetHeaders = {
|
|
|
36
36
|
remainingMs?: number;
|
|
37
37
|
usedMs?: number;
|
|
38
38
|
};
|
|
39
|
+
export type StreamRateLimitSnapshot = {
|
|
40
|
+
limit: number;
|
|
41
|
+
remaining: number;
|
|
42
|
+
reset: number;
|
|
43
|
+
};
|
|
39
44
|
/** Axios response shape used by budget interceptor. */
|
|
40
45
|
export type StreamBudgetInterceptorResponse = {
|
|
41
46
|
headers?: StreamHeaders;
|