UNPKG

@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
/** * @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