refactor(redis-resolve): name the crate error Error and keep only resolver failures in it

RedisError becomes Error, the one top-level error the rust-error skill asks of
each crate, and gateway nests it as litellm_redis_resolve::Error.

NotPlainData moves to gateway as Error::RedisNotPlainData, since only the
gateway merge turns a YAML value into JSON. Unenforceable now carries the check
a Reject row asks for, so its message is not hard-coded to OCSP
This commit is contained in:
Yujong Lee 2026-11-05 12:42:13 +09:00
parent 185c026603
commit f9e19055d2
15 changed files with 121 additions and 105 deletions

View file

@ -13,7 +13,9 @@ pub enum Error {
#[error(transparent)]
Config(#[from] litellm_config::Error),
#[error(transparent)]
Redis(#[from] litellm_redis_resolve::RedisError),
Redis(#[from] litellm_redis_resolve::Error),
#[error("Redis setting {setting} is not plain data")]
RedisNotPlainData { setting: String },
#[error(transparent)]
RedisConnect(#[from] litellm_redis_connect::Error),
#[error("MCP requires working gateway authentication")]

View file

@ -2,11 +2,12 @@ use std::collections::BTreeMap;
use litellm_config::{RedisSettings, Value};
use litellm_core_utils::settings::Lookup;
use litellm_redis_resolve::{Expected, Kwargs, RedisError};
use litellm_redis_resolve::{Expected, Kwargs};
use litellm_redis_types::{Auth, AzureAdAuth, ProviderHandle, Resolution};
use veil::Redact;
use super::{layers, secrets::SecretSnapshot};
use crate::Error;
const CACHE_ONLY: &[&str] = &[
"type",
@ -44,12 +45,14 @@ pub(super) enum Nulls {
impl ConfiguredRedis {
/// `general_settings.coordination_redis`, which the proxy validates as a pydantic model first.
/// An `os.environ/` port is only known after merging, where it is read like any other port.
pub(crate) fn coordination(settings: &RedisSettings) -> Result<Self, RedisError> {
pub(crate) fn coordination(
settings: &RedisSettings,
) -> Result<Self, litellm_redis_resolve::Error> {
if let Some(port) = settings.fields().get("port").and_then(text)
&& !port.starts_with("os.environ/")
&& port.parse::<u16>().is_err()
{
return Err(RedisError::Invalid {
return Err(litellm_redis_resolve::Error::Invalid {
setting: "port".to_owned(),
expected: Expected::Integer,
});
@ -113,7 +116,7 @@ impl ConfiguredRedis {
/// `_merge_redis_kwargs` in `litellm/_redis.py`: the kwargs over their `REDIS_*` values with every
/// `get_secret` fallback filled.
pub(crate) fn merged(&self, secrets: &SecretSnapshot) -> Result<Kwargs, RedisError> {
pub(crate) fn merged(&self, secrets: &SecretSnapshot) -> Result<Kwargs, Error> {
layers::merged(self, secrets)
}
@ -122,7 +125,7 @@ impl ConfiguredRedis {
&self,
secrets: &SecretSnapshot,
environment: &dyn Lookup,
) -> Result<Resolution, RedisError> {
) -> Result<Resolution, Error> {
let resolution =
litellm_redis_resolve::interpret(&self.merged(secrets)?, self.credential_provider)?;
Ok(match resolution.auth {

View file

@ -1,8 +1,9 @@
use litellm_config::Value;
use litellm_core_utils::settings::Lookup;
use litellm_redis_resolve::{Entry, Kwargs, RedisError, SETTINGS, is_present, setting};
use litellm_redis_resolve::{Entry, Kwargs, SETTINGS, is_present, setting};
use super::configured::{ConfiguredRedis, Nulls};
use crate::Error;
const NAMED_BY_CACHE: &[&str] = &[
"host",
@ -47,17 +48,14 @@ const AWS: &[(&str, Read)] = &[("aws_iam_region", Read::SecretStr)];
/// The configured settings over their `REDIS_*` values, `{**environment_kwargs, **env_overrides}`
/// in `_merge_redis_kwargs`, with the `get_secret` fallbacks it reads filled in. A null left in the result is
/// an explicit `None` that hid its environment value.
pub(super) fn merged(
configured: &ConfiguredRedis,
lookup: &dyn Lookup,
) -> Result<Kwargs, RedisError> {
pub(super) fn merged(configured: &ConfiguredRedis, lookup: &dyn Lookup) -> Result<Kwargs, Error> {
let block: Kwargs = configured
.fields
.iter()
.map(|(key, value)| (key, environment_reference(value, lookup)))
.filter(|(key, value)| !value.is_null() || keeps_null(configured.nulls, key))
.map(|(key, value)| {
let value = serde_json::to_value(&value).map_err(|_| RedisError::NotPlainData {
let value = serde_json::to_value(&value).map_err(|_| Error::RedisNotPlainData {
setting: key.clone(),
})?;
let entry = Entry {
@ -66,7 +64,7 @@ pub(super) fn merged(
};
Ok((key.clone(), entry))
})
.collect::<Result<_, RedisError>>()?;
.collect::<Result<_, Error>>()?;
let names_a_target = ["host", "startup_nodes", "sentinel_nodes"]
.iter()
.any(|key| is_set(&block, key));

View file

@ -4,7 +4,7 @@ use litellm_circuit_breaker::{Breaker, Config as BreakerConfig, State};
use litellm_config::{CacheSettings, Config, RedisSettings, Value};
use litellm_core_utils::settings::Lookup;
use litellm_redis_client::RedisClient;
use litellm_redis_resolve::{Expected, RedisError};
use litellm_redis_resolve::Expected;
use litellm_redis_types::Resolution;
use crate::Error;
@ -113,7 +113,7 @@ fn redis_cache_params(config: &Config) -> Option<&RedisSettings> {
}
}
fn resolve(configured: &ConfiguredRedis, lookup: &dyn Lookup) -> Result<Params, RedisError> {
fn resolve(configured: &ConfiguredRedis, lookup: &dyn Lookup) -> Result<Params, Error> {
let secrets = SecretSnapshot::read(configured.secret_names(), lookup);
let resolution = configured.resolve(&secrets, lookup)?;
for ignored in &resolution.ignored {
@ -246,11 +246,11 @@ fn environment_value<T>(
name: &str,
expected: Expected,
parse: impl Fn(&str) -> Option<T>,
) -> Result<Option<T>, RedisError> {
) -> Result<Option<T>, litellm_redis_resolve::Error> {
lookup
.get(name)
.map(|value| {
parse(&value).ok_or_else(|| RedisError::Invalid {
parse(&value).ok_or_else(|| litellm_redis_resolve::Error::Invalid {
setting: name.to_owned(),
expected,
})
@ -264,7 +264,6 @@ mod tests {
use litellm_config::Config;
use litellm_redis_client::{Standalone, StaticAuth};
use litellm_redis_resolve::RedisError;
use litellm_redis_testing::RedisServer;
use litellm_redis_types::{Auth, HealthCheck, Passthrough};
use rstest::rstest;
@ -472,7 +471,7 @@ mod tests {
.unwrap_err();
assert!(
matches!(error, Error::Redis(RedisError::Invalid { setting, .. }) if setting == expected)
matches!(error, Error::Redis(litellm_redis_resolve::Error::Invalid { setting, .. }) if setting == expected)
);
}

View file

@ -1,7 +1,7 @@
use std::collections::{BTreeMap, HashMap};
use litellm_config::{CacheSettings, Config, Value};
use litellm_redis_resolve::{Expected, RedisError};
use litellm_redis_resolve::Expected;
use litellm_redis_types::{
Auth, AwsIamAuth, AzureAdAuth, Node, ProviderHandle, Resolution, StaticAuth, Target, TlsOptions,
};
@ -10,11 +10,12 @@ use rstest::rstest;
use serde_json::json;
use super::{configured::ConfiguredRedis, secrets::SecretSnapshot};
use crate::Error;
fn resolution(
configured: &ConfiguredRedis,
environment: &HashMap<String, String>,
) -> Result<Resolution, RedisError> {
) -> Result<Resolution, Error> {
let lookup = |name: &str| environment.get(name).cloned();
configured.resolve(
&SecretSnapshot::read(configured.secret_names(), &lookup),
@ -40,14 +41,11 @@ fn redis_cache_kwargs(kwargs: serde_json::Value) -> ConfiguredRedis {
fn resolve_kwargs(
kwargs: serde_json::Value,
environment: &HashMap<String, String>,
) -> Result<Resolution, RedisError> {
) -> Result<Resolution, Error> {
resolution(&redis_cache_kwargs(kwargs), environment)
}
fn coordination_resolution(
yaml: &str,
environment: &[(&str, &str)],
) -> Result<Resolution, RedisError> {
fn coordination_resolution(yaml: &str, environment: &[(&str, &str)]) -> Result<Resolution, Error> {
let environment: HashMap<String, String> = environment
.iter()
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
@ -61,15 +59,15 @@ fn coordination_resolution(
resolution(&configured, &environment)
}
fn resolve(yaml: &str, environment: &[(&str, &str)]) -> Result<Resolution, RedisError> {
fn resolve(yaml: &str, environment: &[(&str, &str)]) -> Result<Resolution, Error> {
coordination_resolution(yaml, environment)
}
fn resolve_cache(yaml: &str) -> Result<Resolution, RedisError> {
fn resolve_cache(yaml: &str) -> Result<Resolution, Error> {
resolve_cache_in(yaml, &[])
}
fn resolve_cache_in(yaml: &str, environment: &[(&str, &str)]) -> Result<Resolution, RedisError> {
fn resolve_cache_in(yaml: &str, environment: &[(&str, &str)]) -> Result<Resolution, Error> {
let lookup = |name: &str| {
environment
.iter()
@ -209,7 +207,7 @@ fn an_ocsp_check_fails_instead_of_connecting_without_it(
let error = coordination_resolution(yaml, environment).unwrap_err();
assert!(
matches!(&error, RedisError::Unenforceable { setting: named } if named == setting),
matches!(&error, Error::Redis(litellm_redis_resolve::Error::Unenforceable { setting: named, .. }) if named == setting),
"{error:?}"
);
}
@ -325,7 +323,10 @@ fn a_credential_provider_without_a_host_handle_fails(
let error = resolve(yaml, environment).unwrap_err();
assert!(
matches!(error, RedisError::ProviderWithoutHandle),
matches!(
error,
Error::Redis(litellm_redis_resolve::Error::ProviderWithoutHandle)
),
"{error}"
);
}
@ -393,7 +394,7 @@ fn an_unknown_cert_requirement_fails() {
assert!(matches!(
error,
RedisError::Invalid { setting, expected: Expected::CertRequirement } if setting == "ssl_cert_reqs"
Error::Redis(litellm_redis_resolve::Error::Invalid { setting, expected: Expected::CertRequirement }) if setting == "ssl_cert_reqs"
));
}
@ -467,7 +468,13 @@ fn sentinels_without_a_service_name_fail() {
)
.unwrap_err();
assert!(matches!(error, RedisError::NoSentinelService), "{error:?}");
assert!(
matches!(
error,
Error::Redis(litellm_redis_resolve::Error::NoSentinelService)
),
"{error:?}"
);
}
#[rstest]
@ -489,7 +496,7 @@ fn a_host_or_port_cannot_reach_sentinel(
let error = resolve(yaml, environment).unwrap_err();
assert!(
matches!(&error, RedisError::SentinelWithHost { setting } if setting == source),
matches!(&error, Error::Redis(litellm_redis_resolve::Error::SentinelWithHost { setting }) if setting == source),
"{error:?}"
);
}
@ -513,7 +520,7 @@ fn invalid_sentinel_nodes_name_their_source(
let error = resolve(yaml, environment).unwrap_err();
assert!(
matches!(&error, RedisError::Invalid { setting, expected: Expected::SentinelNodes } if setting == source),
matches!(&error, Error::Redis(litellm_redis_resolve::Error::Invalid { setting, expected: Expected::SentinelNodes }) if setting == source),
"{error:?}"
);
}
@ -530,7 +537,7 @@ fn invalid_values_name_their_source(
let error = resolve(yaml, environment).unwrap_err();
assert!(
matches!(&error, RedisError::Invalid { setting, .. } if setting == source),
matches!(&error, Error::Redis(litellm_redis_resolve::Error::Invalid { setting, .. }) if setting == source),
"{error:?}"
);
}
@ -572,7 +579,10 @@ fn connect_timeout_is_resolved_separately(
fn a_block_without_a_usable_target_fails(#[case] yaml: &str, #[case] environment: &[(&str, &str)]) {
let error = resolve(yaml, environment).unwrap_err();
assert!(matches!(error, RedisError::NoTarget), "{error:?}");
assert!(
matches!(error, Error::Redis(litellm_redis_resolve::Error::NoTarget)),
"{error:?}"
);
}
#[rstest]
@ -642,7 +652,7 @@ fn rejects_bad_socket_timeout(#[case] timeout: &str) {
assert!(matches!(
error,
RedisError::Invalid { setting, expected: Expected::Seconds } if setting == "socket_timeout"
Error::Redis(litellm_redis_resolve::Error::Invalid { setting, expected: Expected::Seconds }) if setting == "socket_timeout"
));
}

View file

@ -1,7 +1,7 @@
## Interpreting merged kwargs
- The boundary is merged redis-py kwargs in, `litellm_redis_types::Resolution` out. `interpret` takes the dict a host already merged the way `_merge_redis_kwargs` in `litellm/_redis.py` does, with its `REDIS_*` values and `get_secret` fallbacks filled, and stands in for redis-py's constructors and `from_url`. It never reads the environment or a secret, so both hosts share it. Every `Entry` keeps the source a warning or error names, which `kwargs` sets to the kwarg name and the gateway merge sets to `REDIS_<KWARG>` or the fallback name
- The `Resolution` holds the target, the auth and the timeouts Rust models, every other plain setting verbatim in `passthrough`, the names of object-valued settings in `host_objects`, and every setting the Rust client ignores as an `Ignored` with its source and consequence, which the host logs. A host passes its `credential_provider` object as a `ProviderHandle`, and a `credential_provider` key without one is `RedisError::ProviderWithoutHandle`. Azure's username is read from `os.environ` by Python, so `interpret` leaves `AzureAdAuth::username` unset and the host fills it in
- The `Resolution` holds the target, the auth and the timeouts Rust models, every other plain setting verbatim in `passthrough`, the names of object-valued settings in `host_objects`, and every setting the Rust client ignores as an `Ignored` with its source and consequence, which the host logs. A host passes its `credential_provider` object as a `ProviderHandle`, and a `credential_provider` key without one is `Error::ProviderWithoutHandle`. Azure's username is read from `os.environ` by Python, so `interpret` leaves `AzureAdAuth::username` unset and the host fills it in
- `options.rs` reads the kwargs into the typed `Options` and splits the unmodeled settings into passthrough and host objects and collects ignored ones. `endpoint.rs`, `auth.rs` and `tls.rs` pick the target, the credentials and TLS from `Options` alone
- Rust never introspects redis-py. Every Redis kwarg redis-py or `litellm/_redis.py` accepts is one row in `SETTINGS` in `src/keys.rs`, and that row is the whole answer for the kwarg: its `Handling` (`Read`, `Warn` with the consequence the warning names, or `Reject`), its `Shape` (plain data lands in `passthrough`, a Python object in `host_objects`), whether a url query carries it (`FromUrl`), whether `_get_redis_env_kwarg_mapping` reads it as `REDIS_<KWARG>`, and the names `_merge_redis_kwargs` falls back to. The last two columns are only read by the gateway merge, and they stay on the row so there is one table. A `Read` row is a field of `Options`, which an inline test pins. A kwarg with no row is not a Redis setting, so it passes through without a warning
- `redis-py-kwargs.json` holds the Python facts each row answers: which redis-py versions name the kwarg in which signature, and whether Python reads its `REDIS_*` name under the locked redis-py. `test_redis_py_kwargs_catalog_matches_the_installed_redis_py` in `tests/unit/test_redis.py` checks it against each version the `redis-compat` CI matrix installs, and `tests/catalog.rs` fails until `SETTINGS` has exactly one row per kwarg in it with the same `environment`. A redis-py bump that adds a kwarg therefore cannot land without a row deciding how Rust handles it
@ -10,11 +10,11 @@
- TLS options and `db` are only read for the endpoint that uses them, so a bad `ssl_cert_reqs` never fails a url target. A cluster with a non-zero `db`, and Sentinel with a `host` or `port`, are errors because redis-py raises building those clients
- An empty value (`""`, `[]`, `{}`) is the same as an unset one, like Python's `x or ...`
- Precedence matches Python. The target is cluster, then URL, then Sentinel (`sentinel_nodes` plus `service_name`), then host. Auth is a `credential_provider` handle, then GCP, then Azure, then AWS, and the first one wins with a warning
- Numbers are read with `litellm_python_compat::number`, so whitespace and digit underscores parse like Python's `int()` and `float()`. A value they cannot parse (`port: abc`, `socket_timeout: slow`) is dropped with a warning and the default is used, like `_coerce_redis_kwargs_types`. A number that parses but is out of range (negative seconds, a port above 65535) fails with `RedisError::Invalid`
- Flags (`ssl`, `ssl_check_hostname`, `aws_iam_auth`, `aws_iam_serverless`) are read one way on every surface. `true`, `1` and `yes` are true and `false`, `0`, `no`, `off`, `f` and `n` are false, the text redis-py's `_str_to_bool` and the proxy's pydantic models agree on. Any other text is `RedisError::Invalid`, so a TLS setting is never guessed
- The resolver fails on a setting it cannot honor only when that setting is a security check. A `Warn` row is dropped and returned as an `Ignored` naming its source (`ssl_ciphers` or `REDIS_SSL_CIPHERS`) and the consequence on its row, because a Python deployment that worked must keep working when its stores move to Rust. Only a value that is malformed (`RedisError::Invalid`) or a target that is missing fails. Promoting a `Warn` row to `Read` is then a pure improvement with no behavior cliff
- Numbers are read with `litellm_python_compat::number`, so whitespace and digit underscores parse like Python's `int()` and `float()`. A value they cannot parse (`port: abc`, `socket_timeout: slow`) is dropped with a warning and the default is used, like `_coerce_redis_kwargs_types`. A number that parses but is out of range (negative seconds, a port above 65535) fails with `Error::Invalid`
- Flags (`ssl`, `ssl_check_hostname`, `aws_iam_auth`, `aws_iam_serverless`) are read one way on every surface. `true`, `1` and `yes` are true and `false`, `0`, `no`, `off`, `f` and `n` are false, the text redis-py's `_str_to_bool` and the proxy's pydantic models agree on. Any other text is `Error::Invalid`, so a TLS setting is never guessed
- The resolver fails on a setting it cannot honor only when that setting is a security check. A `Warn` row is dropped and returned as an `Ignored` naming its source (`ssl_ciphers` or `REDIS_SSL_CIPHERS`) and the consequence on its row, because a Python deployment that worked must keep working when its stores move to Rust. Only a value that is malformed (`Error::Invalid`) or a target that is missing fails. Promoting a `Warn` row to `Read` is then a pure improvement with no behavior cliff
- A `Warn` row with a `met` check is dropped silently when its value asks for nothing more than Rust does, so `ssl_min_version` of TLS 1.2 or lower never warns
- A `Reject` row fails closed. `ssl_validate_ocsp` and `ssl_validate_ocsp_stapled` set to true, from the kwargs, `REDIS_*` or a url query, are `RedisError::Unenforceable`, because rustls has no client-side OCSP and connecting without the check would silently drop it. redis-py 5.3.1's async client refuses both with a `TypeError`, so this matches the proxy's path. Set to false they ask for nothing and are dropped without a warning
- A `Reject` row fails closed. `ssl_validate_ocsp` and `ssl_validate_ocsp_stapled` set to true, from the kwargs, `REDIS_*` or a url query, are `Error::Unenforceable`, because rustls has no client-side OCSP and connecting without the check would silently drop it. redis-py 5.3.1's async client refuses both with a `TypeError`, so this matches the proxy's path. Set to false they ask for nothing and are dropped without a warning
- `ssl_ca_data` and `ssl_ca_path` are read into `TlsOptions` and trusted next to `ssl_ca_certs`. `ssl_password` is read into `TlsOptions::key_password`, which `redis-client` uses to decrypt a PKCS#8 key like the sync client's `load_cert_chain(password=...)`. redis-py 5.3.1's async client refuses it, so this is a superset of the path the proxy uses, like `ssl_ca_path`
- `unix_socket_path` resolves to `Target::Unix`, which like redis-py's `UnixDomainSocketConnection` replaces host, port and TLS. Python still requires `host` next to it, so Rust does too
- A url's `?db=`, `?username=` and `?password=` are read like `parse_url` reads them, so `?db=` wins over the path and the userinfo wins over `?username=` and `?password=`. Any other query parameter, including one with no row, is ignored naming `url ?<name>`, because redis-py turns each one into a client kwarg. Which ones a url carries is the `FromUrl` column
@ -22,5 +22,5 @@
- `health_check_interval` is read as an `int` of seconds like `_coerce_redis_kwargs_types` into `HealthCheck`. Zero is `Disabled` and stays off on every target, a negative interval checks before every command like redis-py, and only `Unset` takes a default. `redis-connect` gives an unset cluster 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
- 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
- `on`, `t` and `y` for a flag are `Error::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
- Empty `sentinel_nodes` with no `REDIS_SENTINEL_NODES` is no target at all, so it fails as "either host or url must be specified". redis-py keeps the empty list and fails building the Sentinel client

View file

@ -1,17 +1,17 @@
use litellm_redis_types::{Auth, AwsIamAuth, AzureAdAuth, ProviderHandle, StaticAuth};
use crate::{
RedisError,
Error,
options::{self, AwsOptions, Options, flag, secret, text},
};
pub(super) fn redis_auth(
options: &Options,
provider: Option<ProviderHandle>,
) -> Result<Auth, RedisError> {
) -> Result<Auth, Error> {
match (provider, &options.credential_provider) {
(Some(handle), _) => return Ok(Auth::Provider(handle)),
(None, Some(_)) => return Err(RedisError::ProviderWithoutHandle),
(None, Some(_)) => return Err(Error::ProviderWithoutHandle),
(None, None) => {}
}
let gcp = text(options.gcp.service_account.as_ref());
@ -54,7 +54,7 @@ pub(super) fn redis_auth(
}
// _uses_tls: a url's scheme decides unless cluster nodes are set, otherwise the ssl kwarg does
fn uses_tls(options: &Options) -> Result<bool, RedisError> {
fn uses_tls(options: &Options) -> Result<bool, Error> {
match (&options.startup_nodes, text(options.url.as_ref())) {
(None, Some(url)) => Ok(url
.split_once("://")
@ -63,9 +63,9 @@ fn uses_tls(options: &Options) -> Result<bool, RedisError> {
}
}
fn aws_iam(aws: &AwsOptions, uses_tls: bool) -> Result<AwsIamAuth, RedisError> {
fn aws_iam(aws: &AwsOptions, uses_tls: bool) -> Result<AwsIamAuth, Error> {
if !uses_tls {
return Err(RedisError::AwsIamWithoutTls);
return Err(Error::AwsIamWithoutTls);
}
match (
text(aws.user_name.as_ref()),
@ -78,7 +78,7 @@ fn aws_iam(aws: &AwsOptions, uses_tls: bool) -> Result<AwsIamAuth, RedisError> {
region,
serverless: options::flag(aws.serverless.as_ref())?,
}),
(user_name, cache_name, region) => Err(RedisError::AwsIamIncomplete {
(user_name, cache_name, region) => Err(Error::AwsIamIncomplete {
missing: [
("aws_iam_user_name", user_name.is_none()),
("aws_iam_cache_name", cache_name.is_none()),

View file

@ -7,14 +7,14 @@ use litellm_secret_value::SecretValue;
use serde_json::Value;
use crate::{
Entry, Expected, RedisError,
Entry, Error, Expected,
options::{Options, as_text, flag, integer, invalid, secret, text},
tls,
};
/// The target, first match wins like `get_redis_client`: cluster, url, Sentinel, then host. A
/// url also returns the query parameters it ignores.
pub(super) fn target(options: &Options, auth: &Auth) -> Result<(Target, Vec<Ignored>), RedisError> {
pub(super) fn target(options: &Options, auth: &Auth) -> Result<(Target, Vec<Ignored>), Error> {
if let Some(nodes) = cluster_nodes(options.startup_nodes.as_ref())? {
reject_cluster_db(options.db.as_ref())?;
let cluster = Cluster {
@ -41,7 +41,7 @@ pub(super) fn target(options: &Options, auth: &Auth) -> Result<(Target, Vec<Igno
let sentinel = sentinel(options, auth, sentinels)?;
return Ok((Target::Sentinel(sentinel), Vec::new()));
}
let host = text(options.host.as_ref()).ok_or(RedisError::NoTarget)?;
let host = text(options.host.as_ref()).ok_or(Error::NoTarget)?;
let target = match text(options.unix_socket_path.as_ref()) {
Some(path) => Target::Unix(UnixSocket {
path: path.into(),
@ -57,7 +57,7 @@ pub(super) fn target(options: &Options, auth: &Auth) -> Result<(Target, Vec<Igno
Ok((target, Vec::new()))
}
fn tls_for(options: &Options, auth: &Auth) -> Result<Option<TlsOptions>, RedisError> {
fn tls_for(options: &Options, auth: &Auth) -> Result<Option<TlsOptions>, Error> {
if !flag(options.ssl.as_ref())? {
return Ok(None);
}
@ -68,19 +68,19 @@ fn tls_for(options: &Options, auth: &Auth) -> Result<Option<TlsOptions>, RedisEr
tls::tls_options(&options.tls, gcp_ca_certs).map(Some)
}
fn db(options: &Options) -> Result<i64, RedisError> {
fn db(options: &Options) -> Result<i64, Error> {
integer(options.db.as_ref()).map(|db| db.unwrap_or(0))
}
fn sentinel(options: &Options, auth: &Auth, sentinels: &Entry) -> Result<Sentinel, RedisError> {
fn sentinel(options: &Options, auth: &Auth, sentinels: &Entry) -> Result<Sentinel, Error> {
let monitors = sentinel_nodes(&sentinels.value)
.ok_or_else(|| invalid(&sentinels.source, Expected::SentinelNodes))?;
let service_name =
text(options.sentinel.service_name.as_ref()).ok_or(RedisError::NoSentinelService)?;
text(options.sentinel.service_name.as_ref()).ok_or(Error::NoSentinelService)?;
// redis.asyncio.Sentinel passes the connection kwargs to each monitor's Redis(host=..., port=...), so a
// host or port setting raises TypeError when litellm builds the client
if let Some(entry) = options.host.as_ref().or(options.port.as_ref()) {
return Err(RedisError::SentinelWithHost {
return Err(Error::SentinelWithHost {
setting: entry.source.clone(),
});
}
@ -107,7 +107,7 @@ fn monitor_auth(options: &Options, auth: &Auth) -> Option<StaticAuth> {
})
}
fn cluster_nodes(entry: Option<&Entry>) -> Result<Option<Vec<Node>>, RedisError> {
fn cluster_nodes(entry: Option<&Entry>) -> Result<Option<Vec<Node>>, Error> {
let Some(entry) = entry else {
return Ok(None);
};
@ -149,7 +149,7 @@ fn node_address(host: Option<String>, port: Option<String>) -> Option<Node> {
}
// redis-py's RedisCluster raises unless db is 0 or None
fn reject_cluster_db(db: Option<&Entry>) -> Result<(), RedisError> {
fn reject_cluster_db(db: Option<&Entry>) -> Result<(), Error> {
let Some(entry) = db else {
return Ok(());
};
@ -161,7 +161,7 @@ fn reject_cluster_db(db: Option<&Entry>) -> Result<(), RedisError> {
};
match is_zero {
true => Ok(()),
false => Err(RedisError::DbInCluster {
false => Err(Error::DbInCluster {
setting: entry.source.clone(),
}),
}

View file

@ -1,5 +1,5 @@
#[derive(Debug, thiserror::Error)]
pub enum RedisError {
pub enum Error {
#[error("invalid Redis setting {setting}: expected {expected}")]
Invalid { setting: String, expected: Expected },
#[error("either host or url must be specified for redis")]
@ -13,11 +13,12 @@ pub enum RedisError {
#[error("credential_provider is a host object, and the host passed no handle for it")]
ProviderWithoutHandle,
#[error(
"{setting} asks for OCSP revocation checks, which rustls cannot perform, so Redis is not connected without them"
"{setting} asks for {check}, which rustls cannot perform, so Redis is not connected without them"
)]
Unenforceable { setting: String },
#[error("Redis setting {setting} is not plain data")]
NotPlainData { setting: String },
Unenforceable {
setting: String,
check: &'static str,
},
#[error("AWS ElastiCache IAM Redis authentication requires TLS")]
AwsIamWithoutTls,
#[error("AWS ElastiCache IAM Redis authentication requires: {}", missing.join(", "))]

View file

@ -13,7 +13,7 @@ pub enum Handling {
met: Option<fn(&Value) -> bool>,
},
/// A security check the Rust client cannot perform, so setting it to true fails instead of connecting without it.
Reject,
Reject { check: &'static str },
}
/// What the value is, which decides where an unread setting lands in a `Resolution`.
@ -127,8 +127,8 @@ const fn warn_unless(
)
}
const fn reject(kwarg: &'static str) -> Setting {
Setting::new(kwarg, Handling::Reject)
const fn reject(kwarg: &'static str, check: &'static str) -> Setting {
Setting::new(kwarg, Handling::Reject { check })
}
const NO_EQUIVALENT: &str = "the Rust client has no equivalent";
@ -137,6 +137,7 @@ const OWN_CONNECTION: &str = "it tunes redis-py's own connection, and the Rust c
const OWN_CLUSTER: &str = "the Rust cluster client discovers, routes and retries on its own";
const MAINTENANCE: &str = "the Rust client does not follow server maintenance notifications";
const RUSTLS: &str = "rustls negotiates TLS 1.2+ with its own ECDHE AEAD suites";
const REVOCATION: &str = "OCSP revocation checks";
const OCSP: &str = "it only configures ssl_validate_ocsp, which the Rust client refuses when set";
const VERIFY_FLAGS: &str = "rustls verifies certificates with its own fixed rules, so no extra X.509 flag such as CRL checking applies";
@ -249,8 +250,8 @@ pub const SETTINGS: &[Setting] = &[
warn("ssl_ocsp_context", OCSP).env().object(),
warn("ssl_ocsp_expected_cert", OCSP).env(),
read("ssl_password").env().in_url(FromUrl::Rediss),
reject("ssl_validate_ocsp").env(),
reject("ssl_validate_ocsp_stapled").env(),
reject("ssl_validate_ocsp", REVOCATION).env(),
reject("ssl_validate_ocsp_stapled", REVOCATION).env(),
read("startup_nodes").or_secrets(&["REDIS_CLUSTER_NODES"]),
read("unix_socket_path").env(),
read("url").env(),
@ -277,8 +278,12 @@ pub fn setting(kwarg: &str) -> Option<&'static Setting> {
SETTINGS.iter().find(|setting| setting.kwarg == kwarg)
}
pub(crate) fn is_refused_when_set(kwarg: &str) -> bool {
setting(kwarg).is_some_and(|setting| matches!(setting.handling, Handling::Reject))
/// The check a `Reject` row asks for when it is set to true.
pub(crate) fn refused_check(kwarg: &str) -> Option<&'static str> {
match setting(kwarg)?.handling {
Handling::Reject { check } => Some(check),
Handling::Read | Handling::Warn { .. } => None,
}
}
/// A warned setting the kwargs set, with what dropping it means. A kwarg with no row is not a Redis setting, so it

View file

@ -10,7 +10,7 @@ mod keys;
mod options;
mod tls;
pub use error::{Expected, RedisError};
pub use error::{Error, Expected};
pub use keys::{FromUrl, Handling, SETTINGS, Setting, Shape, setting};
pub use options::is_present;
@ -41,10 +41,7 @@ pub fn kwargs(fields: impl IntoIterator<Item = (String, Value)>) -> Kwargs {
/// What redis-py's constructors make of kwargs a host already merged the way `_get_redis_client_logic` does. A
/// host passes its `credential_provider` object as `provider`.
pub fn interpret(
kwargs: &Kwargs,
provider: Option<ProviderHandle>,
) -> Result<Resolution, RedisError> {
pub fn interpret(kwargs: &Kwargs, provider: Option<ProviderHandle>) -> Result<Resolution, Error> {
let (options, unmodeled) = Options::read(kwargs)?;
let auth = auth::redis_auth(&options, provider)?;
let (target, url_ignored) = endpoint::target(&options, &auth)?;
@ -70,7 +67,7 @@ pub fn interpret(
// redis-py checks health only when the interval is truthy, so zero turns it off and a negative interval checks
// before every command
fn health_check(entry: Option<&Entry>) -> Result<HealthCheck, RedisError> {
fn health_check(entry: Option<&Entry>) -> Result<HealthCheck, Error> {
Ok(match options::integer::<i64>(entry)? {
None => HealthCheck::Unset,
Some(0) => HealthCheck::Disabled,

View file

@ -6,7 +6,7 @@ use litellm_secret_value::SecretValue;
use serde_json::Value;
use crate::{
Entry, Expected, Kwargs, RedisError,
Entry, Error, Expected, Kwargs,
keys::{self, Shape},
};
@ -79,7 +79,7 @@ pub(super) struct Unmodeled {
}
impl Unmodeled {
fn read(kwargs: &Kwargs) -> Result<Self, RedisError> {
fn read(kwargs: &Kwargs) -> Result<Self, Error> {
refuse_unenforceable(kwargs.iter())?;
let unmodeled = || {
kwargs
@ -105,7 +105,7 @@ impl Unmodeled {
impl Options {
/// The typed settings and the ones the resolver leaves to the host.
pub(super) fn read(kwargs: &Kwargs) -> Result<(Self, Unmodeled), RedisError> {
pub(super) fn read(kwargs: &Kwargs) -> Result<(Self, Unmodeled), Error> {
let unmodeled = Unmodeled::read(kwargs)?;
let options = Self::from_lookup(|key| {
kwargs
@ -171,13 +171,14 @@ impl Options {
/// A `Reject` row that is set to true asks for a check Rust cannot perform. One set to false asks for nothing.
pub(super) fn refuse_unenforceable<'a>(
entries: impl IntoIterator<Item = (&'a String, &'a Entry)>,
) -> Result<(), RedisError> {
) -> Result<(), Error> {
entries
.into_iter()
.filter(|(key, _)| keys::is_refused_when_set(key))
.try_for_each(|(_, entry)| match flag(Some(entry))? {
true => Err(RedisError::Unenforceable {
.filter_map(|(key, entry)| Some((keys::refused_check(key)?, entry)))
.try_for_each(|(check, entry)| match flag(Some(entry))? {
true => Err(Error::Unenforceable {
setting: entry.source.clone(),
check,
}),
false => Ok(()),
})
@ -194,8 +195,8 @@ pub fn is_present(value: &Value) -> bool {
}
}
pub(super) fn invalid(setting: &str, expected: Expected) -> RedisError {
RedisError::Invalid {
pub(super) fn invalid(setting: &str, expected: Expected) -> Error {
Error::Invalid {
setting: setting.to_owned(),
expected,
}
@ -221,7 +222,7 @@ pub(super) fn secret(entry: Option<&Entry>) -> Option<SecretValue> {
/// A flag every Python surface reads the same way. `_str_to_bool` takes only `true`, `1` and `yes`
/// as true while the proxy's pydantic models also take `on`, `t` and `y`, so text they disagree on,
/// or text neither accepts, is an error instead of a guess.
pub(super) fn flag(entry: Option<&Entry>) -> Result<bool, RedisError> {
pub(super) fn flag(entry: Option<&Entry>) -> Result<bool, Error> {
let Some(entry) = entry else {
return Ok(false);
};
@ -241,20 +242,20 @@ pub(super) fn parsed<T>(
entry: Option<&Entry>,
expected: Expected,
parse: impl Fn(&str) -> Option<T>,
) -> Result<Option<T>, RedisError> {
) -> Result<Option<T>, Error> {
entry
.and_then(|entry| Some((as_text(&entry.value)?, &entry.source)))
.map(|(text, source)| parse(&text).ok_or_else(|| invalid(source, expected)))
.transpose()
}
pub(super) fn integer<T: TryFrom<BigInt>>(entry: Option<&Entry>) -> Result<Option<T>, RedisError> {
pub(super) fn integer<T: TryFrom<BigInt>>(entry: Option<&Entry>) -> Result<Option<T>, Error> {
coerced(entry, Expected::Integer, number::int, |number| {
T::try_from(number).ok()
})
}
pub(super) fn seconds(entry: Option<&Entry>) -> Result<Option<Duration>, RedisError> {
pub(super) fn seconds(entry: Option<&Entry>) -> Result<Option<Duration>, Error> {
coerced(entry, Expected::Seconds, number::float, |seconds| {
Duration::try_from_secs_f64(seconds).ok()
})
@ -265,7 +266,7 @@ fn coerced<N, T>(
expected: Expected,
parse: impl Fn(&str) -> Option<N>,
convert: impl Fn(N) -> Option<T>,
) -> Result<Option<T>, RedisError> {
) -> Result<Option<T>, Error> {
let Some((entry, text)) = entry.and_then(|entry| Some((entry, as_text(&entry.value)?))) else {
return Ok(None);
};

View file

@ -5,7 +5,7 @@ use litellm_secret_value::SecretValue;
use serde_json::Value;
use crate::{
Entry, Expected, RedisError,
Entry, Error, Expected,
keys::{self, FromUrl},
options::{self, TlsInput, flag, parsed, secret, text},
};
@ -14,7 +14,7 @@ use crate::{
pub(super) fn tls_options(
input: &TlsInput,
gcp_ca_certs: Option<&Entry>,
) -> Result<TlsOptions, RedisError> {
) -> Result<TlsOptions, Error> {
Ok(TlsOptions {
ca_certs: text(gcp_ca_certs.or(input.ca_certs.as_ref())).map(Into::into),
ca_data: text(input.ca_data.as_ref()),
@ -27,7 +27,7 @@ pub(super) fn tls_options(
})
}
fn verify_certificate(cert_reqs: Option<&Entry>) -> Result<bool, RedisError> {
fn verify_certificate(cert_reqs: Option<&Entry>) -> Result<bool, Error> {
parsed(cert_reqs, Expected::CertRequirement, |text| match text {
"required" | "optional" => Some(true),
"none" => Some(false),
@ -45,7 +45,7 @@ pub(super) struct UrlQuery {
pub(super) ignored: Vec<Ignored>,
}
pub(super) fn url_query(url: &str) -> Result<UrlQuery, RedisError> {
pub(super) fn url_query(url: &str) -> Result<UrlQuery, Error> {
let Ok(url) = url::Url::parse(url) else {
return Ok(UrlQuery {
tls: None,

View file

@ -1,6 +1,6 @@
use std::collections::{BTreeMap, BTreeSet};
use litellm_redis_resolve::{Handling, RedisError, SETTINGS, Shape, interpret, kwargs};
use litellm_redis_resolve::{Error, Handling, SETTINGS, Shape, interpret, kwargs};
use rstest::rstest;
use serde::Deserialize;
use serde_json::json;
@ -81,13 +81,13 @@ fn every_warned_setting_is_reported_and_still_reaches_the_host() {
fn every_rejected_setting_fails_when_asked_for() {
let rejected = SETTINGS
.iter()
.filter(|setting| matches!(setting.handling, Handling::Reject));
.filter(|setting| matches!(setting.handling, Handling::Reject { .. }));
for setting in rejected {
let error = interpret(&host_with(setting.kwarg, json!(true)), None).unwrap_err();
assert!(
matches!(&error, RedisError::Unenforceable { setting: source } if source == setting.kwarg),
matches!(&error, Error::Unenforceable { setting: source, .. } if source == setting.kwarg),
"{error:?}"
);
}

View file

@ -1,6 +1,6 @@
use std::collections::BTreeMap;
use litellm_redis_resolve::{Entry, Expected, Kwargs, RedisError, interpret, kwargs};
use litellm_redis_resolve::{Entry, Error, Expected, Kwargs, interpret, kwargs};
use litellm_redis_types::{
Auth, HealthCheck, Node, ProviderHandle, Resolution, StaticAuth, Target, TlsOptions,
};
@ -27,14 +27,14 @@ fn merged(fields: serde_json::Value, sources: &[(&str, &str)]) -> Kwargs {
fn interpret_merged(
fields: serde_json::Value,
sources: &[(&str, &str)],
) -> Result<Resolution, RedisError> {
) -> Result<Resolution, Error> {
let has_provider = fields
.get("credential_provider")
.is_some_and(|provider| !provider.is_null());
interpret(&merged(fields, sources), has_provider.then_some(PROVIDER))
}
fn interpret_kwargs(fields: serde_json::Value) -> Result<Resolution, RedisError> {
fn interpret_kwargs(fields: serde_json::Value) -> Result<Resolution, Error> {
interpret_merged(fields, &[])
}
@ -93,7 +93,7 @@ fn a_url_query_db_that_is_not_an_integer_fails_like_parse_url() {
let error = interpret_kwargs(json!({"url": "redis://cache:6379?db=first"})).unwrap_err();
assert!(
matches!(error, RedisError::Invalid { setting, expected: Expected::Integer } if setting == "url ?db")
matches!(error, Error::Invalid { setting, expected: Expected::Integer } if setting == "url ?db")
);
}