Skip to main content

couchbase_core/
retry.rs

1/*
2 *
3 *  * Copyright (c) 2025 Couchbase, Inc.
4 *  *
5 *  * Licensed under the Apache License, Version 2.0 (the "License");
6 *  * you may not use this file except in compliance with the License.
7 *  * You may obtain a copy of the License at
8 *  *
9 *  *    http://www.apache.org/licenses/LICENSE-2.0
10 *  *
11 *  * Unless required by applicable law or agreed to in writing, software
12 *  * distributed under the License is distributed on an "AS IS" BASIS,
13 *  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 *  * See the License for the specific language governing permissions and
15 *  * limitations under the License.
16 *
17 */
18
19use std::collections::HashSet;
20use std::fmt::{Debug, Display};
21use std::future::Future;
22use std::sync::Arc;
23use std::time::Duration;
24
25use crate::errmapcomponent::ErrMapComponent;
26use crate::error::{Error, ErrorKind};
27use crate::memdx::error::ErrorKind::{Cancelled, Dispatch, Resource, Server};
28use crate::memdx::error::{CancellationErrorKind, ServerError, ServerErrorKind};
29use crate::retryfailfast::FailFastRetryStrategy;
30use crate::tracingcomponent::SPAN_ATTRIB_RETRIES;
31use crate::{analyticsx, error, httpx, mgmtx, queryx, searchx};
32use async_trait::async_trait;
33use tokio::time::sleep;
34use tracing::{debug, info};
35
36lazy_static! {
37    pub(crate) static ref DEFAULT_RETRY_STRATEGY: Arc<dyn RetryStrategy> =
38        Arc::new(FailFastRetryStrategy::default());
39}
40
41/// The reason an operation is being retried.
42///
43/// Each variant identifies a specific transient failure condition that triggered
44/// a retry. The SDK passes this to [`RetryStrategy::retry_after`] so the strategy
45/// can decide whether (and how long) to wait before retrying.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
47#[non_exhaustive]
48pub enum RetryReason {
49    /// The server indicated the vBucket is not owned by this node.
50    KvNotMyVbucket,
51    /// The vBucket map is invalid and must be refreshed.
52    KvInvalidVbucketMap,
53    /// A temporary failure occurred on the KV engine.
54    KvTemporaryFailure,
55    /// The collection ID is outdated and must be re-resolved.
56    KvCollectionOutdated,
57    /// The server error map indicated the operation should be retried.
58    KvErrorMapRetryIndicated,
59    /// The document is locked by another operation.
60    KvLocked,
61    /// A sync-write (durable operation) is already in progress on this key.
62    KvSyncWriteInProgress,
63    /// A sync-write recommit is in progress on this key.
64    KvSyncWriteRecommitInProgress,
65    /// The required service is temporarily unavailable.
66    ServiceNotAvailable,
67    /// The connection was closed while the request was in flight.
68    SocketClosedWhileInFlight,
69    /// No connection is currently available.
70    SocketNotAvailable,
71    /// A prepared statement for the query was invalidated.
72    QueryPreparedStatementFailure,
73    /// The query index was not found (may still be building).
74    QueryIndexNotFound,
75    /// The operation is retryable as indicated by the query engine.
76    QueryErrorRetryable,
77    /// The search service is rejecting requests due to rate limiting.
78    SearchTooManyRequests,
79    /// An HTTP request failed to send.
80    HttpSendRequestFailed,
81    /// An HTTP connection failed to be established.
82    HttpConnectFailed,
83    /// The SDK is not yet ready to perform the operation.
84    NotReady,
85}
86
87impl RetryReason {
88    /// Returns `true` if this reason allows retrying non-idempotent operations.
89    ///
90    /// Most retry reasons are safe for non-idempotent retries because the
91    /// server never processed the original request.
92    pub fn allows_non_idempotent_retry(&self) -> bool {
93        matches!(
94            self,
95            RetryReason::KvInvalidVbucketMap
96                | RetryReason::KvNotMyVbucket
97                | RetryReason::KvTemporaryFailure
98                | RetryReason::KvCollectionOutdated
99                | RetryReason::KvErrorMapRetryIndicated
100                | RetryReason::KvLocked
101                | RetryReason::ServiceNotAvailable
102                | RetryReason::SocketNotAvailable
103                | RetryReason::KvSyncWriteInProgress
104                | RetryReason::KvSyncWriteRecommitInProgress
105                | RetryReason::QueryPreparedStatementFailure
106                | RetryReason::QueryIndexNotFound
107                | RetryReason::QueryErrorRetryable
108                | RetryReason::SearchTooManyRequests
109                | RetryReason::HttpSendRequestFailed
110                | RetryReason::HttpConnectFailed
111                | RetryReason::NotReady
112        )
113    }
114
115    /// Returns `true` if the SDK should always retry for this reason,
116    /// regardless of the retry strategy's decision.
117    pub fn always_retry(&self) -> bool {
118        matches!(
119            self,
120            RetryReason::KvInvalidVbucketMap
121                | RetryReason::KvNotMyVbucket
122                | RetryReason::KvCollectionOutdated
123        )
124    }
125}
126
127impl Display for RetryReason {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            RetryReason::KvNotMyVbucket => write!(f, "KV_NOT_MY_VBUCKET"),
131            RetryReason::KvInvalidVbucketMap => write!(f, "KV_INVALID_VBUCKET_MAP"),
132            RetryReason::KvTemporaryFailure => write!(f, "KV_TEMPORARY_FAILURE"),
133            RetryReason::KvCollectionOutdated => write!(f, "KV_COLLECTION_OUTDATED"),
134            RetryReason::KvErrorMapRetryIndicated => write!(f, "KV_ERROR_MAP_RETRY_INDICATED"),
135            RetryReason::KvLocked => write!(f, "KV_LOCKED"),
136            RetryReason::ServiceNotAvailable => write!(f, "SERVICE_NOT_AVAILABLE"),
137            RetryReason::SocketClosedWhileInFlight => write!(f, "SOCKET_CLOSED_WHILE_IN_FLIGHT"),
138            RetryReason::SocketNotAvailable => write!(f, "SOCKET_NOT_AVAILABLE"),
139            RetryReason::KvSyncWriteInProgress => write!(f, "KV_SYNC_WRITE_IN_PROGRESS"),
140            RetryReason::KvSyncWriteRecommitInProgress => {
141                write!(f, "KV_SYNC_WRITE_RECOMMIT_IN_PROGRESS")
142            }
143            RetryReason::QueryPreparedStatementFailure => {
144                write!(f, "QUERY_PREPARED_STATEMENT_FAILURE")
145            }
146            RetryReason::QueryIndexNotFound => write!(f, "QUERY_INDEX_NOT_FOUND"),
147            RetryReason::QueryErrorRetryable => write!(f, "QUERY_ERROR_RETRYABLE"),
148            RetryReason::SearchTooManyRequests => write!(f, "SEARCH_TOO_MANY_REQUESTS"),
149            RetryReason::NotReady => write!(f, "NOT_READY"),
150            RetryReason::HttpSendRequestFailed => write!(f, "HTTP_SEND_REQUEST_FAILED"),
151            RetryReason::HttpConnectFailed => write!(f, "HTTP_CONNECT_FAILED"),
152        }
153    }
154}
155
156/// The action a [`RetryStrategy`] returns to indicate when to retry.
157///
158/// Contains the [`Duration`] to wait before the next retry attempt.
159#[derive(Clone, Debug)]
160pub struct RetryAction {
161    /// How long to wait before retrying.
162    pub duration: Duration,
163}
164
165impl RetryAction {
166    /// Creates a new `RetryAction` with the given backoff duration.
167    pub fn new(duration: Duration) -> Self {
168        Self { duration }
169    }
170}
171
172/// A strategy that decides whether and when to retry a failed operation.
173///
174/// Implement this trait to provide custom retry behavior. The SDK calls
175/// [`retry_after`](RetryStrategy::retry_after) each time a retryable failure
176/// occurs, passing the request metadata and the reason for the failure.
177///
178/// Return `Some(RetryAction)` to retry after the specified duration,
179/// or `None` to stop retrying and propagate the error.
180///
181/// # Example
182///
183/// ```rust
184/// use couchbase_core::retry::{RetryStrategy, RetryAction, RetryRequest, RetryReason};
185/// use std::fmt::Debug;
186/// use std::time::Duration;
187///
188/// #[derive(Debug)]
189/// struct FixedDelayRetry(Duration);
190///
191/// impl RetryStrategy for FixedDelayRetry {
192///     fn retry_after(&self, request: &RetryRequest, reason: &RetryReason) -> Option<RetryAction> {
193///         if request.retry_attempts < 3 {
194///             Some(RetryAction::new(self.0))
195///         } else {
196///             None // give up after 3 attempts
197///         }
198///     }
199/// }
200/// ```
201pub trait RetryStrategy: Debug + Send + Sync {
202    /// Decides whether to retry an operation and how long to wait.
203    ///
204    /// * `request` — Metadata about the in-flight request (attempt count, idempotency, etc.).
205    /// * `reason` — Why the operation failed.
206    ///
207    /// Return `Some(RetryAction)` to retry, or `None` to stop.
208    fn retry_after(&self, request: &RetryRequest, reason: &RetryReason) -> Option<RetryAction>;
209}
210
211/// Metadata about a request that is being considered for retry.
212#[derive(Clone, Debug)]
213pub struct RetryRequest {
214    pub(crate) operation: &'static str,
215    /// Whether the operation is idempotent (safe to retry without side effects).
216    pub is_idempotent: bool,
217    /// The number of retry attempts that have already been made.
218    pub retry_attempts: u32,
219    /// The set of reasons this request has been retried so far.
220    pub retry_reasons: HashSet<RetryReason>,
221    pub(crate) unique_id: Option<String>,
222}
223
224impl RetryRequest {
225    pub(crate) fn new(operation: &'static str, is_idempotent: bool) -> Self {
226        Self {
227            operation,
228            is_idempotent,
229            retry_attempts: 0,
230            retry_reasons: Default::default(),
231            unique_id: None,
232        }
233    }
234
235    pub(crate) fn add_retry_attempt(&mut self, reason: RetryReason) {
236        self.retry_attempts += 1;
237        tracing::Span::current().record(SPAN_ATTRIB_RETRIES, self.retry_attempts);
238        self.retry_reasons.insert(reason);
239    }
240
241    pub fn is_idempotent(&self) -> bool {
242        self.is_idempotent
243    }
244
245    pub fn retry_attempts(&self) -> u32 {
246        self.retry_attempts
247    }
248
249    pub fn retry_reasons(&self) -> &HashSet<RetryReason> {
250        &self.retry_reasons
251    }
252}
253
254impl Display for RetryRequest {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        write!(
257            f,
258            "{{ operation: {}, id: {}, is_idempotent: {}, retry_attempts: {}, retry_reasons: {} }}",
259            self.operation,
260            self.unique_id.as_ref().unwrap_or(&"".to_string()),
261            self.is_idempotent,
262            self.retry_attempts,
263            self.retry_reasons
264                .iter()
265                .map(|r| r.to_string())
266                .collect::<Vec<_>>()
267                .join(", ")
268        )
269    }
270}
271
272pub struct RetryManager {
273    err_map_component: Arc<ErrMapComponent>,
274}
275
276impl RetryManager {
277    pub fn new(err_map_component: Arc<ErrMapComponent>) -> Self {
278        Self { err_map_component }
279    }
280
281    pub async fn maybe_retry(
282        &self,
283        strategy: Arc<dyn RetryStrategy>,
284        request: &mut RetryRequest,
285        reason: RetryReason,
286    ) -> Option<Duration> {
287        if reason.always_retry() {
288            request.add_retry_attempt(reason);
289            let backoff = controlled_backoff(request.retry_attempts);
290
291            return Some(backoff);
292        }
293
294        let action = strategy.retry_after(request, &reason);
295
296        if let Some(a) = action {
297            request.add_retry_attempt(reason);
298
299            return Some(a.duration);
300        }
301
302        None
303    }
304}
305
306pub(crate) async fn orchestrate_retries<Fut, Resp>(
307    rs: Arc<RetryManager>,
308    strategy: Arc<dyn RetryStrategy>,
309    mut retry_info: RetryRequest,
310    operation: impl Fn() -> Fut + Send + Sync,
311) -> error::Result<Resp>
312where
313    Fut: Future<Output = error::Result<Resp>> + Send,
314    Resp: Send,
315{
316    loop {
317        let mut err = match operation().await {
318            Ok(r) => {
319                return Ok(r);
320            }
321            Err(e) => e,
322        };
323
324        if let Some(reason) = error_to_retry_reason(&rs, &mut retry_info, &err) {
325            if let Some(duration) = rs
326                .maybe_retry(strategy.clone(), &mut retry_info, reason)
327                .await
328            {
329                debug!(
330                    "Retrying {} after {:?} due to {}",
331                    &retry_info, duration, reason
332                );
333                sleep(duration).await;
334                continue;
335            }
336        }
337
338        if retry_info.retry_attempts > 0 {
339            // If we aren't retrying then attach any retry info that we have.
340            err.set_retry_info(retry_info);
341        }
342
343        return Err(err);
344    }
345}
346
347pub(crate) fn error_to_retry_reason(
348    rs: &Arc<RetryManager>,
349    retry_info: &mut RetryRequest,
350    err: &Error,
351) -> Option<RetryReason> {
352    match err.kind() {
353        ErrorKind::Memdx(err) => {
354            retry_info.unique_id = err.has_opaque().map(|o| o.to_string());
355
356            match err.kind() {
357                Server(e) => return server_error_to_retry_reason(rs, e),
358                Resource(e) => return server_error_to_retry_reason(rs, e.cause()),
359                Cancelled(e) if e == &CancellationErrorKind::ClosedInFlight => {
360                    return Some(RetryReason::SocketClosedWhileInFlight);
361                }
362                Dispatch { .. } => return Some(RetryReason::SocketNotAvailable),
363                _ => {}
364            }
365        }
366        ErrorKind::NoVbucketMap => {
367            return Some(RetryReason::KvInvalidVbucketMap);
368        }
369        ErrorKind::ServiceNotAvailable { .. } => {
370            return Some(RetryReason::ServiceNotAvailable);
371        }
372        ErrorKind::Query(e) => match e.kind() {
373            queryx::error::ErrorKind::Server(e) => match e.kind() {
374                queryx::error::ServerErrorKind::PreparedStatementFailure => {
375                    return Some(RetryReason::QueryPreparedStatementFailure);
376                }
377                queryx::error::ServerErrorKind::IndexNotFound => {
378                    return Some(RetryReason::QueryIndexNotFound);
379                }
380                _ => {
381                    if e.retry() {
382                        return Some(RetryReason::QueryErrorRetryable);
383                    }
384                }
385            },
386            queryx::error::ErrorKind::Http { error, .. } => match error.kind() {
387                httpx::error::ErrorKind::SendRequest(_) => {
388                    return Some(RetryReason::HttpSendRequestFailed);
389                }
390                httpx::error::ErrorKind::Connect { .. } => {
391                    return Some(RetryReason::HttpConnectFailed);
392                }
393                _ => {}
394            },
395            _ => {}
396        },
397        ErrorKind::Search(e) => match e.kind() {
398            searchx::error::ErrorKind::Server(e) if e.status_code() == 429 => {
399                return Some(RetryReason::SearchTooManyRequests);
400            }
401            searchx::error::ErrorKind::Http { error, .. } => match error.kind() {
402                httpx::error::ErrorKind::SendRequest(_) => {
403                    return Some(RetryReason::HttpSendRequestFailed);
404                }
405                httpx::error::ErrorKind::Connect { .. } => {
406                    return Some(RetryReason::HttpConnectFailed);
407                }
408                _ => {}
409            },
410            _ => {}
411        },
412        ErrorKind::Analytics(e) => {
413            if let analyticsx::error::ErrorKind::Http { error, .. } = e.kind() {
414                match error.kind() {
415                    httpx::error::ErrorKind::SendRequest(_) => {
416                        return Some(RetryReason::HttpSendRequestFailed);
417                    }
418                    httpx::error::ErrorKind::Connect { .. } => {
419                        return Some(RetryReason::HttpConnectFailed);
420                    }
421                    _ => {}
422                }
423            }
424        }
425        ErrorKind::Mgmt(e) => {
426            if let mgmtx::error::ErrorKind::Http(error) = e.kind() {
427                match error.kind() {
428                    httpx::error::ErrorKind::SendRequest(_) => {
429                        return Some(RetryReason::HttpSendRequestFailed);
430                    }
431                    httpx::error::ErrorKind::Connect { .. } => {
432                        return Some(RetryReason::HttpConnectFailed);
433                    }
434                    _ => {}
435                }
436            }
437        }
438        _ => {}
439    }
440
441    None
442}
443
444fn server_error_to_retry_reason(rs: &Arc<RetryManager>, e: &ServerError) -> Option<RetryReason> {
445    match e.kind() {
446        ServerErrorKind::NotMyVbucket => {
447            return Some(RetryReason::KvNotMyVbucket);
448        }
449        ServerErrorKind::TmpFail => {
450            return Some(RetryReason::KvTemporaryFailure);
451        }
452        ServerErrorKind::UnknownCollectionID => {
453            return Some(RetryReason::KvCollectionOutdated);
454        }
455        ServerErrorKind::UnknownCollectionName => {
456            return Some(RetryReason::KvCollectionOutdated);
457        }
458        ServerErrorKind::UnknownScopeName => {
459            return Some(RetryReason::KvCollectionOutdated);
460        }
461        ServerErrorKind::Locked => {
462            return Some(RetryReason::KvLocked);
463        }
464        ServerErrorKind::SyncWriteInProgress => {
465            return Some(RetryReason::KvSyncWriteInProgress);
466        }
467        ServerErrorKind::SyncWriteRecommitInProgress => {
468            return Some(RetryReason::KvSyncWriteRecommitInProgress);
469        }
470        ServerErrorKind::UnknownStatus { status } if rs.err_map_component.should_retry(status) => {
471            return Some(RetryReason::KvErrorMapRetryIndicated);
472        }
473        _ => {}
474    }
475
476    None
477}
478
479pub(crate) fn controlled_backoff(retry_attempts: u32) -> Duration {
480    match retry_attempts {
481        0 => Duration::from_millis(1),
482        1 => Duration::from_millis(10),
483        2 => Duration::from_millis(50),
484        3 => Duration::from_millis(100),
485        4 => Duration::from_millis(500),
486        _ => Duration::from_millis(1000),
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use crate::queryx;
494    use http::StatusCode;
495
496    fn make_retry_manager() -> Arc<RetryManager> {
497        Arc::new(RetryManager::new(Arc::new(ErrMapComponent::default())))
498    }
499
500    fn make_query_server_error(kind: queryx::error::ServerErrorKind, retry: bool) -> Error {
501        let server_error = queryx::error::ServerError::new(
502            kind,
503            "localhost:8093",
504            StatusCode::INTERNAL_SERVER_ERROR,
505            12345,
506            retry,
507            "test error",
508        );
509        queryx::error::Error::new_server_error(server_error).into()
510    }
511
512    #[test]
513    fn test_query_error_retryable_when_retry_true() {
514        let rs = make_retry_manager();
515        let mut retry_info = RetryRequest::new("query", false);
516        let err = make_query_server_error(queryx::error::ServerErrorKind::Unknown, true);
517
518        let reason = error_to_retry_reason(&rs, &mut retry_info, &err);
519        assert_eq!(reason, Some(RetryReason::QueryErrorRetryable));
520    }
521
522    #[test]
523    fn test_query_error_not_retryable_when_retry_false() {
524        let rs = make_retry_manager();
525        let mut retry_info = RetryRequest::new("query", false);
526        let err = make_query_server_error(queryx::error::ServerErrorKind::Unknown, false);
527
528        let reason = error_to_retry_reason(&rs, &mut retry_info, &err);
529        assert_eq!(reason, None);
530    }
531
532    #[test]
533    fn test_query_prepared_statement_failure_ignores_retry_flag() {
534        let rs = make_retry_manager();
535        let mut retry_info = RetryRequest::new("query", false);
536        let err = make_query_server_error(
537            queryx::error::ServerErrorKind::PreparedStatementFailure,
538            false,
539        );
540
541        let reason = error_to_retry_reason(&rs, &mut retry_info, &err);
542        assert_eq!(reason, Some(RetryReason::QueryPreparedStatementFailure));
543    }
544
545    #[test]
546    fn test_query_index_not_found_ignores_retry_flag() {
547        let rs = make_retry_manager();
548        let mut retry_info = RetryRequest::new("query", false);
549        let err = make_query_server_error(queryx::error::ServerErrorKind::IndexNotFound, false);
550
551        let reason = error_to_retry_reason(&rs, &mut retry_info, &err);
552        assert_eq!(reason, Some(RetryReason::QueryIndexNotFound));
553    }
554
555    #[test]
556    fn test_query_error_retryable_allows_non_idempotent_retry() {
557        assert!(RetryReason::QueryErrorRetryable.allows_non_idempotent_retry());
558    }
559
560    #[test]
561    fn test_query_error_retryable_does_not_always_retry() {
562        assert!(!RetryReason::QueryErrorRetryable.always_retry());
563    }
564}