rust_etcd_utils/
retry.rs

1use {
2    retry::delay::Exponential,
3    std::{error::Error, future::Future, time::Duration},
4    tracing::{error, warn},
5};
6
7///
8/// Check if an etcd error is transient.
9///
10/// Transient error are error that are caused by "outside" forces that cannot be prevented such as a network partition.
11///
12/// If the error is for example gRPC status "Not found", the function will return false.
13///
14/// Transient errors are worth retrying -- See [`retry_etcd`] for an example.
15///
16pub fn is_transient(err: &etcd_client::Error) -> bool {
17    match err {
18        etcd_client::Error::GRpcStatus(status) => match status.code() {
19            tonic::Code::Ok => false,
20            tonic::Code::Cancelled => false,
21            tonic::Code::Unknown => {
22                match status.source() {
23                    Some(e) => {
24                        match e.downcast_ref::<tonic::transport::Error>() {
25                            Some(_) => {
26                                // Because if the error is a transport error, it's likely a transient error due to connection reset.
27                                true
28                            }
29                            None => false,
30                        }
31                    }
32                    None => true,
33                }
34            }
35            tonic::Code::InvalidArgument => false,
36            tonic::Code::DeadlineExceeded => true,
37            tonic::Code::NotFound => false,
38            tonic::Code::AlreadyExists => false,
39            tonic::Code::PermissionDenied => false,
40            tonic::Code::ResourceExhausted => true,
41            tonic::Code::FailedPrecondition => false,
42            tonic::Code::Aborted => false,
43            tonic::Code::OutOfRange => false,
44            tonic::Code::Unimplemented => false,
45            tonic::Code::Internal => true,
46            tonic::Code::Unavailable => true,
47            tonic::Code::DataLoss => true,
48            tonic::Code::Unauthenticated => false,
49        },
50        _ => false,
51    }
52}
53
54///
55/// Retry an etcd transaction if the error is transient.
56///
57pub async fn retry_etcd_txn(
58    etcd: etcd_client::Client,
59    txn: etcd_client::Txn,
60) -> Result<etcd_client::TxnResponse, etcd_client::Error> {
61    retry_etcd(etcd, (txn,), move |etcd, (txn,)| async move {
62        etcd.kv_client().txn(txn).await
63    })
64    .await
65}
66
67///
68/// Retry an etcd get if the error is transient.
69///
70pub async fn retry_etcd_get(
71    etcd: etcd_client::Client,
72    key: String,
73    opts: Option<etcd_client::GetOptions>,
74) -> Result<etcd_client::GetResponse, etcd_client::Error> {
75    retry_etcd(etcd, (key, opts), move |etcd, (key, opts)| async move {
76        etcd.kv_client().get(key, opts).await
77    })
78    .await
79}
80
81///
82/// Retry an etcd operation by captures a reusable args and a closure that compute the future to try.
83///
84/// The future must return an etcd result type where the error could be any etcd error.
85///
86/// The `retry_etcd` function retry only on "transient" error, meaning error that happen because of "outside" forces
87/// that cannot be prevented such as a network partition.
88///
89/// If the error is for example gRPC status "Not found", the function won't retry it.
90///
91/// Examples
92///
93/// ```
94/// use rust_etcd_utils::{retry::retry_etcd};
95/// use etcd_client::Client;
96///
97/// let etcd = Client::connect(["http://localhost:2379"], None).await.expect("failed to connect to etcd");
98///
99/// let result = retry_etcd(
100///     etcd.clone(),
101///     ("my_key",),
102///     move |etcd, (my_key,)| {
103///         async move {
104///             etcd.kv_client().get(my_key, None).await
105///         }
106///     }   
107/// ).await;
108///
109/// ```
110///
111pub async fn retry_etcd<A, T, F, Fut>(
112    etcd: etcd_client::Client,
113    reusable_args: A,
114    f: F,
115) -> Result<T, etcd_client::Error>
116where
117    A: Clone + Send + 'static,
118    Fut: Future<Output = Result<T, etcd_client::Error>> + Send + 'static,
119    F: FnMut(etcd_client::Client, A) -> Fut,
120    T: Send + 'static,
121{
122    let retry_strategy = Exponential::from_millis_with_factor(10, 10.0).take(3);
123    retry_etcd_with_strategy(etcd, reusable_args, retry_strategy, f).await
124}
125
126///
127/// Similar to [`retry_etcd`] but with a custom retry strategy.
128///
129pub async fn retry_etcd_with_strategy<A, T, F, Fut>(
130    etcd: etcd_client::Client,
131    reusable_args: A,
132    retry_strategy: impl IntoIterator<Item = Duration>,
133    mut f: F,
134) -> Result<T, etcd_client::Error>
135where
136    A: Clone + Send + 'static,
137    Fut: Future<Output = Result<T, etcd_client::Error>> + Send + 'static,
138    F: FnMut(etcd_client::Client, A) -> Fut,
139    T: Send + 'static,
140{
141    let mut retry_strategy = retry_strategy.into_iter();
142    loop {
143        match f(etcd.clone(), reusable_args.clone()).await {
144            Ok(o) => return Ok(o),
145            Err(e) => {
146                if is_transient(&e) {
147                    warn!("failed due to transient state {:?}", e);
148                    match retry_strategy.next() {
149                        Some(duration) => {
150                            tokio::time::sleep(duration).await;
151                        }
152                        None => return Err(e),
153                    }
154                } else {
155                    error!("failed due to non-transient state: {:?}", e);
156                    return Err(e);
157                }
158            }
159        }
160    }
161}
162
163pub(crate) async fn retry_etcd_legacy<T, F, Fut>(
164    retry_strategy: impl IntoIterator<Item = Duration>,
165    mut f: F,
166) -> Result<T, etcd_client::Error>
167where
168    Fut: Future<Output = Result<T, etcd_client::Error>> + Send + 'static,
169    F: FnMut() -> Fut,
170    T: Send + 'static,
171{
172    let mut retry_strategy = retry_strategy.into_iter();
173    loop {
174        match f().await {
175            Ok(o) => return Ok(o),
176            Err(e) => {
177                if is_transient(&e) {
178                    warn!("failed due to transient state {:?}", e);
179                    match retry_strategy.next() {
180                        Some(duration) => {
181                            tokio::time::sleep(duration).await;
182                        }
183                        None => return Err(e),
184                    }
185                } else {
186                    error!("failed due to non-transient state: {:?}", e);
187                    return Err(e);
188                }
189            }
190        }
191    }
192}