Skip to main content

qcs_api_client_grpc/tonic/
retry.rs

1use super::Body;
2use qcs_api_client_common::{
3    backoff::{self, BackoffBuilder, ExponentialBackoff, ExponentialBuilder},
4    configuration::TokenError,
5};
6use qcs_dependencies_client::http::{HeaderValue, Request, Response};
7use qcs_dependencies_client::tonic::{Status, client::GrpcService};
8
9use qcs_api_client_common::backoff::duration_from_response as duration_from_http_response;
10use std::{
11    future::{Future, poll_fn},
12    pin::Pin,
13    task::{Context, Poll},
14    time::Duration,
15};
16
17use super::{RequestBodyDuplicationError, build_duplicate_request};
18use qcs_dependencies_client::tower::Layer;
19
20/// The [`Layer`] used to apply exponential backoff retry logic to requests.
21#[derive(Debug, Clone)]
22pub struct RetryLayer {
23    pub(crate) backoff: ExponentialBuilder,
24}
25
26impl Default for RetryLayer {
27    fn default() -> Self {
28        Self {
29            backoff: backoff::default_backoff(),
30        }
31    }
32}
33
34impl<S: GrpcService<Body>> Layer<S> for RetryLayer {
35    type Service = RetryService<S>;
36
37    fn layer(&self, service: S) -> Self::Service {
38        Self::Service {
39            backoff: self.backoff,
40            service,
41        }
42    }
43}
44
45/// The [`GrpcService`] that wraps the gRPC client in order to provide exponential backoff retry
46/// logic.
47///
48/// This middleware will add a `x-request-id` header to each request with a unique UUID and a
49/// `x-request-retry-index` header with the number of retries that have been attempted for the
50/// request.
51///
52/// See also: [`RetryLayer`].
53#[derive(Clone, Debug)]
54pub struct RetryService<S: GrpcService<Body>> {
55    backoff: ExponentialBuilder,
56    service: S,
57}
58
59/// Return `Some` if the request should be retried and the provided `backoff`
60/// has another backoff to try, or, for an http request, if the response
61/// specifies a `Retry-After` header. If `None` is returned, the request should
62/// not be retried.
63fn duration_from_response<T>(
64    response: &Response<T>,
65    backoff: &mut ExponentialBackoff,
66) -> Option<Duration> {
67    if let Some(grpc_status) = Status::from_header_map(response.headers()) {
68        match grpc_status.code() {
69            // gRPC has no equivalent to RETRY-AFTER, so just use the backoff
70            qcs_dependencies_client::tonic::Code::Unavailable => backoff.next(),
71            // No other gRPC statuses are retried.
72            _ => None,
73        }
74    } else {
75        duration_from_http_response(response.status(), response.headers(), backoff)
76    }
77}
78
79impl<S> GrpcService<Body> for RetryService<S>
80where
81    S: GrpcService<Body> + Send + Clone + 'static,
82    S::Future: Send,
83    S::ResponseBody: Send,
84    super::error::Error<TokenError>: From<S::Error> + From<RequestBodyDuplicationError>,
85{
86    type ResponseBody = <S as GrpcService<Body>>::ResponseBody;
87    type Error = super::error::Error<TokenError>;
88    type Future =
89        Pin<Box<dyn Future<Output = Result<Response<Self::ResponseBody>, Self::Error>> + Send>>;
90
91    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
92        self.service
93            .poll_ready(cx)
94            .map_err(super::error::Error::from)
95    }
96
97    fn call(&mut self, mut req: Request<Body>) -> Self::Future {
98        if let Ok(request_id) = new_request_id() {
99            req.headers_mut().insert(KEY_X_REQUEST_ID, request_id);
100        }
101
102        // Build a fresh `backoff` so that new requests don't reuse state
103        // and so that the `backoff` can be moved into the async closure.
104        let mut backoff = self.backoff.build();
105        let mut service = self.service.clone();
106        // It is necessary to replace self.service with the above clone
107        // because the cloned version may not be "ready".
108        //
109        // See this github issue for more context:
110        // https://github.com/tower-rs/tower/issues/547
111        std::mem::swap(&mut self.service, &mut service);
112
113        super::common::pin_future_with_otel_context_if_available(async move {
114            let mut attempt = 0;
115            loop {
116                let (mut request, retained) = build_duplicate_request(req).await?;
117                req = retained;
118
119                // Ensure that the service is ready before trying to use it.
120                // Failure to do this *will* cause a panic.
121                poll_fn(|cx| -> Poll<Result<(), _>> { service.poll_ready(cx) })
122                    .await
123                    .map_err(super::error::Error::from)?;
124
125                if let Ok(retry_index_header_value) =
126                    qcs_dependencies_client::http::HeaderValue::from_str(
127                        attempt.to_string().as_str(),
128                    )
129                {
130                    request
131                        .headers_mut()
132                        .insert(KEY_X_REQUEST_RETRY_INDEX, retry_index_header_value);
133                }
134                let duration = match service.call(request).await {
135                    Ok(response) => {
136                        if let Some(duration) = duration_from_response(&response, &mut backoff) {
137                            duration
138                        } else {
139                            break Ok(response);
140                        }
141                    }
142                    Err(error) => break Err(super::error::Error::from(error)),
143                };
144
145                tokio::time::sleep(duration).await;
146                attempt += 1;
147            }
148        })
149    }
150}
151
152fn new_request_id() -> Result<HeaderValue, qcs_dependencies_client::http::header::InvalidHeaderValue>
153{
154    let request_id = uuid::Uuid::new_v4().to_string();
155    HeaderValue::from_str(request_id.as_str())
156}
157
158const KEY_X_REQUEST_ID: &str = "x-request-id";
159const KEY_X_REQUEST_RETRY_INDEX: &str = "x-request-retry-index";
160
161#[cfg(test)]
162mod tests {
163    use std::sync::atomic::{AtomicUsize, Ordering};
164
165    use crate::tonic::uds_grpc_stream;
166    use crate::tonic::wrap_channel_with_retry;
167
168    use super::*;
169    use qcs_dependencies_client::tonic::Request;
170    use qcs_dependencies_client::tonic::server::NamedService;
171    use qcs_dependencies_client::tonic_health::pb::health_check_response::ServingStatus;
172    use qcs_dependencies_client::tonic_health::pb::health_server::{Health, HealthServer};
173    use qcs_dependencies_client::tonic_health::{
174        pb::health_client::HealthClient, server::HealthService,
175    };
176
177    struct FlakyHealthService {
178        required_tries_count: AtomicUsize,
179    }
180
181    impl FlakyHealthService {
182        const fn new(required_tries_count: usize) -> Self {
183            Self {
184                required_tries_count: AtomicUsize::new(required_tries_count),
185            }
186        }
187
188        #[allow(clippy::result_large_err)]
189        fn make_response(
190            &self,
191        ) -> Result<qcs_dependencies_client::tonic_health::pb::HealthCheckResponse, Status>
192        {
193            let remaining = self.required_tries_count.fetch_sub(1, Ordering::SeqCst);
194            if remaining == 0 {
195                let response = qcs_dependencies_client::tonic_health::pb::HealthCheckResponse {
196                    status: ServingStatus::Serving as i32,
197                };
198                Ok(response)
199            } else {
200                self.required_tries_count
201                    .store(remaining - 1, Ordering::SeqCst);
202                Err(Status::unavailable("unavailable"))
203            }
204        }
205    }
206
207    impl Default for FlakyHealthService {
208        fn default() -> Self {
209            Self::new(3)
210        }
211    }
212
213    #[qcs_dependencies_client::tonic::async_trait]
214    impl Health for FlakyHealthService {
215        type WatchStream = tokio_stream::wrappers::ReceiverStream<
216            Result<qcs_dependencies_client::tonic_health::pb::HealthCheckResponse, Status>,
217        >;
218
219        async fn check(
220            &self,
221            _request: Request<qcs_dependencies_client::tonic_health::pb::HealthCheckRequest>,
222        ) -> Result<
223            qcs_dependencies_client::tonic::Response<
224                qcs_dependencies_client::tonic_health::pb::HealthCheckResponse,
225            >,
226            Status,
227        > {
228            self.make_response()
229                .map(qcs_dependencies_client::tonic::Response::new)
230        }
231
232        async fn watch(
233            &self,
234            _request: Request<qcs_dependencies_client::tonic_health::pb::HealthCheckRequest>,
235        ) -> Result<qcs_dependencies_client::tonic::Response<Self::WatchStream>, Status> {
236            let (tx, rx) = tokio::sync::mpsc::channel(1);
237            tx.send(self.make_response()).await.unwrap();
238            Ok(qcs_dependencies_client::tonic::Response::new(
239                tokio_stream::wrappers::ReceiverStream::new(rx),
240            ))
241        }
242    }
243
244    #[tokio::test(flavor = "multi_thread")]
245    async fn test_retry_logic() {
246        let health_server = HealthServer::new(FlakyHealthService::default());
247
248        uds_grpc_stream::serve(health_server, |channel| async {
249            let wrapped_channel = wrap_channel_with_retry(channel);
250            let response = HealthClient::new(wrapped_channel)
251                .check(Request::new(
252                    qcs_dependencies_client::tonic_health::pb::HealthCheckRequest {
253                        service: <HealthServer<HealthService> as NamedService>::NAME.to_string(),
254                    },
255                ))
256                .await
257                .unwrap();
258            assert_eq!(response.into_inner().status(), ServingStatus::Serving);
259        })
260        .await
261        .unwrap();
262    }
263
264    #[tokio::test(flavor = "multi_thread")]
265    async fn test_retry_is_not_infinite_long() {
266        let health_server = HealthServer::new(FlakyHealthService::new(50));
267
268        uds_grpc_stream::serve(health_server, |channel| async {
269            let status = HealthClient::new(
270                RetryLayer {
271                    backoff: ExponentialBuilder::new()
272                        .with_max_delay(Duration::from_millis(100))
273                        .with_total_delay(Some(Duration::from_secs(1)))
274                        .without_max_times(),
275                }
276                .layer(channel),
277            )
278            .check(Request::new(
279                qcs_dependencies_client::tonic_health::pb::HealthCheckRequest {
280                    service: <HealthServer<HealthService> as NamedService>::NAME.to_string(),
281                },
282            ))
283            .await
284            .unwrap_err();
285            assert_eq!(
286                status.code(),
287                qcs_dependencies_client::tonic::Code::Unavailable
288            );
289        })
290        .await
291        .unwrap();
292    }
293}