@wildix/xbees-conversations-utils 1.1.75 → 1.1.77
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 +17 -62
- package/dist-cjs/rateLimits/createRateLimitedStreamProxy.js +20 -7
- package/dist-cjs/rateLimits/generatedMethodNames.js +320 -0
- package/dist-cjs/rateLimits/helpers/RedisCooldown.js +16 -0
- package/dist-cjs/rateLimits/helpers/createRateLimitExceededException.js +5 -5
- package/dist-cjs/rateLimits/helpers/executeWithRateLimitHandling.js +21 -35
- package/dist-cjs/rateLimits/helpers/headerUtils.js +40 -0
- package/dist-cjs/rateLimits/helpers/isStreamChannelLike.js +9 -1
- package/dist-cjs/rateLimits/helpers/processStreamBudgetHeaders.js +41 -35
- package/dist-cjs/rateLimits/helpers/processStreamRateLimitException.js +7 -18
- package/dist-cjs/rateLimits/helpers/processStreamRateLimitHeaders.js +4 -28
- package/dist-cjs/rateLimits/helpers/registerStreamInterceptors.js +48 -0
- package/dist-es/rateLimits/constants.js +9 -61
- package/dist-es/rateLimits/createRateLimitedStreamProxy.js +21 -8
- package/dist-es/rateLimits/generatedMethodNames.js +317 -0
- package/dist-es/rateLimits/helpers/RedisCooldown.js +16 -0
- package/dist-es/rateLimits/helpers/createRateLimitExceededException.js +5 -5
- package/dist-es/rateLimits/helpers/executeWithRateLimitHandling.js +22 -36
- package/dist-es/rateLimits/helpers/headerUtils.js +34 -0
- package/dist-es/rateLimits/helpers/isStreamChannelLike.js +9 -1
- package/dist-es/rateLimits/helpers/processStreamBudgetHeaders.js +42 -36
- package/dist-es/rateLimits/helpers/processStreamRateLimitException.js +5 -16
- package/dist-es/rateLimits/helpers/processStreamRateLimitHeaders.js +4 -28
- package/dist-es/rateLimits/helpers/registerStreamInterceptors.js +44 -0
- package/dist-types/rateLimits/constants.d.ts +8 -4
- package/dist-types/rateLimits/generatedMethodNames.d.ts +11 -0
- package/dist-types/rateLimits/helpers/RedisCooldown.d.ts +1 -0
- package/dist-types/rateLimits/helpers/headerUtils.d.ts +4 -0
- package/dist-types/rateLimits/helpers/processStreamBudgetHeaders.d.ts +2 -1
- package/dist-types/rateLimits/helpers/registerStreamInterceptors.d.ts +2 -0
- package/package.json +2 -1
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
export const STREAM_METHOD_NAMES = [
|
|
2
|
+
'_addChannelConfig',
|
|
3
|
+
'_buildWSPayload',
|
|
4
|
+
'_callClientListeners',
|
|
5
|
+
'_createImport',
|
|
6
|
+
'_createImportURL',
|
|
7
|
+
'_deleteUserMessageReference',
|
|
8
|
+
'_enrichAxiosOptions',
|
|
9
|
+
'_getConnectionID',
|
|
10
|
+
'_getImport',
|
|
11
|
+
'_getToken',
|
|
12
|
+
'_handleClientEvent',
|
|
13
|
+
'_handleUserEvent',
|
|
14
|
+
'_hasConnectionID',
|
|
15
|
+
'_isUsingServerAuth',
|
|
16
|
+
'_listImports',
|
|
17
|
+
'_logApiError',
|
|
18
|
+
'_logApiRequest',
|
|
19
|
+
'_logApiResponse',
|
|
20
|
+
'_muteStatus',
|
|
21
|
+
'_normalizeDate',
|
|
22
|
+
'_normalizeExpiration',
|
|
23
|
+
'_queryFlagReports',
|
|
24
|
+
'_queryFlags',
|
|
25
|
+
'_reviewFlagReport',
|
|
26
|
+
'_sayHi',
|
|
27
|
+
'_setToken',
|
|
28
|
+
'_setupConnection',
|
|
29
|
+
'_setUser',
|
|
30
|
+
'_startCleaning',
|
|
31
|
+
'_unblockMessage',
|
|
32
|
+
'_updateMemberWatcherReferences',
|
|
33
|
+
'_updateUserMessageReferences',
|
|
34
|
+
'_updateUserReferences',
|
|
35
|
+
'_validateAndGetMessageId',
|
|
36
|
+
'addDevice',
|
|
37
|
+
'banUser',
|
|
38
|
+
'channel',
|
|
39
|
+
'closeConnection',
|
|
40
|
+
'commitMessage',
|
|
41
|
+
'connect',
|
|
42
|
+
'connectAnonymousUser',
|
|
43
|
+
'connectUser',
|
|
44
|
+
'createAbortControllerForNextRequest',
|
|
45
|
+
'createBlockList',
|
|
46
|
+
'createCampaign',
|
|
47
|
+
'createChannelType',
|
|
48
|
+
'createCommand',
|
|
49
|
+
'createPermission',
|
|
50
|
+
'createRole',
|
|
51
|
+
'createSegment',
|
|
52
|
+
'createToken',
|
|
53
|
+
'deactivateUser',
|
|
54
|
+
'deactivateUsers',
|
|
55
|
+
'delete',
|
|
56
|
+
'deleteBlockList',
|
|
57
|
+
'deleteCampaign',
|
|
58
|
+
'deleteChannels',
|
|
59
|
+
'deleteChannelType',
|
|
60
|
+
'deleteCommand',
|
|
61
|
+
'deleteMessage',
|
|
62
|
+
'deletePermission',
|
|
63
|
+
'deletePushProvider',
|
|
64
|
+
'deleteRole',
|
|
65
|
+
'deleteSegment',
|
|
66
|
+
'deleteUser',
|
|
67
|
+
'deleteUsers',
|
|
68
|
+
'devToken',
|
|
69
|
+
'disconnect',
|
|
70
|
+
'disconnectUser',
|
|
71
|
+
'dispatchEvent',
|
|
72
|
+
'doAxiosRequest',
|
|
73
|
+
'enrichURL',
|
|
74
|
+
'errorFromResponse',
|
|
75
|
+
'exportChannel',
|
|
76
|
+
'exportChannels',
|
|
77
|
+
'exportUser',
|
|
78
|
+
'exportUsers',
|
|
79
|
+
'flagMessage',
|
|
80
|
+
'flagUser',
|
|
81
|
+
'get',
|
|
82
|
+
'getAppSettings',
|
|
83
|
+
'getAuthType',
|
|
84
|
+
'getBlockList',
|
|
85
|
+
'getCallToken',
|
|
86
|
+
'getChannelById',
|
|
87
|
+
'getChannelByMembers',
|
|
88
|
+
'getChannelType',
|
|
89
|
+
'getCommand',
|
|
90
|
+
'getDevices',
|
|
91
|
+
'getExportChannelStatus',
|
|
92
|
+
'getMessage',
|
|
93
|
+
'getPermission',
|
|
94
|
+
'getRateLimits',
|
|
95
|
+
'getTask',
|
|
96
|
+
'getUnreadCount',
|
|
97
|
+
'getUserAgent',
|
|
98
|
+
'handleEvent',
|
|
99
|
+
'handleResponse',
|
|
100
|
+
'hydrateActiveChannels',
|
|
101
|
+
'listBlockLists',
|
|
102
|
+
'listChannelTypes',
|
|
103
|
+
'listCommands',
|
|
104
|
+
'listPermissions',
|
|
105
|
+
'listPushProviders',
|
|
106
|
+
'listRoles',
|
|
107
|
+
'markAllRead',
|
|
108
|
+
'markChannelsRead',
|
|
109
|
+
'muteUser',
|
|
110
|
+
'off',
|
|
111
|
+
'on',
|
|
112
|
+
'openConnection',
|
|
113
|
+
'partialUpdateMessage',
|
|
114
|
+
'partialUpdateUser',
|
|
115
|
+
'partialUpdateUsers',
|
|
116
|
+
'patch',
|
|
117
|
+
'pinMessage',
|
|
118
|
+
'post',
|
|
119
|
+
'put',
|
|
120
|
+
'queryBannedUsers',
|
|
121
|
+
'queryCampaigns',
|
|
122
|
+
'queryChannels',
|
|
123
|
+
'queryMessageFlags',
|
|
124
|
+
'queryRecipients',
|
|
125
|
+
'querySegments',
|
|
126
|
+
'queryUsers',
|
|
127
|
+
'reactivateUser',
|
|
128
|
+
'reactivateUsers',
|
|
129
|
+
'recoverState',
|
|
130
|
+
'removeDevice',
|
|
131
|
+
'removeShadowBan',
|
|
132
|
+
'restoreUsers',
|
|
133
|
+
'resumeCampaign',
|
|
134
|
+
'revokeTokens',
|
|
135
|
+
'revokeUsersToken',
|
|
136
|
+
'revokeUserToken',
|
|
137
|
+
'scheduleCampaign',
|
|
138
|
+
'search',
|
|
139
|
+
'sendFile',
|
|
140
|
+
'sendUserCustomEvent',
|
|
141
|
+
'setAnonymousUser',
|
|
142
|
+
'setBaseURL',
|
|
143
|
+
'setGuestUser',
|
|
144
|
+
'setLocalDevice',
|
|
145
|
+
'setUser',
|
|
146
|
+
'setUserAgent',
|
|
147
|
+
'shadowBan',
|
|
148
|
+
'stopCampaign',
|
|
149
|
+
'sync',
|
|
150
|
+
'testCampaign',
|
|
151
|
+
'testPushSettings',
|
|
152
|
+
'testSQSSettings',
|
|
153
|
+
'translateMessage',
|
|
154
|
+
'unbanUser',
|
|
155
|
+
'unblockMessage',
|
|
156
|
+
'unflagMessage',
|
|
157
|
+
'unflagUser',
|
|
158
|
+
'unmuteUser',
|
|
159
|
+
'unpinMessage',
|
|
160
|
+
'updateAppSettings',
|
|
161
|
+
'updateBlockList',
|
|
162
|
+
'updateCampaign',
|
|
163
|
+
'updateChannelType',
|
|
164
|
+
'updateCommand',
|
|
165
|
+
'updateMessage',
|
|
166
|
+
'updatePermission',
|
|
167
|
+
'updateSegment',
|
|
168
|
+
'updateUser',
|
|
169
|
+
'updateUsers',
|
|
170
|
+
'upsertPushProvider',
|
|
171
|
+
'upsertUser',
|
|
172
|
+
'upsertUsers',
|
|
173
|
+
'userMuteStatus',
|
|
174
|
+
'verifyWebhook',
|
|
175
|
+
];
|
|
176
|
+
export const STREAM_SYNC_METHOD_NAMES = [
|
|
177
|
+
'_addChannelConfig',
|
|
178
|
+
'_buildWSPayload',
|
|
179
|
+
'_callClientListeners',
|
|
180
|
+
'_deleteUserMessageReference',
|
|
181
|
+
'_enrichAxiosOptions',
|
|
182
|
+
'_getConnectionID',
|
|
183
|
+
'_getToken',
|
|
184
|
+
'_handleClientEvent',
|
|
185
|
+
'_handleUserEvent',
|
|
186
|
+
'_hasConnectionID',
|
|
187
|
+
'_isUsingServerAuth',
|
|
188
|
+
'_logApiError',
|
|
189
|
+
'_logApiRequest',
|
|
190
|
+
'_logApiResponse',
|
|
191
|
+
'_muteStatus',
|
|
192
|
+
'_normalizeDate',
|
|
193
|
+
'_normalizeExpiration',
|
|
194
|
+
'_sayHi',
|
|
195
|
+
'_setUser',
|
|
196
|
+
'_startCleaning',
|
|
197
|
+
'_updateMemberWatcherReferences',
|
|
198
|
+
'_updateUserMessageReferences',
|
|
199
|
+
'_updateUserReferences',
|
|
200
|
+
'_validateAndGetMessageId',
|
|
201
|
+
'createAbortControllerForNextRequest',
|
|
202
|
+
'createToken',
|
|
203
|
+
'devToken',
|
|
204
|
+
'dispatchEvent',
|
|
205
|
+
'errorFromResponse',
|
|
206
|
+
'getAuthType',
|
|
207
|
+
'getUserAgent',
|
|
208
|
+
'handleEvent',
|
|
209
|
+
'handleResponse',
|
|
210
|
+
'off',
|
|
211
|
+
'on',
|
|
212
|
+
'setBaseURL',
|
|
213
|
+
'setLocalDevice',
|
|
214
|
+
'setUserAgent',
|
|
215
|
+
'userMuteStatus',
|
|
216
|
+
'verifyWebhook',
|
|
217
|
+
];
|
|
218
|
+
export const STREAM_CHANNEL_RESULT_METHOD_NAMES = [
|
|
219
|
+
'channel',
|
|
220
|
+
'getChannelById',
|
|
221
|
+
'getChannelByMembers',
|
|
222
|
+
'hydrateActiveChannels',
|
|
223
|
+
'queryChannels',
|
|
224
|
+
];
|
|
225
|
+
export const STREAM_SYNC_CHANNEL_RESULT_METHOD_NAMES = [
|
|
226
|
+
'channel',
|
|
227
|
+
'getChannelById',
|
|
228
|
+
'getChannelByMembers',
|
|
229
|
+
'hydrateActiveChannels',
|
|
230
|
+
];
|
|
231
|
+
export const CHANNEL_METHOD_NAMES = [
|
|
232
|
+
'_callChannelListeners',
|
|
233
|
+
'_channelURL',
|
|
234
|
+
'_checkInitialized',
|
|
235
|
+
'_countMessageAsUnread',
|
|
236
|
+
'_disconnect',
|
|
237
|
+
'_extendEventWithOwnReactions',
|
|
238
|
+
'_handleChannelEvent',
|
|
239
|
+
'_initializeState',
|
|
240
|
+
'_update',
|
|
241
|
+
'acceptInvite',
|
|
242
|
+
'addMembers',
|
|
243
|
+
'addModerators',
|
|
244
|
+
'assignRoles',
|
|
245
|
+
'banUser',
|
|
246
|
+
'clean',
|
|
247
|
+
'countUnread',
|
|
248
|
+
'countUnreadMentions',
|
|
249
|
+
'create',
|
|
250
|
+
'createCall',
|
|
251
|
+
'delete',
|
|
252
|
+
'deleteFile',
|
|
253
|
+
'deleteImage',
|
|
254
|
+
'deleteReaction',
|
|
255
|
+
'demoteModerators',
|
|
256
|
+
'disableSlowMode',
|
|
257
|
+
'enableSlowMode',
|
|
258
|
+
'getClient',
|
|
259
|
+
'getConfig',
|
|
260
|
+
'getMessagesById',
|
|
261
|
+
'getPinnedMessages',
|
|
262
|
+
'getReactions',
|
|
263
|
+
'getReplies',
|
|
264
|
+
'hide',
|
|
265
|
+
'inviteMembers',
|
|
266
|
+
'keystroke',
|
|
267
|
+
'lastMessage',
|
|
268
|
+
'lastRead',
|
|
269
|
+
'markRead',
|
|
270
|
+
'markUnread',
|
|
271
|
+
'mute',
|
|
272
|
+
'muteStatus',
|
|
273
|
+
'off',
|
|
274
|
+
'on',
|
|
275
|
+
'query',
|
|
276
|
+
'queryMembers',
|
|
277
|
+
'rejectInvite',
|
|
278
|
+
'removeMembers',
|
|
279
|
+
'removeShadowBan',
|
|
280
|
+
'search',
|
|
281
|
+
'sendAction',
|
|
282
|
+
'sendEvent',
|
|
283
|
+
'sendFile',
|
|
284
|
+
'sendImage',
|
|
285
|
+
'sendMessage',
|
|
286
|
+
'sendReaction',
|
|
287
|
+
'shadowBan',
|
|
288
|
+
'show',
|
|
289
|
+
'stopTyping',
|
|
290
|
+
'stopWatching',
|
|
291
|
+
'truncate',
|
|
292
|
+
'unbanUser',
|
|
293
|
+
'unmute',
|
|
294
|
+
'update',
|
|
295
|
+
'updatePartial',
|
|
296
|
+
'watch',
|
|
297
|
+
];
|
|
298
|
+
export const CHANNEL_SYNC_METHOD_NAMES = [
|
|
299
|
+
'_callChannelListeners',
|
|
300
|
+
'_channelURL',
|
|
301
|
+
'_checkInitialized',
|
|
302
|
+
'_countMessageAsUnread',
|
|
303
|
+
'_disconnect',
|
|
304
|
+
'_extendEventWithOwnReactions',
|
|
305
|
+
'_handleChannelEvent',
|
|
306
|
+
'_initializeState',
|
|
307
|
+
'clean',
|
|
308
|
+
'countUnread',
|
|
309
|
+
'countUnreadMentions',
|
|
310
|
+
'getConfig',
|
|
311
|
+
'lastMessage',
|
|
312
|
+
'lastRead',
|
|
313
|
+
'muteStatus',
|
|
314
|
+
'off',
|
|
315
|
+
'on',
|
|
316
|
+
];
|
|
317
|
+
export const CHANNEL_SYNC_PROXY_RESULT_METHOD_NAMES = ['getClient'];
|
|
@@ -53,6 +53,22 @@ export class RedisCooldown {
|
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
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
|
+
}
|
|
56
72
|
static async clear(cooldownKey, context, options) {
|
|
57
73
|
const { redis, logger } = context;
|
|
58
74
|
try {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { RateLimitExceededException } from '@wildix/xbees-conversations-client';
|
|
2
2
|
import { DEFAULT_RETRY_AFTER_MS } from '../constants';
|
|
3
|
+
const RETRY_AFTER_HEADER_PADDING_MS = 3000;
|
|
3
4
|
function toPositiveMilliseconds(milliseconds) {
|
|
4
5
|
return Math.max(0, Math.ceil(milliseconds));
|
|
5
6
|
}
|
|
6
|
-
function
|
|
7
|
-
|
|
8
|
-
return new Date(Date.now() + positiveDelayMs).toUTCString();
|
|
7
|
+
function toEpochSecondsFromNow(delayMs) {
|
|
8
|
+
return Math.ceil((Date.now() + toPositiveMilliseconds(delayMs)) / 1000);
|
|
9
9
|
}
|
|
10
10
|
export function createRateLimitExceededException(options = {}) {
|
|
11
11
|
const { rateLimit, message, operation, retryAfterMs = DEFAULT_RETRY_AFTER_MS } = options;
|
|
@@ -13,15 +13,15 @@ export function createRateLimitExceededException(options = {}) {
|
|
|
13
13
|
const retryAfterFromResetMs = rateLimit?.reset ? rateLimit.reset - Date.now() : undefined;
|
|
14
14
|
const effectiveRetryAfterMs = retryAfterFromResetMs ?? retryAfterMs;
|
|
15
15
|
const retryAfterMilliseconds = toPositiveMilliseconds(effectiveRetryAfterMs);
|
|
16
|
-
const retryAfterHttpDate = toHttpDateFromDelayMs(effectiveRetryAfterMs);
|
|
17
16
|
const rateLimitResetMilliseconds = rateLimit?.reset
|
|
18
17
|
? toPositiveMilliseconds(retryAfterFromResetMs ?? 0)
|
|
19
18
|
: retryAfterMilliseconds;
|
|
19
|
+
const retryAfterEpochSeconds = toEpochSecondsFromNow(effectiveRetryAfterMs + RETRY_AFTER_HEADER_PADDING_MS);
|
|
20
20
|
return new RateLimitExceededException({
|
|
21
21
|
$metadata: {},
|
|
22
22
|
message: message || defaultMessage,
|
|
23
23
|
rateLimitRemaining: rateLimit?.remaining || 0,
|
|
24
|
-
retryAfter:
|
|
24
|
+
retryAfter: retryAfterEpochSeconds,
|
|
25
25
|
rateLimit: rateLimit?.limit?.toString() || 'unknown',
|
|
26
26
|
rateLimitReset: rateLimitResetMilliseconds,
|
|
27
27
|
});
|
|
@@ -1,35 +1,12 @@
|
|
|
1
1
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
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, 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_SOFT_COOLDOWN_KEY, } from '../constants';
|
|
3
3
|
import { createRateLimitExceededException } from './createRateLimitExceededException';
|
|
4
4
|
import { processStreamRateLimitException } from './processStreamRateLimitException';
|
|
5
5
|
import { RedisCooldown } from './RedisCooldown';
|
|
6
|
-
function normalizeRetryAfterToDelayMs(retryAfter) {
|
|
7
|
-
if (typeof retryAfter === 'string') {
|
|
8
|
-
const asDateMs = Date.parse(retryAfter);
|
|
9
|
-
if (!Number.isNaN(asDateMs)) {
|
|
10
|
-
return Math.max(0, Math.round(asDateMs - Date.now()));
|
|
11
|
-
}
|
|
12
|
-
const asSeconds = Number(retryAfter);
|
|
13
|
-
if (Number.isFinite(asSeconds) && asSeconds > 0) {
|
|
14
|
-
return Math.max(0, Math.round(asSeconds * 1000));
|
|
15
|
-
}
|
|
16
|
-
return 0;
|
|
17
|
-
}
|
|
18
|
-
if (typeof retryAfter !== 'number' || !Number.isFinite(retryAfter) || retryAfter <= 0) {
|
|
19
|
-
return 0;
|
|
20
|
-
}
|
|
21
|
-
if (retryAfter > 1000000000) {
|
|
22
|
-
return Math.max(0, Math.round(retryAfter * 1000 - Date.now()));
|
|
23
|
-
}
|
|
24
|
-
if (retryAfter >= 1000) {
|
|
25
|
-
return Math.max(0, Math.round(retryAfter));
|
|
26
|
-
}
|
|
27
|
-
return Math.max(0, Math.round(retryAfter * 1000));
|
|
28
|
-
}
|
|
29
6
|
function calculateRateLimitDelayMs(error, maxDelayMs) {
|
|
30
|
-
const
|
|
31
|
-
const hasServerRetryAfter =
|
|
32
|
-
const baseDelayMs = hasServerRetryAfter ?
|
|
7
|
+
const retryAfterMs = typeof error.rateLimitReset === 'number' && error.rateLimitReset > 0 ? error.rateLimitReset : 0;
|
|
8
|
+
const hasServerRetryAfter = retryAfterMs > 0;
|
|
9
|
+
const baseDelayMs = hasServerRetryAfter ? retryAfterMs + RETRY_AFTER_BUFFER_MS : DEFAULT_RETRY_AFTER_MS;
|
|
33
10
|
if (baseDelayMs > 0) {
|
|
34
11
|
const jitterMs = Math.floor(Math.random() * DEFAULT_JITTER_MS);
|
|
35
12
|
const computedDelayMs = baseDelayMs + jitterMs;
|
|
@@ -45,6 +22,14 @@ export async function executeWithRateLimitHandling(execute, operation, context,
|
|
|
45
22
|
const { enableCooldown, maxAttempts, maxDelayMs, maxRetryableDelayMs } = options;
|
|
46
23
|
let attempt = 1;
|
|
47
24
|
if (enableCooldown) {
|
|
25
|
+
const cooldownMs = await 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 createRateLimitExceededException({ operation, retryAfterMs: cooldownMs });
|
|
32
|
+
}
|
|
48
33
|
const softCooldownMs = await RedisCooldown.getRemainingMs(STREAM_RATE_LIMIT_SOFT_COOLDOWN_KEY, { redis, logger }, { keyPrefix: STREAM_RATE_LIMIT_COOLDOWN_KEY_PREFIX });
|
|
49
34
|
if (softCooldownMs > 0) {
|
|
50
35
|
logger.debug('Delaying stream request due to active soft rate-limit cooldown', {
|
|
@@ -59,15 +44,16 @@ export async function executeWithRateLimitHandling(execute, operation, context,
|
|
|
59
44
|
operation,
|
|
60
45
|
cooldown: budgetCooldownMs,
|
|
61
46
|
});
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
47
|
+
let totalBudgetWaitMs = 0;
|
|
48
|
+
for (;;) {
|
|
49
|
+
const waitMs = await RedisCooldown.getRemainingMs(STREAM_BUDGET_COOLDOWN_KEY, { redis, logger }, { keyPrefix: STREAM_BUDGET_COOLDOWN_KEY_PREFIX });
|
|
50
|
+
if (waitMs <= 0 || totalBudgetWaitMs >= BUDGET_DELAY_MAX_MS) {
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
const chunkMs = Math.min(waitMs, BUDGET_COOLDOWN_SLEEP_CHUNK_MS);
|
|
54
|
+
await sleep(chunkMs);
|
|
55
|
+
totalBudgetWaitMs += chunkMs;
|
|
56
|
+
}
|
|
71
57
|
}
|
|
72
58
|
}
|
|
73
59
|
for (;;) {
|
|
@@ -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
|
+
}
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
import { Channel } from 'stream-chat';
|
|
1
2
|
function hasFunctionProperty(value, key) {
|
|
2
3
|
return key in value && typeof value[key] === 'function';
|
|
3
4
|
}
|
|
5
|
+
function hasStringProperty(value, key) {
|
|
6
|
+
return key in value && typeof value[key] === 'string';
|
|
7
|
+
}
|
|
4
8
|
export function isStreamChannelLike(value) {
|
|
5
9
|
if (typeof value !== 'object' || value === null) {
|
|
6
10
|
return false;
|
|
7
11
|
}
|
|
8
|
-
|
|
12
|
+
if (value instanceof Channel) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return (hasStringProperty(value, 'cid') &&
|
|
16
|
+
hasFunctionProperty(value, 'sendMessage') &&
|
|
9
17
|
hasFunctionProperty(value, 'queryMembers') &&
|
|
10
18
|
hasFunctionProperty(value, 'updatePartial'));
|
|
11
19
|
}
|
|
@@ -1,36 +1,11 @@
|
|
|
1
|
-
import { BUDGET_DELAY_MAX_MS, BUDGET_DELAY_MIN_MS, BUDGET_THRESHOLD, STREAM_BUDGET_COOLDOWN_KEY, STREAM_BUDGET_COOLDOWN_KEY_PREFIX, } from '../constants';
|
|
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';
|
|
2
3
|
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
4
|
function extractBudgetHeaders(headers) {
|
|
30
5
|
return {
|
|
31
|
-
limitMs: parseHeaderNumber(
|
|
32
|
-
remainingMs: parseHeaderNumber(
|
|
33
|
-
usedMs: parseHeaderNumber(
|
|
6
|
+
limitMs: parseHeaderNumber(headers ?? {}, 'x-budget-limit-ms'),
|
|
7
|
+
remainingMs: parseHeaderNumber(headers ?? {}, 'x-budget-remaining-ms'),
|
|
8
|
+
usedMs: parseHeaderNumber(headers ?? {}, 'x-budget-used-ms'),
|
|
34
9
|
};
|
|
35
10
|
}
|
|
36
11
|
function isBudgetThresholdReached(limitMs, remainingMs, usedMs) {
|
|
@@ -46,8 +21,20 @@ function isBudgetThresholdReached(limitMs, remainingMs, usedMs) {
|
|
|
46
21
|
}
|
|
47
22
|
return false;
|
|
48
23
|
}
|
|
49
|
-
function
|
|
50
|
-
return Math.round(
|
|
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;
|
|
51
38
|
}
|
|
52
39
|
export async function processStreamBudgetHeaders(headers, context) {
|
|
53
40
|
const { logger } = context;
|
|
@@ -58,26 +45,45 @@ export async function processStreamBudgetHeaders(headers, context) {
|
|
|
58
45
|
if (typeof limitMs !== 'number' || !Number.isFinite(limitMs)) {
|
|
59
46
|
return;
|
|
60
47
|
}
|
|
48
|
+
const usage = getBudgetUsage(limitMs, remainingMs, usedMs);
|
|
61
49
|
const thresholdReached = isBudgetThresholdReached(limitMs, remainingMs, usedMs);
|
|
62
50
|
if (thresholdReached) {
|
|
63
|
-
const delayMs =
|
|
64
|
-
await RedisCooldown.
|
|
51
|
+
const delayMs = getRandomDelayInRange(BUDGET_DELAY_MIN_MS, BUDGET_DELAY_MAX_MS);
|
|
52
|
+
await RedisCooldown.setRemainingMsExact(STREAM_BUDGET_COOLDOWN_KEY, delayMs, context, {
|
|
65
53
|
keyPrefix: STREAM_BUDGET_COOLDOWN_KEY_PREFIX,
|
|
66
54
|
});
|
|
67
55
|
logger.warn('Stream budget threshold reached, enabling cooldown', {
|
|
68
56
|
budgetLimitMs: limitMs,
|
|
69
57
|
budgetRemainingMs: remainingMs,
|
|
70
58
|
budgetUsedMs: usedMs,
|
|
59
|
+
budgetUsage: usage,
|
|
71
60
|
cooldownMs: delayMs,
|
|
72
61
|
});
|
|
73
62
|
return;
|
|
74
63
|
}
|
|
75
|
-
|
|
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, {
|
|
76
80
|
keyPrefix: STREAM_BUDGET_COOLDOWN_KEY_PREFIX,
|
|
77
81
|
});
|
|
78
|
-
logger.
|
|
82
|
+
logger.info('Stream budget is below threshold, reducing cooldown', {
|
|
79
83
|
budgetLimitMs: limitMs,
|
|
80
84
|
budgetRemainingMs: remainingMs,
|
|
81
85
|
budgetUsedMs: usedMs,
|
|
86
|
+
budgetUsage: usage,
|
|
87
|
+
cooldownMs: delayMs,
|
|
82
88
|
});
|
|
83
89
|
}
|
|
@@ -1,18 +1,7 @@
|
|
|
1
1
|
import { createRateLimitExceededException } from './createRateLimitExceededException';
|
|
2
|
-
|
|
3
|
-
const raw = headers[key];
|
|
4
|
-
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
5
|
-
return raw;
|
|
6
|
-
}
|
|
7
|
-
if (typeof raw === 'string') {
|
|
8
|
-
const parsed = Number(raw);
|
|
9
|
-
if (Number.isFinite(parsed)) {
|
|
10
|
-
return parsed;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
}
|
|
2
|
+
import { getHeaderCaseInsensitive, normalizeResetToEpochMs, parseHeaderNumber } from './headerUtils';
|
|
14
3
|
function parseRetryAfterMs(headers) {
|
|
15
|
-
const retryAfter = headers
|
|
4
|
+
const retryAfter = getHeaderCaseInsensitive(headers, 'retry-after');
|
|
16
5
|
if (typeof retryAfter === 'number' && Number.isFinite(retryAfter) && retryAfter >= 0) {
|
|
17
6
|
return Math.round(retryAfter * 1000);
|
|
18
7
|
}
|
|
@@ -31,14 +20,14 @@ function parseRetryAfterMs(headers) {
|
|
|
31
20
|
function getRateLimitFromHeaders(headers) {
|
|
32
21
|
const limit = parseHeaderNumber(headers, 'x-ratelimit-limit');
|
|
33
22
|
const remaining = parseHeaderNumber(headers, 'x-ratelimit-remaining');
|
|
34
|
-
const
|
|
35
|
-
if (limit === undefined || remaining === undefined ||
|
|
23
|
+
const resetRaw = parseHeaderNumber(headers, 'x-ratelimit-reset');
|
|
24
|
+
if (limit === undefined || remaining === undefined || resetRaw === undefined) {
|
|
36
25
|
return;
|
|
37
26
|
}
|
|
38
27
|
return {
|
|
39
28
|
limit,
|
|
40
29
|
remaining,
|
|
41
|
-
reset:
|
|
30
|
+
reset: normalizeResetToEpochMs(resetRaw),
|
|
42
31
|
};
|
|
43
32
|
}
|
|
44
33
|
function isStreamHttpRateLimitError(error) {
|