Skip to content

fix(router): clamp least_busy request counter to prevent negative drift - #25325

Open
rudra717 wants to merge 3 commits into
BerriAI:mainfrom
rudra717:fix/least-busy-negative-counter
Open

fix(router): clamp least_busy request counter to prevent negative drift#25325
rudra717 wants to merge 3 commits into
BerriAI:mainfrom
rudra717:fix/least-busy-negative-counter

Conversation

@rudra717

@rudra717 rudra717 commented Apr 8, 2026

Copy link
Copy Markdown

Summary

Fixes the least_busy routing strategy silently suppressing traffic to certain deployments by clamping the per-deployment request counter to prevent negative values.

Root Cause

The request counter is incremented in log_pre_api_call and decremented in success/failure callbacks. Under race conditions (callback fires before pre-call, or fires twice), the counter goes negative.

A negative count is always less than the 0 assigned to fresh/unused deployments in _get_available_deployments, so the negative-count deployment wins every comparison and attracts ALL traffic, while others gradually starve to zero.

Fix

Added max(value - 1, 0) on all 4 decrement paths:

  • log_success_event (sync)
  • log_failure_event (sync)
  • async_log_success_event
  • async_log_failure_event

This ensures the counter never goes below 0, preventing any single deployment from monopolizing traffic.

Testing

The fix is a defensive floor guard on an integer counter. Existing routing tests validate the least_busy selection logic.

Disclaimer

AI agents (Claude Code) assisted with this contribution.

Fixes #25323

The least_busy router strategy decrements a per-deployment request
counter on success/failure callbacks. Under race conditions (callback
fires before pre-call, or fires twice), the counter can go negative.

A negative count is always less than the 0 assigned to unused
deployments, so the negative-count deployment attracts ALL traffic
while others starve to zero requests.

Fix: clamp the counter with max(value - 1, 0) on all 4 decrement
paths (sync success, sync failure, async success, async failure).

Fixes BerriAI#25323

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Apr 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Apr 8, 2026 7:37pm

Request Review

@codspeed-hq

codspeed-hq Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing rudra717:fix/least-busy-negative-counter (07faa5c) with main (2dac54b)

Open in CodSpeed

@codecov

codecov Bot commented Apr 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Addresses Codecov 0% patch coverage. Tests verify:
- sync success callback clamps counter at 0 (not -1)
- sync failure callback clamps counter at 0
- multiple duplicate callbacks never push counter below 0
- counter decrements normally from positive values (1 -> 0)
@greptile-apps

greptile-apps Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR applies a one-line defensive clamp (max(value - 1, 0)) to all four decrement paths in LeastBusyLoggingHandler, preventing the per-deployment request counter from going negative under race conditions. The accompanying tests are well-structured: a FakeCache stub with both sync and async methods covers all four changed paths directly.

Confidence Score: 5/5

Safe to merge — minimal, correct fix with full test coverage across all four changed paths.

The change is a one-liner defensive clamp applied identically to all four symmetrical decrement paths. Tests cover every path (sync success, sync failure, async success, async failure) using a proper FakeCache stub that implements both sync and async cache methods. Prior review concerns about the async test path and the removed asyncio.coroutine usage have been addressed in this version. No security, data-integrity, or breaking-change concerns.

No files require special attention.

Vulnerabilities

No security concerns identified.

Important Files Changed

Filename Overview
litellm/router_strategy/least_busy.py Adds max(value - 1, 0) clamping on all four decrement paths — log_success_event, log_failure_event, async_log_success_event, async_log_failure_event — preventing negative counter drift in the least-busy routing strategy.
tests/test_litellm/test_least_busy_counter_clamp.py New unit-test file with five tests covering all four changed decrement paths (two sync, two async) plus a multi-decrement scenario; FakeCache correctly implements both sync and async cache methods, and async tests properly await the async handlers.

Sequence Diagram

sequenceDiagram
    participant R as Router
    participant H as LeastBusyLoggingHandler
    participant C as DualCache

    R->>H: log_pre_api_call(kwargs)
    H->>C: get_cache(model_group_request_count)
    C-->>H: {deploy_id: N}
    H->>C: set_cache({deploy_id: N+1})

    Note over R,C: Request in flight...

    alt Success or Failure callback
        R->>H: log_success_event / log_failure_event
        H->>C: get_cache(model_group_request_count)
        C-->>H: {deploy_id: M}
        Note over H: counter = max(M - 1, 0) <- NEW clamp
        H->>C: set_cache({deploy_id: max(M-1, 0)})
    end

    R->>H: get_available_deployments(model_group)
    H->>C: get_cache(model_group_request_count)
    C-->>H: {deploy_id: counter >= 0}
    H-->>R: deployment with min traffic (never negative)
Loading

Reviews (2): Last reviewed commit: "test(router): fix async tests to actuall..." | Re-trigger Greptile

Comment on lines +79 to +101
@pytest.mark.asyncio
async def test_async_success_counter_never_goes_negative():
"""async_log_success_event should clamp the counter at 0."""
cache = MagicMock()
cache.async_get_cache = pytest.importorskip("asyncio").coroutine(
lambda *a, **kw: None
)

# Use a real FakeCache but wrap async methods
real_cache = FakeCache()
model_group = "test-group"
deploy_id = "deploy-1"
cache_key = f"{model_group}_request_count"
real_cache.store[cache_key] = {deploy_id: 0}

handler = LeastBusyLoggingHandler(router_cache=real_cache)

# Patch sync cache methods to work (async methods call sync internally)
kwargs = _make_kwargs(model_group, deploy_id)

# Test the sync path which is equivalent
handler.log_success_event(kwargs, None, None, None)
assert real_cache.store[cache_key][deploy_id] == 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.

P1 Async test doesn't test the async path

test_async_success_counter_never_goes_negative never calls async_log_success_event — it falls back to handler.log_success_event (the sync version) on line 100. The cache MagicMock and the asyncio.coroutine setup on lines 82-85 are completely unused; the handler is initialised with real_cache (a FakeCache that has no async_get_cache/async_set_cache methods), and the test then calls the sync method. Two of the four changed decrement paths (async_log_success_event, async_log_failure_event) have zero effective test coverage here.

A minimal correct replacement:

@pytest.mark.asyncio
async def test_async_success_counter_never_goes_negative():
    """async_log_success_event should clamp the counter at 0."""
    class AsyncFakeCache(FakeCache):
        async def async_get_cache(self, key, **kwargs):
            return self.store.get(key)

        async def async_set_cache(self, key, value, **kwargs):
            self.store[key] = value

    cache = AsyncFakeCache()
    handler = LeastBusyLoggingHandler(router_cache=cache)

    model_group = "test-group"
    deploy_id = "deploy-1"
    cache_key = f"{model_group}_request_count"
    cache.store[cache_key] = {deploy_id: 0}

    kwargs = _make_kwargs(model_group, deploy_id)
    await handler.async_log_success_event(kwargs, None, None, None)
    assert cache.store[cache_key][deploy_id] == 0, "Counter went negative"

There should be a matching test for async_log_failure_event as well.

Rule Used: What: Flag any modifications to existing tests and... (source)

Comment on lines +83 to +85
cache.async_get_cache = pytest.importorskip("asyncio").coroutine(
lambda *a, **kw: None
)

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.

P1 asyncio.coroutine removed in Python 3.11

pytest.importorskip("asyncio").coroutine accesses asyncio.coroutine, which was deprecated in Python 3.8 and removed in Python 3.11 (PEP 530). On any Python 3.11+ environment this line raises AttributeError: module 'asyncio' has no attribute 'coroutine', causing the test to error rather than skip or pass. Since the whole block is dead code anyway (see the parallel comment), it should just be deleted.

Greptile review: the async test was calling sync path and used
deprecated asyncio.coroutine (removed in Python 3.11).

Now FakeCache has async methods, tests call async_log_success_event
and async_log_failure_event directly. All 4 changed paths covered.
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.

@github-actions github-actions Bot added the stale label Jul 8, 2026
@4-FLOSS-Free-Libre-Open-Source-Software

Copy link
Copy Markdown

Not stale until merged

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.

[Bug]: There is a bug in least_busy.py that causes traffic to some interfaces to be suppressed to zero.

2 participants