mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-22 11:20:15 +01:00
🔄 refactor: MCP Registry System with Distributed Caching (#10191)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* refactor: Restructure MCP registry system with caching - Split MCPServersRegistry into modular components: - MCPServerInspector: handles server inspection and health checks - MCPServersInitializer: manages server initialization logic - MCPServersRegistry: simplified registry coordination - Add distributed caching layer: - ServerConfigsCacheRedis: Redis-backed configuration cache - ServerConfigsCacheInMemory: in-memory fallback cache - RegistryStatusCache: distributed leader election state - Add promise utilities (withTimeout) replacing Promise.race patterns - Add comprehensive cache integration tests for all cache implementations - Remove unused MCPManager.getAllToolFunctions method * fix: Update OAuth flow to include user-specific headers * chore: Update Jest configuration to ignore additional test files - Added patterns to ignore files ending with .helper.ts and .helper.d.ts in testPathIgnorePatterns for cleaner test runs. * fix: oauth headers in callback * chore: Update Jest testPathIgnorePatterns to exclude helper files - Modified testPathIgnorePatterns in package.json to ignore files ending with .helper.ts and .helper.d.ts for cleaner test execution. * ci: update test mocks --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
961f87cfda
commit
ce7e6edad8
45 changed files with 3116 additions and 1150 deletions
|
|
@ -10,6 +10,7 @@ export * from './key';
|
|||
export * from './llm';
|
||||
export * from './math';
|
||||
export * from './openid';
|
||||
export * from './promise';
|
||||
export * from './sanitizeTitle';
|
||||
export * from './tempChatRetention';
|
||||
export * from './text';
|
||||
|
|
|
|||
115
packages/api/src/utils/promise.spec.ts
Normal file
115
packages/api/src/utils/promise.spec.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { withTimeout } from './promise';
|
||||
|
||||
describe('withTimeout', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
it('should resolve when promise completes before timeout', async () => {
|
||||
const promise = Promise.resolve('success');
|
||||
const result = await withTimeout(promise, 1000);
|
||||
expect(result).toBe('success');
|
||||
});
|
||||
|
||||
it('should reject when promise rejects before timeout', async () => {
|
||||
const promise = Promise.reject(new Error('test error'));
|
||||
await expect(withTimeout(promise, 1000)).rejects.toThrow('test error');
|
||||
});
|
||||
|
||||
it('should timeout when promise takes too long', async () => {
|
||||
const promise = new Promise((resolve) => setTimeout(() => resolve('late'), 2000));
|
||||
await expect(withTimeout(promise, 100, 'Custom timeout message')).rejects.toThrow(
|
||||
'Custom timeout message',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default error message when none provided', async () => {
|
||||
const promise = new Promise((resolve) => setTimeout(() => resolve('late'), 2000));
|
||||
await expect(withTimeout(promise, 100)).rejects.toThrow('Operation timed out after 100ms');
|
||||
});
|
||||
|
||||
it('should clear timeout when promise resolves', async () => {
|
||||
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
|
||||
const promise = Promise.resolve('fast');
|
||||
|
||||
await withTimeout(promise, 1000);
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should clear timeout when promise rejects', async () => {
|
||||
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
|
||||
const promise = Promise.reject(new Error('fail'));
|
||||
|
||||
await expect(withTimeout(promise, 1000)).rejects.toThrow('fail');
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle multiple concurrent timeouts', async () => {
|
||||
const promise1 = Promise.resolve('first');
|
||||
const promise2 = new Promise((resolve) => setTimeout(() => resolve('second'), 50));
|
||||
const promise3 = new Promise((resolve) => setTimeout(() => resolve('third'), 2000));
|
||||
|
||||
const [result1, result2] = await Promise.all([
|
||||
withTimeout(promise1, 1000),
|
||||
withTimeout(promise2, 1000),
|
||||
]);
|
||||
|
||||
expect(result1).toBe('first');
|
||||
expect(result2).toBe('second');
|
||||
|
||||
await expect(withTimeout(promise3, 100)).rejects.toThrow('Operation timed out after 100ms');
|
||||
});
|
||||
|
||||
it('should work with async functions', async () => {
|
||||
const asyncFunction = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return 'async result';
|
||||
};
|
||||
|
||||
const result = await withTimeout(asyncFunction(), 1000);
|
||||
expect(result).toBe('async result');
|
||||
});
|
||||
|
||||
it('should work with any return type', async () => {
|
||||
const numberPromise = Promise.resolve(42);
|
||||
const objectPromise = Promise.resolve({ key: 'value' });
|
||||
const arrayPromise = Promise.resolve([1, 2, 3]);
|
||||
|
||||
expect(await withTimeout(numberPromise, 1000)).toBe(42);
|
||||
expect(await withTimeout(objectPromise, 1000)).toEqual({ key: 'value' });
|
||||
expect(await withTimeout(arrayPromise, 1000)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should call logger when timeout occurs', async () => {
|
||||
const loggerMock = jest.fn();
|
||||
const promise = new Promise((resolve) => setTimeout(() => resolve('late'), 2000));
|
||||
const errorMessage = 'Custom timeout with logger';
|
||||
|
||||
await expect(withTimeout(promise, 100, errorMessage, loggerMock)).rejects.toThrow(errorMessage);
|
||||
|
||||
expect(loggerMock).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock).toHaveBeenCalledWith(errorMessage, expect.any(Error));
|
||||
});
|
||||
|
||||
it('should not call logger when promise resolves', async () => {
|
||||
const loggerMock = jest.fn();
|
||||
const promise = Promise.resolve('success');
|
||||
|
||||
const result = await withTimeout(promise, 1000, 'Should not timeout', loggerMock);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(loggerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should work without logger parameter', async () => {
|
||||
const promise = new Promise((resolve) => setTimeout(() => resolve('late'), 2000));
|
||||
|
||||
await expect(withTimeout(promise, 100, 'No logger provided')).rejects.toThrow(
|
||||
'No logger provided',
|
||||
);
|
||||
});
|
||||
});
|
||||
42
packages/api/src/utils/promise.ts
Normal file
42
packages/api/src/utils/promise.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Wraps a promise with a timeout. If the promise doesn't resolve/reject within
|
||||
* the specified time, it will be rejected with a timeout error.
|
||||
*
|
||||
* @param promise - The promise to wrap with a timeout
|
||||
* @param timeoutMs - Timeout duration in milliseconds
|
||||
* @param errorMessage - Custom error message for timeout (optional)
|
||||
* @param logger - Optional logger function to log timeout errors (e.g., console.warn, logger.warn)
|
||||
* @returns Promise that resolves/rejects with the original promise or times out
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await withTimeout(
|
||||
* fetchData(),
|
||||
* 5000,
|
||||
* 'Failed to fetch data within 5 seconds',
|
||||
* console.warn
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
errorMessage?: string,
|
||||
logger?: (message: string, error: Error) => void,
|
||||
): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
const error = new Error(errorMessage ?? `Operation timed out after ${timeoutMs}ms`);
|
||||
if (logger) logger(error.message, error);
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
clearTimeout(timeoutId!);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue