Repository navigation
fix(rate-limit): watchdog, env override, and stage tracing to prevent silent wedges - #1828
Conversation
… silent wedges
The auto-enabled Bottleneck safety net for API-key providers can desync
its internal state and silently stop dispatching jobs (queued > 0,
running == 0, executing == 0). Once that happens nothing recovers without
a process restart. This change adds a self-heal layer plus operator
visibility, and keeps existing behavior intact by default.
- Watchdog (30s tick) detects wedged limiters and force-resets them with
stop({dropWaitingJobs:true}) so queued callers actually fail rather than
stall forever.
- RATE_LIMIT_AUTO_ENABLE env var overrides the dashboard auto-enable
toggle (highest precedence). Lets operators flip the safety net off
during an incident without needing dashboard access.
- disableRateLimitProtection now uses stop({dropWaitingJobs:true})
instead of disconnect(); disconnect leaks queued promises (observed
while debugging this).
- STAGE_TRACE checkpoints in chatCore (post_injection, post_translation,
pre/post_semaphore, pre/inside/post_rate_limit, pre/post_executor)
pinpoint which await a hung request was stuck on.
- New /api/admin/concurrency endpoint exposes per-limiter counts and
semaphore stats so operators can see liveness in real time.
- Test coverage for the env-var override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a watchdog mechanism to detect and recover from wedged rate limiters, adds an environment variable override for rate limit auto-enablement, and implements a new admin endpoint for monitoring concurrency and resetting semaphores. The review feedback highlights two critical issues: the watchdog threshold of 5 seconds is likely too aggressive and may cause false positives for providers with high latency or strict rate limits, and the new admin route lacks explicit authentication or authorization checks, posing a security risk.
| const lastDispatchAt = new Map<string, number>(); | ||
| let watchdogInterval: ReturnType<typeof setInterval> | null = null; | ||
| const WATCHDOG_INTERVAL_MS = 30_000; | ||
| const WEDGE_THRESHOLD_MS = 5_000; |
There was a problem hiding this comment.
The WEDGE_THRESHOLD_MS of 5 seconds is likely too aggressive for a general-purpose rate limiter. Many API providers have rate limits that result in a minTime (the delay between requests) significantly higher than 5 seconds. If a limiter is correctly waiting to respect its minTime or reservoir refresh, the watchdog will incorrectly identify it as wedged and force-reset it, dropping all queued requests. Consider increasing this threshold to a value higher than the maximum expected minTime (e.g., 60-120 seconds) or making it dynamic based on the limiter's current settings.
There was a problem hiding this comment.
Agreed — pushed a fix raising it to 120 s with rationale in the commit message and an inline comment. Default reservoirRefreshInterval is 60 s and adaptive minTime can climb to ~60 s for 1-RPM providers (see updateFromHeaders), so 120 s gives a 2× margin against both. The actual wedge we observed had been stalled 3+ minutes, so detection latency stays within usable bounds. A fully dynamic per-limiter threshold would require peeking at Bottleneck internals; punted on that for now.
| export async function GET() { | ||
| return NextResponse.json({ | ||
| timestamp: new Date().toISOString(), | ||
| rateLimits: getAllRateLimitStatus(), | ||
| semaphores: getSemaphoreStats(), | ||
| }); | ||
| } | ||
|
|
||
| export async function POST(request: Request) { | ||
| const url = new URL(request.url); | ||
| const action = url.searchParams.get("action"); | ||
| if (action === "reset-semaphores") { | ||
| resetAllSemaphores(); | ||
| return NextResponse.json({ ok: true, action }); | ||
| } | ||
| return NextResponse.json({ error: "unknown action" }, { status: 400 }); | ||
| } |
There was a problem hiding this comment.
This new admin endpoint lacks explicit authentication or authorization checks in the route handler. While the PR description mentions it is gated by a management policy, the code itself does not verify the caller's identity or permissions. Exposing internal concurrency stats and providing a reset-semaphores action without protection is a security risk. Ensure this route is protected by a session check or appropriate middleware.
There was a problem hiding this comment.
Disagree — the route is protected, the protection just isn't in the handler.
src/server/authz/classify.ts:83-87 auto-classifies any path under /api/ that isn't /api/v1/* or explicitly public as routeClass: "MANAGEMENT". The management policy (src/server/authz/policies/management.ts) then requires either auth-disabled mode, the internal model-sync token, or a valid dashboard session — otherwise AUTH_001. Verified live: curl -s http://localhost:20128/api/admin/concurrency returns {"error":{"code":"AUTH_001","message":"Authentication required"}} without a session cookie.
This is the project-wide convention — see src/app/api/rate-limits/route.ts and src/app/api/resilience/route.ts, both also without handler-level auth checks. Adding redundant in-handler auth would diverge from the established pattern. If we want defense-in-depth, that should be a project-wide change rather than one-off here.
Addresses gemini-code-assist review on diegosouzapw#1828: at 5s, the watchdog can false-trigger on a healthy limiter that's correctly waiting on its reservoir refresh (60s default) or adaptive minTime (up to ~60s for 1-RPM providers). 120s gives a 2× margin against both while still catching the actual wedge case (observed at 3+ minutes stalled). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 294751d8a3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const lastDispatch = lastDispatchAt.get(key) ?? 0; | ||
| const stalledMs = now - lastDispatch; | ||
| if (stalledMs < WEDGE_THRESHOLD_MS) continue; |
There was a problem hiding this comment.
Avoid treating unknown dispatch time as a wedge
The watchdog computes stalledMs from lastDispatchAt.get(key) ?? 0, which means any limiter missing a heartbeat timestamp is considered stalled since epoch. In this file, auto-enabled limiters are created in reconcileEnabledConnections() via direct new Bottleneck(...) and never register the executing listener or initialize lastDispatchAt, so they can be force-reset and have queued jobs dropped during normal throttling windows (QUEUED>0, RUNNING==0, EXECUTING==0, e.g. reservoir waiting for refresh), not just true wedges.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — fixed.
The auto-enable branch in reconcileEnabledConnections was instantiating new Bottleneck(...) directly, so the executing listener that updates lastDispatchAt never got registered. Combined with lastDispatchAt.get(key) ?? 0, the watchdog computed stalledMs ≈ now - 0 ≈ Unix time and false-triggered on the first reservoir-refresh wait — exactly as you described.
Two-part fix:
- Reconcile path now goes through
getLimiter(provider, connectionId)instead of constructing directly, so thequeued/executinglisteners andlastDispatchAtheartbeat are wired up the same way as on-demand creation. - Watchdog hardened: if
lastDispatchAtisundefinedfor any reason, seed it on the current tick and skip — never compute stall against a missing baseline.
Pushed as a follow-up commit on this branch.
…rtbeat is registered Addresses chatgpt-codex-connector review on diegosouzapw#1828: reconcileEnabledConnections created auto-enabled limiters via direct \`new Bottleneck(...)\`, bypassing the \`executing\` event listener that updates lastDispatchAt. The watchdog then read \`lastDispatchAt.get(key) ?? 0\` and computed \`stalledMs = now - 0\` ≈ Unix time, force-resetting healthy idle limiters during normal reservoir- refresh waits. - Reconcile path now calls getLimiter(provider, connectionId), which registers both queued/executing listeners and seeds lastDispatchAt to now. - Watchdog also hardened: if lastDispatch is undefined for any reason, seed it on the current tick and skip — never compute stall against epoch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks @payne0420 for this great contribution! 🎉 It's now officially merged into the release/v3.7.7 branch and will be part of the next release. The watchdog and tracing are excellent additions. We appreciate your effort! |
Addresses gemini-code-assist review on diegosouzapw#1828: at 5s, the watchdog can false-trigger on a healthy limiter that's correctly waiting on its reservoir refresh (60s default) or adaptive minTime (up to ~60s for 1-RPM providers). 120s gives a 2× margin against both while still catching the actual wedge case (observed at 3+ minutes stalled). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rtbeat is registered Addresses chatgpt-codex-connector review on diegosouzapw#1828: reconcileEnabledConnections created auto-enabled limiters via direct \`new Bottleneck(...)\`, bypassing the \`executing\` event listener that updates lastDispatchAt. The watchdog then read \`lastDispatchAt.get(key) ?? 0\` and computed \`stalledMs = now - 0\` ≈ Unix time, force-resetting healthy idle limiters during normal reservoir- refresh waits. - Reconcile path now calls getLimiter(provider, connectionId), which registers both queued/executing listeners and seeds lastDispatchAt to now. - Watchdog also hardened: if lastDispatch is undefined for any reason, seed it on the current tick and skip — never compute stall against epoch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… silent wedges (diegosouzapw#1828) Integrated into release/v3.7.7
Addresses gemini-code-assist review on diegosouzapw#1828: at 5s, the watchdog can false-trigger on a healthy limiter that's correctly waiting on its reservoir refresh (60s default) or adaptive minTime (up to ~60s for 1-RPM providers). 120s gives a 2× margin against both while still catching the actual wedge case (observed at 3+ minutes stalled). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rtbeat is registered Addresses chatgpt-codex-connector review on diegosouzapw#1828: reconcileEnabledConnections created auto-enabled limiters via direct \`new Bottleneck(...)\`, bypassing the \`executing\` event listener that updates lastDispatchAt. The watchdog then read \`lastDispatchAt.get(key) ?? 0\` and computed \`stalledMs = now - 0\` ≈ Unix time, force-resetting healthy idle limiters during normal reservoir- refresh waits. - Reconcile path now calls getLimiter(provider, connectionId), which registers both queued/executing listeners and seeds lastDispatchAt to now. - Watchdog also hardened: if lastDispatch is undefined for any reason, seed it on the current tick and skip — never compute stall against epoch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… silent wedges (diegosouzapw#1828) Integrated into release/v3.7.7
Addresses gemini-code-assist review on diegosouzapw#1828: at 5s, the watchdog can false-trigger on a healthy limiter that's correctly waiting on its reservoir refresh (60s default) or adaptive minTime (up to ~60s for 1-RPM providers). 120s gives a 2× margin against both while still catching the actual wedge case (observed at 3+ minutes stalled). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rtbeat is registered Addresses chatgpt-codex-connector review on diegosouzapw#1828: reconcileEnabledConnections created auto-enabled limiters via direct \`new Bottleneck(...)\`, bypassing the \`executing\` event listener that updates lastDispatchAt. The watchdog then read \`lastDispatchAt.get(key) ?? 0\` and computed \`stalledMs = now - 0\` ≈ Unix time, force-resetting healthy idle limiters during normal reservoir- refresh waits. - Reconcile path now calls getLimiter(provider, connectionId), which registers both queued/executing listeners and seeds lastDispatchAt to now. - Watchdog also hardened: if lastDispatch is undefined for any reason, seed it on the current tick and skip — never compute stall against epoch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… silent wedges (diegosouzapw#1828) Integrated into release/v3.7.7
Summary
The auto-enabled Bottleneck "safety net" for API-key providers can desync its
internal state and silently stop dispatching jobs. When that happens, the
limiter sits at
QUEUED > 0, RUNNING == 0, EXECUTING == 0and nothingrecovers without a process restart. New requests pile up in the queue, the
client retries (which adds more queued jobs), and the symptom looks like a
provider outage even though upstream is perfectly healthy.
This change adds a self-heal layer plus operator visibility, without
altering default behavior — the safety net stays on, the dashboard toggle
keeps working the same way.
Why this is needed
The bug, in one diagnostic session
A
minimax/minimax-m2.7workload wedged twice in ~90 minutes. From the logs:After adding stage tracing, the hung step was clearly visible:
pre_rate_limitfired but
inside_rate_limitnever did. The Bottleneck queue was acceptingjobs and never dispatching them. Limiter snapshot at the moment of wedge:
100 prior
pre_executortraces matched 100post_executortraces — everyfetch that started had returned. So nothing leaked at the fetch layer; the
leak is in Bottleneck's internal scheduler state.
Why the existing design is fragile
reconcileEnabledConnections(inrateLimitManager.ts) auto-enables aBottleneck for every active API-key provider connection at startup, with
defaults
reservoir=100/60s, minTime=200ms, maxConcurrent=10. The intent is"adaptive throttling" — read
x-ratelimit-*response headers and tighten orrelax the limiter accordingly.
Three problems with that as currently shipped:
MiniMax sends none. Once the limiter's reservoir/minTime/etc. drift into
a state where the scheduler stops moving, there's no header-driven path
to restore it.
shared across requests with no watchdog. If a counter increments without
a corresponding decrement, or a refresh timer doesn't fire, the queue
stays stuck forever.
disableRateLimitProtectioncallsdisconnect()instead ofstop({dropWaitingJobs:true}).disconnectshuts the limiter downwithout draining the queue — every queued promise leaks. (Confirmed:
the 8 stuck promises in the wedged limiter never settled even after I
disabled protection mid-incident.)
What this PR changes
but no running jobs and hasn't dispatched anything in ≥5 s, it logs the
wedge loudly and force-resets the limiter via
stop({dropWaitingJobs:true}). Queued promises actually reject so callersfall back instead of hanging. Self-healing.
RATE_LIMIT_AUTO_ENABLEenv var layered above the existing dashboardtoggle (highest precedence). Lets operators flip the safety net off during
an incident without dashboard access. Unset by default — behavior unchanged
for existing deployments.
disableRateLimitProtectionnow drains queued jobs when tearing downa limiter. Fixes the leak that left 8 promises hanging in the incident.
STAGE_TRACEcheckpoints inchatCore.ts(post_injection,post_translation,pre/post_semaphore,pre/inside/post_rate_limit,pre/post_executor). Each line includes a 6-char request id and elapsedms. Next time something hangs, the last printed label tells you exactly
which await stalled — minutes of debugging instead of hours.
/api/admin/concurrencyGET returns per-limiter counts and accountsemaphore stats. POST
?action=reset-semaphoresfor emergency unwedge.Gated by the existing
MANAGEMENTpolicy (dashboard session).Why this is the actual fix and not a workaround
process restart now self-clears in ≤30 s and is logged loudly.
API-key provider, not just MiniMax. The watchdog covers all of them.
autoEnableApiKeyProviderssetting still drive normal operation. The envvar is an emergency override, the watchdog is a backstop, the admin
endpoint is read-only telemetry. No surprises for existing users.
similar incident takes one log grep to triage.
Files
open-sse/services/rateLimitManager.ts— env override, watchdog,disconnect()→stop({dropWaitingJobs:true}).open-sse/handlers/chatCore.ts— stage trace checkpoints between theinjection step and the upstream fetch.
src/app/api/admin/concurrency/route.ts— new admin endpoint.tests/unit/rate-limit-manager.test.ts— env-var override coverage.Test plan
npm run typecheck:corecleannode --import tsx/esm --test tests/unit/rate-limit-manager.test.ts—7/7 pass (added 1 new test for env override precedence)
pre_rate_limit → inside_rate_limittraversal happens in the samemillisecond when auto-enable is off (pure passthrough)
{rateLimits, semaphores}shapeand resets a wedged limiter in production-like conditions
to ship without since the new path is purely additive defensive code)
🤖 Generated with Claude Code