@cyanheads/pubmed-mcp-server
Version:
Search PubMed/Europe PMC, fetch articles and full text (PMC/EPMC/Unpaywall), citations, MeSH terms via MCP. STDIO or Streamable HTTP.
131 lines • 5.59 kB
JavaScript
/**
* @fileoverview Rate-limited request scheduler for NCBI E-utility calls. Caps
* concurrent in-flight requests and enforces a minimum start-gap between
* dispatches to stay within NCBI's documented per-second ceiling. Supports
* abort-during-wait so callers' deadlines can bound queue time end-to-end.
* @module src/services/ncbi/request-queue
*/
import { JsonRpcErrorCode, McpError } from '@cyanheads/mcp-ts-core/errors';
import { logger, requestContextService } from '@cyanheads/mcp-ts-core/utils';
import { recoveryFor } from '../../services/error-contracts.js';
const DEFAULT_MAX_CONCURRENT = 8;
const DEFAULT_MAX_QUEUE_SIZE = 100;
/**
* Schedules NCBI API requests against two independent ceilings:
*
* - **Throughput** (`minStartGapMs`): minimum delay between two consecutive
* dispatch times. Matches NCBI's per-second start-rate cap (≈3/s without
* an API key, ≈10/s with one).
* - **Concurrency** (`maxConcurrent`): maximum number of requests in flight
* simultaneously. Decouples concurrency from rate, so slow upstream
* responses don't block new dispatches.
*
* Enqueue accepts an optional `AbortSignal` so callers can bound their total
* time inside the scheduler — when the signal fires, a still-waiting task
* rejects immediately instead of sitting behind a saturated worker for
* minutes.
*/
export class NcbiRequestQueue {
waiters = [];
minStartGapMs;
maxConcurrent;
maxQueueSize;
inFlight = 0;
lastStartTime = 0;
nextDispatchTimer;
constructor(minStartGapMs, maxConcurrent = DEFAULT_MAX_CONCURRENT, maxQueueSize = DEFAULT_MAX_QUEUE_SIZE) {
this.minStartGapMs = minStartGapMs;
this.maxConcurrent = maxConcurrent;
this.maxQueueSize = maxQueueSize;
}
enqueue(task, endpoint, params, signal) {
if (this.waiters.length >= this.maxQueueSize) {
return Promise.reject(new McpError(JsonRpcErrorCode.RateLimited, `NCBI request queue is full (max ${this.maxQueueSize}).`, {
reason: 'queue_full',
endpoint,
queueSize: this.waiters.length,
...recoveryFor('queue_full'),
}));
}
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const waiter = {
resolve,
reject,
task,
endpoint,
params,
...(signal && { signal }),
};
if (signal) {
const onAbort = () => {
const idx = this.waiters.indexOf(waiter);
if (idx === -1)
return; // already dispatched — the task forwards its own signal
this.waiters.splice(idx, 1);
reject(signal.reason);
};
waiter.onAbort = onAbort;
signal.addEventListener('abort', onAbort, { once: true });
}
this.waiters.push(waiter);
this.tryDispatch();
});
}
tryDispatch() {
if (this.nextDispatchTimer !== undefined)
return;
while (this.inFlight < this.maxConcurrent && this.waiters.length > 0) {
const now = Date.now();
const gap = this.minStartGapMs - (now - this.lastStartTime);
if (gap > 0) {
logger.debug(`Delaying next NCBI dispatch by ${gap}ms to respect rate limit.`, requestContextService.createRequestContext({
operation: 'NcbiQueueWait',
delayMs: gap,
}));
this.nextDispatchTimer = setTimeout(() => {
this.nextDispatchTimer = undefined;
this.tryDispatch();
}, gap);
return;
}
const waiter = this.waiters.shift();
if (!waiter)
return;
// Detach the cancel listener now that we're about to start; the task is
// responsible for forwarding its own signal to downstream I/O.
if (waiter.signal && waiter.onAbort) {
waiter.signal.removeEventListener('abort', waiter.onAbort);
}
this.lastStartTime = Date.now();
this.inFlight += 1;
logger.info(`Executing NCBI request via queue: ${waiter.endpoint}`, requestContextService.createRequestContext({
operation: 'NcbiQueueDispatch',
endpoint: waiter.endpoint,
inFlight: this.inFlight,
queueDepth: this.waiters.length,
}));
// `Promise.resolve().then(() => task())` converts any sync throw from
// `task()` into a rejected promise so the finally() bookkeeping always
// runs.
Promise.resolve()
.then(() => waiter.task())
.then(waiter.resolve, (err) => {
logger.error('Error processing NCBI request from queue.', requestContextService.createRequestContext({
operation: 'NcbiQueueProcess',
endpoint: waiter.endpoint,
errorMessage: err instanceof Error ? err.message : String(err),
}));
waiter.reject(err);
})
.finally(() => {
this.inFlight -= 1;
this.tryDispatch();
});
}
}
}
//# sourceMappingURL=request-queue.js.map