Skip to content

Commit c3d6b17

Browse files
danny-avilamiguelwon
authored andcommitted
🛡️ feat: Rate Limiting for Conversation Forking (danny-avila#8269)
* chore: Improve error logging for fetching conversations, and use new TS packages for utils * feat: Implement fork limiters for conversation forking requests * chore: error message for conversation index deletion to clarify syncing behavior * feat: Enhance error handling for forking with rate limit message
1 parent ede1e69 commit c3d6b17

File tree

7 files changed

+111
-9
lines changed

7 files changed

+111
-9
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
const rateLimit = require('express-rate-limit');
2+
const { isEnabled } = require('@librechat/api');
3+
const { RedisStore } = require('rate-limit-redis');
4+
const { logger } = require('@librechat/data-schemas');
5+
const { ViolationTypes } = require('librechat-data-provider');
6+
const ioredisClient = require('~/cache/ioredisClient');
7+
const logViolation = require('~/cache/logViolation');
8+
9+
const getEnvironmentVariables = () => {
10+
const FORK_IP_MAX = parseInt(process.env.FORK_IP_MAX) || 30;
11+
const FORK_IP_WINDOW = parseInt(process.env.FORK_IP_WINDOW) || 1;
12+
const FORK_USER_MAX = parseInt(process.env.FORK_USER_MAX) || 7;
13+
const FORK_USER_WINDOW = parseInt(process.env.FORK_USER_WINDOW) || 1;
14+
15+
const forkIpWindowMs = FORK_IP_WINDOW * 60 * 1000;
16+
const forkIpMax = FORK_IP_MAX;
17+
const forkIpWindowInMinutes = forkIpWindowMs / 60000;
18+
19+
const forkUserWindowMs = FORK_USER_WINDOW * 60 * 1000;
20+
const forkUserMax = FORK_USER_MAX;
21+
const forkUserWindowInMinutes = forkUserWindowMs / 60000;
22+
23+
return {
24+
forkIpWindowMs,
25+
forkIpMax,
26+
forkIpWindowInMinutes,
27+
forkUserWindowMs,
28+
forkUserMax,
29+
forkUserWindowInMinutes,
30+
};
31+
};
32+
33+
const createForkHandler = (ip = true) => {
34+
const { forkIpMax, forkIpWindowInMinutes, forkUserMax, forkUserWindowInMinutes } =
35+
getEnvironmentVariables();
36+
37+
return async (req, res) => {
38+
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
39+
const errorMessage = {
40+
type,
41+
max: ip ? forkIpMax : forkUserMax,
42+
limiter: ip ? 'ip' : 'user',
43+
windowInMinutes: ip ? forkIpWindowInMinutes : forkUserWindowInMinutes,
44+
};
45+
46+
await logViolation(req, res, type, errorMessage);
47+
res.status(429).json({ message: 'Too many conversation fork requests. Try again later' });
48+
};
49+
};
50+
51+
const createForkLimiters = () => {
52+
const { forkIpWindowMs, forkIpMax, forkUserWindowMs, forkUserMax } = getEnvironmentVariables();
53+
54+
const ipLimiterOptions = {
55+
windowMs: forkIpWindowMs,
56+
max: forkIpMax,
57+
handler: createForkHandler(),
58+
};
59+
const userLimiterOptions = {
60+
windowMs: forkUserWindowMs,
61+
max: forkUserMax,
62+
handler: createForkHandler(false),
63+
keyGenerator: function (req) {
64+
return req.user?.id;
65+
},
66+
};
67+
68+
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
69+
logger.debug('Using Redis for fork rate limiters.');
70+
const sendCommand = (...args) => ioredisClient.call(...args);
71+
const ipStore = new RedisStore({
72+
sendCommand,
73+
prefix: 'fork_ip_limiter:',
74+
});
75+
const userStore = new RedisStore({
76+
sendCommand,
77+
prefix: 'fork_user_limiter:',
78+
});
79+
ipLimiterOptions.store = ipStore;
80+
userLimiterOptions.store = userStore;
81+
}
82+
83+
const forkIpLimiter = rateLimit(ipLimiterOptions);
84+
const forkUserLimiter = rateLimit(userLimiterOptions);
85+
return { forkIpLimiter, forkUserLimiter };
86+
};
87+
88+
module.exports = { createForkLimiters };

api/server/middleware/limiters/importLimiters.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
const rateLimit = require('express-rate-limit');
2+
const { isEnabled } = require('@librechat/api');
23
const { RedisStore } = require('rate-limit-redis');
4+
const { logger } = require('@librechat/data-schemas');
35
const { ViolationTypes } = require('librechat-data-provider');
46
const ioredisClient = require('~/cache/ioredisClient');
57
const logViolation = require('~/cache/logViolation');
6-
const { isEnabled } = require('~/server/utils');
7-
const { logger } = require('~/config');
88

99
const getEnvironmentVariables = () => {
1010
const IMPORT_IP_MAX = parseInt(process.env.IMPORT_IP_MAX) || 100;

api/server/middleware/limiters/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const createSTTLimiters = require('./sttLimiters');
44
const loginLimiter = require('./loginLimiter');
55
const importLimiters = require('./importLimiters');
66
const uploadLimiters = require('./uploadLimiters');
7+
const forkLimiters = require('./forkLimiters');
78
const registerLimiter = require('./registerLimiter');
89
const toolCallLimiter = require('./toolCallLimiter');
910
const messageLimiters = require('./messageLimiters');
@@ -14,6 +15,7 @@ module.exports = {
1415
...uploadLimiters,
1516
...importLimiters,
1617
...messageLimiters,
18+
...forkLimiters,
1719
loginLimiter,
1820
registerLimiter,
1921
toolCallLimiter,

api/server/routes/convos.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
const multer = require('multer');
22
const express = require('express');
3+
const { sleep } = require('@librechat/agents');
4+
const { isEnabled } = require('@librechat/api');
5+
const { logger } = require('@librechat/data-schemas');
36
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
47
const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
58
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
9+
const { createImportLimiters, createForkLimiters } = require('~/server/middleware');
610
const { storage, importFileFilter } = require('~/server/routes/files/multer');
711
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
812
const { importConversations } = require('~/server/utils/import');
9-
const { createImportLimiters } = require('~/server/middleware');
1013
const { deleteToolCalls } = require('~/models/ToolCall');
11-
const { isEnabled, sleep } = require('~/server/utils');
1214
const getLogStores = require('~/cache/getLogStores');
13-
const { logger } = require('~/config');
1415

1516
const assistantClients = {
1617
[EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
@@ -43,6 +44,7 @@ router.get('/', async (req, res) => {
4344
});
4445
res.status(200).json(result);
4546
} catch (error) {
47+
logger.error('Error fetching conversations', error);
4648
res.status(500).json({ error: 'Error fetching conversations' });
4749
}
4850
});
@@ -156,6 +158,7 @@ router.post('/update', async (req, res) => {
156158
});
157159

158160
const { importIpLimiter, importUserLimiter } = createImportLimiters();
161+
const { forkIpLimiter, forkUserLimiter } = createForkLimiters();
159162
const upload = multer({ storage: storage, fileFilter: importFileFilter });
160163

161164
/**
@@ -189,7 +192,7 @@ router.post(
189192
* @param {express.Response<TForkConvoResponse>} res - Express response object.
190193
* @returns {Promise<void>} - The response after forking the conversation.
191194
*/
192-
router.post('/fork', async (req, res) => {
195+
router.post('/fork', forkIpLimiter, forkUserLimiter, async (req, res) => {
193196
try {
194197
/** @type {TForkConvoRequest} */
195198
const { conversationId, messageId, option, splitAtTarget, latestMessageId } = req.body;

client/src/components/Chat/Messages/Fork.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,17 @@ export default function Fork({
233233
status: 'info',
234234
});
235235
},
236-
onError: () => {
236+
onError: (error) => {
237+
/** Rate limit error (429 status code) */
238+
const isRateLimitError =
239+
(error as any)?.response?.status === 429 ||
240+
(error as any)?.status === 429 ||
241+
(error as any)?.statusCode === 429;
242+
237243
showToast({
238-
message: localize('com_ui_fork_error'),
244+
message: isRateLimitError
245+
? localize('com_ui_fork_error_rate_limit')
246+
: localize('com_ui_fork_error'),
239247
status: 'error',
240248
});
241249
},

client/src/locales/en/translation.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,7 @@
775775
"com_ui_fork_change_default": "Default fork option",
776776
"com_ui_fork_default": "Use default fork option",
777777
"com_ui_fork_error": "There was an error forking the conversation",
778+
"com_ui_fork_error_rate_limit": "Too many fork requests. Please try again later",
778779
"com_ui_fork_from_message": "Select a fork option",
779780
"com_ui_fork_info_1": "Use this setting to fork messages with the desired behavior.",
780781
"com_ui_fork_info_2": "\"Forking\" refers to creating a new conversation that start/end from specific messages in the current conversation, creating a copy according to the options selected.",

packages/data-schemas/src/models/plugins/mongoMeili.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,7 @@ export default function mongoMeili(schema: Schema, options: MongoMeiliOptions):
716716
} catch (error) {
717717
if (meiliEnabled) {
718718
logger.error(
719-
'[MeiliMongooseModel.deleteMany] There was an issue deleting conversation indexes upon deletion. Next startup may be slow due to syncing.',
719+
'[MeiliMongooseModel.deleteMany] There was an issue deleting conversation indexes upon deletion. Next startup may trigger syncing.',
720720
error,
721721
);
722722
}

0 commit comments

Comments
 (0)