From b6e3cf46d2a40d3efcc9477365ebbce4d9ad53d3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 08:36:21 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=95=AF=EF=B8=8F=20fix:=20Decay=20Violatio?= =?UTF-8?q?n=20Scores=20With=20a=20Configurable=20TTL=20(#15153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + .../src/cache/__tests__/cacheConfig.spec.ts | 36 ++++++++++++ .../cacheFactory/violationCache.spec.ts | 56 +++++++++++++++++++ packages/api/src/cache/cacheConfig.ts | 16 +++++- packages/api/src/cache/cacheFactory.ts | 8 ++- 5 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 packages/api/src/cache/__tests__/cacheFactory/violationCache.spec.ts diff --git a/.env.example b/.env.example index 4d47b6b064..eb7350a93e 100644 --- a/.env.example +++ b/.env.example @@ -677,6 +677,9 @@ BAN_VIOLATIONS=true BAN_DURATION=1000 * 60 * 60 * 2 BAN_INTERVAL=20 +# Violation scores expire after this long (in ms) without new violations; 0 = never expire +VIOLATION_SCORE_TTL=1000 * 60 * 60 + LOGIN_VIOLATION_SCORE=1 REGISTRATION_VIOLATION_SCORE=1 CONCURRENT_VIOLATION_SCORE=1 diff --git a/packages/api/src/cache/__tests__/cacheConfig.spec.ts b/packages/api/src/cache/__tests__/cacheConfig.spec.ts index 820815b5f5..0ef09a2710 100644 --- a/packages/api/src/cache/__tests__/cacheConfig.spec.ts +++ b/packages/api/src/cache/__tests__/cacheConfig.spec.ts @@ -15,6 +15,7 @@ describe('cacheConfig', () => { delete process.env.REDIS_CLUSTER_SAFE_DELETE; delete process.env.REDIS_PING_INTERVAL; delete process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES; + delete process.env.VIOLATION_SCORE_TTL; // Clear module cache jest.resetModules(); @@ -263,4 +264,39 @@ describe('cacheConfig', () => { expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual(['CONFIG_STORE', 'APP_CONFIG']); }); }); + + describe('VIOLATION_SCORE_TTL configuration', () => { + test('should default to one hour when not set', async () => { + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.VIOLATION_SCORE_TTL).toBe(3600000); + }); + + test('should evaluate math expressions from the environment', async () => { + process.env.VIOLATION_SCORE_TTL = '1000 * 60 * 60 * 24'; + + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.VIOLATION_SCORE_TTL).toBe(86400000); + }); + + test('should disable expiry when set to 0', async () => { + process.env.VIOLATION_SCORE_TTL = '0'; + + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.VIOLATION_SCORE_TTL).toBeUndefined(); + }); + + test('should disable expiry for negative values', async () => { + process.env.VIOLATION_SCORE_TTL = '-1000'; + + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.VIOLATION_SCORE_TTL).toBeUndefined(); + }); + + test('should fall back to the default on invalid input', async () => { + process.env.VIOLATION_SCORE_TTL = 'not-a-duration'; + + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.VIOLATION_SCORE_TTL).toBe(3600000); + }); + }); }); diff --git a/packages/api/src/cache/__tests__/cacheFactory/violationCache.spec.ts b/packages/api/src/cache/__tests__/cacheFactory/violationCache.spec.ts new file mode 100644 index 0000000000..cc6382e2ff --- /dev/null +++ b/packages/api/src/cache/__tests__/cacheFactory/violationCache.spec.ts @@ -0,0 +1,56 @@ +describe('violationCache TTL defaults', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + delete process.env.USE_REDIS; + delete process.env.REDIS_URI; + delete process.env.VIOLATION_SCORE_TTL; + jest.resetModules(); + }); + + afterEach(() => { + process.env = originalEnv; + jest.resetModules(); + }); + + test('applies the default violation score TTL when none is given', async () => { + const { violationCache } = await import('../../cacheFactory'); + const cache = violationCache('logins'); + + expect(cache.opts.ttl).toBe(3600000); + expect(cache.opts.namespace).toBe('violations:logins'); + }); + + test('an explicit TTL overrides the default', async () => { + const { violationCache } = await import('../../cacheFactory'); + const cache = violationCache('logins', 60000); + + expect(cache.opts.ttl).toBe(60000); + }); + + test('honors VIOLATION_SCORE_TTL from the environment', async () => { + process.env.VIOLATION_SCORE_TTL = '1000 * 60 * 5'; + + const { violationCache } = await import('../../cacheFactory'); + expect(violationCache('concurrent').opts.ttl).toBe(300000); + }); + + test('VIOLATION_SCORE_TTL=0 disables expiry', async () => { + process.env.VIOLATION_SCORE_TTL = '0'; + + const { violationCache } = await import('../../cacheFactory'); + expect(violationCache('concurrent').opts.ttl).toBeUndefined(); + }); + + test('expires violation entries once the TTL elapses', async () => { + const { violationCache } = await import('../../cacheFactory'); + const cache = violationCache('expiry-check', 500); + + await cache.set('user-1', 3); + await expect(cache.get('user-1')).resolves.toBe(3); + + await new Promise((resolve) => setTimeout(resolve, 800)); + await expect(cache.get('user-1')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/api/src/cache/cacheConfig.ts b/packages/api/src/cache/cacheConfig.ts index 21fe1e7d03..303fb968c8 100644 --- a/packages/api/src/cache/cacheConfig.ts +++ b/packages/api/src/cache/cacheConfig.ts @@ -1,6 +1,6 @@ import { readFileSync, existsSync } from 'fs'; import { logger } from '@librechat/data-schemas'; -import { CacheKeys } from 'librechat-data-provider'; +import { Time, CacheKeys } from 'librechat-data-provider'; import { math, isEnabled } from '~/utils'; // To ensure that different deployments do not interfere with each other's cache, we use a prefix for the Redis keys. @@ -48,6 +48,12 @@ if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) { } } +// Violation scores expire after this long without new violations; every violation write +// restarts the countdown. Non-positive values disable expiry, restoring the legacy +// accumulate-forever behavior. +const VIOLATION_SCORE_TTL_MS = math(process.env.VIOLATION_SCORE_TTL, Time.ONE_HOUR); +const VIOLATION_SCORE_TTL = VIOLATION_SCORE_TTL_MS > 0 ? VIOLATION_SCORE_TTL_MS : undefined; + /** Helper function to safely read Redis CA certificate from file * @returns {string|null} The contents of the CA certificate file, or null if not set or on error */ @@ -105,6 +111,13 @@ const cacheConfig: { CI: boolean; DEBUG_MEMORY_CACHE: boolean; BAN_DURATION: number; // 2 hours + /** + * TTL in ms for violation scores: a score expires after this long without new violations + * (each violation write restarts the countdown). `undefined` — from a non-positive + * setting — disables expiry so scores accumulate forever. + * @default 3600000 (1 hour) + */ + VIOLATION_SCORE_TTL: number | undefined; /** * Number of keys to delete in each batch during Redis DEL operations. * In cluster mode, keys are deleted individually in parallel chunks to avoid CROSSSLOT errors. @@ -176,6 +189,7 @@ const cacheConfig: { DEBUG_MEMORY_CACHE: isEnabled(process.env.DEBUG_MEMORY_CACHE), BAN_DURATION: math(process.env.BAN_DURATION, 7200000), // 2 hours + VIOLATION_SCORE_TTL, /** * Number of keys to delete in each batch during Redis DEL operations. diff --git a/packages/api/src/cache/cacheFactory.ts b/packages/api/src/cache/cacheFactory.ts index d61986d62a..4e760058e8 100644 --- a/packages/api/src/cache/cacheFactory.ts +++ b/packages/api/src/cache/cacheFactory.ts @@ -116,10 +116,14 @@ export const tokenConfigCache = (): Keyv => * Creates a cache instance for storing violation data. * Uses a file-based fallback store if Redis is not enabled. * @param namespace - The cache namespace for violations. - * @param ttl - Time to live for cache entries. + * @param ttl - Time to live for cache entries. Defaults to `cacheConfig.VIOLATION_SCORE_TTL` + * so violation scores decay instead of accumulating forever; each write restarts the countdown. * @returns Cache instance for violations. */ -export const violationCache = (namespace: string, ttl?: number): Keyv => { +export const violationCache = ( + namespace: string, + ttl: number | undefined = cacheConfig.VIOLATION_SCORE_TTL, +): Keyv => { return standardCache(`violations:${namespace}`, ttl, violationFile); };