Skip to main content

ff_script/
retry.rs

1//! Retry classification for `ferriskey::ErrorKind`.
2//!
3//! Shared by ff-server, ff-scheduler, ff-sdk so a single table decides
4//! what's retryable. Kept here (not ff-core) because `ferriskey::ErrorKind`
5//! lives in the transport client; ff-core is kept transport-free.
6
7/// Classify a ferriskey `ErrorKind` as retryable by the caller.
8///
9/// Returns `true` for kinds that are **known-safe** to retry:
10/// - `IoError`, `FatalSendError`: the request never reached the server.
11/// - `TryAgain`: Valkey explicitly asked for a retry.
12/// - `BusyLoadingError`: Valkey is booting; transient.
13/// - `ClusterDown`: cluster is rebalancing; transient.
14///
15/// Returns `false` for:
16/// - `FatalReceiveError`: the request **may have been applied** server-side
17///   but the response was lost. Treated as non-retryable by default because
18///   ff-server cannot know if the operation was idempotent. Callers that
19///   know the operation is idempotent may retry anyway, but this helper
20///   errs on the safe side.
21/// - `Moved` / `Ask`: ferriskey handles cluster redirects internally; if
22///   they surface to the caller it means the redirect chain already failed,
23///   and another caller-level retry will hit the same wall.
24/// - `AuthenticationFailed` / `PermissionDenied` / `InvalidClientConfig`:
25///   config mismatch, not transient.
26/// - `NoScriptError`: `fcall_with_reload` already did the reload-retry
27///   internally; if we surface NOSCRIPT to the caller, the library is
28///   missing even after reload.
29/// - Any other kind: conservative default false.
30pub fn is_retryable_kind(kind: ferriskey::ErrorKind) -> bool {
31    use ferriskey::ErrorKind::*;
32    matches!(
33        kind,
34        IoError | FatalSendError | TryAgain | BusyLoadingError | ClusterDown
35    )
36}
37
38/// Map a ferriskey `ErrorKind` to a stable, snake_case wire string.
39///
40/// Used in HTTP `ErrorBody.kind` and any other external API so callers can
41/// dispatch on a string contract without depending on the `Debug` repr of
42/// `ferriskey::ErrorKind` (which is a library-internal formatting choice
43/// that may change across ferriskey versions).
44///
45/// Contract: these strings are part of the public wire API. Do not rename
46/// without bumping a major version. New `ErrorKind` variants added upstream
47/// map to `"unknown"` until explicitly handled here.
48pub fn kind_to_stable_str(kind: ferriskey::ErrorKind) -> &'static str {
49    use ferriskey::ErrorKind::*;
50    match kind {
51        ResponseError => "response_error",
52        ParseError => "parse_error",
53        AuthenticationFailed => "authentication_failed",
54        PermissionDenied => "permission_denied",
55        TypeError => "type_error",
56        ExecAbortError => "exec_abort",
57        BusyLoadingError => "busy_loading",
58        NoScriptError => "no_script",
59        InvalidClientConfig => "invalid_client_config",
60        Moved => "moved",
61        Ask => "ask",
62        TryAgain => "try_again",
63        ClusterDown => "cluster_down",
64        CrossSlot => "cross_slot",
65        MasterDown => "master_down",
66        IoError => "io_error",
67        FatalSendError => "fatal_send",
68        FatalReceiveError => "fatal_receive",
69        ClientError => "client_error",
70        ExtensionError => "extension_error",
71        ReadOnly => "read_only",
72        MasterNameNotFoundBySentinel => "master_name_not_found_by_sentinel",
73        NoValidReplicasFoundBySentinel => "no_valid_replicas_found_by_sentinel",
74        _ => "unknown",
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use ferriskey::ErrorKind;
82
83    #[test]
84    fn retryable_kinds_whitelist() {
85        assert!(is_retryable_kind(ErrorKind::IoError));
86        assert!(is_retryable_kind(ErrorKind::FatalSendError));
87        assert!(is_retryable_kind(ErrorKind::TryAgain));
88        assert!(is_retryable_kind(ErrorKind::BusyLoadingError));
89        assert!(is_retryable_kind(ErrorKind::ClusterDown));
90    }
91
92    #[test]
93    fn non_retryable_kinds() {
94        assert!(!is_retryable_kind(ErrorKind::FatalReceiveError));
95        assert!(!is_retryable_kind(ErrorKind::AuthenticationFailed));
96        assert!(!is_retryable_kind(ErrorKind::PermissionDenied));
97        assert!(!is_retryable_kind(ErrorKind::InvalidClientConfig));
98        assert!(!is_retryable_kind(ErrorKind::NoScriptError));
99        assert!(!is_retryable_kind(ErrorKind::Moved));
100        assert!(!is_retryable_kind(ErrorKind::Ask));
101        assert!(!is_retryable_kind(ErrorKind::ResponseError));
102        assert!(!is_retryable_kind(ErrorKind::ParseError));
103        assert!(!is_retryable_kind(ErrorKind::TypeError));
104        assert!(!is_retryable_kind(ErrorKind::ReadOnly));
105    }
106
107    #[test]
108    fn stable_str_for_common_kinds() {
109        assert_eq!(kind_to_stable_str(ErrorKind::IoError), "io_error");
110        assert_eq!(kind_to_stable_str(ErrorKind::FatalSendError), "fatal_send");
111        assert_eq!(kind_to_stable_str(ErrorKind::FatalReceiveError), "fatal_receive");
112        assert_eq!(kind_to_stable_str(ErrorKind::NoScriptError), "no_script");
113        assert_eq!(kind_to_stable_str(ErrorKind::AuthenticationFailed), "authentication_failed");
114        assert_eq!(kind_to_stable_str(ErrorKind::ClusterDown), "cluster_down");
115        assert_eq!(kind_to_stable_str(ErrorKind::Moved), "moved");
116        assert_eq!(kind_to_stable_str(ErrorKind::ReadOnly), "read_only");
117    }
118
119    #[test]
120    fn stable_str_is_snake_case_and_nonempty() {
121        use ferriskey::ErrorKind::*;
122        let all = [
123            ResponseError, ParseError, AuthenticationFailed, PermissionDenied,
124            TypeError, ExecAbortError, BusyLoadingError, NoScriptError,
125            InvalidClientConfig, Moved, Ask, TryAgain, ClusterDown, CrossSlot,
126            MasterDown, IoError, FatalSendError, FatalReceiveError, ClientError,
127            ExtensionError, ReadOnly,
128        ];
129        for k in all {
130            let s = kind_to_stable_str(k);
131            assert!(!s.is_empty(), "empty stable str for {k:?}");
132            assert!(
133                s.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
134                "non snake_case stable str '{s}' for {k:?}"
135            );
136        }
137    }
138}