1use super::{CacheBackend, CacheError, Result, memory::MemoryCache};
10use async_trait::async_trait;
11use redis::{AsyncCommands, ExistenceCheck, SetExpiry, SetOptions};
12use std::future::Future;
13use std::sync::Mutex;
14use std::time::{Duration, Instant};
15
16const REDIS_CACHE_OPERATION_TIMEOUT: Duration = Duration::from_millis(250);
17const REDIS_CACHE_CONNECTION_TIMEOUT: Duration = Duration::from_millis(500);
18const REDIS_CACHE_RECONNECT_MIN_DELAY: Duration = Duration::from_millis(100);
19const REDIS_CACHE_RECONNECT_MAX_DELAY: Duration = Duration::from_millis(500);
20const REDIS_CACHE_RECONNECT_RETRIES: usize = 1;
21const REDIS_CACHE_FALLBACK_COOLDOWN: Duration = Duration::from_secs(5);
22
23fn escape_scan_glob_literal(prefix: &str) -> String {
31 prefix
32 .replace('\\', "\\\\")
33 .replace('*', "\\*")
34 .replace('?', "\\?")
35 .replace('[', "\\[")
36 .replace(']', "\\]")
37}
38
39pub struct RedisCache {
41 inner: RedisCacheInner<RedisConnectionManager>,
42}
43
44struct RedisCacheInner<R> {
45 redis: R,
46 default_ttl: u64,
47 local: MemoryCache,
48 availability: RedisAvailability,
49}
50
51#[async_trait]
52trait RedisClient: Send + Sync {
53 async fn get(&self, key: &str) -> redis::RedisResult<Option<Vec<u8>>>;
54 async fn take(&self, key: &str) -> redis::RedisResult<Option<Vec<u8>>>;
55 async fn set_ex(&self, key: &str, value: Vec<u8>, ttl: u64) -> redis::RedisResult<()>;
56 async fn set_nx_ex(
57 &self,
58 key: &str,
59 value: Vec<u8>,
60 ttl: u64,
61 ) -> redis::RedisResult<Option<String>>;
62 async fn delete(&self, key: &str) -> redis::RedisResult<()>;
63 async fn scan_prefix(
64 &self,
65 cursor: u64,
66 pattern: &str,
67 ) -> redis::RedisResult<(u64, Vec<String>)>;
68 async fn delete_keys(&self, keys: &[String]) -> redis::RedisResult<()>;
69 async fn ping(&self) -> redis::RedisResult<String>;
70}
71
72struct RedisConnectionManager {
73 conn: redis::aio::ConnectionManager,
74}
75
76impl RedisConnectionManager {
77 fn new(conn: redis::aio::ConnectionManager) -> Self {
78 Self { conn }
79 }
80}
81
82#[async_trait]
83impl RedisClient for RedisConnectionManager {
84 async fn get(&self, key: &str) -> redis::RedisResult<Option<Vec<u8>>> {
85 let mut conn = self.conn.clone();
86 conn.get::<_, Option<Vec<u8>>>(key).await
87 }
88
89 async fn take(&self, key: &str) -> redis::RedisResult<Option<Vec<u8>>> {
90 let mut conn = self.conn.clone();
91 redis::Script::new(
92 r#"
93 local value = redis.call("GET", KEYS[1])
94 if value then
95 redis.call("DEL", KEYS[1])
96 end
97 return value
98 "#,
99 )
100 .key(key)
101 .invoke_async::<Option<Vec<u8>>>(&mut conn)
102 .await
103 }
104
105 async fn set_ex(&self, key: &str, value: Vec<u8>, ttl: u64) -> redis::RedisResult<()> {
106 let mut conn = self.conn.clone();
107 conn.set_ex::<_, _, ()>(key, value, ttl).await
108 }
109
110 async fn set_nx_ex(
111 &self,
112 key: &str,
113 value: Vec<u8>,
114 ttl: u64,
115 ) -> redis::RedisResult<Option<String>> {
116 let options = SetOptions::default()
117 .conditional_set(ExistenceCheck::NX)
118 .with_expiration(SetExpiry::EX(ttl));
119 let mut conn = self.conn.clone();
120 conn.set_options::<_, _, Option<String>>(key, value, options)
121 .await
122 }
123
124 async fn delete(&self, key: &str) -> redis::RedisResult<()> {
125 let mut conn = self.conn.clone();
126 conn.del::<_, ()>(key).await
127 }
128
129 async fn scan_prefix(
130 &self,
131 cursor: u64,
132 pattern: &str,
133 ) -> redis::RedisResult<(u64, Vec<String>)> {
134 let mut conn = self.conn.clone();
135 let mut scan_cmd = redis::cmd("SCAN");
136 scan_cmd
137 .arg(cursor)
138 .arg("MATCH")
139 .arg(pattern)
140 .arg("COUNT")
141 .arg(100)
142 .query_async::<(u64, Vec<String>)>(&mut conn)
143 .await
144 }
145
146 async fn delete_keys(&self, keys: &[String]) -> redis::RedisResult<()> {
147 let mut conn = self.conn.clone();
148 conn.del::<_, ()>(keys).await
149 }
150
151 async fn ping(&self) -> redis::RedisResult<String> {
152 let mut conn = self.conn.clone();
153 redis::cmd("PING").query_async::<String>(&mut conn).await
154 }
155}
156
157impl RedisCache {
158 pub async fn new(url: &str, default_ttl: u64) -> Result<Self> {
160 let client = redis::Client::open(url)?;
161 let manager_config = redis::aio::ConnectionManagerConfig::new()
162 .set_response_timeout(Some(REDIS_CACHE_OPERATION_TIMEOUT))
163 .set_connection_timeout(Some(REDIS_CACHE_CONNECTION_TIMEOUT))
164 .set_min_delay(REDIS_CACHE_RECONNECT_MIN_DELAY)
165 .set_max_delay(REDIS_CACHE_RECONNECT_MAX_DELAY)
166 .set_number_of_retries(REDIS_CACHE_RECONNECT_RETRIES);
167 let conn = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
168 Ok(Self {
169 inner: RedisCacheInner::new(RedisConnectionManager::new(conn), default_ttl),
170 })
171 }
172}
173
174impl<R> RedisCacheInner<R>
175where
176 R: RedisClient,
177{
178 fn new(redis: R, default_ttl: u64) -> Self {
179 Self {
180 redis,
181 default_ttl,
182 local: MemoryCache::new(default_ttl),
183 availability: RedisAvailability::default(),
184 }
185 }
186
187 async fn get_local_bytes(&self, key: &str) -> Option<Vec<u8>> {
188 self.local.get_bytes(key).await
189 }
190
191 async fn set_local_bytes(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>) {
192 self.local.set_bytes(key, value, ttl_secs).await;
193 }
194
195 async fn set_local_bytes_if_absent(
196 &self,
197 key: &str,
198 value: Vec<u8>,
199 ttl_secs: Option<u64>,
200 ) -> bool {
201 self.local.set_bytes_if_absent(key, value, ttl_secs).await
202 }
203
204 async fn delete_local(&self, key: &str) {
205 self.local.delete(key).await;
206 }
207
208 async fn invalidate_local_prefix(&self, prefix: &str) {
209 self.local.invalidate_prefix(prefix).await;
210 }
211
212 async fn redis_operation<T, Fut>(&self, operation: &'static str, future: Fut) -> Option<T>
213 where
214 T: Send,
215 Fut: Future<Output = redis::RedisResult<T>> + Send,
216 {
217 if let Some(remaining) = self.redis_unavailable_for() {
218 tracing::trace!(
219 operation,
220 remaining_ms = duration_millis_u64(remaining),
221 "redis cache circuit open; skipping redis operation"
222 );
223 return None;
224 }
225
226 match tokio::time::timeout(REDIS_CACHE_OPERATION_TIMEOUT, future).await {
227 Ok(Ok(value)) => {
228 self.mark_redis_success(operation);
229 Some(value)
230 }
231 Ok(Err(error)) => {
232 self.mark_redis_error(operation, &error);
233 None
234 }
235 Err(_) => {
236 self.mark_redis_timeout(operation);
237 None
238 }
239 }
240 }
241
242 fn redis_unavailable_for(&self) -> Option<Duration> {
243 self.availability.unavailable_for(Instant::now())
244 }
245
246 fn mark_redis_success(&self, operation: &'static str) {
247 if self.availability.mark_success() {
248 tracing::info!(operation, "redis cache recovered; closing fallback circuit");
249 }
250 }
251
252 fn mark_redis_error(&self, operation: &'static str, error: &redis::RedisError) {
253 if !redis_error_indicates_unavailability(error) {
254 tracing::warn!(
255 operation,
256 error = %error,
257 "redis cache command error; leaving fallback circuit closed"
258 );
259 return;
260 }
261 if self
262 .availability
263 .mark_failure(Instant::now(), REDIS_CACHE_FALLBACK_COOLDOWN)
264 {
265 tracing::warn!(
266 operation,
267 error = %error,
268 cooldown_secs = REDIS_CACHE_FALLBACK_COOLDOWN.as_secs(),
269 "redis cache unavailable; using local fallback temporarily"
270 );
271 } else {
272 tracing::debug!(
273 operation,
274 error = %error,
275 "redis cache operation failed while fallback circuit is already open"
276 );
277 }
278 }
279
280 fn mark_redis_timeout(&self, operation: &'static str) {
281 if self
282 .availability
283 .mark_failure(Instant::now(), REDIS_CACHE_FALLBACK_COOLDOWN)
284 {
285 tracing::warn!(
286 operation,
287 timeout_ms = duration_millis_u64(REDIS_CACHE_OPERATION_TIMEOUT),
288 cooldown_secs = REDIS_CACHE_FALLBACK_COOLDOWN.as_secs(),
289 "redis cache operation timed out; using local fallback temporarily"
290 );
291 } else {
292 tracing::debug!(
293 operation,
294 timeout_ms = duration_millis_u64(REDIS_CACHE_OPERATION_TIMEOUT),
295 "redis cache operation timed out while fallback circuit is already open"
296 );
297 }
298 }
299}
300
301impl<R> RedisCacheInner<R>
302where
303 R: RedisClient,
304{
305 async fn health_check(&self) -> Result<()> {
306 if let Some(remaining) = self.redis_unavailable_for() {
307 return Err(CacheError::RedisFallbackMode {
308 remaining_ms: remaining.as_millis(),
309 });
310 }
311
312 match tokio::time::timeout(REDIS_CACHE_OPERATION_TIMEOUT, self.redis.ping()).await {
313 Ok(Ok(_)) => {
314 self.mark_redis_success("health_check");
315 Ok(())
316 }
317 Ok(Err(error)) => {
318 self.mark_redis_error("health_check", &error);
319 Err(CacheError::RedisHealthCheck(error.to_string()))
320 }
321 Err(_) => {
322 self.mark_redis_timeout("health_check");
323 Err(CacheError::RedisHealthCheckTimeout {
324 timeout_ms: REDIS_CACHE_OPERATION_TIMEOUT.as_millis(),
325 })
326 }
327 }
328 }
329
330 async fn get_bytes(&self, key: &str) -> Option<Vec<u8>> {
331 match self.redis_operation("get", self.redis.get(key)).await {
332 Some(value) => {
336 self.delete_local(key).await;
337 value
338 }
339 None => self.get_local_bytes(key).await,
340 }
341 }
342
343 async fn take_bytes(&self, key: &str) -> Option<Vec<u8>> {
344 match self.redis_operation("take", self.redis.take(key)).await {
345 Some(value) => {
346 self.delete_local(key).await;
347 value
348 }
349 None => self.local.take_bytes(key).await,
350 }
351 }
352
353 async fn set_bytes(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>) {
354 let ttl = ttl_secs.unwrap_or(self.default_ttl);
355 if ttl == 0 {
356 self.delete(key).await;
360 return;
361 }
362 if self
363 .redis_operation("set", self.redis.set_ex(key, value.clone(), ttl))
364 .await
365 .is_some()
366 {
367 self.delete_local(key).await;
368 } else {
369 self.set_local_bytes(key, value, ttl_secs).await;
370 }
371 }
372
373 async fn set_bytes_if_absent(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>) -> bool {
374 let ttl = ttl_secs.unwrap_or(self.default_ttl);
375 if ttl == 0 {
376 return match self
379 .redis_operation("set_if_absent", self.redis.get(key))
380 .await
381 {
382 Some(existing) => {
383 self.delete_local(key).await;
384 existing.is_none()
385 }
386 None => self.set_local_bytes_if_absent(key, value, Some(0)).await,
387 };
388 }
389 match self
390 .redis_operation(
391 "set_if_absent",
392 self.redis.set_nx_ex(key, value.clone(), ttl),
393 )
394 .await
395 {
396 Some(Some(_)) => {
397 self.delete_local(key).await;
398 true
399 }
400 Some(None) => {
401 self.delete_local(key).await;
402 false
403 }
404 None => self.set_local_bytes_if_absent(key, value, ttl_secs).await,
405 }
406 }
407
408 async fn delete(&self, key: &str) {
409 self.delete_local(key).await;
410 let _: Option<()> = self.redis_operation("delete", self.redis.delete(key)).await;
411 }
412
413 async fn delete_many(&self, keys: &[String]) {
414 for key in keys {
415 self.delete_local(key).await;
416 }
417 if !keys.is_empty() {
418 let _: Option<()> = self
419 .redis_operation("delete_many", self.redis.delete_keys(keys))
420 .await;
421 }
422 }
423
424 async fn invalidate_prefix(&self, prefix: &str) {
425 self.invalidate_local_prefix(prefix).await;
426
427 let pattern = format!("{}*", escape_scan_glob_literal(prefix));
428 let mut cursor: u64 = 0;
429 loop {
430 let Some((next_cursor, keys)) = self
431 .redis_operation(
432 "invalidate_prefix_scan",
433 self.redis.scan_prefix(cursor, &pattern),
434 )
435 .await
436 else {
437 break;
438 };
439 if !keys.is_empty()
440 && self
441 .redis_operation("invalidate_prefix_delete", self.redis.delete_keys(&keys))
442 .await
443 .is_none()
444 {
445 break;
446 }
447 cursor = next_cursor;
448 if cursor == 0 {
449 break;
450 }
451 }
452 }
453}
454
455#[async_trait]
456impl CacheBackend for RedisCache {
457 fn backend_name(&self) -> &'static str {
458 "redis"
459 }
460
461 async fn health_check(&self) -> Result<()> {
462 self.inner.health_check().await
463 }
464
465 async fn get_bytes(&self, key: &str) -> Option<Vec<u8>> {
466 self.inner.get_bytes(key).await
467 }
468
469 async fn take_bytes(&self, key: &str) -> Option<Vec<u8>> {
470 self.inner.take_bytes(key).await
471 }
472
473 async fn set_bytes(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>) {
474 self.inner.set_bytes(key, value, ttl_secs).await;
475 }
476
477 async fn set_bytes_if_absent(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>) -> bool {
478 self.inner.set_bytes_if_absent(key, value, ttl_secs).await
479 }
480
481 async fn delete(&self, key: &str) {
482 self.inner.delete(key).await;
483 }
484
485 async fn delete_many(&self, keys: &[String]) {
486 self.inner.delete_many(keys).await;
487 }
488
489 async fn invalidate_prefix(&self, prefix: &str) {
490 self.inner.invalidate_prefix(prefix).await;
491 }
492}
493
494fn duration_millis_u64(duration: Duration) -> u64 {
495 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
496}
497
498fn redis_error_indicates_unavailability(error: &redis::RedisError) -> bool {
502 match error.kind() {
503 redis::ErrorKind::Io | redis::ErrorKind::ClusterConnectionNotFound => true,
504 redis::ErrorKind::Server(kind) => matches!(
505 kind,
506 redis::ServerErrorKind::BusyLoading
507 | redis::ServerErrorKind::TryAgain
508 | redis::ServerErrorKind::ClusterDown
509 | redis::ServerErrorKind::MasterDown
510 | redis::ServerErrorKind::ReadOnly
511 ),
512 _ => false,
513 }
514}
515
516#[derive(Default)]
517struct RedisAvailability {
518 unavailable_until: Mutex<Option<Instant>>,
519}
520
521impl RedisAvailability {
522 fn unavailable_for(&self, now: Instant) -> Option<Duration> {
523 let mut unavailable_until = self.lock_unavailable_until();
524 match *unavailable_until {
525 Some(deadline) if deadline > now => Some(deadline.duration_since(now)),
526 Some(_) => {
527 *unavailable_until = None;
528 None
529 }
530 None => None,
531 }
532 }
533
534 fn mark_failure(&self, now: Instant, cooldown: Duration) -> bool {
535 let mut unavailable_until = self.lock_unavailable_until();
536 let was_available = unavailable_until.is_none_or(|deadline| deadline <= now);
537 *unavailable_until = now.checked_add(cooldown).or(Some(now));
538 was_available
539 }
540
541 fn mark_success(&self) -> bool {
542 self.lock_unavailable_until().take().is_some()
543 }
544
545 fn lock_unavailable_until(&self) -> std::sync::MutexGuard<'_, Option<Instant>> {
546 self.unavailable_until
547 .lock()
548 .unwrap_or_else(|poisoned| poisoned.into_inner())
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::{
555 CacheBackend, REDIS_CACHE_FALLBACK_COOLDOWN, RedisAvailability, RedisCacheInner,
556 RedisClient, escape_scan_glob_literal,
557 };
558 use async_trait::async_trait;
559 use std::collections::HashMap;
560 use std::sync::{
561 Arc, Mutex,
562 atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
563 };
564 use std::time::{Duration, Instant};
565 use tokio::time::sleep;
566
567 #[derive(Default)]
568 struct FakeRedisClient {
569 entries: Mutex<HashMap<String, Vec<u8>>>,
570 scan_pages: Mutex<HashMap<u64, Vec<String>>>,
571 fail_operations: AtomicBool,
572 fail_command_errors: AtomicBool,
573 next_scan_cursor: AtomicU64,
574 get_calls: AtomicUsize,
575 take_calls: AtomicUsize,
576 set_calls: AtomicUsize,
577 set_nx_calls: AtomicUsize,
578 delete_calls: AtomicUsize,
579 scan_calls: AtomicUsize,
580 delete_keys_calls: AtomicUsize,
581 ping_calls: AtomicUsize,
582 }
583
584 impl FakeRedisClient {
585 fn set_fail_operations(&self, fail: bool) {
586 self.fail_operations.store(fail, Ordering::SeqCst);
587 }
588
589 fn set_fail_command_errors(&self, fail: bool) {
590 self.fail_command_errors.store(fail, Ordering::SeqCst);
591 }
592
593 fn insert(&self, key: &str, value: &[u8]) {
594 self.entries
595 .lock()
596 .unwrap_or_else(|poisoned| poisoned.into_inner())
597 .insert(key.to_string(), value.to_vec());
598 }
599
600 fn contains_key(&self, key: &str) -> bool {
601 self.entries
602 .lock()
603 .unwrap_or_else(|poisoned| poisoned.into_inner())
604 .contains_key(key)
605 }
606
607 fn get_call_count(&self) -> usize {
608 self.get_calls.load(Ordering::SeqCst)
609 }
610
611 fn take_call_count(&self) -> usize {
612 self.take_calls.load(Ordering::SeqCst)
613 }
614
615 fn set_call_count(&self) -> usize {
616 self.set_calls.load(Ordering::SeqCst)
617 }
618
619 fn set_nx_call_count(&self) -> usize {
620 self.set_nx_calls.load(Ordering::SeqCst)
621 }
622
623 fn delete_call_count(&self) -> usize {
624 self.delete_calls.load(Ordering::SeqCst)
625 }
626
627 fn scan_call_count(&self) -> usize {
628 self.scan_calls.load(Ordering::SeqCst)
629 }
630
631 fn delete_keys_call_count(&self) -> usize {
632 self.delete_keys_calls.load(Ordering::SeqCst)
633 }
634
635 fn ping_call_count(&self) -> usize {
636 self.ping_calls.load(Ordering::SeqCst)
637 }
638
639 fn maybe_fail(&self) -> redis::RedisResult<()> {
640 if self.fail_operations.load(Ordering::SeqCst) {
641 Err(redis::RedisError::from((
642 redis::ErrorKind::Io,
643 "fake redis unavailable",
644 )))
645 } else if self.fail_command_errors.load(Ordering::SeqCst) {
646 Err(redis::RedisError::from((
647 redis::ErrorKind::Server(redis::ServerErrorKind::ResponseError),
648 "ERR invalid expire time in 'setex' command",
649 )))
650 } else {
651 Ok(())
652 }
653 }
654 }
655
656 #[async_trait]
657 impl RedisClient for Arc<FakeRedisClient> {
658 async fn get(&self, key: &str) -> redis::RedisResult<Option<Vec<u8>>> {
659 self.get_calls.fetch_add(1, Ordering::SeqCst);
660 self.maybe_fail()?;
661 Ok(self
662 .entries
663 .lock()
664 .unwrap_or_else(|poisoned| poisoned.into_inner())
665 .get(key)
666 .cloned())
667 }
668
669 async fn take(&self, key: &str) -> redis::RedisResult<Option<Vec<u8>>> {
670 self.take_calls.fetch_add(1, Ordering::SeqCst);
671 self.maybe_fail()?;
672 Ok(self
673 .entries
674 .lock()
675 .unwrap_or_else(|poisoned| poisoned.into_inner())
676 .remove(key))
677 }
678
679 async fn set_ex(&self, key: &str, value: Vec<u8>, _ttl: u64) -> redis::RedisResult<()> {
680 self.set_calls.fetch_add(1, Ordering::SeqCst);
681 self.maybe_fail()?;
682 self.entries
683 .lock()
684 .unwrap_or_else(|poisoned| poisoned.into_inner())
685 .insert(key.to_string(), value);
686 Ok(())
687 }
688
689 async fn set_nx_ex(
690 &self,
691 key: &str,
692 value: Vec<u8>,
693 _ttl: u64,
694 ) -> redis::RedisResult<Option<String>> {
695 self.set_nx_calls.fetch_add(1, Ordering::SeqCst);
696 self.maybe_fail()?;
697 let mut entries = self
698 .entries
699 .lock()
700 .unwrap_or_else(|poisoned| poisoned.into_inner());
701 if entries.contains_key(key) {
702 Ok(None)
703 } else {
704 entries.insert(key.to_string(), value);
705 Ok(Some("OK".to_string()))
706 }
707 }
708
709 async fn delete(&self, key: &str) -> redis::RedisResult<()> {
710 self.delete_calls.fetch_add(1, Ordering::SeqCst);
711 self.maybe_fail()?;
712 self.entries
713 .lock()
714 .unwrap_or_else(|poisoned| poisoned.into_inner())
715 .remove(key);
716 Ok(())
717 }
718
719 async fn scan_prefix(
720 &self,
721 cursor: u64,
722 pattern: &str,
723 ) -> redis::RedisResult<(u64, Vec<String>)> {
724 self.scan_calls.fetch_add(1, Ordering::SeqCst);
725 self.maybe_fail()?;
726 let prefix = pattern
727 .strip_suffix('*')
728 .expect("prefix invalidation should scan a trailing-star pattern");
729 const PAGE_SIZE: usize = 2;
730 let mut keys = if cursor == 0 {
731 let mut keys: Vec<String> = self
732 .entries
733 .lock()
734 .unwrap_or_else(|poisoned| poisoned.into_inner())
735 .keys()
736 .filter(|key| key.starts_with(prefix))
737 .cloned()
738 .collect();
739 keys.sort();
740 keys
741 } else {
742 self.scan_pages
743 .lock()
744 .unwrap_or_else(|poisoned| poisoned.into_inner())
745 .remove(&cursor)
746 .unwrap_or_default()
747 };
748 if keys.is_empty() {
749 return Ok((0, Vec::new()));
750 }
751 let page_len = PAGE_SIZE.min(keys.len());
752 let remaining = keys.split_off(page_len);
753 let page = keys;
754 let next_cursor = if remaining.is_empty() {
755 0
756 } else {
757 let next_cursor = self.next_scan_cursor.fetch_add(1, Ordering::SeqCst) + 1;
758 self.scan_pages
759 .lock()
760 .unwrap_or_else(|poisoned| poisoned.into_inner())
761 .insert(next_cursor, remaining);
762 next_cursor
763 };
764 Ok((next_cursor, page))
765 }
766
767 async fn delete_keys(&self, keys: &[String]) -> redis::RedisResult<()> {
768 self.delete_keys_calls.fetch_add(1, Ordering::SeqCst);
769 self.maybe_fail()?;
770 let mut entries = self
771 .entries
772 .lock()
773 .unwrap_or_else(|poisoned| poisoned.into_inner());
774 for key in keys {
775 entries.remove(key);
776 }
777 Ok(())
778 }
779
780 async fn ping(&self) -> redis::RedisResult<String> {
781 self.ping_calls.fetch_add(1, Ordering::SeqCst);
782 self.maybe_fail()?;
783 Ok("PONG".to_string())
784 }
785 }
786
787 fn cache_with_fake_redis(
788 default_ttl: u64,
789 ) -> (RedisCacheInner<Arc<FakeRedisClient>>, Arc<FakeRedisClient>) {
790 let redis = Arc::new(FakeRedisClient::default());
791 (RedisCacheInner::new(redis.clone(), default_ttl), redis)
792 }
793
794 fn open_fallback_circuit<R: RedisClient>(cache: &RedisCacheInner<R>) {
795 assert!(
796 cache
797 .availability
798 .mark_failure(Instant::now(), REDIS_CACHE_FALLBACK_COOLDOWN)
799 );
800 }
801
802 #[test]
803 fn redis_availability_skips_until_cooldown_expires() {
804 let availability = RedisAvailability::default();
805 let now = Instant::now();
806
807 assert!(availability.unavailable_for(now).is_none());
808 assert!(availability.mark_failure(now, Duration::from_secs(5)));
809 assert_eq!(
810 availability.unavailable_for(now + Duration::from_secs(2)),
811 Some(Duration::from_secs(3))
812 );
813 assert!(
814 availability
815 .unavailable_for(now + Duration::from_secs(6))
816 .is_none()
817 );
818 }
819
820 #[test]
821 fn redis_availability_reports_recovery_once() {
822 let availability = RedisAvailability::default();
823 let now = Instant::now();
824
825 assert!(availability.mark_failure(now, Duration::from_secs(5)));
826 assert!(availability.mark_success());
827 assert!(!availability.mark_success());
828 }
829
830 #[test]
831 fn redis_availability_repeated_failures_only_report_transition_once() {
832 let availability = RedisAvailability::default();
833 let now = Instant::now();
834
835 assert!(availability.mark_failure(now, Duration::from_secs(5)));
836 assert!(!availability.mark_failure(now + Duration::from_secs(1), Duration::from_secs(5)));
837 }
838
839 #[tokio::test]
840 async fn fallback_set_and_get_round_trip_while_circuit_is_open() {
841 let (cache, redis) = cache_with_fake_redis(60);
842 open_fallback_circuit(&cache);
843
844 cache.set_bytes("ticket", b"local".to_vec(), Some(60)).await;
845
846 assert_eq!(cache.get_bytes("ticket").await, Some(b"local".to_vec()));
847 assert_eq!(
848 redis.set_call_count(),
849 0,
850 "circuit-open set should skip Redis"
851 );
852 assert_eq!(
853 redis.get_call_count(),
854 0,
855 "circuit-open get should skip Redis"
856 );
857 }
858
859 #[tokio::test]
860 async fn failed_redis_set_stores_value_in_local_fallback() {
861 let (cache, redis) = cache_with_fake_redis(60);
862 redis.set_fail_operations(true);
863
864 cache
865 .set_bytes("session", b"fallback".to_vec(), Some(60))
866 .await;
867
868 assert_eq!(redis.set_call_count(), 1);
869 assert_eq!(cache.get_bytes("session").await, Some(b"fallback".to_vec()));
870 assert_eq!(
871 redis.get_call_count(),
872 0,
873 "first failed set opens the circuit, so later get should skip Redis"
874 );
875 }
876
877 #[tokio::test]
878 async fn redis_miss_does_not_return_stale_local_value_when_redis_is_available() {
879 let (cache, redis) = cache_with_fake_redis(60);
880 open_fallback_circuit(&cache);
881 cache
882 .set_bytes("snapshot", b"stale-local".to_vec(), Some(60))
883 .await;
884 redis.set_fail_operations(false);
885 cache.availability.mark_success();
886
887 assert_eq!(cache.get_bytes("snapshot").await, None);
888 assert_eq!(redis.get_call_count(), 1);
889 }
890
891 #[tokio::test]
892 async fn successful_redis_get_clears_local_shadow_so_it_cannot_resurrect() {
893 let (cache, redis) = cache_with_fake_redis(60);
894 open_fallback_circuit(&cache);
896 cache
897 .set_bytes("snapshot", b"local-only".to_vec(), Some(60))
898 .await;
899 redis.set_fail_operations(false);
900 cache.availability.mark_success();
901
902 assert_eq!(cache.get_bytes("snapshot").await, None);
904 assert_eq!(cache.local.get_bytes("snapshot").await, None);
905
906 open_fallback_circuit(&cache);
908 assert_eq!(cache.get_bytes("snapshot").await, None);
909 }
910
911 #[tokio::test]
912 async fn successful_redis_hit_clears_divergent_local_shadow() {
913 let (cache, redis) = cache_with_fake_redis(60);
914 redis.insert("profile", b"redis-value");
915 open_fallback_circuit(&cache);
916 cache
917 .set_bytes("profile", b"local-shadow".to_vec(), Some(60))
918 .await;
919 redis.set_fail_operations(false);
920 cache.availability.mark_success();
921
922 assert_eq!(
923 cache.get_bytes("profile").await,
924 Some(b"redis-value".to_vec())
925 );
926 assert_eq!(cache.local.get_bytes("profile").await, None);
927 }
928
929 #[tokio::test]
930 async fn successful_redis_set_clears_local_fallback_shadow() {
931 let (cache, redis) = cache_with_fake_redis(60);
932 open_fallback_circuit(&cache);
933 cache
934 .set_bytes("profile", b"local-shadow".to_vec(), Some(60))
935 .await;
936 cache.availability.mark_success();
937
938 cache
939 .set_bytes("profile", b"redis-value".to_vec(), Some(60))
940 .await;
941
942 assert_eq!(redis.set_call_count(), 1);
943 assert!(redis.contains_key("profile"));
944 assert_eq!(cache.local.get_bytes("profile").await, None);
945 assert_eq!(
946 cache.get_bytes("profile").await,
947 Some(b"redis-value".to_vec())
948 );
949 }
950
951 #[tokio::test]
952 async fn take_bytes_consumes_redis_entry_atomically() {
953 let (cache, redis) = cache_with_fake_redis(60);
954 redis.insert("challenge", b"value");
955
956 assert_eq!(cache.take_bytes("challenge").await, Some(b"value".to_vec()));
957 assert_eq!(cache.take_bytes("challenge").await, None);
958 assert!(!redis.contains_key("challenge"));
959 assert_eq!(redis.take_call_count(), 2);
960 assert_eq!(
961 redis.get_call_count(),
962 0,
963 "take should not read through a separate GET"
964 );
965 }
966
967 #[tokio::test]
968 async fn take_bytes_consumes_local_fallback_when_circuit_is_open() {
969 let (cache, redis) = cache_with_fake_redis(60);
970 open_fallback_circuit(&cache);
971 cache
972 .set_bytes("challenge", b"local".to_vec(), Some(60))
973 .await;
974
975 assert_eq!(cache.take_bytes("challenge").await, Some(b"local".to_vec()));
976 assert_eq!(cache.take_bytes("challenge").await, None);
977 assert_eq!(
978 redis.take_call_count(),
979 0,
980 "circuit-open take should skip Redis"
981 );
982 }
983
984 #[tokio::test]
985 async fn take_bytes_falls_back_to_local_without_dropping_value_on_redis_failure() {
986 let (cache, redis) = cache_with_fake_redis(60);
987 open_fallback_circuit(&cache);
988 cache
989 .set_bytes("challenge", b"local".to_vec(), Some(60))
990 .await;
991 cache.availability.mark_success();
992 redis.set_fail_operations(true);
993
994 assert_eq!(cache.take_bytes("challenge").await, Some(b"local".to_vec()));
995 assert_eq!(cache.take_bytes("challenge").await, None);
996 assert_eq!(redis.take_call_count(), 1);
997 }
998
999 #[tokio::test]
1000 async fn fallback_set_if_absent_stores_value_and_rejects_second_insert() {
1001 let (cache, redis) = cache_with_fake_redis(60);
1002 open_fallback_circuit(&cache);
1003
1004 assert!(
1005 cache
1006 .set_bytes_if_absent("nonce", b"first".to_vec(), Some(60))
1007 .await
1008 );
1009 assert!(
1010 !cache
1011 .set_bytes_if_absent("nonce", b"second".to_vec(), Some(60))
1012 .await
1013 );
1014
1015 assert_eq!(cache.get_bytes("nonce").await, Some(b"first".to_vec()));
1016 assert_eq!(redis.set_nx_call_count(), 0);
1017 }
1018
1019 #[tokio::test]
1020 async fn fallback_set_if_absent_is_atomic_for_concurrent_callers() {
1021 let (cache, redis) = cache_with_fake_redis(60);
1022 open_fallback_circuit(&cache);
1023 let cache = Arc::new(cache);
1024 let mut tasks = Vec::new();
1025
1026 for index in 0..32 {
1027 let cache = cache.clone();
1028 tasks.push(tokio::spawn(async move {
1029 cache
1030 .set_bytes_if_absent(
1031 "concurrent-nonce",
1032 format!("value-{index}").into_bytes(),
1033 Some(60),
1034 )
1035 .await
1036 }));
1037 }
1038
1039 let inserted = futures::future::join_all(tasks)
1040 .await
1041 .into_iter()
1042 .map(|result| result.expect("fallback reservation task should not panic"))
1043 .filter(|inserted| *inserted)
1044 .count();
1045
1046 assert_eq!(inserted, 1);
1047 assert!(cache.get_bytes("concurrent-nonce").await.is_some());
1048 assert_eq!(redis.set_nx_call_count(), 0);
1049 }
1050
1051 #[tokio::test]
1052 async fn fallback_entries_respect_zero_ttl_boundary() {
1053 let (cache, _redis) = cache_with_fake_redis(60);
1054 open_fallback_circuit(&cache);
1055
1056 cache.set_bytes("expired", b"value".to_vec(), Some(0)).await;
1057 assert_eq!(cache.get_bytes("expired").await, None);
1058
1059 assert!(
1060 cache
1061 .set_bytes_if_absent("zero-nonce", b"first".to_vec(), Some(0))
1062 .await
1063 );
1064 assert_eq!(cache.get_bytes("zero-nonce").await, None);
1065 assert!(
1066 cache
1067 .set_bytes_if_absent("zero-nonce", b"second".to_vec(), Some(0))
1068 .await
1069 );
1070 }
1071
1072 #[tokio::test]
1073 async fn fallback_entries_expire_after_configured_ttl() {
1074 let (cache, _redis) = cache_with_fake_redis(60);
1075 open_fallback_circuit(&cache);
1076
1077 cache
1078 .set_bytes("short-lived", b"value".to_vec(), Some(1))
1079 .await;
1080 assert_eq!(
1081 cache.get_bytes("short-lived").await,
1082 Some(b"value".to_vec())
1083 );
1084
1085 sleep(Duration::from_millis(1_100)).await;
1086
1087 assert_eq!(cache.get_bytes("short-lived").await, None);
1088 }
1089
1090 #[tokio::test]
1091 async fn delete_clears_local_fallback_even_when_redis_is_unavailable() {
1092 let (cache, redis) = cache_with_fake_redis(60);
1093 open_fallback_circuit(&cache);
1094 cache
1095 .set_bytes("delete-me", b"value".to_vec(), Some(60))
1096 .await;
1097
1098 cache.delete("delete-me").await;
1099
1100 assert_eq!(cache.get_bytes("delete-me").await, None);
1101 assert_eq!(
1102 redis.delete_call_count(),
1103 0,
1104 "circuit-open delete should skip Redis"
1105 );
1106 }
1107
1108 #[tokio::test]
1109 async fn delete_many_removes_requested_redis_and_local_entries_in_one_batch() {
1110 let (cache, redis) = cache_with_fake_redis(60);
1111 redis.insert("remove:1", b"one");
1112 redis.insert("remove:2", b"two");
1113 redis.insert("keep", b"keep");
1114 open_fallback_circuit(&cache);
1115 cache
1116 .set_bytes("remove:local", b"local".to_vec(), Some(60))
1117 .await;
1118 cache.availability.mark_success();
1119
1120 cache
1121 .delete_many(&[
1122 "remove:1".to_string(),
1123 "remove:2".to_string(),
1124 "remove:local".to_string(),
1125 "missing".to_string(),
1126 ])
1127 .await;
1128 cache.delete_many(&[]).await;
1129
1130 assert!(!redis.contains_key("remove:1"));
1131 assert!(!redis.contains_key("remove:2"));
1132 assert!(redis.contains_key("keep"));
1133 assert_eq!(cache.local.get_bytes("remove:local").await, None);
1134 assert_eq!(redis.delete_keys_call_count(), 1);
1135 }
1136
1137 #[tokio::test]
1138 async fn delete_many_clears_local_only_when_circuit_is_open() {
1139 let (cache, redis) = cache_with_fake_redis(60);
1140 open_fallback_circuit(&cache);
1141 cache
1142 .set_bytes("remove:local", b"local".to_vec(), Some(60))
1143 .await;
1144
1145 cache.delete_many(&["remove:local".to_string()]).await;
1146
1147 assert_eq!(cache.get_bytes("remove:local").await, None);
1148 assert_eq!(
1149 redis.delete_keys_call_count(),
1150 0,
1151 "circuit-open batch delete should skip Redis"
1152 );
1153 }
1154
1155 #[test]
1156 fn escape_scan_glob_literal_escapes_redis_glob_metacharacters() {
1157 assert_eq!(escape_scan_glob_literal("plain:prefix:"), "plain:prefix:");
1158 assert_eq!(escape_scan_glob_literal("a*b"), "a\\*b");
1159 assert_eq!(escape_scan_glob_literal("a?b"), "a\\?b");
1160 assert_eq!(escape_scan_glob_literal("[ab]"), "\\[ab\\]");
1161 assert_eq!(escape_scan_glob_literal("a\\b"), "a\\\\b");
1162 assert_eq!(escape_scan_glob_literal("\\*"), "\\\\\\*");
1165 }
1166
1167 #[tokio::test]
1168 async fn invalidate_prefix_clears_local_fallback_even_when_redis_is_unavailable() {
1169 let (cache, redis) = cache_with_fake_redis(60);
1170 open_fallback_circuit(&cache);
1171 cache.set_bytes("folder:1", b"one".to_vec(), Some(60)).await;
1172 cache.set_bytes("folder:2", b"two".to_vec(), Some(60)).await;
1173 cache.set_bytes("other:1", b"keep".to_vec(), Some(60)).await;
1174
1175 cache.invalidate_prefix("folder:").await;
1176
1177 assert_eq!(cache.get_bytes("folder:1").await, None);
1178 assert_eq!(cache.get_bytes("folder:2").await, None);
1179 assert_eq!(cache.get_bytes("other:1").await, Some(b"keep".to_vec()));
1180 assert_eq!(
1181 redis.scan_call_count(),
1182 0,
1183 "circuit-open prefix invalidation should skip Redis"
1184 );
1185 }
1186
1187 #[tokio::test]
1188 async fn invalidate_prefix_deletes_matching_redis_keys_and_local_shadow() {
1189 let (cache, redis) = cache_with_fake_redis(60);
1190 redis.insert("folder:1", b"one");
1191 redis.insert("folder:2", b"two");
1192 redis.insert("other:1", b"keep");
1193 open_fallback_circuit(&cache);
1194 cache
1195 .set_bytes("folder:local", b"local".to_vec(), Some(60))
1196 .await;
1197 cache.availability.mark_success();
1198
1199 cache.invalidate_prefix("folder:").await;
1200
1201 assert!(!redis.contains_key("folder:1"));
1202 assert!(!redis.contains_key("folder:2"));
1203 assert!(redis.contains_key("other:1"));
1204 assert_eq!(cache.local.get_bytes("folder:local").await, None);
1205 assert_eq!(redis.scan_call_count(), 1);
1206 assert_eq!(redis.delete_keys_call_count(), 1);
1207 }
1208
1209 #[tokio::test]
1210 async fn invalidate_prefix_scans_and_deletes_multiple_redis_pages() {
1211 let (cache, redis) = cache_with_fake_redis(60);
1212 for index in 0..5 {
1213 redis.insert(&format!("folder:{index}"), b"value");
1214 }
1215 redis.insert("other:1", b"keep");
1216
1217 cache.invalidate_prefix("folder:").await;
1218
1219 for index in 0..5 {
1220 assert!(!redis.contains_key(&format!("folder:{index}")));
1221 }
1222 assert!(redis.contains_key("other:1"));
1223 assert_eq!(redis.scan_call_count(), 3);
1224 assert_eq!(redis.delete_keys_call_count(), 3);
1225 }
1226
1227 #[tokio::test]
1228 async fn health_check_reports_fallback_without_pinging_redis_while_circuit_is_open() {
1229 let (cache, redis) = cache_with_fake_redis(60);
1230 open_fallback_circuit(&cache);
1231
1232 let error = cache
1233 .health_check()
1234 .await
1235 .expect_err("open fallback circuit should report degraded Redis health");
1236
1237 assert!(
1238 error
1239 .to_string()
1240 .contains("redis cache is in fallback mode")
1241 );
1242 assert_eq!(redis.ping_call_count(), 0);
1243 }
1244
1245 #[tokio::test]
1246 async fn zero_ttl_set_deletes_key_instead_of_issuing_set_ex() {
1247 let (cache, redis) = cache_with_fake_redis(60);
1248 redis.insert("ephemeral", b"old");
1249
1250 cache.set_bytes("ephemeral", b"new".to_vec(), Some(0)).await;
1251
1252 assert_eq!(
1253 redis.set_call_count(),
1254 0,
1255 "zero-TTL set must not issue SETEX"
1256 );
1257 assert_eq!(redis.delete_call_count(), 1);
1258 assert!(!redis.contains_key("ephemeral"));
1259 assert!(
1260 cache.availability.unavailable_for(Instant::now()).is_none(),
1261 "zero-TTL set must not open the fallback circuit"
1262 );
1263 assert_eq!(cache.get_bytes("ephemeral").await, None);
1264 assert_eq!(
1265 redis.get_call_count(),
1266 1,
1267 "circuit stays closed, so the read reaches Redis"
1268 );
1269 }
1270
1271 #[tokio::test]
1272 async fn zero_ttl_set_if_absent_reports_absence_without_storing() {
1273 let (cache, redis) = cache_with_fake_redis(60);
1274
1275 assert!(
1276 cache
1277 .set_bytes_if_absent("nonce", b"v".to_vec(), Some(0))
1278 .await
1279 );
1280 assert_eq!(
1281 redis.set_nx_call_count(),
1282 0,
1283 "zero-TTL insert must not issue SET NX EX"
1284 );
1285 assert!(!redis.contains_key("nonce"));
1286 assert_eq!(cache.get_bytes("nonce").await, None);
1287 assert!(
1288 cache
1289 .set_bytes_if_absent("nonce", b"v2".to_vec(), Some(0))
1290 .await,
1291 "nothing is retained, so a second zero-TTL insert also succeeds"
1292 );
1293
1294 redis.insert("live", b"real");
1295 assert!(
1296 !cache
1297 .set_bytes_if_absent("live", b"v".to_vec(), Some(0))
1298 .await,
1299 "an existing live value rejects the insert"
1300 );
1301 assert_eq!(cache.get_bytes("live").await, Some(b"real".to_vec()));
1302 }
1303
1304 #[tokio::test]
1305 async fn zero_ttl_set_if_absent_with_open_circuit_uses_local_semantics() {
1306 let (cache, redis) = cache_with_fake_redis(60);
1307 open_fallback_circuit(&cache);
1308
1309 assert!(
1310 cache
1311 .set_bytes_if_absent("nonce", b"v".to_vec(), Some(0))
1312 .await
1313 );
1314 assert_eq!(
1315 redis.get_call_count(),
1316 0,
1317 "circuit-open existence check should skip Redis"
1318 );
1319 assert_eq!(cache.get_bytes("nonce").await, None);
1320 assert!(
1321 cache
1322 .set_bytes_if_absent("nonce", b"v2".to_vec(), Some(0))
1323 .await,
1324 "local zero-TTL entries expire immediately and stay insertable"
1325 );
1326 }
1327
1328 #[tokio::test]
1329 async fn zero_ttl_set_with_open_circuit_only_clears_local_shadow() {
1330 let (cache, redis) = cache_with_fake_redis(60);
1331 open_fallback_circuit(&cache);
1332 cache.set_bytes("shadow", b"local".to_vec(), Some(60)).await;
1333
1334 cache.set_bytes("shadow", b"gone".to_vec(), Some(0)).await;
1335
1336 assert_eq!(
1337 redis.delete_call_count(),
1338 0,
1339 "circuit-open zero-TTL set should skip the Redis delete"
1340 );
1341 assert_eq!(cache.local.get_bytes("shadow").await, None);
1342 assert_eq!(cache.get_bytes("shadow").await, None);
1343 }
1344
1345 #[tokio::test]
1346 async fn zero_default_ttl_treats_missing_ttl_as_immediate_expiry() {
1347 let (cache, redis) = cache_with_fake_redis(0);
1348
1349 cache.set_bytes("key", b"value".to_vec(), None).await;
1350
1351 assert_eq!(redis.set_call_count(), 0);
1352 assert_eq!(redis.delete_call_count(), 1);
1353 assert_eq!(cache.get_bytes("key").await, None);
1354 }
1355
1356 #[tokio::test]
1357 async fn command_error_falls_back_for_single_operation_without_opening_circuit() {
1358 let (cache, redis) = cache_with_fake_redis(60);
1359 redis.set_fail_command_errors(true);
1360
1361 cache
1362 .set_bytes("session", b"fallback".to_vec(), Some(60))
1363 .await;
1364
1365 assert_eq!(redis.set_call_count(), 1);
1366 assert_eq!(
1367 cache.local.get_bytes("session").await,
1368 Some(b"fallback".to_vec()),
1369 "the failed operation still falls back locally"
1370 );
1371 assert!(
1372 cache.availability.unavailable_for(Instant::now()).is_none(),
1373 "command errors must not open the fallback circuit"
1374 );
1375
1376 redis.set_fail_command_errors(false);
1377 redis.insert("session", b"redis-value");
1378 assert_eq!(
1379 cache.get_bytes("session").await,
1380 Some(b"redis-value".to_vec()),
1381 "later operations keep reaching Redis"
1382 );
1383 }
1384
1385 #[tokio::test]
1386 async fn transient_server_error_opens_fallback_circuit() {
1387 let (cache, redis) = cache_with_fake_redis(60);
1388 redis.set_fail_operations(true);
1389
1390 cache
1391 .set_bytes("session", b"value".to_vec(), Some(60))
1392 .await;
1393
1394 assert!(
1395 cache.availability.unavailable_for(Instant::now()).is_some(),
1396 "I/O errors still open the fallback circuit"
1397 );
1398 }
1399
1400 #[test]
1401 fn redis_error_indicates_unavailability_classifies_error_kinds() {
1402 use redis::{ErrorKind, ServerErrorKind};
1403
1404 fn error(kind: ErrorKind) -> redis::RedisError {
1405 redis::RedisError::from((kind, "fake error"))
1406 }
1407
1408 for kind in [
1409 ErrorKind::Io,
1410 ErrorKind::ClusterConnectionNotFound,
1411 ErrorKind::Server(ServerErrorKind::BusyLoading),
1412 ErrorKind::Server(ServerErrorKind::TryAgain),
1413 ErrorKind::Server(ServerErrorKind::ClusterDown),
1414 ErrorKind::Server(ServerErrorKind::MasterDown),
1415 ErrorKind::Server(ServerErrorKind::ReadOnly),
1416 ] {
1417 assert!(
1418 super::redis_error_indicates_unavailability(&error(kind)),
1419 "{kind:?} should indicate unavailability"
1420 );
1421 }
1422
1423 for kind in [
1424 ErrorKind::Server(ServerErrorKind::ResponseError),
1425 ErrorKind::Server(ServerErrorKind::ExecAbort),
1426 ErrorKind::Server(ServerErrorKind::NoScript),
1427 ErrorKind::Server(ServerErrorKind::Moved),
1428 ErrorKind::Server(ServerErrorKind::Ask),
1429 ErrorKind::Server(ServerErrorKind::CrossSlot),
1430 ErrorKind::Server(ServerErrorKind::NotBusy),
1431 ErrorKind::Server(ServerErrorKind::NoSub),
1432 ErrorKind::Server(ServerErrorKind::NoPerm),
1433 ErrorKind::AuthenticationFailed,
1434 ErrorKind::InvalidClientConfig,
1435 ErrorKind::UnexpectedReturnType,
1436 ErrorKind::Client,
1437 ErrorKind::Extension,
1438 ErrorKind::RESP3NotSupported,
1439 ErrorKind::Parse,
1440 ] {
1441 assert!(
1442 !super::redis_error_indicates_unavailability(&error(kind)),
1443 "{kind:?} should not indicate unavailability"
1444 );
1445 }
1446 }
1447}