Skip to main content

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