Skip to content

fix(ratelimit): honest 429 when every virtual-model target is saturated#492

Merged
SantiagoDePolonia merged 2 commits into
mainfrom
fix/ratelimit-saturated-alias-429
Jul 6, 2026
Merged

fix(ratelimit): honest 429 when every virtual-model target is saturated#492
SantiagoDePolonia merged 2 commits into
mainfrom
fix/ratelimit-saturated-alias-429

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

What

Fixes the one known behavioral gap left from #482: a virtual model whose targets are all rate-saturated fell through the same resolution path as an alias with no live targets — the client got an unavailable-model error instead of 429 + Retry-After, and the alias even vanished from GET /v1/models while throttled.

Why it happened

Rate-limit capacity was folded into the catalog's ModelAvailable via a decorator, which conflated throttled (come back in 30s) with dead (stale inventory). Everything consuming catalog availability inherited that conflation: target selection, model listing, and dashboard redirect validity.

How it's fixed

Capacity now steers load balancing only, through an explicit TargetCapacity probe on the virtual models service (wired in app init from ratelimit.Service.RouteAvailable):

  • The balancer prefers targets with live capacity — same route-around behavior as before for partially saturated aliases.
  • When every live target is saturated, it falls back to the first declared target, so the request reaches admission and receives the honest 429 with Retry-After — or defers to configured failover rules, per the saturated-primary behavior shipped in feat(ratelimit): scoped request, token, and concurrency limits (user paths, providers, models) #482.
  • Catalog membership is untouched by throttling: saturated aliases stay listed in /v1/models and their redirects stay valid in the dashboard.
  • Targets the catalog marks unavailable (stale inventory) stay excluded even during the fallback.

The rateLimitAwareCatalog decorator is deleted; the registry is passed to virtual models directly again.

Testing

Three new balancer tests pin the contract: capacity-preferred selection, deterministic first-target fallback when all are saturated, and fallback still skipping catalog-unavailable targets. Full unit + e2e suites and lint pass; docs (rate-limits.mdx, spec, CLAUDE.md) updated to describe the fallback.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Capacity-aware model routing now prefers targets with available rate-limit capacity; if all targets are saturated, it falls back to the first target to keep behavior predictable.
  • Bug Fixes
    • Saturation handling is more truthful: when no viable target remains, clients receive an honest 429 with Retry-After (instead of unavailable-model errors). Saturation does not remove aliases from /v1/models.
  • Documentation
    • Updated rate-limiting documentation to clarify the revised failover and saturation semantics.

A fully rate-saturated alias previously fell through the same path as an
alias with no live targets: resolution failed and the client got an
unavailable-model error instead of 429 with Retry-After, and the alias
even disappeared from /v1/models while throttled — because rate-limit
capacity was folded into the catalog's ModelAvailable, conflating
"throttled" with "dead".

Capacity now steers load balancing only, through an explicit
TargetCapacity probe on the virtual models service: the balancer prefers
targets with live capacity and, when every live target is saturated,
falls back to the first declared one so the request reaches admission and
receives the honest 429 (or defers to configured failover). Catalog
membership, /v1/models listing, and dashboard redirect validity are no
longer affected by throttling, and targets the catalog marks unavailable
stay excluded even during the fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d4dab212-b4d2-47d6-8d7f-e786ab510ae0

📥 Commits

Reviewing files that changed from the base of the PR and between 7b92ffb and 4228da2.

📒 Files selected for processing (1)
  • internal/virtualmodels/balancer_test.go

📝 Walkthrough

Walkthrough

The virtual-models balancer now filters targets by a rate-limit capacity probe before selecting a target, with fallback to the first target when all are saturated. App initialization wires the probe from the rate-limit service, and the docs and tests reflect the updated saturation behavior.

Changes

Capacity-aware routing

Layer / File(s) Summary
Service capacity probe field and setter
internal/virtualmodels/service.go
Adds a targetCapacity field and SetTargetCapacity setter for injecting a capacity probe.
Balancer capacity filtering and fallback logic
internal/virtualmodels/balancer.go
balancedResolution filters targets through targetsWithCapacity, falls back to the first target when all are saturated, and then applies the existing strategy over the filtered pool.
Balancer capacity tests
internal/virtualmodels/balancer_test.go
Adds tests covering saturated-target skipping, all-saturated fallback, stale-target interaction, and cost strategy behavior under capacity filtering.
App initialization wiring
internal/app/app.go
Passes the registry directly into virtualmodels.New* and sets SetTargetCapacity from RouteAvailable.
Documentation updates for saturation semantics
CLAUDE.md, docs/dev/2026-07-05_rate-limiting-spec.md, docs/features/rate-limits.mdx
Updates the saturation and failover documentation to match the new target-selection behavior and /v1/models listing semantics.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant App as app.go init
  participant VM as virtualmodels.Service
  participant Limiter as ratelimit.Service
  participant Balancer as balancedResolution

  App->>VM: New(providerResult.Registry)
  App->>VM: SetTargetCapacity(func(qualifiedModel) bool)
  VM->>Limiter: RouteAvailable(provider, qualifiedModel)
  Limiter-->>VM: capacity result

  VM->>Balancer: balancedResolution(supported targets)
  Balancer->>Balancer: targetsWithCapacity(supported)
  alt all targets saturated
    Balancer->>Balancer: fallback to first target
  else capacity available
    Balancer->>Balancer: select via strategy
  end
  Balancer-->>VM: selected target
Loading

Poem

A rabbit hopped through saturated dens,
Picking the burrows with room for its friends.
If every lane filled up to the brim,
It chose the first path and still looked quite trim.
Hop, filter, fall back — the warren stays bright 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: returning an honest 429 when all virtual-model targets are saturated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ratelimit-saturated-alias-429

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 65.51724% with 10 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/app/app.go 0.00% 8 Missing ⚠️
internal/virtualmodels/service.go 50.00% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/virtualmodels/balancer.go`:
- Around line 43-70: Add a test for Service.balancedResolution that covers
StrategyCost with capacity filtering: build a redirectEntry whose
supportedTargets includes a cheaper saturated target and a costlier target with
live capacity, then verify balancedResolution selects the costlier one after
targetsWithCapacity filtering. Make the test reference balancedResolution,
cheapestTarget, and targetsWithCapacity so the behavior change is locked in
alongside the existing round-robin coverage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c366fd01-3af5-4882-bd8a-6139a5b00fe5

📥 Commits

Reviewing files that changed from the base of the PR and between 9adb553 and 7b92ffb.

📒 Files selected for processing (8)
  • CLAUDE.md
  • docs/dev/2026-07-05_rate-limiting-spec.md
  • docs/features/rate-limits.mdx
  • internal/app/app.go
  • internal/app/ratelimit_catalog.go
  • internal/virtualmodels/balancer.go
  • internal/virtualmodels/balancer_test.go
  • internal/virtualmodels/service.go
💤 Files with no reviewable changes (1)
  • internal/app/ratelimit_catalog.go

Comment on lines 43 to +70
func (s *Service) balancedResolution(entry redirectEntry) (core.ModelSelector, bool) {
supported := entry.supportedTargets(s.catalog)
switch len(supported) {
case 0:
if len(supported) == 0 {
return core.ModelSelector{}, false
case 1:
// A single live target needs no strategy and must not advance round-robin
// state, so an alias and a one-target-available redirect behave identically.
return supported[0].selector, true
}
// Prefer targets with live rate-limit capacity. When every live target is
// saturated, fall back to the first declared one: the request then reaches
// admission and receives an honest 429 with Retry-After (or defers to
// failover) instead of the all-targets-down error path.
pool := s.targetsWithCapacity(supported)
if len(pool) == 0 {
pool = supported[:1]
}
if len(pool) == 1 {
// A single viable target needs no strategy and must not advance
// round-robin state, so an alias and a one-target-available redirect
// behave identically.
return pool[0].selector, true
}

switch normalizeStrategy(entry.strategy) {
case StrategyCost:
return s.cheapestTarget(supported).selector, true
return s.cheapestTarget(pool).selector, true
default: // StrategyRoundRobin
index := weightedIndex(supported, s.balancer.next(entry.vm.Source))
return supported[index].selector, true
index := weightedIndex(pool, s.balancer.next(entry.vm.Source))
return pool[index].selector, true
}
}

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Recommend a test for the Cost strategy + capacity-filtering interaction.

cheapestTarget now runs over the capacity-filtered pool rather than the full supported list — a real behavior change (a cheaper but saturated target is now skipped in favor of a costlier one with capacity). The new tests only exercise StrategyRoundRobin; there's no test verifying this for StrategyCost.

As per coding guidelines, **/*_test.go: "Add or update tests for behavior changes; tests should cover request translation, response normalization, error handling, default configuration, and provider-specific parameter mapping." Load-balancing strategy selection is a critical path worth the same rigor.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/virtualmodels/balancer.go` around lines 43 - 70, Add a test for
Service.balancedResolution that covers StrategyCost with capacity filtering:
build a redirectEntry whose supportedTargets includes a cheaper saturated target
and a costlier target with live capacity, then verify balancedResolution selects
the costlier one after targetsWithCapacity filtering. Make the test reference
balancedResolution, cheapestTarget, and targetsWithCapacity so the behavior
change is locked in alongside the existing round-robin coverage.

Source: Coding guidelines

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

Safe to merge with minimal risk.

The change is narrowly scoped and keeps catalog membership separate from runtime rate-limit capacity. Tests cover capacity preference, all-saturated fallback, and unavailable-target skipping.

No files require special attention.

T-Rex T-Rex Logs

What T-Rex did

  • The virtualmodels balancer capacity tests were executed and completed with exit code 0, as documented in the test log.

View all artifacts

T-Rex Ran code and verified through T-Rex

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Client
participant VM as Virtual Models Service
participant Catalog as Provider Catalog
participant RL as Rate Limit Service
participant Admission as Request Admission/Failover

Client->>VM: Resolve virtual model alias
VM->>Catalog: Filter targets by ModelAvailable
Catalog-->>VM: Live inventory targets
loop Each live target
    VM->>RL: TargetCapacity(qualified model)
    RL-->>VM: RouteAvailable(provider, model)
end
alt At least one target has capacity
    VM-->>Admission: Selected capacity target
else Every live target is saturated
    VM-->>Admission: First live target fallback
    Admission-->>Client: 429 + Retry-After or configured failover result
end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Client
participant VM as Virtual Models Service
participant Catalog as Provider Catalog
participant RL as Rate Limit Service
participant Admission as Request Admission/Failover

Client->>VM: Resolve virtual model alias
VM->>Catalog: Filter targets by ModelAvailable
Catalog-->>VM: Live inventory targets
loop Each live target
    VM->>RL: TargetCapacity(qualified model)
    RL-->>VM: RouteAvailable(provider, model)
end
alt At least one target has capacity
    VM-->>Admission: Selected capacity target
else Every live target is saturated
    VM-->>Admission: First live target fallback
    Admission-->>Client: 429 + Retry-After or configured failover result
end
Loading

Reviews (1): Last reviewed commit: "fix(ratelimit): honest 429 when every vi..." | Re-trigger Greptile

@mintlify

mintlify Bot commented Jul 6, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
gomodel 🟢 Ready View Preview Jul 6, 2026, 2:14 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

A cheaper but rate-saturated target loses to a costlier one with live
capacity — a behavior change of the capacity filter that only had
round-robin coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@SantiagoDePolonia SantiagoDePolonia merged commit 383248d into main Jul 6, 2026
20 checks passed
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