mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
🛡️ feat: Rate Limiting for Conversation Forking (#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
This commit is contained in:
parent
3554625a06
commit
97a99985fa
7 changed files with 111 additions and 9 deletions
88
api/server/middleware/limiters/forkLimiters.js
Normal file
88
api/server/middleware/limiters/forkLimiters.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const { isEnabled } = require('@librechat/api');
|
||||||
|
const { RedisStore } = require('rate-limit-redis');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
|
const ioredisClient = require('~/cache/ioredisClient');
|
||||||
|
const logViolation = require('~/cache/logViolation');
|
||||||
|
|
||||||
|
const getEnvironmentVariables = () => {
|
||||||
|
const FORK_IP_MAX = parseInt(process.env.FORK_IP_MAX) || 30;
|
||||||
|
const FORK_IP_WINDOW = parseInt(process.env.FORK_IP_WINDOW) || 1;
|
||||||
|
const FORK_USER_MAX = parseInt(process.env.FORK_USER_MAX) || 7;
|
||||||
|
const FORK_USER_WINDOW = parseInt(process.env.FORK_USER_WINDOW) || 1;
|
||||||
|
|
||||||
|
const forkIpWindowMs = FORK_IP_WINDOW * 60 * 1000;
|
||||||
|
const forkIpMax = FORK_IP_MAX;
|
||||||
|
const forkIpWindowInMinutes = forkIpWindowMs / 60000;
|
||||||
|
|
||||||
|
const forkUserWindowMs = FORK_USER_WINDOW * 60 * 1000;
|
||||||
|
const forkUserMax = FORK_USER_MAX;
|
||||||
|
const forkUserWindowInMinutes = forkUserWindowMs / 60000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
forkIpWindowMs,
|
||||||
|
forkIpMax,
|
||||||
|
forkIpWindowInMinutes,
|
||||||
|
forkUserWindowMs,
|
||||||
|
forkUserMax,
|
||||||
|
forkUserWindowInMinutes,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createForkHandler = (ip = true) => {
|
||||||
|
const { forkIpMax, forkIpWindowInMinutes, forkUserMax, forkUserWindowInMinutes } =
|
||||||
|
getEnvironmentVariables();
|
||||||
|
|
||||||
|
return async (req, res) => {
|
||||||
|
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
|
||||||
|
const errorMessage = {
|
||||||
|
type,
|
||||||
|
max: ip ? forkIpMax : forkUserMax,
|
||||||
|
limiter: ip ? 'ip' : 'user',
|
||||||
|
windowInMinutes: ip ? forkIpWindowInMinutes : forkUserWindowInMinutes,
|
||||||
|
};
|
||||||
|
|
||||||
|
await logViolation(req, res, type, errorMessage);
|
||||||
|
res.status(429).json({ message: 'Too many conversation fork requests. Try again later' });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createForkLimiters = () => {
|
||||||
|
const { forkIpWindowMs, forkIpMax, forkUserWindowMs, forkUserMax } = getEnvironmentVariables();
|
||||||
|
|
||||||
|
const ipLimiterOptions = {
|
||||||
|
windowMs: forkIpWindowMs,
|
||||||
|
max: forkIpMax,
|
||||||
|
handler: createForkHandler(),
|
||||||
|
};
|
||||||
|
const userLimiterOptions = {
|
||||||
|
windowMs: forkUserWindowMs,
|
||||||
|
max: forkUserMax,
|
||||||
|
handler: createForkHandler(false),
|
||||||
|
keyGenerator: function (req) {
|
||||||
|
return req.user?.id;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
|
||||||
|
logger.debug('Using Redis for fork rate limiters.');
|
||||||
|
const sendCommand = (...args) => ioredisClient.call(...args);
|
||||||
|
const ipStore = new RedisStore({
|
||||||
|
sendCommand,
|
||||||
|
prefix: 'fork_ip_limiter:',
|
||||||
|
});
|
||||||
|
const userStore = new RedisStore({
|
||||||
|
sendCommand,
|
||||||
|
prefix: 'fork_user_limiter:',
|
||||||
|
});
|
||||||
|
ipLimiterOptions.store = ipStore;
|
||||||
|
userLimiterOptions.store = userStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forkIpLimiter = rateLimit(ipLimiterOptions);
|
||||||
|
const forkUserLimiter = rateLimit(userLimiterOptions);
|
||||||
|
return { forkIpLimiter, forkUserLimiter };
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { createForkLimiters };
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const { isEnabled } = require('@librechat/api');
|
||||||
const { RedisStore } = require('rate-limit-redis');
|
const { RedisStore } = require('rate-limit-redis');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { ViolationTypes } = require('librechat-data-provider');
|
const { ViolationTypes } = require('librechat-data-provider');
|
||||||
const ioredisClient = require('~/cache/ioredisClient');
|
const ioredisClient = require('~/cache/ioredisClient');
|
||||||
const logViolation = require('~/cache/logViolation');
|
const logViolation = require('~/cache/logViolation');
|
||||||
const { isEnabled } = require('~/server/utils');
|
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const getEnvironmentVariables = () => {
|
const getEnvironmentVariables = () => {
|
||||||
const IMPORT_IP_MAX = parseInt(process.env.IMPORT_IP_MAX) || 100;
|
const IMPORT_IP_MAX = parseInt(process.env.IMPORT_IP_MAX) || 100;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const createSTTLimiters = require('./sttLimiters');
|
||||||
const loginLimiter = require('./loginLimiter');
|
const loginLimiter = require('./loginLimiter');
|
||||||
const importLimiters = require('./importLimiters');
|
const importLimiters = require('./importLimiters');
|
||||||
const uploadLimiters = require('./uploadLimiters');
|
const uploadLimiters = require('./uploadLimiters');
|
||||||
|
const forkLimiters = require('./forkLimiters');
|
||||||
const registerLimiter = require('./registerLimiter');
|
const registerLimiter = require('./registerLimiter');
|
||||||
const toolCallLimiter = require('./toolCallLimiter');
|
const toolCallLimiter = require('./toolCallLimiter');
|
||||||
const messageLimiters = require('./messageLimiters');
|
const messageLimiters = require('./messageLimiters');
|
||||||
|
|
@ -14,6 +15,7 @@ module.exports = {
|
||||||
...uploadLimiters,
|
...uploadLimiters,
|
||||||
...importLimiters,
|
...importLimiters,
|
||||||
...messageLimiters,
|
...messageLimiters,
|
||||||
|
...forkLimiters,
|
||||||
loginLimiter,
|
loginLimiter,
|
||||||
registerLimiter,
|
registerLimiter,
|
||||||
toolCallLimiter,
|
toolCallLimiter,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { sleep } = require('@librechat/agents');
|
||||||
|
const { isEnabled } = require('@librechat/api');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
||||||
const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
|
const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
|
||||||
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
|
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
|
||||||
|
const { createImportLimiters, createForkLimiters } = require('~/server/middleware');
|
||||||
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
||||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||||
const { importConversations } = require('~/server/utils/import');
|
const { importConversations } = require('~/server/utils/import');
|
||||||
const { createImportLimiters } = require('~/server/middleware');
|
|
||||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||||
const { isEnabled, sleep } = require('~/server/utils');
|
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const assistantClients = {
|
const assistantClients = {
|
||||||
[EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
|
[EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
|
||||||
|
|
@ -43,6 +44,7 @@ router.get('/', async (req, res) => {
|
||||||
});
|
});
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('Error fetching conversations', error);
|
||||||
res.status(500).json({ error: 'Error fetching conversations' });
|
res.status(500).json({ error: 'Error fetching conversations' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -156,6 +158,7 @@ router.post('/update', async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const { importIpLimiter, importUserLimiter } = createImportLimiters();
|
const { importIpLimiter, importUserLimiter } = createImportLimiters();
|
||||||
|
const { forkIpLimiter, forkUserLimiter } = createForkLimiters();
|
||||||
const upload = multer({ storage: storage, fileFilter: importFileFilter });
|
const upload = multer({ storage: storage, fileFilter: importFileFilter });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -189,7 +192,7 @@ router.post(
|
||||||
* @param {express.Response<TForkConvoResponse>} res - Express response object.
|
* @param {express.Response<TForkConvoResponse>} res - Express response object.
|
||||||
* @returns {Promise<void>} - The response after forking the conversation.
|
* @returns {Promise<void>} - The response after forking the conversation.
|
||||||
*/
|
*/
|
||||||
router.post('/fork', async (req, res) => {
|
router.post('/fork', forkIpLimiter, forkUserLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
/** @type {TForkConvoRequest} */
|
/** @type {TForkConvoRequest} */
|
||||||
const { conversationId, messageId, option, splitAtTarget, latestMessageId } = req.body;
|
const { conversationId, messageId, option, splitAtTarget, latestMessageId } = req.body;
|
||||||
|
|
|
||||||
|
|
@ -233,9 +233,17 @@ export default function Fork({
|
||||||
status: 'info',
|
status: 'info',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error) => {
|
||||||
|
/** Rate limit error (429 status code) */
|
||||||
|
const isRateLimitError =
|
||||||
|
(error as any)?.response?.status === 429 ||
|
||||||
|
(error as any)?.status === 429 ||
|
||||||
|
(error as any)?.statusCode === 429;
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
message: localize('com_ui_fork_error'),
|
message: isRateLimitError
|
||||||
|
? localize('com_ui_fork_error_rate_limit')
|
||||||
|
: localize('com_ui_fork_error'),
|
||||||
status: 'error',
|
status: 'error',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -773,6 +773,7 @@
|
||||||
"com_ui_fork_change_default": "Default fork option",
|
"com_ui_fork_change_default": "Default fork option",
|
||||||
"com_ui_fork_default": "Use default fork option",
|
"com_ui_fork_default": "Use default fork option",
|
||||||
"com_ui_fork_error": "There was an error forking the conversation",
|
"com_ui_fork_error": "There was an error forking the conversation",
|
||||||
|
"com_ui_fork_error_rate_limit": "Too many fork requests. Please try again later",
|
||||||
"com_ui_fork_from_message": "Select a fork option",
|
"com_ui_fork_from_message": "Select a fork option",
|
||||||
"com_ui_fork_info_1": "Use this setting to fork messages with the desired behavior.",
|
"com_ui_fork_info_1": "Use this setting to fork messages with the desired behavior.",
|
||||||
"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.",
|
"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.",
|
||||||
|
|
|
||||||
|
|
@ -716,7 +716,7 @@ export default function mongoMeili(schema: Schema, options: MongoMeiliOptions):
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (meiliEnabled) {
|
if (meiliEnabled) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'[MeiliMongooseModel.deleteMany] There was an issue deleting conversation indexes upon deletion. Next startup may be slow due to syncing.',
|
'[MeiliMongooseModel.deleteMany] There was an issue deleting conversation indexes upon deletion. Next startup may trigger syncing.',
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue