pediatric-ai-scribe-v3/test/assistant-rate-limit-and-cache.test.js
Daniel 5d28ebad9c
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
feat: the assistant degrades politely under load — bounded retrieval queue, per-user limit, short search cache
Three things the load path lacked. The retrieval slots had an unbounded
line behind them, so a burst meant silent waiting; past a bounded line, or
after eight seconds in it, a caller now gets 'the library is busy' and a
503 with a retry hint. The paid routes had no per-account ceiling; they
now get one, counted in Redis so every replica sees the same count and
nothing is refused when Redis is absent. And the same library search
asked twice within a minute (a retry, a refresh) went to the library
twice; it is now answered from Redis, with 0 turning that off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-14 07:56:26 +02:00

68 lines
3.5 KiB
JavaScript

// A person gets a bounded number of paid questions a minute, counted in
// Redis; and the same library search twice in a minute is answered from
// Redis. Neither refuses anything when Redis is absent.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const Module = require('node:module');
function fakeRedis() {
const store = new Map();
return {
store,
incr: async k => { const v = (store.get(k) || 0) + 1; store.set(k, v); return v; },
expire: async () => 1
};
}
function loadLimiter(redis) {
const target = require.resolve('../src/middleware/rateLimit');
delete require.cache[target];
const original = Module._load;
Module._load = function(request, parent) {
if (/utils\/redis$/.test(request)) return { getRedis: async () => redis };
return original.apply(this, arguments);
};
try { return require(target); } finally { Module._load = original; }
}
function run(mw, userId) {
return new Promise(resolve => {
const res = { headers: {}, set(k, v) { this.headers[k] = v; }, status(c) { this.code = c; return this; }, json(b) { this.body = b; resolve({ res, next: false }); } };
mw({ user: { id: userId } }, res, () => resolve({ res, next: true }));
});
}
test('the limit counts per user per window and answers 429 with a retry hint past it', async () => {
const { rateLimit } = loadLimiter(fakeRedis());
const mw = rateLimit('t', { limit: 2, windowSeconds: 60 });
assert.equal((await run(mw, 1)).next, true);
assert.equal((await run(mw, 1)).next, true);
const third = await run(mw, 1);
assert.equal(third.next, false);
assert.equal(third.res.code, 429);
assert.equal(third.res.body.code, 'RATE_LIMITED');
assert.ok(Number(third.res.headers['Retry-After']) >= 1);
assert.equal((await run(mw, 2)).next, true, 'another user has their own count');
});
test('without Redis nothing is refused', async () => {
const { rateLimit } = loadLimiter(null);
const mw = rateLimit('t', { limit: 1, windowSeconds: 60 });
assert.equal((await run(mw, 1)).next, true);
assert.equal((await run(mw, 1)).next, true);
});
test('the paid assistant routes sit behind the limiter, and the library search is cached briefly', () => {
const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/clinicalAssistant.js'), 'utf8');
assert.match(route, /router\.post\(\['\/clinical-assistant\/chat', '\/clinical-assistant\/chat\/stream', '\/clinical-assistant\/image', '\/clinical-assistant\/image\/jobs'\], askLimit\)/);
assert.match(route, /rateLimit\('clinical-ask', \{\s*limit: positiveIntEnv\('CLINICAL_ASSISTANT_ASK_LIMIT_PER_MINUTE', 30\), windowSeconds: 60/);
assert.match(route, /await cachedSemanticSearch\(searchQuery, \{/);
assert.match(route, /RETRIEVAL_CACHE_TTL_S = positiveIntEnv\('CLINICAL_ASSISTANT_RETRIEVAL_CACHE_TTL_S', 60\)/);
assert.match(route, /if \(!RETRIEVAL_CACHE_TTL_S \|\| !cache\) return semanticSearch\(query, opts\)/, '0 turns the cache off, and so does a missing cache');
assert.match(route, /cache\.setJson\(key, response, RETRIEVAL_CACHE_TTL_S\)\.catch/, 'a cache write failure never fails the answer');
const client = fs.readFileSync(path.join(__dirname, '..', 'src/utils/clinicalMcpClient.js'), 'utf8');
assert.match(client, /e\.message\s*=|new Error\('The library is busy right now/, 'the busy answer reads as a sentence');
assert.doesNotMatch(client.slice(client.indexOf('function busyError'), client.indexOf('function acquireSlot')), /MCP/, 'so assistantErrorMessage passes it through unchanged');
});