feat(redis): bridge Python to a shared native Redis client and request batches

NativeRedisClient resolves a RedisCache's redis_kwargs through the RedisCache
kwargs surface and hands every native store in the process one shared client,
breaker and ServiceLogging event sink. A setting Rust ignores is logged with
its consequence, and a config it cannot use leaves the stores on memory

NativeRequestRedis carries one request's pre-call and post-call batches from
RequestRedisBatches into the stores. The keyspaces that use these land in the
PRs above

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yujong Lee 2026-11-04 12:34:24 +09:00
parent 8ed055000f
commit 0676f800a9
34 changed files with 1293 additions and 12 deletions

View file

@ -167,6 +167,7 @@ jobs:
tests/unit/google_genai
tests/unit/router_strategy
tests/unit/router_utils
tests/unit/redis
tests/unit/proxy/common_utils/test_cache_aware_routing.py
tests/unit/enterprise/enterprise_callbacks/send_emails
tests/unit/enterprise/proxy/test_afile_retrieve_returns_unified_id.py

View file

@ -4358,6 +4358,8 @@ dependencies = [
"litellm-cache-redis",
"litellm-cache-response",
"litellm-callbacks-legacy-python",
"litellm-circuit-breaker",
"litellm-config",
"litellm-core-utils",
"litellm-error",
"litellm-host",
@ -4372,6 +4374,10 @@ dependencies = [
"litellm-llms",
"litellm-llms-types",
"litellm-model-catalog",
"litellm-redis-client",
"litellm-redis-connect",
"litellm-redis-store",
"litellm-redis-types",
"litellm-router-types",
"litellm-secret-value",
"litellm-secrets",
@ -4467,6 +4473,25 @@ dependencies = [
"veil",
]
[[package]]
name = "litellm-redis-store"
version = "0.1.0"
dependencies = [
"futures-util",
"litellm-cache",
"litellm-cache-memory",
"litellm-circuit-breaker",
"litellm-python-compat",
"litellm-redis-client",
"litellm-redis-testing",
"litellm-secret-value",
"rstest",
"serde_json",
"thiserror 2.0.19",
"tokio",
"tracing",
]
[[package]]
name = "litellm-redis-testing"
version = "0.1.0"

View file

@ -74,6 +74,7 @@ litellm-python-compat = { path = "crates/python-compat" }
litellm-circuit-breaker = { path = "crates/circuit-breaker" }
litellm-redis-client = { path = "crates/redis-client" }
litellm-redis-connect = { path = "crates/redis-connect" }
litellm-redis-store = { path = "crates/redis-store" }
litellm-redis-testing = { path = "crates/redis-testing" }
litellm-redis-types = { path = "crates/redis-types" }
litellm-cost = { path = "crates/cost" }

View file

@ -20,6 +20,7 @@
- A url query parameter other than the TLS ones, or any query parameter on a plain `redis://` url, is ignored naming `url ?<name>`. redis-py turns each one into a client kwarg
- `retry_on_timeout`, `retry_on_error`, `protocol`, `cache`, `cache_config`, `socket_keepalive` and `max_connections` are ignored. Cluster clients still turn TCP keepalive on, like Python's async cluster default
- `health_check_interval` is read as an `int` of seconds like `_coerce_redis_kwargs_types`, zero meaning off. `redis-connect` gives cluster clients Python's 25s default and `redis-client` pings every node before the next command once the client sat idle that long, outside the breaker and the call events
- A `credential_provider` object resolves to `RedisAuth::Host`, the way Python's `{"kind": "host"}` does. `redis-connect` has no host credential source yet, so python-bridge leaves that family on memory with a warning, and rust-proxy never sees one because yaml cannot carry an object
- Cases where Rust and Python disagree on purpose are cases in both contract tests with a different expected outcome on each side
- `ssl_ca_path`: redis-py 5.3.1's async `Redis` has no such parameter and raises `TypeError` building the client, while Rust trusts the directory like `load_verify_locations(capath=...)`
- `on`, `t` and `y` for a flag are `RedisError::Invalid` on every surface. pydantic reads them as true for `coordination_redis` and `_str_to_bool` reads them as false everywhere else, so Rust refuses to pick one. Other unrecognized text is false in Python and an error here

View file

@ -37,7 +37,7 @@ pub use owned::PythonOwned;
pub use runtime::{
ForkedAfterNativeRuntimeStarted, ProcessReservedForForking, enter_native, poll_async_value,
ready_future, reserve_process_for_forking, run_async, run_async_value, run_sync,
run_sync_value, runtime_started,
run_sync_value, runtime_handle, runtime_started,
};
pub use services::PythonHostCalls;

View file

@ -68,6 +68,11 @@ fn runtime() -> PyResult<&'static Runtime> {
Ok(pyo3_async_runtimes::tokio::get_runtime())
}
/// The native runtime, for work that must outlive the call that schedules it.
pub fn runtime_handle() -> PyResult<Handle> {
Ok(runtime()?.handle().clone())
}
#[expect(clippy::disallowed_methods, reason = "this is the gated door")]
fn future_into_py<F, T>(py: Python<'_>, future: F) -> PyResult<Bound<'_, PyAny>>
where

View file

@ -31,6 +31,13 @@ litellm-cache.workspace = true
litellm-error.workspace = true
litellm-cache-memory.workspace = true
litellm-cache-redis.workspace = true
litellm-circuit-breaker.workspace = true
litellm-redis-client.workspace = true
litellm-redis-connect.workspace = true
litellm-redis-store.workspace = true
litellm-config.workspace = true
litellm-redis-types.workspace = true
tracing.workspace = true
litellm-cache-response.workspace = true
serde.workspace = true
litellm-auth.workspace = true

View file

@ -12,6 +12,7 @@ mod marshal;
mod model_registry;
mod preflight;
mod python_settings;
mod redis;
mod routes;
mod secrets;
mod tokenizer;
@ -36,6 +37,12 @@ mod _native {
#[pymodule_export]
use crate::model_registry::bundled_model_cost_map;
#[pymodule_export]
use crate::redis::client::NativeRedisClient;
#[pymodule_export]
use crate::redis::client::native_redis_breaker_states;
#[pymodule_export]
use crate::redis::store::request::NativeRequestRedis;
#[pymodule_export]
use crate::routes::audio_transcription::{atranscription, transcription};
#[pymodule_export]
use crate::routes::chat_completions::{acompletion, completion};
@ -113,6 +120,8 @@ mod tests {
"aresponses",
"ResponsesWebSocketConnection",
"NativeDiagnosticProcessor",
"NativeRequestRedis",
"native_redis_breaker_states",
"NativeClickHouseSpendConfig",
"NativeClickHouseSpendStorage",
"NativeDatabase",

View file

@ -0,0 +1,182 @@
use std::{
collections::{BTreeMap, BTreeSet},
sync::{Arc, LazyLock, Mutex, PoisonError},
};
use litellm_config::{ConfiguredRedis, Resolution, SecretSnapshot, Value};
use litellm_core_utils::settings::ProcessEnvironment;
use litellm_redis_client::{RedisClient, RedisEvents};
use litellm_redis_connect::{HostServices, SharedEnvironment};
use litellm_redis_types::ResolvedRedis;
use pyo3::{
prelude::*,
sync::MutexExt,
types::{PyBool, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple},
};
use crate::redis::events::{ServiceLoggingEvents, state_label};
/// The `redis_kwargs` dict `RedisCache` hands redis-py, read the way `litellm/_redis.py` reads it.
/// A `None` stays an explicit null that hides its `REDIS_*` value, as in redis-py. A value that is not
/// plain data, such as a `credential_provider` object, reaches the resolver by its type name, so the
/// resolver or the connector names the setting it cannot honor.
pub(crate) struct PythonRedisSettings(ConfiguredRedis);
impl<'a, 'py> FromPyObject<'a, 'py> for PythonRedisSettings {
type Error = PyErr;
fn extract(kwargs: Borrowed<'a, 'py, PyAny>) -> PyResult<Self> {
let py = kwargs.py();
let plain = PyDict::new(py);
for (name, value) in kwargs.cast::<PyDict>()?.iter() {
if value.is_none() || is_plain(&value) {
plain.set_item(&name, value)?;
} else {
plain.set_item(&name, value.get_type().name()?)?;
}
}
let fields: BTreeMap<String, Value> = litellm_host_python::from_py_argument(plain.as_any())?;
Ok(Self(ConfiguredRedis::redis_cache_kwargs(fields)))
}
}
fn is_plain(value: &Bound<'_, PyAny>) -> bool {
value.is_instance_of::<PyString>()
|| value.is_instance_of::<PyBool>()
|| value.is_instance_of::<PyInt>()
|| value.is_instance_of::<PyFloat>()
|| value.is_instance_of::<PyList>()
|| value.is_instance_of::<PyTuple>()
|| value.is_instance_of::<PyDict>()
}
struct Shared {
resolved: ResolvedRedis,
client: RedisClient,
}
static CLIENTS: LazyLock<Mutex<Vec<Shared>>> = LazyLock::new(|| Mutex::new(Vec::new()));
/// The one client for a resolved Redis config, so every family in this process shares its pool
/// and its breaker. Like redis-py, which never connects at construction, a config the resolver or
/// the connector cannot honor leaves the family on memory with a warning rather than failing the
/// `Router`.
fn shared_client(
py: Python<'_>,
kwargs: PythonRedisSettings,
namespace: Option<String>,
) -> PyResult<Option<RedisClient>> {
let environment: SharedEnvironment = Arc::new(ProcessEnvironment);
let secrets = match secret_snapshot(py, kwargs.0.secret_names())? {
Ok(secrets) => secrets,
Err(error) => return Ok(memory_only(py, &error)),
};
let logger = crate::logger::capture(py);
let resolved = match logger.scope(|| kwargs.0.resolve(&secrets, &*environment)) {
Ok(Resolution { redis, ignored }) => {
logger.scope(|| {
for ignored in ignored {
litellm_tracing::warn!(
"Redis setting {} is not supported by the Rust client and is ignored: {}",
ignored.setting,
ignored.consequence
);
}
});
ResolvedRedis { namespace, ..redis }
}
Err(error) => return Ok(memory_only(py, &error)),
};
let breaker = match logger.scope(|| litellm_redis_connect::breaker_config(&*environment)) {
Ok(breaker) => breaker,
Err(error) => return Ok(memory_only(py, &error)),
};
let runtime = litellm_host_python::runtime_handle()?;
let events: Arc<dyn RedisEvents> = Arc::new(ServiceLoggingEvents::new(py, runtime.clone())?);
let host = HostServices {
events: Some(events),
breaker,
http: crate::http::host_client(py, litellm_http::ClientVariant::Provider)?,
environment,
};
let mut clients = CLIENTS
.lock_py_attached(py)
.unwrap_or_else(PoisonError::into_inner);
if let Some(shared) = clients.iter().find(|shared| shared.resolved == resolved) {
return Ok(Some(shared.client.clone()));
}
let connected = {
let _runtime = runtime.enter();
litellm_redis_connect::connect(resolved.clone(), host)
};
let client = match connected {
Ok(client) => client,
Err(error) => {
drop(clients);
return Ok(memory_only(py, &error));
}
};
clients.push(Shared {
resolved,
client: client.clone(),
});
Ok(Some(client))
}
/// The Rust client for one `RedisCache`, built once and handed to every native store, so a store
/// never sees the redis-py kwargs or the namespace.
#[pyclass(frozen, name = "NativeRedisClient")]
pub(crate) struct NativeRedisClient(pub(crate) RedisClient);
#[pymethods]
impl NativeRedisClient {
/// `None` when Redis is not usable, which leaves every store given it on memory.
#[staticmethod]
#[pyo3(signature = (redis_kwargs, *, namespace = None))]
fn connect(
py: Python<'_>,
redis_kwargs: PythonRedisSettings,
namespace: Option<String>,
) -> PyResult<Option<Self>> {
Ok(shared_client(py, redis_kwargs, namespace)?.map(Self))
}
}
/// The breaker state of every native Redis client this process built, so the Prometheus state
/// gauge counts them next to the Python `RedisCache` breakers.
#[pyfunction]
pub(crate) fn native_redis_breaker_states(py: Python<'_>) -> Vec<&'static str> {
let clients = CLIENTS
.lock_py_attached(py)
.unwrap_or_else(PoisonError::into_inner);
clients
.iter()
.filter_map(|shared| shared.client.breaker_state())
.map(|state| state_label(&state))
.collect()
}
/// Every name the resolver may read, fetched once the way Python's `get_secret` fetches it: the
/// configured secret manager first, then the process environment.
fn secret_snapshot(
py: Python<'_>,
names: BTreeSet<String>,
) -> PyResult<Result<SecretSnapshot, litellm_secrets::Error>> {
let source = crate::secrets::source(py)?;
let owned: Vec<String> = names.iter().cloned().collect();
let lookup = crate::execution::run_sync_value(py, async move {
let borrowed: Vec<&str> = owned.iter().map(String::as_str).collect();
Ok(source.resolve(&borrowed).await)
})?;
Ok(lookup.map(|lookup| SecretSnapshot::read(names, &*lookup)))
}
fn memory_only(py: Python<'_>, error: &(dyn std::error::Error + 'static)) -> Option<RedisClient> {
let report = litellm_error::Report::new(error).to_string();
crate::logger::capture(py).scope(|| {
litellm_tracing::warn!(
"Redis is not usable from the Rust stores, their state stays in memory: {report}"
)
});
None
}

View file

@ -0,0 +1,274 @@
use std::{sync::Arc, time::Duration};
use litellm_circuit_breaker::{State, Transition};
use litellm_host_python::{PythonContext, attach_blocking};
use litellm_redis_client::{CallEvent, FailureKind, Operation, Outcome, RedisEvents};
use pyo3::{prelude::*, types::PyModule};
use tokio::runtime::Handle;
tokio::task_local! {
static CALL: RedisCall;
}
/// The Python caller of one store operation. Its context carries `with_service_target`, and its
/// `call_type` and `caller` are the labels `RedisCache` reports, so dashboards keep their series.
#[derive(Clone)]
pub(crate) struct RedisCall {
pub(crate) context: PythonContext,
pub(crate) call_type: &'static str,
pub(crate) caller: Option<String>,
}
impl RedisCall {
pub(crate) async fn scope<F: Future>(self, operation: F) -> F::Output {
CALL.scope(self, operation).await
}
}
/// Reports every native Redis call and breaker transition to `ServiceLogging`.
pub(crate) struct ServiceLoggingEvents {
record_call: Arc<Py<PyAny>>,
record_breaker: Arc<Py<PyAny>>,
context: PythonContext,
runtime: Handle,
}
impl ServiceLoggingEvents {
pub(crate) fn new(py: Python<'_>, runtime: Handle) -> PyResult<Self> {
const MODULE: &str = "litellm.rust_bridge.host.redis_events";
Self::from_module(py, &py.import(MODULE)?, runtime)
}
fn from_module(
py: Python<'_>,
module: &Bound<'_, PyModule>,
runtime: Handle,
) -> PyResult<Self> {
Ok(Self {
record_call: Arc::new(module.getattr("record_redis_call")?.unbind()),
record_breaker: Arc::new(module.getattr("record_redis_breaker")?.unbind()),
context: PythonContext::capture(py)?,
runtime,
})
}
fn report(&self, context: PythonContext, record: Arc<Py<PyAny>>, args: ReportArgs) {
self.runtime.spawn(async move {
let reported = attach_blocking(context, move |py| match args {
ReportArgs::Call(call_type, duration, failure, caller) => record
.bind(py)
.call1((call_type, duration.as_secs_f64(), failure, caller))
.map(drop),
ReportArgs::Breaker(state) => record.bind(py).call1((state,)).map(drop),
})
.await
.and_then(|reported| reported);
if let Err(error) = reported {
tracing::warn!(error = %error, "reporting a Redis event to ServiceLogging failed");
}
});
}
}
enum ReportArgs {
Call(&'static str, Duration, Option<&'static str>, Option<String>),
Breaker(&'static str),
}
fn operation_call_type(operation: Operation) -> &'static str {
match operation {
Operation::Command(name) => name,
Operation::Script => "eval",
Operation::Pipeline { .. } => "pipeline",
}
}
fn failure_label(outcome: Outcome) -> Option<&'static str> {
match outcome {
Outcome::Succeeded => None,
Outcome::Failed(FailureKind::Timeout) => Some("timeout"),
Outcome::Failed(FailureKind::Transport) => Some("transport"),
Outcome::Failed(FailureKind::Server) => Some("server"),
Outcome::Failed(FailureKind::Decode) => Some("decode"),
Outcome::Failed(FailureKind::BreakerOpen) => Some("breaker_open"),
}
}
pub(crate) fn state_label(state: &State) -> &'static str {
match state {
State::Closed { .. } => "closed",
State::Open { .. } => "open",
State::HalfOpen => "half_open",
}
}
impl RedisEvents for ServiceLoggingEvents {
fn call(&self, event: &CallEvent) {
let caller = CALL.try_with(Clone::clone).ok();
let (context, call_type, caller) = match caller {
Some(call) => (call.context, call.call_type, call.caller),
None => (
self.context.clone(),
operation_call_type(event.operation),
None,
),
};
self.report(
context,
self.record_call.clone(),
ReportArgs::Call(
call_type,
event.duration,
failure_label(event.outcome),
caller,
),
);
}
fn breaker(&self, transition: &Transition<State>) {
self.report(
self.context.clone(),
self.record_breaker.clone(),
ReportArgs::Breaker(state_label(&transition.to)),
);
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use litellm_host_python::PythonContext;
use litellm_redis_client::{CallEvent, FailureKind, Operation, Outcome, RedisEvents};
use pyo3::{prelude::*, types::PyModule};
use rstest::rstest;
use tokio::runtime::Handle;
use super::{RedisCall, ServiceLoggingEvents};
const RECORDER: &str = "
import contextvars
target = contextvars.ContextVar('target', default=None)
seen = []
def record_redis_call(call_type, duration, failure, caller):
seen.append((call_type, round(duration, 3), failure, caller, target.get()))
def record_redis_breaker(state):
seen.append(('breaker', state))
";
fn recorder(name: &str) -> (ServiceLoggingEvents, Py<PyModule>) {
Python::initialize();
Python::attach(|py| {
let name = std::ffi::CString::new(format!("redis_events_recorder_{name}")).unwrap();
let module =
PyModule::from_code(py, &std::ffi::CString::new(RECORDER).unwrap(), &name, &name)
.unwrap();
(
ServiceLoggingEvents::from_module(py, &module, Handle::current()).unwrap(),
module.unbind(),
)
})
}
async fn seen(module: &Py<PyModule>, count: usize) -> String {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
let seen: (usize, String) = Python::attach(|py| {
let seen = module.bind(py).getattr("seen").unwrap();
(seen.len().unwrap(), seen.repr().unwrap().to_string())
});
if seen.0 >= count || tokio::time::Instant::now() > deadline {
return seen.1;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[rstest]
#[tokio::test(flavor = "multi_thread")]
async fn a_scoped_call_reports_the_store_labels_in_the_callers_context() {
let (events, namespace) = recorder("scoped");
let call = Python::attach(|py| {
namespace
.bind(py)
.getattr("target")
.unwrap()
.call_method1("set", ("spend_counter",))
.unwrap();
RedisCall {
context: PythonContext::capture(py).unwrap(),
call_type: "async_increment",
caller: Some("router.py:42".to_owned()),
}
});
let event = CallEvent {
operation: Operation::Command("INCRBYFLOAT"),
duration: Duration::from_millis(250),
outcome: Outcome::Failed(FailureKind::Timeout),
};
call.scope(async { events.call(&event) }).await;
assert_eq!(
seen(&namespace, 1).await,
"[('async_increment', 0.25, 'timeout', 'router.py:42', 'spend_counter')]"
);
}
#[rstest]
#[case::opened(litellm_circuit_breaker::State::Open { since: std::time::Instant::now() }, "open")]
#[case::probing(litellm_circuit_breaker::State::HalfOpen, "half_open")]
#[tokio::test(flavor = "multi_thread")]
async fn breaker_transitions_report_python_state_names(
#[case] to: litellm_circuit_breaker::State,
#[case] expected: &str,
) {
let (events, namespace) = recorder(expected);
events.breaker(&litellm_circuit_breaker::Transition {
from: litellm_circuit_breaker::State::HalfOpen,
to,
generation: 1,
});
assert_eq!(
seen(&namespace, 1).await,
format!("[('breaker', '{expected}')]")
);
}
#[rstest]
#[tokio::test(flavor = "multi_thread")]
async fn a_transition_on_a_thread_without_a_runtime_is_still_reported() {
let (events, namespace) = recorder("plain_thread");
std::thread::spawn(move || {
events.breaker(&litellm_circuit_breaker::Transition {
from: litellm_circuit_breaker::State::HalfOpen,
to: litellm_circuit_breaker::State::HalfOpen,
generation: 1,
});
})
.join()
.unwrap();
assert_eq!(seen(&namespace, 1).await, "[('breaker', 'half_open')]");
}
#[rstest]
#[tokio::test(flavor = "multi_thread")]
async fn an_unscoped_call_falls_back_to_the_command_name() {
let (events, namespace) = recorder("unscoped");
events.call(&CallEvent {
operation: Operation::Pipeline { ops: 3 },
duration: Duration::from_millis(5),
outcome: Outcome::Succeeded,
});
assert_eq!(
seen(&namespace, 1).await,
"[('pipeline', 0.005, None, None, None)]"
);
}
}

View file

@ -0,0 +1,3 @@
pub(crate) mod client;
mod events;
pub(crate) mod store;

View file

@ -0,0 +1,20 @@
pub(crate) mod request;
use litellm_host_python::PythonContext;
use pyo3::{
exceptions::{PyRuntimeError, PyTypeError},
prelude::*,
types::{PyBool, PyFloat, PyInt},
};
use crate::redis::events::RedisCall;
/// The labels `RedisCache` reports for this operation, in the caller's context so its
/// `service_target` reaches the service event.
fn call(py: Python<'_>, call_type: &'static str) -> PyResult<RedisCall> {
Ok(RedisCall {
context: PythonContext::capture(py)?,
call_type,
caller: None,
})
}

View file

@ -0,0 +1,41 @@
use std::time::Duration;
use litellm_redis_store::RequestRedis;
use pyo3::prelude::*;
use super::call;
use crate::execution::run_async_value;
/// One request's native Redis batches. Python builds one per request scope and passes it to each
/// request-aware store call.
#[pyclass(frozen, name = "NativeRequestRedis")]
pub(crate) struct NativeRequestRedis(pub(crate) RequestRedis);
#[pymethods]
impl NativeRequestRedis {
#[new]
fn new(post_call_deadline_seconds: f64) -> PyResult<Self> {
Ok(Self(RequestRedis::new(
litellm_host_python::runtime_handle()?,
Duration::from_secs_f64(post_call_deadline_seconds.max(0.0)),
)))
}
fn flush_pre<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let request = self.0.clone();
let scope = call(py, "request_redis_batch")?;
run_async_value(py, async move {
scope.scope(request.flush_pre()).await;
Ok(())
})
}
fn flush_post<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let request = self.0.clone();
let scope = call(py, "post_call_redis_batch")?;
run_async_value(py, async move {
scope.scope(request.flush_post()).await;
Ok(())
})
}
}

View file

@ -17,6 +17,7 @@ litellm-circuit-breaker.workspace = true
litellm-redis-types.workspace = true
litellm-core-utils.workspace = true
litellm-http.workspace = true
rustls.workspace = true
litellm-redis-client.workspace = true
futures-util.workspace = true
serde.workspace = true

View file

@ -29,6 +29,9 @@ pub struct HostServices {
const CLUSTER_HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(25);
pub fn connect(resolved: ResolvedRedis, host: HostServices) -> Result<RedisClient, Error> {
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
tracing::info!(endpoint = ?resolved.endpoint, namespace = ?resolved.namespace, "redis client configured");
let credentials = credentials(resolved.auth, &host)?;
let events = host.events;

View file

@ -0,0 +1,7 @@
- One module per Redis family (`router_cooldowns.rs`, `router_usage.rs`). It is the only place that family's keys, value codec and scripts are written, and it takes domain ids, never keys
- Python and Rust pods share these keys during a rolling deploy, so a module writes the bytes `main`'s Python writes and reads everything `main` reads. Python formats go through `litellm-python-compat` (`repr`, `literal_eval`, `json`), never a hand-rolled codec
- A read-through family builds its own `MemoryTier` (`tier.rs`, `main`'s `DualCache` over a `RedisCache`) from a `TierPolicy` the host passes in. Its `pub const POLICY` is `main`'s defaults, and the Python host overrides what `main` reads from the environment or `litellm` globals (`litellm/rust_bridge/host/settings.py`, `router_tiers`). Callers pass a `RedisClient` (or none), never a cache. Redis-only families are plain functions over `&RedisClient`
- Where `main` swallows a Redis failure and serves memory, the family does the same and logs. It never turns a failure into a miss that `main` would not
- `tests/fixtures/router_wire.json` is the wire contract, checked here against real Redis and in `tests/unit/redis/stores/` against Python's own codec. Change both sides together
- `RequestRedis` is one request's batches: a pre-call and a post-call `RedisBatch` per client, the post one flushed by `flush_post` or its deadline. A family exposes a request-aware variant that declares on it (`declare_active`, `increment_deployment_post_call`) next to its direct method, which runs the same declaration on a batch of its own
- Tests run against `litellm-redis-testing` on standalone and cluster. A test is single-topology only for outages

View file

@ -0,0 +1,24 @@
[package]
name = "litellm-redis-store"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
futures-util.workspace = true
litellm-cache.workspace = true
litellm-cache-memory.workspace = true
litellm-python-compat.workspace = true
litellm-redis-client.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
tracing.workspace = true
[dev-dependencies]
litellm-circuit-breaker.workspace = true
litellm-redis-testing.workspace = true
litellm-secret-value.workspace = true
rstest.workspace = true
tokio.workspace = true

View file

@ -0,0 +1,3 @@
mod request;
pub use request::RequestRedis;

View file

@ -0,0 +1,121 @@
use std::{
future::Future,
sync::{Arc, Mutex, MutexGuard, PoisonError},
time::Duration,
};
use futures_util::future::join_all;
use litellm_redis_client::{RedisBatch, RedisClient};
use tokio::{runtime::Handle, sync::oneshot, task::JoinHandle};
/// One request's Redis batches: a pre-call batch for the reads it needs before answering and a
/// post-call batch for the writes nobody waits on, one of each per Redis client. The host that owns
/// the request builds one and passes it down; it knows nothing else about the request.
#[derive(Clone)]
pub struct RequestRedis(Arc<Shared>);
struct Shared {
runtime: Handle,
post_call_deadline: Duration,
batches: Mutex<Vec<ClientBatches>>,
follow_ups: Mutex<Vec<JoinHandle<()>>>,
deadline: Mutex<Option<oneshot::Sender<()>>>,
}
#[derive(Clone)]
struct ClientBatches {
client: RedisClient,
pre: RedisBatch,
post: RedisBatch,
}
impl RequestRedis {
/// `runtime` runs the post-call deadline, so declarations may come from any thread, including a
/// Python one that has no Tokio context.
pub fn new(runtime: Handle, post_call_deadline: Duration) -> Self {
Self(Arc::new(Shared {
runtime,
post_call_deadline,
batches: Mutex::new(Vec::new()),
follow_ups: Mutex::new(Vec::new()),
deadline: Mutex::new(None),
}))
}
pub fn pre(&self, client: &RedisClient) -> RedisBatch {
self.batches_for(client).pre
}
/// The post-call batch, which goes out on `flush_post` or `post_call_deadline` after the first
/// declaration, whichever comes first.
pub fn post(&self, client: &RedisClient) -> RedisBatch {
self.arm_deadline();
self.batches_for(client).post
}
/// Runs work that follows this request's post-call batch, such as mirroring settled totals
/// into memory, on the request's runtime, from any thread. `flush_post` waits for it, so the
/// shutdown drain does not drop a fallback write still in flight.
pub fn spawn(&self, task: impl Future<Output = ()> + Send + 'static) {
let follow_up = self.0.runtime.spawn(task);
lock(&self.0.follow_ups).push(follow_up);
}
pub async fn flush_pre(&self) {
join_all(self.batches().iter().map(|batches| batches.pre.flush())).await;
}
/// Sends every post-call op declared so far and waits for the follow-ups spawned on them; the
/// shutdown drain calls it too.
pub async fn flush_post(&self) {
lock(&self.0.deadline).take();
let follow_ups = std::mem::take(&mut *lock(&self.0.follow_ups));
join_all(self.batches().iter().map(|batches| batches.post.flush())).await;
join_all(follow_ups)
.await
.into_iter()
.filter_map(Result::err)
.for_each(|error| tracing::warn!(%error, "a post-call follow-up failed"));
}
fn batches(&self) -> Vec<ClientBatches> {
lock(&self.0.batches).clone()
}
fn batches_for(&self, client: &RedisClient) -> ClientBatches {
let mut batches = lock(&self.0.batches);
if let Some(existing) = batches
.iter()
.find(|batches| batches.client.same_client(client))
{
return existing.clone();
}
let created = ClientBatches {
client: client.clone(),
pre: client.batch(),
post: client.batch(),
};
batches.push(created.clone());
created
}
fn arm_deadline(&self) {
let mut deadline = lock(&self.0.deadline);
if deadline.is_some() {
return;
}
let (disarm, disarmed) = oneshot::channel();
*deadline = Some(disarm);
let request = self.clone();
self.0.runtime.spawn(async move {
tokio::select! {
() = tokio::time::sleep(request.0.post_call_deadline) => request.flush_post().await,
_ = disarmed => {}
}
});
}
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}

View file

@ -0,0 +1,157 @@
mod support;
use std::time::Duration;
use litellm_redis_client::{Expiry, Operation};
use litellm_redis_store::RequestRedis;
use litellm_redis_testing::{RedisDeployment, Topology};
use rstest::rstest;
use support::{connect, connect_observed};
use tokio::{runtime::Handle, sync::oneshot};
const KEEP: Expiry = Expiry::KeepExisting(Duration::from_secs(60));
const LONG_DEADLINE: Duration = Duration::from_secs(60);
#[rstest]
#[tokio::test]
async fn pre_declarations_on_one_client_share_one_pipeline_and_another_client_gets_its_own(
#[values(Topology::Standalone, Topology::Cluster)] topology: Topology,
) {
let deployment = RedisDeployment::start(topology).await.unwrap();
let (client, trips) = connect_observed(&deployment);
let (other, other_trips) = connect_observed(&deployment);
let request = RequestRedis::new(Handle::current(), LONG_DEADLINE);
let first = request.pre(&client).incr_by("a", 1, KEEP);
let second = request.pre(&client.clone()).incr_by("b", 2, KEEP);
let third = request.pre(&other).incr_by("c", 3, KEEP);
request.flush_pre().await;
assert_eq!(trips.seen(), [Operation::Pipeline { ops: 2 }]);
assert_eq!(other_trips.seen(), [Operation::Pipeline { ops: 1 }]);
assert_eq!(
[
first.resolve().await.unwrap(),
second.resolve().await.unwrap(),
third.resolve().await.unwrap(),
],
[1, 2, 3]
);
assert_eq!(trips.seen().len(), 1);
}
#[rstest]
#[tokio::test]
async fn a_post_declaration_waits_for_flush_post_and_flush_pre_leaves_it_alone(
#[values(Topology::Standalone, Topology::Cluster)] topology: Topology,
) {
let deployment = RedisDeployment::start(topology).await.unwrap();
let raw = connect(&deployment);
let (client, trips) = connect_observed(&deployment);
let request = RequestRedis::new(Handle::current(), LONG_DEADLINE);
let total = tokio::spawn(
request
.post(&client)
.incr_by_float("spend", 2.5, KEEP)
.settled(),
);
request.flush_pre().await;
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(!total.is_finished());
assert!(trips.seen().is_empty());
assert_eq!(raw.get::<String>("spend").await.unwrap(), None);
request.flush_post().await;
assert_eq!(total.await.unwrap().unwrap(), 2.5);
assert_eq!(trips.seen(), [Operation::Pipeline { ops: 1 }]);
}
#[rstest]
#[tokio::test]
async fn flush_post_returns_after_the_follow_ups_of_the_ops_it_sent(
#[values(Topology::Standalone, Topology::Cluster)] topology: Topology,
) {
let deployment = RedisDeployment::start(topology).await.unwrap();
let client = connect(&deployment);
let request = RequestRedis::new(Handle::current(), LONG_DEADLINE);
let total = request.post(&client).incr_by_float("spend", 2.5, KEEP);
let (mirror, mut mirrored) = oneshot::channel();
request.spawn(async move {
mirror.send(total.settled().await.unwrap()).unwrap();
});
request.flush_post().await;
assert_eq!(mirrored.try_recv(), Ok(2.5));
}
#[rstest]
#[tokio::test]
async fn a_post_batch_nobody_flushes_goes_out_at_the_deadline(
#[values(Topology::Standalone, Topology::Cluster)] topology: Topology,
) {
let deployment = RedisDeployment::start(topology).await.unwrap();
let (client, trips) = connect_observed(&deployment);
let request = RequestRedis::new(Handle::current(), Duration::from_millis(100));
let first = request.post(&client).incr_by("a", 1, KEEP);
let second = request.post(&client).incr_by("b", 2, KEEP);
let settled = tokio::time::timeout(Duration::from_secs(5), async {
(first.settled().await, second.settled().await)
})
.await
.unwrap();
assert_eq!((settled.0.unwrap(), settled.1.unwrap()), (1, 2));
assert_eq!(trips.seen(), [Operation::Pipeline { ops: 2 }]);
}
#[rstest]
#[tokio::test]
async fn a_post_declaration_from_a_thread_without_a_runtime_goes_out_at_the_deadline(
#[values(Topology::Standalone, Topology::Cluster)] topology: Topology,
) {
let deployment = RedisDeployment::start(topology).await.unwrap();
let (client, trips) = connect_observed(&deployment);
let request = RequestRedis::new(Handle::current(), Duration::from_millis(100));
let declared = std::thread::spawn({
let request = request.clone();
move || request.post(&client).incr_by_float("spend", 2.5, KEEP)
})
.join()
.expect("a Python thread has no Tokio context, and declaring there must not panic");
let total = tokio::time::timeout(Duration::from_secs(5), declared.settled())
.await
.unwrap();
assert_eq!(total.unwrap(), 2.5);
assert_eq!(trips.seen(), [Operation::Pipeline { ops: 1 }]);
}
#[rstest]
#[tokio::test]
async fn a_post_declaration_after_a_flush_arms_a_new_deadline(
#[values(Topology::Standalone, Topology::Cluster)] topology: Topology,
) {
let deployment = RedisDeployment::start(topology).await.unwrap();
let (client, trips) = connect_observed(&deployment);
let request = RequestRedis::new(Handle::current(), Duration::from_millis(300));
let early = request.post(&client).incr_by("a", 1, KEEP);
request.flush_post().await;
tokio::time::sleep(Duration::from_millis(200)).await;
let late = request.post(&client).incr_by("a", 1, KEEP);
tokio::time::sleep(Duration::from_millis(200)).await;
let flushed_before_its_deadline = trips.seen().len() > 1;
let late = tokio::time::timeout(Duration::from_secs(5), late.settled())
.await
.unwrap();
assert_eq!(early.settled().await.unwrap(), 1);
assert!(!flushed_before_its_deadline);
assert_eq!(late.unwrap(), 2);
}

View file

@ -0,0 +1,81 @@
#![allow(dead_code)] // each test binary compiles this module on its own and uses a different subset
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use litellm_circuit_breaker::{State, Transition};
use litellm_redis_client::{
CallEvent, ClusterEndpoint, ClusterNode, Credentials, Endpoint, Namespace, Operation,
RedisClient, RedisConfig, RedisEvents, Timeouts, TlsOptions,
};
use litellm_redis_testing::RedisDeployment;
pub fn connect(deployment: &RedisDeployment) -> RedisClient {
connect_with(deployment, None, None)
}
/// Every round trip a client made, as the client reports it.
#[derive(Default)]
pub struct RoundTrips(Mutex<Vec<Operation>>);
impl RoundTrips {
pub fn seen(&self) -> Vec<Operation> {
self.0.lock().unwrap().clone()
}
}
impl RedisEvents for RoundTrips {
fn call(&self, event: &CallEvent) {
self.0.lock().unwrap().push(event.operation);
}
fn breaker(&self, _transition: &Transition<State>) {}
}
pub fn connect_observed(deployment: &RedisDeployment) -> (RedisClient, Arc<RoundTrips>) {
let trips = Arc::new(RoundTrips::default());
(connect_with(deployment, None, Some(trips.clone())), trips)
}
pub fn connect_guarded(
deployment: &RedisDeployment,
breaker: litellm_circuit_breaker::Breaker,
) -> RedisClient {
connect_with(deployment, Some(breaker), None)
}
fn connect_with(
deployment: &RedisDeployment,
breaker: Option<litellm_circuit_breaker::Breaker>,
events: Option<Arc<dyn RedisEvents>>,
) -> RedisClient {
let endpoint = match deployment {
RedisDeployment::Standalone(server) => Endpoint::Url {
url: litellm_secret_value::SecretValue::new(server.url().to_owned()),
tls: TlsOptions::verified(),
},
RedisDeployment::Cluster(cluster) => Endpoint::Cluster(ClusterEndpoint {
nodes: cluster
.nodes()
.map(|(host, port)| ClusterNode {
host: host.to_owned(),
port,
})
.collect(),
tls: None,
}),
};
RedisClient::new(RedisConfig {
endpoint,
credentials: Credentials::NONE,
namespace: Namespace::default(),
timeouts: Timeouts::new(Duration::from_millis(500), None),
breaker,
events,
health_check: None,
})
.unwrap()
}

View file

@ -20,7 +20,7 @@ from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from datetime import timedelta
from types import MappingProxyType, TracebackType
from typing import Final, Generic, Protocol, TypeVar
from typing import TYPE_CHECKING, Final, Generic, Protocol, TypeAlias, TypeVar
from litellm._internal_context import (
REDIS_FAMILIES_METADATA_KEY,
@ -38,7 +38,11 @@ from litellm.caching.redis_cache import (
from litellm.caching.redis_cluster_cache import RedisClusterCache
from litellm.types.services import ServiceTypes
if TYPE_CHECKING:
from litellm.rust_bridge._native import NativeRequestRedis
_T = TypeVar("_T")
_T_co = TypeVar("_T_co", covariant=True)
_ScriptArg = str | bytes | int | float
SettledHook = Callable[[asyncio.Future[_T]], Awaitable[None] | None]
POST_CALL_FLUSH_DEADLINE_SECONDS: Final = 1.0
@ -443,6 +447,37 @@ def _backend_key(redis_cache: RedisCache) -> object:
return (type(redis_cache), redis_cache.namespace, settings)
class NativePending(Protocol[_T_co]):
def resolve(self) -> Awaitable[_T_co]: ...
class NativeBatchResult(Generic[_T]):
"""Awaitable handle for a read declared on the request's native pre-call batch; awaiting it runs the
native flush hooks, flushes that batch, then answers from what it read."""
__slots__ = ("_pending", "_request")
def __init__(self, request: RequestRedisBatches, pending: NativePending[_T]) -> None:
self._request: Final = request
self._pending: Final = pending
def __await__(self) -> Generator[object, None, _T]:
return self._wait().__await__()
async def _wait(self) -> _T:
await self._request.flush_native_pre()
return await self._pending.resolve()
NativeRequestFactory: TypeAlias = Callable[[float], "NativeRequestRedis"]
def _native_request(post_call_deadline: float) -> NativeRequestRedis:
from litellm.rust_bridge._native import NativeRequestRedis
return NativeRequestRedis(post_call_deadline)
_open_post_call: Final[weakref.WeakSet[RequestRedisBatches]] = weakref.WeakSet()
"""Requests whose post-call batch still holds declared ops, so a shutdown can send them before Redis goes away."""
@ -460,15 +495,25 @@ class RequestRedisBatches:
"_batches",
"_deadline",
"_deadline_flush",
"_native",
"_native_factory",
"_native_flush_hooks",
"_post_call",
"post_call_deadline",
"prefetched",
)
def __init__(self, post_call_deadline: float = POST_CALL_FLUSH_DEADLINE_SECONDS) -> None:
def __init__(
self,
post_call_deadline: float = POST_CALL_FLUSH_DEADLINE_SECONDS,
native_factory: NativeRequestFactory = _native_request,
) -> None:
self._batches: Final[dict[object, RedisBatch]] = {} # mutable-ok: lazily filled per backend
self._post_call: Final[dict[object, RedisBatch]] = {} # mutable-ok: lazily filled per backend
self.post_call_deadline: Final = post_call_deadline
self._native_factory: Final = native_factory
self._native: NativeRequestRedis | None = None
self._native_flush_hooks: Final[list[Callable[[], None]]] = [] # mutable-ok: append-only registry
self._deadline: asyncio.TimerHandle | None = None
self._deadline_flush: asyncio.Task[None] | None = None
# Reads declared early for a consumer that runs later in the request, keyed by consumer name.
@ -495,20 +540,49 @@ class RequestRedisBatches:
_open_post_call.add(self)
return batch
def native(self) -> NativeRequestRedis:
"""The request's native batches, built on first use so requests that touch no native store pay nothing."""
if self._native is None:
self._native = self._native_factory(self.post_call_deadline)
return self._native
def native_post_call(self) -> NativeRequestRedis:
"""``native`` for a post-call declaration, so a shutdown drain also flushes it."""
_open_post_call.add(self)
return self.native()
def native_result(self, pending: NativePending[_T]) -> NativeBatchResult[_T]:
return NativeBatchResult(self, pending)
def add_native_flush_hook(self, hook: Callable[[], None]) -> None:
"""Called before every native pre-call flush so late readers can declare into the same trip."""
self._native_flush_hooks.append(hook)
async def flush_native_pre(self) -> None:
for hook in self._native_flush_hooks:
hook()
if self._native is not None:
await self._native.flush_pre()
def _flush_on_deadline(self) -> None:
self._deadline = None
self._deadline_flush = asyncio.ensure_future(self.flush_post_call())
async def flush_all(self) -> None:
"""Send whatever is still declared (write-backs nobody awaits) before the request scope closes."""
await asyncio.gather(*(batch.flush() for batch in self._batches.values() if batch.pending))
await asyncio.gather(
*(batch.flush() for batch in self._batches.values() if batch.pending), self.flush_native_pre()
)
async def flush_post_call(self) -> None:
"""One pipeline per backend for the post-call writes; the deadline is disarmed since this is that flush."""
if self._deadline is not None:
self._deadline.cancel()
self._deadline = None
await asyncio.gather(*(batch.flush() for batch in self._post_call.values() if batch.pending))
await asyncio.gather(
*(batch.flush() for batch in self._post_call.values() if batch.pending),
*(() if self._native is None else (self._native.flush_post(),)),
)
if not any(batch.pending for batch in self._post_call.values()):
_open_post_call.discard(self)
@ -557,17 +631,24 @@ async def drain_post_call_redis_batches() -> None:
class request_redis_batch_scope:
"""Redis reads declared inside share one pipeline per backend; nested scopes join the outer one."""
__slots__ = ("_post_call_deadline", "_token")
__slots__ = ("_native_factory", "_post_call_deadline", "_token")
def __init__(self, post_call_deadline: float = POST_CALL_FLUSH_DEADLINE_SECONDS) -> None:
def __init__(
self,
post_call_deadline: float = POST_CALL_FLUSH_DEADLINE_SECONDS,
native_factory: NativeRequestFactory = _native_request,
) -> None:
self._token: Token[RequestRedisBatches | None] | None = None
self._post_call_deadline: Final = post_call_deadline
self._native_factory: Final = native_factory
def __enter__(self) -> RequestRedisBatches:
outer: Final = _active_request_batches.get()
if outer is not None:
return outer
batches: Final = RequestRedisBatches(post_call_deadline=self._post_call_deadline)
batches: Final = RequestRedisBatches(
post_call_deadline=self._post_call_deadline, native_factory=self._native_factory
)
self._token = _active_request_batches.set(batches)
return batches

View file

@ -472,8 +472,14 @@ _LIVE_BREAKERS: Final[weakref.WeakSet[RedisCircuitBreaker]] = weakref.WeakSet()
def redis_breaker_state_counts() -> Mapping[str, int]:
"""How many live Redis circuit breakers sit in each state, for the Prometheus services logger."""
return MappingProxyType(dict(Counter(breaker.state for breaker in _LIVE_BREAKERS)))
"""How many live Redis circuit breakers sit in each state, for the Prometheus services logger.
Counts the Python breakers of every live ``RedisCache`` and the breaker of every native Redis
client the router stores built, since each opens and closes on its own."""
from litellm.rust_bridge._native import native_redis_breaker_states
python_states: Final = Counter(breaker.state for breaker in _LIVE_BREAKERS)
return MappingProxyType(dict(python_states + Counter(native_redis_breaker_states())))
class RedisCircuitBreakerOpenError(Exception):

View file

View file

View file

@ -0,0 +1,21 @@
from collections.abc import Mapping
from typing import Final
from pydantic import TypeAdapter
from litellm.caching.redis_cache import RedisCache
from litellm.rust_bridge._native import NativeRedisClient
_KWARGS: Final = TypeAdapter(dict[str, object])
def _redis_kwargs(connection: RedisCache) -> Mapping[str, object]:
return _KWARGS.validate_python(
connection.redis_kwargs # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # untyped legacy dict
)
def native_client(connection: RedisCache | None) -> NativeRedisClient | None:
if connection is None:
return None
return NativeRedisClient.connect(_redis_kwargs(connection), namespace=connection.namespace)

View file

@ -15,6 +15,7 @@ class ProcessReservedForForking(RuntimeError): ...
def trace_encode_error(message: str) -> bytes: ...
def bundled_model_cost_map() -> bytes: ...
def native_redis_breaker_states() -> list[str]: ...
@final
class NativeClickHouseSpendConfig:
@ -26,6 +27,18 @@ class NativeClickHouseSpendStorage:
def ensure_schema(self) -> Future[None]: ...
def insert_rows(self, rows: Sequence[Mapping[str, object]]) -> Future[None]: ...
@final
class NativeRedisClient:
def __new__(cls, _uninstantiable: Never, /) -> Never: ...
@staticmethod
def connect(redis_kwargs: Mapping[str, object], *, namespace: str | None = None) -> NativeRedisClient | None: ...
@final
class NativeRequestRedis:
def __new__(cls, post_call_deadline_seconds: float) -> NativeRequestRedis: ...
def flush_pre(self) -> Future[None]: ...
def flush_post(self) -> Future[None]: ...
@final
class NativeDatabase:
@staticmethod
@ -216,6 +229,8 @@ __all__ = [
"NativeClickHouseSpendStorage",
"NativeDatabase",
"NativeDiagnosticProcessor",
"NativeRedisClient",
"NativeRequestRedis",
"ProcessReservedForForking",
"ResponsesWebSocketConnection",
"RustBridgeDeclined",
@ -233,6 +248,7 @@ __all__ = [
"embedding",
"gil_stats",
"messages",
"native_redis_breaker_states",
"ocr",
"ocr_health_check_document",
"ocr_passthrough_response",

View file

@ -2,7 +2,7 @@
Python state Rust needs but does not own, behind one module per question. Rust imports a module here by path and calls a function by name
- One module answers one question: `model_capabilities` (what a model can do), `callbacks_legacy_python` (the legacy callback registries), `settings` (configured snapshots), `cache` (the live configured cache), `diagnostics` (legacy failure logging), `secret_manager` (the configured secret manager and native config capture), `http_transport` (which async transport is in use), `timeouts` (timeout to seconds)
- One module answers one question: `model_capabilities` (what a model can do), `callbacks_legacy_python` (the legacy callback registries), `settings` (configured snapshots), `cache` (the live configured cache), `diagnostics` (legacy failure logging), `secret_manager` (the configured secret manager and native config capture), `http_transport` (which async transport is in use), `timeouts` (timeout to seconds), `redis_events` (reporting native Redis calls and breaker transitions to `ServiceLogging`)
- A module is the only place Rust reads that Python state. Rust never does `py.import("litellm").getattr(...)` on a global; it asks here
- Each module reuses the Python implementation the Python route runs (`AnthropicModelInfo`, `Logging`). Do not re-derive those rules here or in Rust; one resolver, two callers
- The module path and function signature are a contract. The Rust side pins the path in a `const MODULE` in the crate that asks (`python-bridge/src/routes/messages/host.rs`, `python-bridge/src/secrets/`, `callbacks-legacy-python/src/python.rs`); change both sides together and keep `tests/unit/rust_bridge/host/` green

View file

@ -0,0 +1,46 @@
"""Redis call and circuit breaker events from the native Redis client, reported the way ``RedisCache`` reports them."""
from __future__ import annotations
from functools import lru_cache
from typing import Final, Literal, TypeAlias
from litellm._service_logger import ServiceLogging
from litellm.types.services import ServiceTypes
Failure: TypeAlias = Literal["timeout", "transport", "server", "decode", "breaker_open"]
BreakerState: TypeAlias = Literal["closed", "open", "half_open"]
_HEALTH_FAILURE_CLASSES: Final = {"timeout": "timeout", "transport": "connectivity"}
class RedisCallError(Exception):
"""The failure class of a native Redis call, the only detail the Rust client hands over."""
@lru_cache(maxsize=1)
def _service_logging() -> ServiceLogging:
return ServiceLogging()
def record_redis_call(call_type: str, duration: float, failure: Failure | None, caller: str | None) -> None:
if failure == "breaker_open":
return
logger: Final = _service_logging()
if failure is None:
logger.service_success_hook(service=ServiceTypes.REDIS, duration=duration, call_type=call_type, caller=caller)
return
logger.service_failure_hook(
service=ServiceTypes.REDIS,
duration=duration,
error=RedisCallError(failure),
call_type=call_type,
caller=caller,
)
failure_class: Final = _HEALTH_FAILURE_CLASSES.get(failure)
if failure_class is not None:
logger.redis_breaker_hook(transitions_to=None, failure_class=failure_class)
def record_redis_breaker(state: BreakerState) -> None:
_service_logging().redis_breaker_hook(transitions_to=state, failure_class=None)

View file

@ -6,8 +6,9 @@ import asyncio
import hashlib
import json
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any
from typing import Any, Generic, TypeVar
import pytest
from redis.exceptions import NoScriptError
@ -28,6 +29,7 @@ from litellm.caching.redis_cache import (
from litellm.caching.redis_cluster_cache import RedisClusterCache
SCRIPT = "return redis.call('GET', KEYS[1])"
_Answer = TypeVar("_Answer")
SHA = hashlib.sha1(SCRIPT.encode()).hexdigest() # noqa: S324
@ -93,6 +95,7 @@ class FakeRedisCache(RedisCache):
def __init__(self, client: FakeClient, namespace: str | None = None) -> None: # super().__init__ needs a server
self.client = client
self.namespace = namespace
self.redis_kwargs = {"host": "127.0.0.1", "port": 1, "socket_timeout": 0.2, "db": id(self) % 1_000_000}
self._circuit_breaker = RedisCircuitBreaker(failure_threshold=5, recovery_timeout=30)
self.service_logger_obj = ServiceLogging()
self.default_ttl = None
@ -147,6 +150,43 @@ def replies(command: tuple[Any, ...]) -> Any:
raise AssertionError(command)
@dataclass
class FakeNativeRequest:
"""Stands in for ``NativeRequestRedis``: records each flush in ``events`` next to what the test's pendings log."""
post_call_deadline: float
events: list[str] = field(default_factory=list)
async def flush_pre(self) -> None:
self.events.append("flush_pre")
async def flush_post(self) -> None:
self.events.append("flush_post")
@dataclass
class FakeNatives:
built: list[FakeNativeRequest] = field(default_factory=list)
def __call__(self, post_call_deadline: float) -> FakeNativeRequest:
self.built.append(FakeNativeRequest(post_call_deadline))
return self.built[-1]
@dataclass
class FakeNativePending(Generic[_Answer]):
"""A read declared on a ``FakeNativeRequest``; it only answers once that request's pre-call batch was flushed."""
request: FakeNativeRequest
answer: Callable[[], _Answer]
async def resolve(self) -> _Answer:
if "flush_pre" not in self.request.events:
raise ConnectionError("resolved before its batch was flushed")
self.request.events.append("resolve")
return self.answer()
def make(fail: Exception | None = None, namespace: str | None = None) -> tuple[FakeRedisCache, FakeClient]:
client = FakeClient(replies, fail)
return FakeRedisCache(client, namespace), client

View file

View file

View file

@ -0,0 +1,24 @@
import uuid
from concurrent.futures import ThreadPoolExecutor
from typing import Final
import pytest
from litellm.rust_bridge._native import NativeRedisClient, native_redis_breaker_states
def test_concurrent_first_connects_share_one_client_without_deadlocking(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("REDIS_CIRCUIT_BREAKER_ENABLED", "true")
before: Final = len(native_redis_breaker_states())
namespace: Final = f"concurrent-connect-{uuid.uuid4()}"
pool: Final = ThreadPoolExecutor(max_workers=8)
futures: Final = tuple(
pool.submit(NativeRedisClient.connect, {"host": "127.0.0.1", "port": 1}, namespace=namespace) for _ in range(8)
)
try:
clients: Final = tuple(future.result(timeout=30) for future in futures)
finally:
pool.shutdown(wait=False)
assert all(client is not None for client in clients)
assert len(native_redis_breaker_states()) == before + 1

View file

@ -0,0 +1,81 @@
from typing import Final
import pytest
from prometheus_client import REGISTRY
import litellm
from litellm.rust_bridge.host import redis_events
def _sample(name: str, labels: dict[str, str]) -> float:
return REGISTRY.get_sample_value(name, labels) or 0.0
@pytest.fixture
def prometheus(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "service_callback", ["prometheus_system"])
redis_events._service_logging.cache_clear()
yield
redis_events._service_logging.cache_clear()
@pytest.mark.parametrize(
("failure", "failure_class"),
[("timeout", "timeout"), ("transport", "connectivity")],
)
def test_a_health_failure_counts_as_a_failed_request_and_a_breaker_failure(
prometheus: None, failure: redis_events.Failure, failure_class: str
):
failed: Final = (
"litellm_redis_failed_requests_total",
{"redis": "redis", "error_class": "RedisCallError", "function_name": "async_increment"},
)
breaker: Final = ("litellm_redis_circuit_breaker_failures_total", {"failure_class": failure_class})
before: Final = (_sample(*failed), _sample(*breaker))
redis_events.record_redis_call("async_increment", 0.25, failure, "caller.py")
assert (_sample(*failed), _sample(*breaker)) == (before[0] + 1, before[1] + 1)
@pytest.mark.parametrize("failure", ["server", "decode"])
def test_a_rejected_command_is_a_failed_request_but_not_a_breaker_failure(
prometheus: None, failure: redis_events.Failure
):
breaker: Final = {
failure_class: _sample("litellm_redis_circuit_breaker_failures_total", {"failure_class": failure_class})
for failure_class in ("timeout", "connectivity")
}
failed: Final = (
"litellm_redis_failed_requests_total",
{"redis": "redis", "error_class": "RedisCallError", "function_name": "async_increment"},
)
before: Final = _sample(*failed)
redis_events.record_redis_call("async_increment", 0.25, failure, None)
assert _sample(*failed) == before + 1
assert {
failure_class: _sample("litellm_redis_circuit_breaker_failures_total", {"failure_class": failure_class})
for failure_class in ("timeout", "connectivity")
} == breaker
def test_a_success_is_counted_and_a_breaker_rejection_is_not(prometheus: None):
total: Final = ("litellm_redis_total_requests_total", {"redis": "redis"})
before: Final = _sample(*total)
redis_events.record_redis_call("async_get_cache", 0.01, None, None)
redis_events.record_redis_call("async_get_cache", 0.0, "breaker_open", None)
assert _sample(*total) == before + 1
@pytest.mark.parametrize("state", ["open", "half_open", "closed"])
def test_breaker_transitions_are_counted_by_state(prometheus: None, state: redis_events.BreakerState):
labels: Final = {"state": state}
before: Final = _sample("litellm_redis_circuit_breaker_transitions_total", labels)
redis_events.record_redis_breaker(state)
assert _sample("litellm_redis_circuit_breaker_transitions_total", labels) == before + 1