use crate::{
ClientError, Error, ErrorKind, RedisError, RedisErrorKind, Result, TimeoutKind,
commands::StringCommands,
};
use bytes::Bytes;
use serial_test::serial;
fn redis(kind: RedisErrorKind) -> Error {
Error::from(ErrorKind::Redis(RedisError {
kind,
description: Bytes::new(),
}))
}
fn client(client_error: ClientError) -> Error {
Error::from(ErrorKind::Client(client_error))
}
fn timeout(kind: TimeoutKind) -> Error {
Error::from(ErrorKind::Timeout(kind))
}
fn io() -> Error {
Error::from(ErrorKind::IO(std::sync::Arc::new(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"reset",
))))
}
#[test]
fn moved_error() {
let raw_error = b"MOVED 3999 127.0.0.1:6381";
let error = RedisError::try_from(&raw_error[..]);
println!("error: {error:?}");
assert!(matches!(
error,
Ok(RedisError {
kind: RedisErrorKind::Moved { hash_slot: 3999, address: (host, 6381) },
description
}) if description.is_empty() && host == "127.0.0.1"
));
}
#[test]
fn ask_error() {
let raw_error = b"ASK 3999 127.0.0.1:6381";
let error = RedisError::try_from(&raw_error[..]);
assert!(matches!(
error,
Ok(RedisError {
kind: RedisErrorKind::Ask { hash_slot: 3999, address: (host, 6381) },
description
}) if description.is_empty() && host == "127.0.0.1"
));
}
#[test]
fn moved_error_ipv6() {
let raw_error = b"MOVED 3999 2001:db8::1:6380";
let error = RedisError::try_from(&raw_error[..]);
println!("error: {error:?}");
assert!(matches!(
error,
Ok(RedisError {
kind: RedisErrorKind::Moved { hash_slot: 3999, address: (host, 6380) },
description
}) if description.is_empty() && host == "2001:db8::1"
));
}
#[test]
fn an_error_carries_no_command_until_one_is_attached() {
let error = Error::from(ErrorKind::Timeout(TimeoutKind::Command));
assert!(matches!(error.kind(), ErrorKind::Timeout(_)));
assert_eq!(None, error.command());
assert!(error.context().is_none());
assert_eq!(
ErrorKind::Timeout(TimeoutKind::Command).to_string(),
error.to_string()
);
}
#[test]
fn attaching_a_command_names_it_in_the_context_and_the_message() {
let error = Error::from(ErrorKind::Timeout(TimeoutKind::Command))
.with_command(Bytes::from_static(b"BLMPOP"));
assert_eq!(Some("BLMPOP"), error.command());
assert_eq!("BLMPOP", error.context().unwrap().command());
assert!(
error.to_string().contains("BLMPOP"),
"the rendered message must name the command, got {error}"
);
assert!(matches!(error.kind(), ErrorKind::Timeout(_)));
}
#[test]
fn the_innermost_command_wins() {
let error = Error::from(ErrorKind::Timeout(TimeoutKind::Command))
.with_command(Bytes::from_static(b"GET"))
.with_command(Bytes::from_static(b"SET"));
assert_eq!(Some("GET"), error.command());
}
#[test]
fn the_error_type_keeps_its_bounds() {
const fn assert_bounds<T: std::error::Error + Send + Sync + Clone + 'static>() {}
assert_bounds::<Error>();
let boxed: Box<dyn std::error::Error + Send + Sync> = Box::new(
Error::from(ErrorKind::Timeout(TimeoutKind::Command))
.with_command(Bytes::from_static(b"GET")),
);
assert!(boxed.to_string().contains("GET"));
}
#[test]
fn a_connection_error_is_told_from_a_command_error() {
assert!(io().is_connection_error());
assert!(Error::from(ErrorKind::EOF).is_connection_error());
assert!(Error::from(ErrorKind::DisconnectedByPeer).is_connection_error());
assert!(client(ClientError::CannotParseInteger).is_connection_error());
assert!(client(ClientError::UnknownRespTag('@')).is_connection_error());
assert!(!redis(RedisErrorKind::WrongType).is_connection_error());
assert!(!client(ClientError::MismatchedKeySlots).is_connection_error());
assert!(!client(ClientError::CannotParseBytes).is_connection_error());
assert!(!timeout(TimeoutKind::Command).is_connection_error());
assert!(!Error::from(ErrorKind::Aborted).is_connection_error());
}
#[test]
fn a_timeout_is_its_own_class() {
assert!(timeout(TimeoutKind::Command).is_timeout());
assert!(timeout(TimeoutKind::Connect).is_timeout());
assert!(!io().is_timeout());
assert!(!redis(RedisErrorKind::TryAgain).is_timeout());
assert!(!timeout(TimeoutKind::Command).is_server_error());
assert!(!timeout(TimeoutKind::Command).is_connection_error());
}
#[test]
fn a_timeout_names_the_deadline_that_expired() {
assert!(matches!(
timeout(TimeoutKind::Connect).kind(),
ErrorKind::Timeout(TimeoutKind::Connect)
));
assert!(matches!(
timeout(TimeoutKind::Command).kind(),
ErrorKind::Timeout(TimeoutKind::Command)
));
assert_ne!(
timeout(TimeoutKind::Connect).to_string(),
timeout(TimeoutKind::Command).to_string()
);
}
#[cfg(feature = "tokio-runtime")]
#[tokio::test]
#[serial]
async fn each_deadline_reports_its_own_kind() -> Result<()> {
use crate::{
client::{Client, Config, IntoConfig},
tests::fake_server::HELLO_REPLY,
};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn silent_server(
answer_handshake: bool,
) -> Result<(std::net::SocketAddr, tokio::task::JoinHandle<()>)> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let server = tokio::spawn(async move {
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
let mut chunk = [0u8; 1024];
if answer_handshake {
if stream.read(&mut chunk).await.is_err() {
return;
}
if stream.write_all(HELLO_REPLY).await.is_err() {
return;
}
}
while stream.read(&mut chunk).await.is_ok_and(|n| n > 0) {}
});
Ok((addr, server))
}
let (addr, server) = silent_server(false).await?;
let mut config: Config = format!("redis://{addr}").into_config()?;
config.connect_timeout = Duration::from_millis(200);
let Err(error) = Client::connect(config).await else {
panic!("the handshake is never answered, so the connection cannot complete")
};
server.abort();
assert!(
matches!(error.kind(), ErrorKind::Timeout(TimeoutKind::Connect)),
"a connection that never completes is a connect timeout: {error:?}"
);
let (addr, server) = silent_server(true).await?;
let mut config: Config = format!("redis://{addr}").into_config()?;
config.command_timeout = Duration::from_millis(200);
let client = Client::connect(config).await?;
let error = client
.get::<String>("key")
.await
.expect_err("the command is never answered");
server.abort();
assert!(
matches!(error.kind(), ErrorKind::Timeout(TimeoutKind::Command)),
"a command that never gets its reply is a command timeout: {error:?}"
);
Ok(())
}
#[test]
fn an_internal_failure_names_the_condition() {
for error in [
ClientError::MalformedFrame,
ClientError::InconsistentRespTape,
ClientError::NotACollection,
ClientError::MissingTransactionReply,
ClientError::IncompatibleShardReplies,
ClientError::NotAUnitVariant,
ClientError::MissingMapValue,
] {
let message = error.to_string();
assert_ne!(
"Unexpected error", message,
"{error:?} must say what happened"
);
assert!(!message.is_empty());
}
}
#[test]
fn a_framing_failure_belongs_to_the_connection() {
assert!(client(ClientError::MalformedFrame).is_connection_error());
assert!(!client(ClientError::InconsistentRespTape).is_connection_error());
assert!(!client(ClientError::NotACollection).is_connection_error());
assert!(!client(ClientError::MissingTransactionReply).is_connection_error());
assert!(!client(ClientError::IncompatibleShardReplies).is_connection_error());
}
#[test]
fn a_server_error_is_a_reply_the_server_chose_to_send() {
assert!(redis(RedisErrorKind::WrongType).is_server_error());
assert!(redis(RedisErrorKind::Other).is_server_error());
assert!(!io().is_server_error());
assert!(!client(ClientError::CannotParseInteger).is_server_error());
}
#[test]
fn a_retryable_error_covers_every_transient_layer() {
assert!(io().is_retryable());
assert!(Error::from(ErrorKind::EOF).is_retryable());
assert!(timeout(TimeoutKind::Command).is_retryable());
assert!(timeout(TimeoutKind::Connect).is_retryable());
assert!(redis(RedisErrorKind::TryAgain).is_retryable());
assert!(redis(RedisErrorKind::ClusterDown).is_retryable());
assert!(redis(RedisErrorKind::MasterDown).is_retryable());
assert!(redis(RedisErrorKind::NoMasterLink).is_retryable());
assert!(!redis(RedisErrorKind::WrongType).is_retryable());
assert!(!redis(RedisErrorKind::NoAuth).is_retryable());
assert!(!redis(RedisErrorKind::Err).is_retryable());
assert!(!client(ClientError::MismatchedKeySlots).is_retryable());
assert!(!Error::from(ErrorKind::Aborted).is_retryable());
}
#[test]
fn a_non_utf8_server_message_keeps_its_bytes() -> Result<()> {
let raw: &[u8] = b"ERR unknown command '\xff\xfe'";
let error = RedisError::try_from(raw)?;
assert_eq!(b"unknown command '\xff\xfe'", error.description_bytes());
assert!(error.description().contains('\u{fffd}'));
assert_eq!("unknown command '��'", error.description());
Ok(())
}
#[test]
fn a_rendered_error_has_no_stray_space() -> Result<()> {
let unclassified = RedisError::try_from(b"SOMEWEIRD failure text".as_slice())?;
assert_eq!("SOMEWEIRD failure text", unclassified.to_string());
assert_eq!(
"redis server error: SOMEWEIRD failure text",
Error::from(ErrorKind::Redis(unclassified)).to_string()
);
let moved = RedisError::try_from(b"MOVED 3999 127.0.0.1:6381".as_slice())?;
assert_eq!("MOVED 3999 127.0.0.1:6381", moved.to_string());
let wrong_type = RedisError::try_from(b"WRONGTYPE Operation against a key".as_slice())?;
assert_eq!("WRONGTYPE Operation against a key", wrong_type.to_string());
Ok(())
}