Skip to content

refactor: unify resilience controls - #1443

Closed
rdself wants to merge 6 commits into
cheaper-inference:release/v3.7.0from
rdself:coder/pr1441-clean
Closed

rdself wants to merge 6 commits into
cheaper-inference:release/v3.7.0from
rdself:coder/pr1441-clean

Conversation

@rdself

@rdself rdself commented Apr 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR refactors OmniRoute's entire circuit-breaker and cooldown stack for release/v3.7.0.

Before this work, resilience behavior was spread across too many overlapping layers: connection cooldowns, provider breakers, model-availability state, combo-specific health state, and dashboard/API-specific views of runtime health. The result was hard to reason about and hard to trust: the same failure could be interpreted differently depending on where it was handled, and different surfaces could drift away from the actual runtime behavior.

The goal of this refactor is to give OmniRoute one clear resilience model with clear responsibilities at each layer, so the runtime, dashboard, MCP, API, and docs all describe the same system.

What the new model is for

The 3.7.0 resilience model is split into four layers. Each layer exists to answer a different operational question.

1. requestQueue

What this layer is for

This layer protects a single connection/account from being hit by overlapping local requests when that connection should process work sequentially.

What it means operationally

  • if a connection is healthy but already busy, requests can be serialized instead of racing each other
  • this is about local concurrency control, not failure handling
  • it prevents self-inflicted contention on the same account

2. connectionCooldown

What this layer is for

This layer decides when one specific connection/account should temporarily step out of rotation after a retryable failure.

What it means operationally

  • if one account is rate-limited or temporarily unstable, OmniRoute cools down that connection instead of repeatedly hammering it
  • this is connection-scoped protection, not provider-wide protection
  • the runtime uses baseCooldownMs, upstream retry hints, and bounded backoff to decide how long that connection should rest

3. providerBreaker

What this layer is for

This layer decides when a provider as a whole is unhealthy enough that OmniRoute should stop routing traffic into it for a while.

What it means operationally

  • this is the provider-wide safety gate
  • if failures are no longer isolated to one bad account and look like provider-level instability, the breaker opens
  • direct requests and combo routing use the same provider-level breaker state
  • the Health page is the runtime view of this provider-wide state

4. waitForCooldown

What this layer is for

This layer decides what OmniRoute should do when every eligible connection is temporarily unavailable only because of cooldowns.

What it means operationally

  • OmniRoute can either fail fast or wait briefly for the earliest cooldown to expire
  • this is the policy layer for cooldown-aware retry behavior
  • it prevents the system from either giving up too early or retrying forever

What combo routing is responsible for

Combo routing is now treated as a routing layer, not as a second resilience system.

That distinction is important.

A combo should answer questions like:

  • which providers/models are candidates for this request
  • in what order or strategy they should be tried
  • how intelligent routing should score or prioritize those candidates

A combo should not maintain its own parallel understanding of provider health, cooldown state, or breaker state.

What this means operationally

  • combos use the same provider breaker state as direct model requests
  • combos use the same connection cooldown/account availability state as the rest of the runtime
  • combo fallback happens on top of the unified runtime resilience state, not beside it
  • intelligent combo panels focus on routing inputs such as candidate pool, mode pack, and exploration rate
  • runtime health stays on the Health page instead of being reinterpreted inside combo-specific UI panels

In other words: combo decides where to try next; the resilience system decides whether that target is currently eligible.

Runtime behavior after the refactor

The runtime now follows one consistent flow:

  1. queue locally when the selected connection should not run concurrent work
  2. cool down individual connections when a failure is connection-scoped and retryable
  3. open the provider breaker only when failures are provider-scoped
  4. let combo routing choose among the remaining eligible targets using its configured strategy
  5. decide whether to wait or fail when all remaining candidates are only cooling down

That gives each decision a single home instead of spreading the same responsibility across multiple overlapping subsystems.

Account and fallback handling

This refactor also makes the fallback path easier to reason about.

  • cooldown writes, account suppression, and model lockouts all flow through the shared auth/fallback path
  • provider-specific project-routing failures are treated as retryable provider responses instead of terminal account death
  • OAuth invalid-token responses stay in the provider recovery path instead of being flattened into a generic terminal state too early

Dashboard, API, and MCP behavior

The non-runtime surfaces now describe the same resilience model instead of inventing their own parallel layers.

Dashboard

  • the Resilience settings page exposes the same four-part model used by the runtime
  • the Health page shows provider-wide breaker state
  • intelligent combo panels show routing configuration inputs, not a second runtime health model
  • provider pages reflect provider/connection state from the unified runtime model

API

  • /api/resilience exposes the same settings shape used by the runtime and dashboard
  • connectionCooldown is configured with:
    • baseCooldownMs
    • useUpstreamRetryHints
    • maxBackoffSteps

MCP

  • MCP dashboard presets use the same resilience structure
  • advanced MCP resilience tools switch profiles using the same model

Documentation

The docs now describe the same 3.7.0 resilience model used by the implementation:

  • docs/API_REFERENCE.md
  • docs/ARCHITECTURE.md
  • docs/USER_GUIDE.md
  • docs/openapi.yaml

Validation

Type and test coverage

  • npm run typecheck:core
  • node --import tsx/esm --test tests/integration/integration-wiring.test.ts
  • node --import tsx/esm --test tests/unit/autocombo-unification.test.ts
  • git push pre-push hook: npm run test:unit (3240 passing tests)

Playwright coverage

Playwright coverage was run in dev-server mode and verifies the behavior of the new model across UI surfaces:

  • resilience settings UI with the 3.7.0 connection cooldown fields
  • Health page provider breaker rendering for multiple provider states
  • provider dashboard behavior under the unified provider/runtime model
  • intelligent combo panels showing configuration-oriented routing inputs

Commands used:

  • OMNIROUTE_PLAYWRIGHT_SERVER_MODE=dev npx playwright test tests/e2e/resilience-plan-alignment.spec.ts
  • OMNIROUTE_PLAYWRIGHT_SERVER_MODE=dev npx playwright test tests/e2e/combo-unification.spec.ts

Note

Playwright was executed in dev-server mode because the current branch still has unrelated Next build/prerender failures in start/build mode (/_global-error, /callback). The behavior covered above was validated successfully in dev mode.

Copilot AI review requested due to automatic review settings April 20, 2026 05:18
@rdself
rdself requested a review from diegosouzapw as a code owner April 20, 2026 05:18

@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 implements a centralized resilience framework, introducing unified settings for request pacing, connection cooldowns, and provider-level circuit breakers while removing redundant target-local breakers. Feedback points out regressions in the fallback logic where authentication and quota errors now result in zero-second cooldowns, which could lead to immediate retries of invalid accounts. Additionally, the reviewer flagged the fragility of using regex matching on error strings for routing decisions and suggested refining numeric input handling in the settings UI to prevent clearing a field from defaulting to zero and triggering validation errors.

Comment on lines 1078 to 1085
if (status === HTTP_STATUS.UNAUTHORIZED) {
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.unauthorized,
cooldownMs: 0,
baseCooldownMs: 0,
reason: RateLimitReason.AUTH_ERROR,
};
}

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

Setting the cooldown to 0 for UNAUTHORIZED errors is a regression. This will cause accounts with invalid or expired credentials to be retried immediately in the next request cycle (unless auto-disable is active), leading to unnecessary upstream pressure and log noise. It is recommended to restore a significant cooldown period for authentication failures.

Suggested change
if (status === HTTP_STATUS.UNAUTHORIZED) {
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.unauthorized,
cooldownMs: 0,
baseCooldownMs: 0,
reason: RateLimitReason.AUTH_ERROR,
};
}
if (status === HTTP_STATUS.UNAUTHORIZED) {
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.unauthorized,
baseCooldownMs: COOLDOWN_MS.unauthorized,
reason: RateLimitReason.AUTH_ERROR,
};
}

Comment on lines 1087 to 1094
if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.FORBIDDEN) {
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.paymentRequired,
cooldownMs: 0,
baseCooldownMs: 0,
reason: RateLimitReason.QUOTA_EXHAUSTED,
};
}

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

Similar to the UNAUTHORIZED case, setting the cooldown to 0 for PAYMENT_REQUIRED or FORBIDDEN errors will result in immediate retries of exhausted or restricted accounts. A non-zero cooldown should be maintained to prevent spinning on accounts that are known to be unusable in the short term.

Suggested change
if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.FORBIDDEN) {
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.paymentRequired,
cooldownMs: 0,
baseCooldownMs: 0,
reason: RateLimitReason.QUOTA_EXHAUSTED,
};
}
if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.FORBIDDEN) {
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.paymentRequired,
baseCooldownMs: COOLDOWN_MS.paymentRequired,
reason: RateLimitReason.QUOTA_EXHAUSTED,
};
}

Comment thread open-sse/services/combo.ts Outdated
result.headers?.get("content-type") ?? null,
errorText
);
if (/circuit breaker is open/i.test(errorText)) {

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.

medium

Relying on a regex match against a human-readable error string (/circuit breaker is open/i) to control routing logic is fragile. If the error message returned by the provider gate changes in the future, this skip logic will break. Consider using a structured error response or a specific header to signal circuit breaker state to the combo engine.

type="number"
min={min}
value={value}
onChange={(event) => onChange(Number(event.target.value) || 0)}

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.

medium

The || 0 fallback in the onChange handler causes the field to default to 0 whenever the input is cleared. For fields with a minimum value of 1 (like requestsPerMinute), this will cause immediate validation failures upon saving if the user clears the field to type a new value. It would be better to allow the state to hold an empty string or null and handle the conversion/validation during submission or on blur.

@rdself rdself changed the title refactor: rebuild resilience controls on top of release/v3.7.0 refactor: consolidate resilience controls for release/v3.7.0 Apr 20, 2026

Copilot AI 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.

Pull request overview

Rebuilds and consolidates OmniRoute’s resilience controls on top of release/v3.7.0, centering behavior around request queueing, connection cooldowns, a shared provider circuit breaker, and cooldown-aware retry, while restoring 3.7.0 UI/API compatibility.

Changes:

  • Introduces unified resilienceSettings (requestQueue / connectionCooldown / providerBreaker / waitForCooldown) with API + schema support and legacy-compat translation.
  • Removes combo-local breaker behavior and aligns combo routing to respect the shared provider breaker, with updated monitoring/health payloads.
  • Adds/updates unit + integration coverage, including a black-box HTTP resilience e2e test harness.

Reviewed changes

Copilot reviewed 38 out of 38 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/unit/combo-routing-engine.test.ts Updates combo routing expectations to skip provider-breaker-open targets.
tests/unit/combo-context-relay.test.ts Adjusts context-relay behavior to fall through provider breaker open responses.
tests/unit/combo-config.test.ts Updates combo config defaults/legacy key handling assertions.
tests/unit/combo-circuit-breaker.test.ts Removes tests for deprecated combo-local circuit breaker layer.
tests/unit/chat-route-coverage.test.ts Updates chat route coverage for connection cooldown + provider breaker responses.
tests/unit/chat-helpers.test.ts Updates helper tests for new breaker profile keys and proxy fast-fail handling.
tests/unit/chat-combo-live-test.test.ts Adjusts live test expectations for connection cooldown vs breaker state.
tests/unit/autocombo-unification.test.ts Updates intelligent health extraction contract (providerBreakers).
tests/unit/account-fallback-service.test.ts Updates provider profile assertions for new/legacy field mapping.
tests/integration/resilience-http-e2e.test.ts Adds black-box HTTP e2e coverage for resilience layers.
src/types/settings.ts Adds resilienceSettings to Settings type; trims legacy combo defaults fields.
src/sse/services/cooldownAwareRetry.ts Rewires cooldown-aware retry settings to waitForCooldown.
src/sse/services/auth.ts Refactors fallback/cooldown handling and connection persistence updates.
src/sse/handlers/chatHelpers.ts Updates pipeline gate breaker config + removes breaker-execute wrapping from core call.
src/sse/handlers/chat.ts Aligns chat flow with provider breaker settings + new cooldown-aware retry shape.
src/shared/validation/schemas.ts Adds new resilience schemas, renames legacy schemas, and enforces strict update payloads.
src/shared/utils/circuitBreaker.ts Exposes retryAfterMs in breaker status snapshots.
src/lib/resilience/settings.ts Introduces normalized resilience settings + legacy compat mapping/merge helpers.
src/lib/monitoring/observability.ts Adds providerBreakers to health payload and enriches breaker fields.
src/lib/dataPaths.js Removes stale tracked artifact that caused dev import resolution issues.
src/lib/combos/intelligentRouting.ts Reads breaker state from providerBreakers instead of circuitBreakers.
src/app/api/settings/combo-defaults/route.ts Sanitizes combo default/override configs to drop legacy resilience keys.
src/app/api/resilience/route.ts Rebuilds resilience API around new settings model with legacy patch compatibility.
src/app/api/resilience/reset/route.ts Minor cleanup in reset endpoint.
src/app/api/policies/route.ts Removes breaker state from policies API response (locked identifiers only).
src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx Rebuilds UI to edit the four resilience layers (config-only).
src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.tsx Updates UI to show only locked identifiers (no breaker list).
src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx Sanitizes legacy combo resilience keys from defaults/overrides.
src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityPanel.tsx Minor UI cleanup (comment removal).
src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.tsx Minor UI cleanup (comment removal).
src/app/(dashboard)/dashboard/health/page.tsx Displays breaker retryAfterMs in UI.
src/app/(dashboard)/dashboard/combos/page.tsx Sanitizes legacy combo resilience keys and removes legacy fields from templates/UI.
open-sse/services/rateLimitManager.ts Reworks queueing defaults to derive from requestQueue settings + runtime apply hook.
open-sse/services/comboConfig.ts Sanitizes legacy combo resilience keys during config resolution.
open-sse/services/combo.ts Removes combo-local breaker logic; skips targets on provider breaker open responses.
open-sse/services/accountFallback.ts Rebuilds provider profile resolution off resilience settings and refactors fallback cooldown logic.
open-sse/services/AGENTS.md Updates docs to reflect provider-global breaker model.
README.md Updates resilience feature documentation to match new architecture.

Comment on lines +15 to +23
Object.entries(config).filter(([, value]) => value !== undefined && value !== null)
).reduce(
(sanitized, [key, value]) => {
if (!LEGACY_COMBO_RESILIENCE_KEYS.has(key)) {
sanitized[key] = value;
}
return sanitized;
},
{} as Record<string, any>

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

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

sanitizeComboRuntimeConfig() calls .reduce() on the object returned by Object.fromEntries(...). That will throw at runtime ("reduce is not a function") and break GET/PATCH /api/settings/combo-defaults. Refactor this to reduce over the filtered entries array (or just filter out legacy keys and use Object.fromEntries once).

Suggested change
Object.entries(config).filter(([, value]) => value !== undefined && value !== null)
).reduce(
(sanitized, [key, value]) => {
if (!LEGACY_COMBO_RESILIENCE_KEYS.has(key)) {
sanitized[key] = value;
}
return sanitized;
},
{} as Record<string, any>
Object.entries(config).filter(
([key, value]) =>
value !== undefined &&
value !== null &&
!LEGACY_COMBO_RESILIENCE_KEYS.has(key)
)

Copilot uses AI. Check for mistakes.
minTime: currentRequestQueueSettings.minTimeBetweenRequestsMs,
reservoir: currentRequestQueueSettings.requestsPerMinute,
reservoirRefreshAmount: currentRequestQueueSettings.requestsPerMinute,
reservoirRefreshInterval: 60 * 1000,

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

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

applyRequestQueueSettings() updates existing Bottleneck limiters but doesn't apply requestQueue.maxWaitMs (even though buildLimiterDefaults includes maxWait). If maxWait is intended to be runtime-tunable, include it in updateSettings; otherwise consider removing it from the defaults to avoid a misleading setting.

Suggested change
reservoirRefreshInterval: 60 * 1000,
reservoirRefreshInterval: 60 * 1000,
maxWait: currentRequestQueueSettings.maxWaitMs,

Copilot uses AI. Check for mistakes.
@rdself rdself changed the title refactor: consolidate resilience controls for release/v3.7.0 refactor: align resilience rollout with release/v3.7.0 plan Apr 20, 2026
@rdself rdself changed the title refactor: align resilience rollout with release/v3.7.0 plan refactor: unify resilience controls for release/v3.7.0 Apr 20, 2026
@rdself rdself changed the title refactor: unify resilience controls for release/v3.7.0 refactor: unify resilience controls Apr 20, 2026
@rdself

rdself commented Apr 20, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #1449. Re-opened as a clean single-commit PR so the final change set is easier to review.

@rdself rdself closed this Apr 20, 2026
@rdself
rdself deleted the coder/pr1441-clean branch April 20, 2026 07:41
This was referenced Apr 30, 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