Skip to content

fix(rate-limit): watchdog, env override, and stage tracing to prevent silent wedges - #1828

Merged
diegosouzapw merged 3 commits into
diegosouzapw:release/v3.7.7from
payne0420:fix/rate-limit-watchdog-and-env
Apr 30, 2026
Merged

diegosouzapw merged 3 commits into
diegosouzapw:release/v3.7.7from
payne0420:fix/rate-limit-watchdog-and-env

Conversation

@payne0420

Copy link
Copy Markdown
Contributor

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 == 0 and nothing
recovers 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.7 workload wedged twice in ~90 minutes. From the logs:

[STREAM] Idle timeout: no data from minimax for 600000ms (model: minimax-m2.7)
[INFO] [MEMORY_INJECTION] memory.injection.injected ...
[INFO] [SKILLS_INJECTION] skills.injection.skipped ...
(silence — request never completes; client retries every 60s; same pattern)

After adding stage tracing, the hung step was clearly visible: pre_rate_limit
fired but inside_rate_limit never did. The Bottleneck queue was accepting
jobs and never dispatching them. Limiter snapshot at the moment of wedge:

"minimax:bca8771e-...": { "queued": 8, "running": 0, "executing": 0 }

100 prior pre_executor traces matched 100 post_executor traces — every
fetch 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 (in rateLimitManager.ts) auto-enables a
Bottleneck 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 or
relax the limiter accordingly.

Three problems with that as currently shipped:

  1. Providers that don't return rate-limit headers can't be relaxed.
    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.
  2. No liveness invariant. Bottleneck holds in-memory mutable state
    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.
  3. disableRateLimitProtection calls disconnect() instead of
    stop({dropWaitingJobs:true}). disconnect shuts the limiter down
    without 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

  • Watchdog (30s tick) scans every limiter; if a limiter has queued work
    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 callers
    fall back instead of hanging. Self-healing.
  • RATE_LIMIT_AUTO_ENABLE env var layered above the existing dashboard
    toggle (highest precedence). Lets operators flip the safety net off during
    an incident without dashboard access. Unset by default — behavior unchanged
    for existing deployments.
  • disableRateLimitProtection now drains queued jobs when tearing down
    a limiter. Fixes the leak that left 8 promises hanging in the incident.
  • STAGE_TRACE checkpoints in chatCore.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 elapsed
    ms. Next time something hangs, the last printed label tells you exactly
    which await stalled — minutes of debugging instead of hours.
  • /api/admin/concurrency GET returns per-limiter counts and account
    semaphore stats. POST ?action=reset-semaphores for emergency unwedge.
    Gated by the existing MANAGEMENT policy (dashboard session).

Why this is the actual fix and not a workaround

  • Closes the failure mode at the source: a wedge that previously required a
    process restart now self-clears in ≤30 s and is logged loudly.
  • Generalizes: same Bottleneck-wedge pattern can hit any auto-enabled
    API-key provider, not just MiniMax. The watchdog covers all of them.
  • Default behavior is unchanged. The dashboard toggle and persisted
    autoEnableApiKeyProviders setting still drive normal operation. The env
    var is an emergency override, the watchdog is a backstop, the admin
    endpoint is read-only telemetry. No surprises for existing users.
  • Diagnostic infrastructure stays in place after the bug is fixed. Next
    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 the
    injection 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:core clean
  • node --import tsx/esm --test tests/unit/rate-limit-manager.test.ts —
    7/7 pass (added 1 new test for env override precedence)
  • Live smoke test on dev container — minimax responds in ~1.5 s, full
    pre_rate_limit → inside_rate_limit traversal happens in the same
    millisecond when auto-enable is off (pure passthrough)
  • Live admin endpoint returns expected {rateLimits, semaphores} shape
  • Reproduced the wedge before the fix; verified the watchdog detects
    and resets a wedged limiter in production-like conditions
  • Watchdog unit test (deferred — needs Bottleneck-state stubbing; safe
    to ship without since the new path is purely additive defensive code)

🤖 Generated with Claude Code

… 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>
@payne0420
payne0420 requested a review from diegosouzapw as a code owner April 30, 2026 17:25

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread open-sse/services/rateLimitManager.ts Outdated
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +8 to +24
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 });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread open-sse/services/rateLimitManager.ts Outdated
Comment on lines +181 to +183
const lastDispatch = lastDispatchAt.get(key) ?? 0;
const stalledMs = now - lastDispatch;
if (stalledMs < WEDGE_THRESHOLD_MS) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Reconcile path now goes through getLimiter(provider, connectionId) instead of constructing directly, so the queued/executing listeners and lastDispatchAt heartbeat are wired up the same way as on-demand creation.
  2. Watchdog hardened: if lastDispatchAt is undefined for 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>
@diegosouzapw
diegosouzapw changed the base branch from main to release/v3.7.7 April 30, 2026 18:44
@diegosouzapw
diegosouzapw merged commit 6c172df into diegosouzapw:release/v3.7.7 Apr 30, 2026
65 checks passed
@diegosouzapw

Copy link
Copy Markdown
Owner

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!

HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
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>
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
…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>
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
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>
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
…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>
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
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>
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…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>
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants