qcs_api_client_grpc/tonic/
retry.rs1use 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#[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#[derive(Clone, Debug)]
54pub struct RetryService<S: GrpcService<Body>> {
55 backoff: ExponentialBuilder,
56 service: S,
57}
58
59fn 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 qcs_dependencies_client::tonic::Code::Unavailable => backoff.next(),
71 _ => 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 let mut backoff = self.backoff.build();
105 let mut service = self.service.clone();
106 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 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}