Skip to content

feat(llm): per-provider bounded fail-open concurrency cap (Workstream C phase 3) - #347

Merged
guangyu-reflexio merged 2 commits into
mainfrom
workstream-c-phase3-provider-cap
Jul 15, 2026
Merged

feat(llm): per-provider bounded fail-open concurrency cap (Workstream C phase 3)#347
guangyu-reflexio merged 2 commits into
mainfrom
workstream-c-phase3-provider-cap

Conversation

@guangyu-reflexio

@guangyu-reflexio guangyu-reflexio commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What

Per-provider, per-instance bounded fail-open concurrency cap for remote LLM calls
(REFLEXIO_LLM_PROVIDER_MAX_CONCURRENCY, default 8). Part of Scalability Workstream C (C3).

Why

At 4–8 tasks, a publish burst fans N tasks' LLM calls at one provider with no local ceiling →
provider-429 storms. This caps concurrent in-flight calls per provider per instance.

Design (per the enterprise design doc §5, reviewed 2 rounds)

  • New reflexio/server/llm/_provider_concurrency.py: a lazily-built per-provider
    BoundedSemaphore + provider_slot(model) context manager.
  • Parent placement (required): generation wraps self._completion_with_hard_timeout(...) in
    _call_and_parse — the subprocess spawns inside that call, so the permit is held in the
    parent (a child-side semaphore would cap nothing). Embedding wraps the in-process
    litellm.embedding(...). Two distinct seams.
  • Bounded acquire + fail-open: acquire(timeout=30s); on timeout it proceeds WITHOUT a
    permit + logs llm_provider_cap_saturated — never blocks unboundedly (that would re-open the
    hung-provider stall class PYTHON-FASTAPI-62 through the limiter).
  • No 429/retry logic (Decision A): 429 recovery stays with the existing fallback ladder;
    num_retries untouched.
  • Out of scope (§5.2): the remote GPU/CPU embedding cell (httpx, not litellm) and local ONNX
    embedders — bounded by their own knob.
  • Primary-provider approximation: a fallback-provider call counts against the primary's cap.

Tests

  • Deterministic unit tests: cap enforced per provider, fail-open after the bounded timeout,
    per-provider isolation, unknown-provider uncapped, saturation logs.
  • Wiring: both seams import/enter provider_slot. Parent placement is structural (wrap around
    _completion_with_hard_timeout, outside the fork).
  • 560 passed across tests/server/llm/ (4 unrelated errors need an LLM API key at fixture setup).

.env.template doc + the deployment-doctrine 3-knob disambiguation land in the paired enterprise PR.

Summary by CodeRabbit

  • New Features

    • Added per-provider concurrency limits for remote language-model and embedding requests.
    • Added configurable handling for busy providers, allowing requests to continue after a bounded wait.
  • Reliability

    • Prevented excessive simultaneous requests to the same provider while keeping unknown providers available.
    • Added warning visibility when provider capacity is temporarily saturated.
  • Tests

    • Added coverage for provider limits, independent provider handling, timeout behavior, and fallback scenarios.

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

LiteLLM text-generation and embedding requests now use a shared, environment-configured per-provider concurrency limiter with bounded acquisition, fail-open behavior, and permit cleanup. Tests cover provider isolation, unknown providers, logging, and integration seams.

Changes

Provider concurrency limiting

Layer / File(s) Summary
Provider slot manager and validation
reflexio/server/llm/_provider_concurrency.py, tests/server/llm/test_provider_concurrency.py
Adds per-provider semaphore management, bounded fail-open acquisition, provider resolution, cleanup, and tests for isolation and edge cases.
Text generation slot integration
reflexio/server/llm/_litellm_text_generation.py
Wraps hard-timeout LiteLLM completion calls with provider_slot.
Embedding slot integration
reflexio/server/llm/_litellm_embedding.py
Wraps LiteLLM embedding calls with provider_slot without changing request parameters or dispatch behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant TextGenerationOrEmbedding
  participant provider_slot
  participant LiteLLM
  participant ProviderSemaphore
  TextGenerationOrEmbedding->>provider_slot: provider_slot(model)
  provider_slot->>LiteLLM: resolve provider
  provider_slot->>ProviderSemaphore: acquire bounded slot
  ProviderSemaphore-->>provider_slot: permit or fail-open timeout
  provider_slot-->>TextGenerationOrEmbedding: execute request context
  TextGenerationOrEmbedding->>LiteLLM: completion or embedding call
  TextGenerationOrEmbedding->>ProviderSemaphore: release acquired slot
Loading

Possibly related PRs

  • ReflexioAI/reflexio#273: Establishes the LiteLLM embedding and hard-timeout completion call paths wrapped by this PR.
  • ReflexioAI/reflexio#312: Refactors the embedding execution path that this PR augments with provider concurrency control.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: a per-provider fail-open concurrency cap for LLM calls.
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 workstream-c-phase3-provider-cap

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.

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
reflexio/server/llm/_litellm_text_generation.py (1)

794-797: 🧹 Nitpick | 🔵 Trivial

Fallback requests bypass the fallback provider's concurrency cap.

Because provider_slot wraps the entire _completion_with_hard_timeout call, any fallback models executed internally by litellm's fallback ladder will continue to hold the primary provider's concurrency slot while completely bypassing the fallback provider's concurrency limit.

This aligns with the stated decision ("429 recovery stays with the fallback ladder (Decision A)"), but it means that during a sustained primary outage, the fallback provider could temporarily receive a burst of concurrent requests that exceeds its configured limit. Consider noting this behavior for operational monitoring and downstream load management.

🤖 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 `@reflexio/server/llm/_litellm_text_generation.py` around lines 794 - 797,
Document the concurrency behavior around _call_and_parse and provider_slot:
fallback models invoked internally by _completion_with_hard_timeout do not
acquire the fallback provider’s concurrency cap while the primary slot remains
held. Note this for operational monitoring and downstream load management,
without changing the existing fallback-ladder behavior.
🤖 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 `@tests/server/llm/test_provider_concurrency.py`:
- Around line 13-34: Update test_caps_concurrent_holders_per_provider to avoid
importlib.reload(pc) and environment mutation; use monkeypatch.setattr on the
module-level concurrency configuration, matching the approach in
test_fail_open_emits_log. Keep the existing timeout patch and semaphore
assertions, and remove both reload calls so subsequent tests retain their
original module state.

---

Nitpick comments:
In `@reflexio/server/llm/_litellm_text_generation.py`:
- Around line 794-797: Document the concurrency behavior around _call_and_parse
and provider_slot: fallback models invoked internally by
_completion_with_hard_timeout do not acquire the fallback provider’s concurrency
cap while the primary slot remains held. Note this for operational monitoring
and downstream load management, without changing the existing fallback-ladder
behavior.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ee28073a-c6f0-4274-bfee-2c98bcf2143d

📥 Commits

Reviewing files that changed from the base of the PR and between 354f98d and 747f8a7.

📒 Files selected for processing (4)
  • reflexio/server/llm/_litellm_embedding.py
  • reflexio/server/llm/_litellm_text_generation.py
  • reflexio/server/llm/_provider_concurrency.py
  • tests/server/llm/test_provider_concurrency.py

Comment on lines +13 to +34
def test_caps_concurrent_holders_per_provider(monkeypatch):
# Force a small cap and a short acquire timeout via reload.
monkeypatch.setenv("REFLEXIO_LLM_PROVIDER_MAX_CONCURRENCY", "2")
import importlib

importlib.reload(pc)
monkeypatch.setattr(pc, "_ACQUIRE_TIMEOUT_SECONDS", 0.3)
# Force a deterministic provider key (avoid network/model lookups).
monkeypatch.setattr(pc, "_provider_key", lambda _m: "openai")

sem = pc._get_semaphore("openai")
assert sem._value == 2 # BoundedSemaphore initial permits
# Hold both permits.
with pc.provider_slot("gpt-x"), pc.provider_slot("gpt-x"):
assert sem._value == 0
# A third acquire must FAIL OPEN after the bounded timeout (not block forever).
t0 = time.monotonic()
with pc.provider_slot("gpt-x"):
waited = time.monotonic() - t0
assert waited >= 0.3 # waited the bounded timeout, then proceeded
importlib.reload(pc)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Avoid module reloading to prevent test pollution.

Using importlib.reload(pc) inside a test re-evaluates module-level variables. Because the second reload(pc) at the end of the test executes before monkeypatch restores the environment variables, the module will remain initialized with REFLEXIO_LLM_PROVIDER_MAX_CONCURRENCY = 2 for any subsequent tests executed by the same worker.

To avoid test pollution, replace the environment variable and reload approach with monkeypatch.setattr, mirroring the safer strategy you used in test_fail_open_emits_log.

♻️ Proposed refactor
 def test_caps_concurrent_holders_per_provider(monkeypatch):
-    # Force a small cap and a short acquire timeout via reload.
-    monkeypatch.setenv("REFLEXIO_LLM_PROVIDER_MAX_CONCURRENCY", "2")
-    import importlib
-
-    importlib.reload(pc)
+    # Force a small cap and a short acquire timeout.
+    monkeypatch.setattr(pc, "REFLEXIO_LLM_PROVIDER_MAX_CONCURRENCY", 2)
     monkeypatch.setattr(pc, "_ACQUIRE_TIMEOUT_SECONDS", 0.3)
     # Force a deterministic provider key (avoid network/model lookups).
     monkeypatch.setattr(pc, "_provider_key", lambda _m: "openai")
+    pc._semaphores.clear()
 
     sem = pc._get_semaphore("openai")
     assert sem._value == 2  # BoundedSemaphore initial permits
     # Hold both permits.
     with pc.provider_slot("gpt-x"), pc.provider_slot("gpt-x"):
         assert sem._value == 0
         # A third acquire must FAIL OPEN after the bounded timeout (not block forever).
         t0 = time.monotonic()
         with pc.provider_slot("gpt-x"):
             waited = time.monotonic() - t0
         assert waited >= 0.3  # waited the bounded timeout, then proceeded
-    importlib.reload(pc)
+    pc._semaphores.clear()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_caps_concurrent_holders_per_provider(monkeypatch):
# Force a small cap and a short acquire timeout via reload.
monkeypatch.setenv("REFLEXIO_LLM_PROVIDER_MAX_CONCURRENCY", "2")
import importlib
importlib.reload(pc)
monkeypatch.setattr(pc, "_ACQUIRE_TIMEOUT_SECONDS", 0.3)
# Force a deterministic provider key (avoid network/model lookups).
monkeypatch.setattr(pc, "_provider_key", lambda _m: "openai")
sem = pc._get_semaphore("openai")
assert sem._value == 2 # BoundedSemaphore initial permits
# Hold both permits.
with pc.provider_slot("gpt-x"), pc.provider_slot("gpt-x"):
assert sem._value == 0
# A third acquire must FAIL OPEN after the bounded timeout (not block forever).
t0 = time.monotonic()
with pc.provider_slot("gpt-x"):
waited = time.monotonic() - t0
assert waited >= 0.3 # waited the bounded timeout, then proceeded
importlib.reload(pc)
def test_caps_concurrent_holders_per_provider(monkeypatch):
# Force a small cap and a short acquire timeout.
monkeypatch.setattr(pc, "REFLEXIO_LLM_PROVIDER_MAX_CONCURRENCY", 2)
monkeypatch.setattr(pc, "_ACQUIRE_TIMEOUT_SECONDS", 0.3)
# Force a deterministic provider key (avoid network/model lookups).
monkeypatch.setattr(pc, "_provider_key", lambda _m: "openai")
pc._semaphores.clear()
sem = pc._get_semaphore("openai")
assert sem._value == 2 # BoundedSemaphore initial permits
# Hold both permits.
with pc.provider_slot("gpt-x"), pc.provider_slot("gpt-x"):
assert sem._value == 0
# A third acquire must FAIL OPEN after the bounded timeout (not block forever).
t0 = time.monotonic()
with pc.provider_slot("gpt-x"):
waited = time.monotonic() - t0
assert waited >= 0.3 # waited the bounded timeout, then proceeded
pc._semaphores.clear()
🤖 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 `@tests/server/llm/test_provider_concurrency.py` around lines 13 - 34, Update
test_caps_concurrent_holders_per_provider to avoid importlib.reload(pc) and
environment mutation; use monkeypatch.setattr on the module-level concurrency
configuration, matching the approach in test_fail_open_emits_log. Keep the
existing timeout patch and semaphore assertions, and remove both reload calls so
subsequent tests retain their original module state.

@guangyu-reflexio
guangyu-reflexio merged commit 8180e47 into main Jul 15, 2026
1 check passed
@guangyu-reflexio
guangyu-reflexio deleted the workstream-c-phase3-provider-cap branch July 15, 2026 02:31
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.

1 participant