Skip to content

fix(caching): propagate redis errors from set/get so the circuit breaker can trip - #34300

Closed
fob-laurel wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
fob-laurel:fix/redis-circuit-breaker-swallowed-exceptions
Closed

fix(caching): propagate redis errors from set/get so the circuit breaker can trip#34300
fob-laurel wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
fob-laurel:fix/redis-circuit-breaker-swallowed-exceptions

Conversation

@fob-laurel

@fob-laurel fob-laurel commented Jul 22, 2026

Copy link
Copy Markdown

TLDR

Problem this solves:

  • RedisCache.async_set_cache/async_set_cache_pipeline/async_set_cache_sadd/async_get_cache swallow Redis exceptions
  • Their _redis_circuit_breaker_guard never sees a failure, so the breaker can never open

How it solves it:

  • Re-raise after logging in all 4 methods, matching the pattern already used correctly elsewhere in the same file
  • One regression test per method, each asserting the breaker opens after repeated failures and fast-fails once open

Relevant issues

Fixes #34299

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (ran make lint-format-check-changed, make lint-ruff, and the actual CI matrix group covering this code -- test-unit-other: 2230 passed, 26 skipped, 2 failed. The 2 failures are test_s3_cache_async_set_cache_pipeline/test_s3_cache_concurrent_async_operations, unrelated flaky/order-dependent S3 mock assertions -- confirmed present on the base commit before this PR's changes too)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 (pending -- this runs automatically once the PR is opened)

Screenshots / Proof of Fix

I don't have a live proxy + Redis + real LLM provider credentials to produce a full e2e proof costing real $. What I do have is 4 unit tests (one per patched method) that each reproduce the exact regression and pass only with the fix, captured at specific commits:

Before fix (base commit 0fcaadf11c + just the 4 new tests, litellm/caching/redis_cache.py reverted):

$ uv run pytest tests/test_litellm/caching/test_redis_cache.py -k circuit_breaker -v
...
    for _ in range(redis_cache._circuit_breaker.failure_threshold):
        with pytest.raises(ConnectionError, match="Redis down"):
>           await redis_cache.async_set_cache("key", "value")
E           Failed: DID NOT RAISE <class 'ConnectionError'>

FAILED tests/test_litellm/caching/test_redis_cache.py::test_async_get_cache_failures_trip_circuit_breaker
FAILED tests/test_litellm/caching/test_redis_cache.py::test_async_set_cache_failures_trip_circuit_breaker
FAILED tests/test_litellm/caching/test_redis_cache.py::test_async_set_cache_pipeline_failures_trip_circuit_breaker
FAILED tests/test_litellm/caching/test_redis_cache.py::test_async_set_cache_sadd_failures_trip_circuit_breaker
4 failed in 0.31s

After fix (commit 648add819c):

$ uv run pytest tests/test_litellm/caching/test_redis_cache.py -k circuit_breaker -v
tests/test_litellm/caching/test_redis_cache.py::test_async_get_cache_failures_trip_circuit_breaker PASSED
tests/test_litellm/caching/test_redis_cache.py::test_async_set_cache_failures_trip_circuit_breaker PASSED
tests/test_litellm/caching/test_redis_cache.py::test_async_set_cache_pipeline_failures_trip_circuit_breaker PASSED
tests/test_litellm/caching/test_redis_cache.py::test_async_set_cache_sadd_failures_trip_circuit_breaker PASSED
4 passed in 0.26s

If a maintainer wants a live e2e confirmation (real Redis, forced connection failure, watching for the "circuit breaker OPENED" log line), I'm happy to help set that up -- just don't have the credentials/environment for it myself.

Type

🐛 Bug Fix
✅ Test

Changes

  • litellm/caching/redis_cache.py: add raise e after logging in the except blocks of async_set_cache, async_set_cache_pipeline, async_set_cache_sadd (its second except block), and async_get_cache.
  • tests/test_litellm/caching/test_redis_cache.py: add one test per patched method (test_async_set_cache_failures_trip_circuit_breaker, test_async_get_cache_failures_trip_circuit_breaker, test_async_set_cache_sadd_failures_trip_circuit_breaker, test_async_set_cache_pipeline_failures_trip_circuit_breaker), each asserting the breaker opens after failure_threshold consecutive failures and fast-fails (without touching Redis) once open.

QA runbook

Not applicable -- no tests/e2e files added or changed.

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR. I traced every caller of the 4 patched methods (DualCache, CooldownCache, the proxy's spend-tracking path via GLOBAL_LOGGING_WORKER) to confirm none can now cause an actual LLM completion request to fail -- the only caller that doesn't already have a safety net (DualCache.async_set_cache_sadd -> dynamic_rate_limiter hook) requires a config flag (callbacks: ["dynamic_rate_limiter"]) that's opt-in and off by default.

…ker can trip

async_set_cache, async_set_cache_pipeline, async_set_cache_sadd, and
async_get_cache in RedisCache all caught their own Redis exception,
logged it, and returned without re-raising. Each is wrapped by
_redis_circuit_breaker_guard, whose record_failure()/record_success()
bookkeeping can only see an exception if the wrapped method actually
raises one -- since these four swallowed it internally, the guard saw
every call as a success and reset the failure counter every time. The
breaker could never open for these methods no matter how many
consecutive real failures occurred.

Fix: re-raise after logging, matching the pattern already used
correctly elsewhere in the same file (_set_cache_sadd_helper,
async_increment, async_rpush, async_scan_iter, etc.).
@CLAassistant

CLAassistant commented Jul 22, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug in RedisCache where four async methods — async_set_cache, async_set_cache_pipeline, async_set_cache_sadd, and async_get_cache — silently swallowed Redis exceptions in their except blocks, preventing _redis_circuit_breaker_guard from ever seeing a failure and therefore never opening the circuit breaker.

  • litellm/caching/redis_cache.py: Adds a single raise e after the verbose_logger.error(...) call in the except handler of each of the four methods, matching the pattern already in use across all other guarded methods in the file.
  • tests/test_litellm/caching/test_redis_cache.py: Adds test_async_set_cache_failures_trip_circuit_breaker, which drives async_set_cache through a mocked failing Redis client, asserts the circuit breaker opens after failure_threshold consecutive errors, and confirms subsequent calls fast-fail without contacting Redis.

Confidence Score: 4/5

Safe to merge — the four one-line fixes are minimal and match the existing error-propagation pattern used by every other guarded method in the file; DualCache wraps all four callers with exception handlers that keep failures from surfacing to LLM completion paths.

The code change restores the intended contract of the circuit-breaker decorator, and the impact on callers was traced by the author. The only gap is that three of the four patched methods have no circuit-breaker integration test, leaving the door open for silent regressions on those paths.

tests/test_litellm/caching/test_redis_cache.py — consider adding circuit-breaker tests for async_set_cache_pipeline, async_set_cache_sadd, and async_get_cache to match the coverage added for async_set_cache.

Important Files Changed

Filename Overview
litellm/caching/redis_cache.py Adds raise e after logging in the except blocks of async_set_cache, async_set_cache_pipeline, async_set_cache_sadd, and async_get_cache — matching the pattern already used in other decorated methods — so that _redis_circuit_breaker_guard can record failures and open the breaker.
tests/test_litellm/caching/test_redis_cache.py New test test_async_set_cache_failures_trip_circuit_breaker correctly verifies that repeated async_set_cache failures trip the circuit breaker and that subsequent calls fast-fail without contacting Redis; the three other patched methods (async_set_cache_pipeline, async_set_cache_sadd, async_get_cache) have no equivalent circuit-breaker integration test.

Reviews (1): Last reviewed commit: "fix(caching): propagate redis errors fro..." | Re-trigger Greptile

Comment on lines +284 to 315
@pytest.mark.asyncio
async def test_async_set_cache_failures_trip_circuit_breaker(
monkeypatch, redis_no_ping
):
"""async_set_cache must propagate Redis errors so its circuit breaker guard
can see the failure and open after enough consecutive failures -- if the
error is swallowed instead, the guard always records a success and the
breaker can never open."""
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache()

mock_redis_instance = AsyncMock()
mock_redis_instance.set = AsyncMock(side_effect=ConnectionError("Redis down"))

with patch.object(
redis_cache, "init_async_client", return_value=mock_redis_instance
):
for _ in range(redis_cache._circuit_breaker.failure_threshold):
with pytest.raises(ConnectionError, match="Redis down"):
await redis_cache.async_set_cache("key", "value")

assert redis_cache._circuit_breaker.is_open()

# Once open, further calls must fast-fail without touching Redis.
mock_redis_instance.set.reset_mock()
with pytest.raises(Exception, match="circuit breaker is open"):
await redis_cache.async_set_cache("key", "value")
mock_redis_instance.set.assert_not_called()


@pytest.mark.asyncio
async def test_async_lpop_pipeline_single_round_trip(monkeypatch, redis_no_ping):

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.

P2 Circuit-breaker test covers only one of four patched methods

The new test exercises async_set_cache, but the same raise e fix was applied to async_set_cache_pipeline, async_set_cache_sadd, and async_get_cache — and none of those have an analogous circuit-breaker integration test. If a future refactor inadvertently removes the raise from one of those methods, there is no test that would catch the regression. async_get_cache is especially worth covering because it sits on the hot path for cache lookups.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Valid, they follow the same pattern, added in latest commit.

…sion

Extend the previous commit's single async_set_cache test with the same
check for async_get_cache, async_set_cache_sadd, and
async_set_cache_pipeline -- all four had the identical swallow bug.
@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing fob-laurel:fix/redis-circuit-breaker-swallowed-exceptions (e8532dc) with litellm_internal_staging (2412326)

Open in CodSpeed

Comment thread litellm/caching/redis_cache.py
@veria-ai

veria-ai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@fob-laurel

Copy link
Copy Markdown
Author

Closing — superseded by #35273, which fixes the same root cause (swallowed exceptions defeating _redis_circuit_breaker_guard) more thoroughly: it keeps the methods degrading gracefully for callers instead of raising, uses a ContextVar so the guard can tell a swallowed failure from a real success, filters to genuine connectivity failures only, and covers a couple of call sites (async_batch_get_cache, async_get_ttl, the Lua script executor) this PR didn't. Thanks for landing a better fix.

@fob-laurel fob-laurel closed this Aug 3, 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.

[Bug]: RedisCache.async_set_cache / async_set_cache_pipeline swallow exceptions, so their circuit breaker never opens

2 participants