litellm/tests/test_litellm/caching/test_redis_cache.py
Yassin Kortam 9ec900f964
fix(redis): stop an unreachable Redis from blocking every request (#35273)
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.
2026-07-30 14:36:28 -07:00

610 lines
24 KiB
Python

import asyncio
import os
import sys
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
from unittest.mock import AsyncMock
from litellm.caching.redis_cache import RedisCache
@pytest.fixture
def redis_no_ping():
"""Patch RedisCache initialization to prevent async ping tasks from being created"""
with patch("asyncio.get_running_loop") as mock_get_loop:
# Either raise an exception or return a mock that will handle the task creation
mock_get_loop.side_effect = RuntimeError("No running event loop")
yield
@pytest.mark.parametrize("namespace", [None, "litellm"])
@pytest.mark.asyncio
async def test_async_delete_cache_applies_namespace(
namespace, monkeypatch, redis_no_ping
):
"""async_delete_cache must prefix keys with the namespace, matching every
other cache operation. Without this, Redis NOPERM errors occur when an
ACL restricts DEL to the litellm:* pattern."""
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace=namespace)
mock_redis_instance = AsyncMock()
with patch.object(
redis_cache, "init_async_client", return_value=mock_redis_instance
):
await redis_cache.async_delete_cache(key="3997c4abcdef")
expected_key = "litellm:3997c4abcdef" if namespace else "3997c4abcdef"
mock_redis_instance.delete.assert_awaited_once_with(expected_key)
@pytest.mark.parametrize("namespace", [None, "litellm"])
def test_delete_cache_applies_namespace(namespace, monkeypatch, redis_no_ping):
"""delete_cache must prefix keys with the namespace, matching every other
cache operation."""
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace=namespace)
mock_redis_client = MagicMock()
redis_cache.redis_client = mock_redis_client
redis_cache.delete_cache(key="3997c4abcdef")
expected_key = "litellm:3997c4abcdef" if namespace else "3997c4abcdef"
mock_redis_client.delete.assert_called_once_with(expected_key)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"redis_config",
[
pytest.param({"host": "my-fake-host"}, id="host_port"),
pytest.param({"url": "redis://my-fake-host:6379"}, id="url"),
],
)
async def test_redis_client_init_with_socket_timeout(monkeypatch, redis_no_ping, redis_config):
"""socket_timeout has to reach the connection however Redis was configured.
A url config used to drop every connection kwarg, so redis-py was left with
socket_timeout (and socket_connect_timeout, which falls back to it) unset. A
Redis host that drops packets instead of refusing them then blocks each caller
indefinitely, and the circuit breaker never trips because no call ever returns.
"""
monkeypatch.delenv("REDIS_URL", raising=False)
monkeypatch.delenv("REDIS_HOST", raising=False)
redis_cache = RedisCache(socket_timeout=1.0, **redis_config)
assert redis_cache.redis_kwargs["socket_timeout"] == 1.0
client = redis_cache.init_async_client()
assert client is not None
assert client.connection_pool.connection_kwargs["socket_timeout"] == 1.0
@pytest.mark.asyncio
async def test_handle_lpop_count_for_older_redis_versions(monkeypatch):
"""Test the helper method that handles LPOP with count for Redis versions < 7.0"""
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
# Create RedisCache instance
redis_cache = RedisCache()
# Create a mock pipeline
mock_pipeline = AsyncMock()
# Set up execute to return different values each time
mock_pipeline.execute.side_effect = [
[b"value1"], # First execute returns first value
[b"value2"], # Second execute returns second value
]
# Test the helper method
result = await redis_cache.handle_lpop_count_for_older_redis_versions(
pipe=mock_pipeline, key="test_key", count=2
)
# Verify results
assert result == [b"value1", b"value2"]
assert mock_pipeline.lpop.call_count == 2
assert mock_pipeline.execute.call_count == 2
@pytest.mark.asyncio
async def test_async_rpush_pipeline_empty_list_returns_empty(
monkeypatch, redis_no_ping
):
"""Empty rpush_list should return empty list without touching Redis"""
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache()
mock_redis_instance = AsyncMock()
with patch.object(
redis_cache, "init_async_client", return_value=mock_redis_instance
):
result = await redis_cache.async_rpush_pipeline(rpush_list=[])
assert result == []
mock_redis_instance.pipeline.assert_not_called()
@pytest.mark.asyncio
async def test_async_lpop_pipeline_empty_list(monkeypatch, redis_no_ping):
"""Empty lpop_list should return empty list without touching Redis"""
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache()
mock_redis_instance = AsyncMock()
with patch.object(
redis_cache, "init_async_client", return_value=mock_redis_instance
):
result = await redis_cache.async_lpop_pipeline(lpop_list=[])
assert result == []
mock_redis_instance.pipeline.assert_not_called()
# LIT-3374: the namespace must be applied uniformly across every key-taking
# Redis operation, not just get/set/increment. Before the fix these paths wrote
# or read raw keys, so with a namespace configured the prefixed keys other
# operations created were silently missed.
@pytest.mark.parametrize(
"namespace, raw_keys, expected_keys",
[
(None, ["{k:v}:tokens", "{k:v}:requests"], ["{k:v}:tokens", "{k:v}:requests"]),
(
"litellm_sandbox",
["{k:v}:tokens", "{k:v}:requests"],
["litellm_sandbox:{k:v}:tokens", "litellm_sandbox:{k:v}:requests"],
),
],
)
@pytest.mark.asyncio
async def test_async_register_script_namespaces_keys(
namespace, raw_keys, expected_keys, monkeypatch, redis_no_ping
):
"""The callable returned by async_register_script (used by the rate limiter
Lua scripts, pod-lock release, and budget limiters) must namespace every key
it is invoked with. The hash tag is preserved so cluster slotting is intact."""
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace=namespace)
registered_script = AsyncMock(return_value="ok")
mock_redis_instance = MagicMock()
mock_redis_instance.register_script = MagicMock(return_value=registered_script)
with patch.object(
redis_cache, "init_async_client", return_value=mock_redis_instance
):
script = redis_cache.async_register_script("return 1")
result = await script(keys=raw_keys, args=[60])
assert result == "ok"
registered_script.assert_awaited_once_with(
keys=tuple(expected_keys), args=[60], client=None
)
# LIT-3298: rate limits tripped at ~40M instead of 80M. async_register_script
# registered the Lua script once at startup and stored the object on the
# limiter, so a request running on a different event loop awaited a script bound
# to the startup loop's connection -> "got Future attached to a different loop".
# The limiter then fell back to a pipeline that reset the window TTL, so two
# minutes of tokens piled into one window. The script must instead be registered
# lazily against the calling loop's client and cached per loop.
@pytest.mark.parametrize("namespace", [None, "litellm_sandbox"])
def test_async_register_script_binds_per_event_loop(namespace, monkeypatch):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace=namespace)
clients_built = []
def make_client():
client = MagicMock()
client.register_script = MagicMock(return_value=AsyncMock(return_value="ok"))
clients_built.append(client)
return client
unique_script = "return 'lit3298'"
with patch.object(redis_cache, "init_async_client", side_effect=make_client):
script = redis_cache.async_register_script(unique_script)
# Registration is deferred: no client is touched until the script runs.
assert clients_built == []
# Two loops kept alive at once so their ids can't be recycled into one
# cache key. The buggy version reuses the first loop's bound object.
loop_a = asyncio.new_event_loop()
loop_b = asyncio.new_event_loop()
try:
result_a = loop_a.run_until_complete(
script(keys=["{k:v}:tokens"], args=[60])
)
result_b = loop_b.run_until_complete(
script(keys=["{k:v}:tokens"], args=[60])
)
finally:
loop_a.close()
loop_b.close()
assert result_a == "ok"
assert result_b == "ok"
assert len(clients_built) == 2
for client in clients_built:
client.register_script.assert_called_once_with(unique_script)
@pytest.mark.asyncio
async def test_async_register_script_not_shared_across_namespaces(
monkeypatch, redis_no_ping
):
"""Two caches with different namespaces registering the SAME script must
each run against their own client and key prefix. A content-only executor
cache would let the second cache reuse the first's executor and namespace."""
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
cache_a = RedisCache(namespace="ns_a")
cache_b = RedisCache(namespace="ns_b")
reg_a = AsyncMock(return_value="a")
client_a = MagicMock()
client_a.register_script = MagicMock(return_value=reg_a)
reg_b = AsyncMock(return_value="b")
client_b = MagicMock()
client_b.register_script = MagicMock(return_value=reg_b)
same_script = "return redis.call('GET', KEYS[1])"
with patch.object(
cache_a, "init_async_client", return_value=client_a
), patch.object(cache_b, "init_async_client", return_value=client_b):
script_a = cache_a.async_register_script(same_script)
script_b = cache_b.async_register_script(same_script)
result_a = await script_a(keys=["k"], args=[])
result_b = await script_b(keys=["k"], args=[])
assert (result_a, result_b) == ("a", "b")
reg_a.assert_awaited_once_with(keys=("ns_a:k",), args=[], client=None)
reg_b.assert_awaited_once_with(keys=("ns_b:k",), args=[], client=None)
@pytest.mark.asyncio
async def test_async_register_script_cluster_path_uses_evalsha(
monkeypatch, redis_no_ping
):
"""Redis Cluster exposes script_load/evalsha rather than register_script.
The script is loaded once and invoked via evalsha with namespaced keys."""
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace="ns")
cluster_client = MagicMock(spec=["script_load", "evalsha"])
cluster_client.script_load = MagicMock(return_value="sha123")
cluster_client.evalsha = AsyncMock(return_value="cluster-ok")
with patch.object(
redis_cache, "init_async_client", return_value=cluster_client
):
script = redis_cache.async_register_script("return 'cluster'")
result = await script(keys=["{k:v}:tokens"], args=[5, 60])
assert result == "cluster-ok"
cluster_client.script_load.assert_called_once_with("return 'cluster'")
cluster_client.evalsha.assert_awaited_once_with(
"sha123", 1, "ns:{k:v}:tokens", 5, 60
)
@pytest.mark.asyncio
async def test_async_register_script_raises_for_unsupported_client(
monkeypatch, redis_no_ping
):
"""A client exposing neither register_script nor script_load fails loudly
rather than silently returning a no-op callable."""
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache()
bad_client = MagicMock(spec=[])
with patch.object(redis_cache, "init_async_client", return_value=bad_client):
script = redis_cache.async_register_script("return 'x'")
with pytest.raises(ValueError, match="does not support Lua script"):
await script(keys=["k"], args=[1])
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
@pytest.mark.asyncio
async def test_async_delete_cache_namespaces_key(
namespace, expected, monkeypatch, redis_no_ping
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace=namespace)
mock_redis_instance = AsyncMock()
with patch.object(
redis_cache, "init_async_client", return_value=mock_redis_instance
):
await redis_cache.async_delete_cache("k")
mock_redis_instance.delete.assert_awaited_once_with(expected)
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
@pytest.mark.asyncio
async def test_delete_cache_keys_namespaces_keys(
namespace, expected, monkeypatch, redis_no_ping
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace=namespace)
mock_redis_instance = AsyncMock()
with patch.object(
redis_cache, "init_async_client", return_value=mock_redis_instance
):
await redis_cache.delete_cache_keys(["k"])
mock_redis_instance.delete.assert_awaited_once_with(expected)
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
@pytest.mark.asyncio
async def test_async_get_ttl_namespaces_key(
namespace, expected, monkeypatch, redis_no_ping
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace=namespace)
mock_redis_instance = AsyncMock()
mock_redis_instance.ttl = AsyncMock(return_value=42)
with patch.object(
redis_cache, "init_async_client", return_value=mock_redis_instance
):
ttl = await redis_cache.async_get_ttl("k")
assert ttl == 42
mock_redis_instance.ttl.assert_awaited_once_with(expected)
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
@pytest.mark.asyncio
async def test_async_lpop_namespaces_key(
namespace, expected, monkeypatch, redis_no_ping
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace=namespace)
mock_redis_instance = AsyncMock()
mock_redis_instance.lpop = AsyncMock(return_value=b"value")
with patch.object(
redis_cache, "init_async_client", return_value=mock_redis_instance
):
await redis_cache.async_lpop(key="k")
mock_redis_instance.lpop.assert_awaited_once_with(expected, None)
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
@pytest.mark.asyncio
async def test_async_rpush_namespaces_key(
namespace, expected, monkeypatch, redis_no_ping
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace=namespace)
mock_redis_instance = AsyncMock()
mock_redis_instance.rpush = AsyncMock(return_value=1)
with patch.object(
redis_cache, "init_async_client", return_value=mock_redis_instance
):
await redis_cache.async_rpush("k", ["v"])
mock_redis_instance.rpush.assert_awaited_once_with(expected, "v")
@pytest.mark.parametrize("namespace, expected_match", [(None, "k*"), ("ns", "ns:k*")])
@pytest.mark.asyncio
async def test_async_scan_iter_namespaces_pattern(
namespace, expected_match, monkeypatch, redis_no_ping
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace=namespace)
captured = {}
def scan_iter(match, count):
captured["match"] = match
async def gen():
for _ in ():
yield _
return gen()
mock_redis_instance = MagicMock()
mock_redis_instance.scan_iter = scan_iter
with patch.object(
redis_cache, "init_async_client", return_value=mock_redis_instance
):
await redis_cache.async_scan_iter(pattern="k")
assert captured["match"] == expected_match
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
def test_increment_cache_namespaces_key(
namespace, expected, monkeypatch, redis_no_ping
):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace=namespace)
mock_client = MagicMock()
mock_client.incr.return_value = 5
mock_client.ttl.return_value = 100
redis_cache.redis_client = mock_client
redis_cache.increment_cache(key="k", value=1)
mock_client.incr.assert_called_once_with(name=expected, amount=1)
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
def test_delete_cache_namespaces_key(namespace, expected, monkeypatch, redis_no_ping):
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
redis_cache = RedisCache(namespace=namespace)
mock_client = MagicMock()
redis_cache.redis_client = mock_client
redis_cache.delete_cache(key="k")
mock_client.delete.assert_called_once_with(expected)
def _closed_port() -> int:
"""A port with nothing listening, so Redis calls fail fast and deterministically."""
import socket
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"call_method",
[
pytest.param(lambda c: c.async_get_cache("lit4930"), id="async_get_cache"),
pytest.param(lambda c: c.async_batch_get_cache(["lit4930"]), id="async_batch_get_cache"),
pytest.param(lambda c: c.async_set_cache("lit4930", "v"), id="async_set_cache"),
pytest.param(lambda c: c.async_get_ttl("lit4930"), id="async_get_ttl"),
],
)
async def test_circuit_breaker_opens_when_method_swallows_redis_failure(redis_no_ping, call_method):
"""A guarded method that swallows its own Redis error must still count as a failure.
These methods catch connection errors and return a default so callers degrade instead
of failing, which is correct. But that returns cleanly through the circuit breaker
guard, and counting it as a success reset the failure streak on every call, so the
breaker could never open. An unreachable Redis then stayed in the pool and every
request kept paying the full socket timeout on it.
"""
from litellm.constants import REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD
cache = RedisCache(host="127.0.0.1", port=_closed_port(), socket_timeout=0.5)
for _ in range(REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD):
await call_method(cache)
with pytest.raises(Exception, match="circuit breaker is open"):
await call_method(cache)
@pytest.mark.asyncio
async def test_circuit_breaker_success_still_resets_the_failure_streak(redis_no_ping):
"""A reachable Redis must keep the breaker closed, however many earlier calls failed.
The guard now records success only when nothing failed while the method ran, so this
pins the other half of that contract: a call that genuinely reaches Redis has to clear
the streak, or a healthy Redis would eventually be evicted from the pool.
"""
from litellm.constants import REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD
cache = RedisCache(host="127.0.0.1", port=_closed_port(), socket_timeout=0.5)
for _ in range(REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD - 1):
await cache.async_get_cache("lit4930")
assert cache._circuit_breaker.is_open() is False
reachable_redis = AsyncMock()
reachable_redis.get.return_value = None
with patch.object(cache, "init_async_client", return_value=reachable_redis):
await cache.async_get_cache("lit4930")
for _ in range(REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD - 1):
await cache.async_get_cache("lit4930")
assert cache._circuit_breaker.is_open() is False, "one success must clear the streak"
@pytest.mark.asyncio
async def test_circuit_breaker_covers_lua_script_execution(redis_no_ping):
"""Lua script execution must feed the breaker like every other Redis call.
The v3 rate limiter issues all of its Redis traffic through async_register_script, so
leaving that path unguarded meant the coordination calls during an outage never
counted toward taking Redis out of the pool and kept paying a full socket timeout
each, which is the traffic the outage hurts most.
"""
from litellm.constants import REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD
cache = RedisCache(host="127.0.0.1", port=_closed_port(), socket_timeout=0.5)
run_script = cache.async_register_script("return 1")
for _ in range(REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD):
with pytest.raises(Exception):
await run_script(keys=["lit4930"], args=[1])
with pytest.raises(Exception, match="circuit breaker is open"):
await run_script(keys=["lit4930"], args=[1])
@pytest.mark.asyncio
async def test_concurrent_success_is_not_cancelled_by_another_calls_failure():
"""One caller's failure must not discard a different caller's success.
A breaker is shared by every concurrent caller, so tracking "did this call fail" on the
breaker itself cannot tell my failure from someone else's. A Redis that is still
answering would then be evicted from the pool by unrelated in-flight failures, which is
the opposite of the outage this guard exists to handle.
"""
from redis.exceptions import ConnectionError as RedisConnectionError
from litellm.caching.redis_cache import (
RedisCircuitBreaker,
_record_swallowed_redis_failure,
_run_under_circuit_breaker,
)
breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60)
# The failure has to land after both calls are already in flight, which is the only
# ordering where a shared counter confuses the two. Failing before the healthy call
# starts would leave its snapshot correct and prove nothing.
async def swallows_a_failure():
await asyncio.sleep(0.02)
_record_swallowed_redis_failure(breaker, RedisConnectionError("redis unreachable"))
return None
async def succeeds_while_the_other_fails():
await asyncio.sleep(0.05)
return "ok"
rounds = breaker.failure_threshold + 1
for _ in range(rounds):
await asyncio.gather(
_run_under_circuit_breaker(breaker, "failing", swallows_a_failure),
_run_under_circuit_breaker(breaker, "healthy", succeeds_while_the_other_fails),
)
assert breaker._failure_count < breaker.failure_threshold, "the healthy call must clear the streak"
assert breaker.is_open() is False, "a Redis answering every round must stay in the pool"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"error, opens_breaker",
[
pytest.param("ConnectionError", True, id="connection_refused_is_unhealthy"),
pytest.param("TimeoutError", True, id="timeout_is_unhealthy"),
pytest.param("BusyLoadingError", True, id="loading_is_unhealthy"),
pytest.param("ResponseError", False, id="wrong_type_command_is_not"),
pytest.param("DataError", False, id="bad_data_is_not"),
],
)
async def test_only_connectivity_failures_open_the_breaker(error, opens_breaker):
"""Command and data errors must not count against Redis health.
They say nothing about connectivity, and a caller able to provoke them (an INCR against
a non-numeric value, say) could otherwise trip the shared breaker on demand and drop
rate limiting to per-process counters, which spreading traffic across replicas outruns.
"""
import redis.exceptions
from litellm.caching.redis_cache import RedisCircuitBreaker, _run_under_circuit_breaker
breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60)
raised = getattr(redis.exceptions, error)("boom")
async def failing_call():
raise raised
for _ in range(breaker.failure_threshold + 1):
with pytest.raises(Exception):
await _run_under_circuit_breaker(breaker, "op", failing_call)
assert breaker.is_open() is opens_breaker