Skip to content

fix(redis): stop an unreachable Redis from blocking every request - #35273

Merged
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_redis_url_socket_timeout
Jul 30, 2026
Merged

fix(redis): stop an unreachable Redis from blocking every request#35273
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_redis_url_socket_timeout

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • A url-configured Redis silently dropped every connection kwarg
  • No socket_timeout, so an unreachable Redis blocked callers forever
  • The Redis circuit breaker could never open, so Redis was never dropped
  • Result: proxy stayed liveness-healthy while every request hung

How it solves it:

  • Allowlist the kwargs the connection actually accepts, not the client's
  • Count a swallowed Redis error as a failure, not a success
  • Put Lua script execution behind the same circuit breaker

Relevant issues

Linear ticket

Resolves LIT-4930

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 (e.g., lint, format, unit tests)
  • 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 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Live proxy on a real Postgres, calling the real OpenAI API. Redis is a real server that gets frozen mid-flight with docker pause, which is what a server under maintenance looks like from the client's side: the TCP connection stays open and nothing ever replies. Stopping the container instead would be a much weaker test, since a refused connection fails instantly and none of this code is exercised.

Config used for both runs (proxy-level cache and coordination Redis both on a url, which is the shape that was broken):

litellm_settings:
  cache: true
  cache_params:
    type: redis
    url: os.environ/LIT4930_REDIS_URL
router_settings:
  redis_url: os.environ/LIT4930_REDIS_URL

Before, at 4d54324515. Requests are sent with an rpm-limited key so the coordination path is live. Redis is healthy for the first two, then paused:

--- redis HEALTHY (unfixed) ---
  request 1: http=200 total=1.631369s
  request 2: http=200 total=1.149407s
--- redis PAUSED at 12:30:22 ---
  request 1: TIMED OUT after 60s (client gave up; server never answered)
  request 2: TIMED OUT after 60s (client gave up; server never answered)
  request 3: TIMED OUT after 60s (client gave up; server never answered)

The proxy never answers again. What makes this hard to catch in production is that it still looks alive:

$ curl -s -o /dev/null -w 'http=%{http_code} in %{time_total}s\n' http://127.0.0.1:4930/health/liveliness
http=200 in 0.001210s

A load balancer keeps the pod in rotation, and every inference request hangs.

Starting a proxy while Redis is already unreachable was equally fatal, because RedisCache.__init__ makes a synchronous call with the same missing timeout:

$ # unfixed, redis blackholed
t+0s   liveliness=000
...
t+141s liveliness=000        <- never came up

After, at 196ae5ddd6. Same config, same script, same paused Redis:

--- redis HEALTHY (fixed) ---
  request 1: http=200 total=1.873846s
  request 2: http=200 total=1.488952s
--- redis PAUSED at 14:07:45 ---
  request 1: http=200 total=11.091554s
  request 2: http=200 total=11.020037s
  request 3: http=200 total=1.345299s
  request 4: http=200 total=1.232392s
  request 5: http=200 total=1.275938s
  request 6: http=200 total=1.301726s
  request 7: http=200 total=6.520788s

Every request is answered. The first two pay the socket timeout while the failures accumulate, then the breaker opens and latency returns to the healthy baseline of roughly one second with Redis skipped entirely:

Redis circuit breaker OPENED after 5 consecutive failures — fast-failing Redis calls for 60s

Request 7 is a breaker half-opening after their 60s recovery window and sending a probe through. That is the intended design, and it is why the outage numbers are uneven rather than flat: each cache holds its own breaker, so during a sustained outage most requests are served from memory at roughly a second while an occasional probe pays one timeout to find out whether Redis is back.

Unpausing Redis puts it back in the pool on the next probe, so the degradation is not sticky:

--- redis RECOVERED ---
  request 1: http=200 total=1.332444s
  request 2: http=200 total=2.076628s

Startup with Redis already unreachable now completes instead of hanging:

$ # fixed, redis blackholed
t+25s  liveliness=200

The isolated mechanism, measured against a blackholed host (10.255.255.1, packets dropped) so the two config styles can be compared directly:

BEFORE (4d54324515)                     AFTER (196ae5ddd6)
 host/port  construct 10.02s            host/port  construct 10.03s
            get       5.00s                        get       5.00s
 url        construct >30s BLOCKED      url        construct 10.02s
            (killed after 5 min)                   get       5.00s

Note on behaviour during an outage: rate limiting falls back to per-worker in-memory counters, so limits fail open rather than rejecting traffic. That is the existing behaviour of the v3 limiter's fallbacks, not something this PR changes, but it is worth stating explicitly since this PR is what makes that fallback actually reachable. For the same reason only connectivity failures feed the breaker; counting command and data errors would let a caller provoke that fallback on demand.

Type

🐛 Bug Fix

Changes

Two independent defects had to line up for a Redis outage to take the proxy down rather than degrade it.

Connection kwargs were dropped on url configs. _get_redis_url_kwargs built its allowlist from inspect.getfullargspec(redis.Redis.from_url). from_url is declared (cls, url, **kwargs), so the argspec carries no connection kwargs at all and the function returned ['cls', 'url', 'url']. Everything was stripped, socket_timeout included, and socket_connect_timeout falls back to socket_timeout, so both ended up None and redis-py blocked until the OS gave up. get_redis_connection_pool lost the same kwargs by a different route, rebuilding its pool kwargs from scratch as {timeout, url, max_connections}. Host/port configs were unaffected, which is why this survived so long.

The allowlist now comes from the connection class that redis-py ultimately hands those kwargs to, walking the MRO because redis-py splits them between AbstractConnection and its subclasses. Taking the client's signature instead would look reasonable and be wrong: it admits client-only settings such as single_connection_client and auto_close_connection_pool, plus the ssl_* family that only SSLConnection accepts, and all of those reach AbstractConnection and raise TypeError on first connect. TLS on a url config comes from the rediss:// scheme, which selects SSLConnection on its own.

The circuit breaker could not open. _redis_circuit_breaker_guard inferred health from the method returning. But async_get_cache, async_batch_get_cache, async_set_cache, async_set_cache_pipeline, async_set_cache_sadd and async_get_ttl all catch their own connection errors and return a default so callers degrade instead of failing, which is correct behaviour that happens to be indistinguishable from success at the decorator boundary. Every failed call therefore reset the failure streak, the threshold was never reached, and an unreachable Redis stayed in the pool with every request paying a full timeout on it. Measured directly: six consecutive failing calls left failure_count=0, state=closed.

Those methods now record the failure, and success is recorded only when nothing failed while the method ran. Separately, Lua execution bypassed the breaker entirely, which mattered most because the v3 rate limiter issues all of its Redis traffic that way; the guard is now a small module-level helper shared by the decorator and the script executor.

QA runbook

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

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Comment thread litellm/caching/redis_cache.py Outdated
@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR prevents unreachable Redis instances from indefinitely blocking requests.

  • Preserves connection timeout kwargs for URL-configured Redis clients and pools.
  • Records swallowed connectivity errors without confusing concurrent successful calls.
  • Applies the Redis circuit breaker to Lua script execution.
  • Adds regression coverage for URL configuration, connectivity failures, concurrency, and script execution.

Confidence Score: 5/5

The PR appears safe to merge.

The ContextVar-based tracking isolates each asynchronous call’s swallowed failures, so the previously reported concurrent-success issue no longer remains, and no blocking failure remains.

Important Files Changed

Filename Overview
litellm/_redis.py Derives URL-compatible kwargs from Redis connection classes and preserves timeout settings when constructing URL-based clients and pools.
litellm/caching/redis_cache.py Adds per-context swallowed-failure tracking and routes guarded methods and Lua execution through a shared circuit-breaker helper.
tests/test_litellm/caching/test_redis_cache.py Covers swallowed failures, successful resets, concurrent success/failure isolation, Lua execution, and health-error classification.
tests/test_litellm/test_redis.py Verifies timeout propagation and rejection of unsupported kwargs across URL and host/port configurations.

Reviews (3): Last reviewed commit: "fix(redis): stop an unreachable Redis fr..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.44262% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/caching/redis_cache.py 91.48% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

@yassin-berriai
yassin-berriai force-pushed the litellm_redis_url_socket_timeout branch 2 times, most recently from 43101bc to ec8f7b8 Compare July 30, 2026 20:48
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

The concurrency finding was correct, thanks. I reproduced it before changing anything: two calls in flight against one breaker, where the failure lands while the other call is still awaiting, and a Redis that answered a call in every single round was still evicted after 5 rounds.

round 0: failure_count=1 state=closed
round 1: failure_count=2 state=closed
round 2: failure_count=3 state=closed
round 3: failure_count=4 state=closed
round 4: failure_count=5 state=open      <- despite a success in every round

Root cause was the marker itself. total_failures lived on the breaker, and a breaker is shared by every concurrent caller, so it could not distinguish "my call failed" from "some other in-flight call failed"; the second caller then declined to record its own success. That is the opposite of what the guard is for, since it evicts a Redis that is still serving.

Fixed by moving the per-call marker to a ContextVar. asyncio gives each task its own copy of the context, so the marker is genuinely per call, and nested guarded calls still propagate within a task. The total_failures property is gone.

Same scenario after the change:

round 0..5: failure_count=0 state=closed   (healthy Redis kept in the pool)

Also added test_concurrent_success_is_not_cancelled_by_another_calls_failure for exactly the interleaving you described. Worth noting the first version of that test was vacuous: the failing coroutine had no await before recording, so it always completed before the healthy call snapshotted, and it passed against the buggy code. It now sleeps first so the failure lands mid-flight, and I verified it fails when I restore the shared counter and passes with the ContextVar.

@greptileai please review the current head ec8f7b8584

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

veria-ai Bot commented Jul 30, 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

Two defects combined to make a Redis outage take the proxy down rather than
degrade it.

First, connection kwargs were dropped whenever Redis was configured by url.
_get_redis_url_kwargs built its allowlist from
inspect.getfullargspec(redis.Redis.from_url); from_url is declared
(cls, url, **kwargs), so the argspec carried no connection kwargs and the
function returned ['cls', 'url', 'url']. socket_timeout went with the rest,
and socket_connect_timeout falls back to it, so both ended up None and a
Redis host that drops packets rather than refusing them blocked callers
indefinitely. get_redis_connection_pool's url branch lost the same kwargs by
a different route, rebuilding its pool kwargs from scratch.

The allowlist now comes from the connection class redis-py actually forwards
those kwargs to, walking the MRO because redis-py splits them between
AbstractConnection and its subclasses. Deriving it from the client instead
would admit client-only settings such as single_connection_client and the
SSLConnection-only ssl_* family, which reach AbstractConnection and raise
TypeError on first connect.

Second, the circuit breaker could not trip even once calls failed fast.
_redis_circuit_breaker_guard inferred success from the method returning, but
async_get_cache, async_batch_get_cache, async_set_cache, async_set_cache_pipeline,
async_set_cache_sadd and async_get_ttl catch their own connection errors and
return a default so callers degrade. Each failed call therefore reset the
failure streak and the breaker never opened, so an unreachable Redis stayed in
the pool and every request kept paying a full socket timeout on it. Those
methods now mark the failure and the guard records success only when nothing
failed while the method ran. Lua script execution went through none of this,
which mattered most because the rate limiter issues all of its Redis traffic
that way, so the guard is now a small helper shared by both.

The per-call marker is a ContextVar rather than a counter on the breaker.
Breakers are shared by every concurrent caller, so a shared counter cannot
tell "my call failed" from "some other in-flight call failed", and a success
overlapping someone else's failure would be discarded until a Redis that was
still answering got evicted from the pool anyway.

Only connectivity failures feed the breaker. Command and data errors say
nothing about whether Redis is reachable, and counting them would let a caller
provoke evictions on demand (an INCR against a non-numeric value, say),
dropping rate limiting to per-process counters that spreading traffic across
replicas can outrun.
@yassin-berriai
yassin-berriai force-pushed the litellm_redis_url_socket_timeout branch from ec8f7b8 to 196ae5d Compare July 30, 2026 21:07
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

Took the veria finding about request errors opening the shared breaker; it is correct and it is on this PR. Before these changes the breaker could never open at all, so the path was unreachable in practice. Making it functional is exactly what makes it reachable, which is my regression to close, not a pre-existing one to wave through.

The distinction I went with is connectivity vs request:

  • unhealthy, counts: ConnectionError, TimeoutError, BusyLoadingError, ClusterDownError, plus OSError / asyncio.TimeoutError
  • not unhealthy, does not count: ResponseError, DataError, InvalidResponse and anything else

Note ClusterDownError subclasses ResponseError, so this is matched by type against an explicit list rather than by excluding ResponseError wholesale.

ResponseError (bad request)      -> failure_count=0 state=closed
ConnectionError (redis down)     -> failure_count=3 state=open

The import of redis.exceptions is lazy and lru_cached, because litellm/caching/redis_cache.py is reachable from a base import litellm while redis is not a base dependency; a module-level import there would break the base-install jobs the same way a top-level prisma import does.

Covered by test_only_connectivity_failures_open_the_breaker, parametrized over all five error types, and I confirmed the two negative cases fail when I restore the blanket except Exception: record_failure(). Re-verified on a live proxy that a real outage still opens the breaker and requests still return 200 throughout.

@greptileai please review the current head 196ae5ddd6

@yassin-berriai
yassin-berriai merged commit 9ec900f into litellm_internal_staging Jul 30, 2026
77 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_redis_url_socket_timeout branch July 30, 2026 21:36
@codspeed-hq

codspeed-hq Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_redis_url_socket_timeout (196ae5d) with litellm_internal_staging (4eecf7a)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (abd239f) during the generation of this report, so 4eecf7a was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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.

3 participants