fix(rate-limit): replace unsupported Bottleneck maxWait with job-level expiration (#1694)

Bottleneck v2.19.5 does not support a `maxWait` limiter/constructor option — it
was silently ignored, causing queued jobs to wait indefinitely when no 429 response
triggered the drop mechanism.

Replace with Bottleneck's supported `expiration` job-schedule option which rejects
any job that waits+executes longer than maxWaitMs. Also log expiration rejections
so they are observable in production.
This commit is contained in:
diegosouzapw
2026-04-27 23:09:49 -03:00
parent a46e920148
commit 84fbfa36c6
2 changed files with 16 additions and 4 deletions

View File

@@ -20,6 +20,7 @@
- **fix(codex):** prevent unexpected protocol leakage and fabricated instructions on bare chat completion requests without tools (#1686)
- **fix(executors):** truncate tools array to 128 items max in GitHub Copilot and OpenCode executors to mitigate 400 Bad Request errors from upstream (#1687)
- **fix:** add body-read timeout to prevent stuck pending requests (#1680)
- **fix(rate-limit):** replace unsupported Bottleneck `maxWait` option with job-level `expiration` to prevent indefinite queue stalls (#1694)
- **fix:** combo retry loop stops immediately on client disconnect (499) (#1681)
- **fix(search):** support optional bearer auth for SearXNG (#1683)
- **fix(vision):** respect native GPT vision support — prevents VisionBridge from intercepting models that already handle images natively (#1678)

View File

@@ -30,7 +30,6 @@ interface LearnedLimitEntry {
interface LimiterUpdateSettings {
maxConcurrent?: number | null;
minTime: number;
maxWait?: number | null;
reservoir?: number | null;
reservoirRefreshAmount?: number | null;
reservoirRefreshInterval?: number | null;
@@ -76,7 +75,6 @@ function buildLimiterDefaults() {
reservoir: currentRequestQueueSettings.requestsPerMinute,
reservoirRefreshAmount: currentRequestQueueSettings.requestsPerMinute,
reservoirRefreshInterval: 60 * 1000,
maxWait: currentRequestQueueSettings.maxWaitMs,
};
}
@@ -85,7 +83,6 @@ function updateAllLimiterSettings() {
limiter.updateSettings({
maxConcurrent: currentRequestQueueSettings.concurrentRequests,
minTime: currentRequestQueueSettings.minTimeBetweenRequestsMs,
maxWait: currentRequestQueueSettings.maxWaitMs,
reservoir: currentRequestQueueSettings.requestsPerMinute,
reservoirRefreshAmount: currentRequestQueueSettings.requestsPerMinute,
reservoirRefreshInterval: 60 * 1000,
@@ -285,7 +282,21 @@ export async function withRateLimit(provider, connectionId, model, fn) {
}
const limiter = getLimiter(provider, connectionId, model);
return limiter.schedule(fn);
const maxWaitMs = currentRequestQueueSettings.maxWaitMs;
const scheduleOpts = maxWaitMs && maxWaitMs > 0 ? { expiration: maxWaitMs } : {};
try {
return await limiter.schedule(scheduleOpts, fn);
} catch (err) {
// Bottleneck throws when a job exceeds its expiration timeout.
// Surface as a clear rate-limit timeout so callers can fallback.
if (err?.message?.includes("This job timed out")) {
const key = getLimiterKey(provider, connectionId, model);
console.log(
`⏰ [RATE-LIMIT] ${key} — job expired after ${Math.ceil((maxWaitMs || 0) / 1000)}s in queue, dropping`
);
}
throw err;
}
}
// ─── Header Parsing ──────────────────────────────────────────────────────────