1use crate::{
2 ERROR_RETURNED_DUE_TO_SHORT_CIRCUIT, MESSAGE_TOO_LARGE_KEY,
3 grpc::IsUserLongPoll,
4 request_extensions::{IsWorkerTaskLongPoll, NoRetryOnMatching, RetryConfigForCall},
5};
6use backon::{BackoffBuilder, ExponentialBuilder};
7use futures_retry::{ErrorHandler, FutureRetry, RetryPolicy};
8use std::{
9 error::Error,
10 fmt::Debug,
11 future::Future,
12 time::{Duration, Instant},
13};
14use tonic::Code;
15
16const RETRYABLE_ERROR_CODES: [Code; 7] = [
18 Code::DataLoss,
19 Code::Internal,
20 Code::Unknown,
21 Code::ResourceExhausted,
22 Code::Aborted,
23 Code::OutOfRange,
24 Code::Unavailable,
25];
26const LONG_POLL_FATAL_GRACE: Duration = Duration::from_secs(60);
27
28#[derive(Clone, Debug, PartialEq, bon::Builder)]
30#[non_exhaustive]
31pub struct RetryOptions {
32 #[builder(default = Duration::from_millis(100))]
34 pub initial_interval: Duration,
35 #[builder(default = 0.2)]
38 pub randomization_factor: f64,
39 #[builder(default = 1.7)]
41 pub multiplier: f64,
42 #[builder(default = Duration::from_secs(5))]
44 pub max_interval: Duration,
45 #[builder(required, default = Some(Duration::from_secs(10)))]
48 pub max_elapsed_time: Option<Duration>,
49 #[builder(default = 10)]
51 pub max_retries: usize,
52}
53
54impl Default for RetryOptions {
55 fn default() -> Self {
56 Self::builder().build()
57 }
58}
59
60impl RetryOptions {
61 pub(crate) const fn task_poll_retry_policy() -> Self {
62 Self {
63 initial_interval: Duration::from_millis(200),
64 randomization_factor: 0.2,
65 multiplier: 2.0,
66 max_interval: Duration::from_secs(10),
67 max_elapsed_time: None,
68 max_retries: 0,
69 }
70 }
71
72 pub(crate) const fn throttle_retry_policy() -> Self {
73 Self {
74 initial_interval: Duration::from_secs(1),
75 randomization_factor: 0.2,
76 multiplier: 2.0,
77 max_interval: Duration::from_secs(10),
78 max_elapsed_time: None,
79 max_retries: 0,
80 }
81 }
82
83 pub const fn no_retries() -> Self {
85 Self {
86 initial_interval: Duration::from_secs(0),
87 randomization_factor: 0.0,
88 multiplier: 1.0,
89 max_interval: Duration::from_secs(0),
90 max_elapsed_time: None,
91 max_retries: 1,
92 }
93 }
94
95 pub(crate) fn get_call_info<R>(
96 &self,
97 call_name: &'static str,
98 request: Option<&tonic::Request<R>>,
99 ) -> CallInfo {
100 let mut call_type = CallType::Normal;
101 let mut retry_short_circuit = None;
102 let mut retry_cfg_override = None;
103 if let Some(r) = request.as_ref() {
104 let ext = r.extensions();
105 if ext.get::<IsUserLongPoll>().is_some() {
106 call_type = CallType::UserLongPoll;
107 } else if ext.get::<IsWorkerTaskLongPoll>().is_some() {
108 call_type = CallType::TaskLongPoll;
109 }
110
111 retry_short_circuit = ext.get::<NoRetryOnMatching>().cloned();
112 retry_cfg_override = ext.get::<RetryConfigForCall>().cloned();
113 }
114 let retry_cfg = if let Some(ovr) = retry_cfg_override {
115 ovr.0
116 } else if call_type == CallType::TaskLongPoll {
117 RetryOptions::task_poll_retry_policy()
118 } else {
119 self.clone()
120 };
121 CallInfo {
122 call_type,
123 call_name,
124 retry_cfg,
125 retry_short_circuit,
126 }
127 }
128
129 fn jittered_backoff(&self) -> JitteredBackoff {
130 let inner = ExponentialBuilder::new()
131 .with_min_delay(self.initial_interval)
132 .with_factor(self.multiplier as f32)
133 .with_max_delay(self.max_interval)
134 .without_max_times()
135 .build();
136 JitteredBackoff {
137 inner,
138 randomization_factor: self.randomization_factor,
139 max_elapsed_time: self.max_elapsed_time,
140 started_at: Instant::now(),
141 }
142 }
143}
144
145pub(crate) fn make_future_retry<R, F, Fut>(
146 info: CallInfo,
147 factory: F,
148) -> FutureRetry<F, TonicErrorHandler>
149where
150 F: FnMut() -> Fut + Unpin,
151 Fut: Future<Output = Result<R, tonic::Status>>,
152{
153 FutureRetry::new(
154 factory,
155 TonicErrorHandler::new(info, RetryOptions::throttle_retry_policy()),
156 )
157}
158
159#[doc(hidden)]
160pub fn jittered(base: Duration, randomization_factor: f64) -> Duration {
161 if randomization_factor <= 0.0 {
162 return base;
163 }
164 let base_secs = base.as_secs_f64();
169 let spread = randomization_factor * base_secs;
170 let offset = spread * (2.0 * rand::random::<f64>() - 1.0);
171 Duration::try_from_secs_f64((base_secs + offset).max(0.0)).unwrap_or(base)
172}
173
174#[derive(Debug)]
175struct JitteredBackoff {
176 inner: backon::ExponentialBackoff,
177 randomization_factor: f64,
178 max_elapsed_time: Option<Duration>,
179 started_at: Instant,
180}
181
182impl JitteredBackoff {
183 fn next_backoff(&mut self) -> Option<Duration> {
184 let base = self.inner.next()?;
188 let delay = jittered(base, self.randomization_factor);
189 if let Some(max_elapsed_time) = self.max_elapsed_time
190 && self.started_at.elapsed() + delay > max_elapsed_time
191 {
192 return None;
193 }
194 Some(delay)
195 }
196}
197
198#[derive(Debug)]
199pub(crate) struct TonicErrorHandler {
200 backoff: JitteredBackoff,
201 throttle_backoff: JitteredBackoff,
202 max_interval: Duration,
203 retry_started_at: Instant,
204 max_retries: usize,
205 call_type: CallType,
206 call_name: &'static str,
207 retry_short_circuit: Option<NoRetryOnMatching>,
208}
209
210impl TonicErrorHandler {
211 fn new(call_info: CallInfo, throttle_cfg: RetryOptions) -> Self {
212 Self {
213 call_type: call_info.call_type,
214 call_name: call_info.call_name,
215 max_retries: call_info.retry_cfg.max_retries,
216 max_interval: call_info.retry_cfg.max_interval,
217 backoff: call_info.retry_cfg.jittered_backoff(),
218 throttle_backoff: throttle_cfg.jittered_backoff(),
219 retry_started_at: Instant::now(),
220 retry_short_circuit: call_info.retry_short_circuit,
221 }
222 }
223
224 fn maybe_log_retry(&self, cur_attempt: usize, err: &tonic::Status) {
225 let mut do_log = false;
226 if self.max_retries == 0 && cur_attempt > 5 {
228 do_log = true;
229 }
230 if self.max_retries > 0 && cur_attempt * 2 >= self.max_retries {
232 do_log = true;
233 }
234
235 if do_log {
236 if self.max_retries == 0 && cur_attempt > 15 {
238 error!(error=?err, "gRPC call {} retried {} times", self.call_name, cur_attempt);
239 } else {
240 warn!(error=?err, "gRPC call {} retried {} times", self.call_name, cur_attempt);
241 }
242 }
243 }
244}
245
246#[derive(Clone, Debug)]
247pub(crate) struct CallInfo {
248 pub call_type: CallType,
249 call_name: &'static str,
250 retry_cfg: RetryOptions,
251 retry_short_circuit: Option<NoRetryOnMatching>,
252}
253
254#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
255pub(crate) enum CallType {
256 Normal,
257 UserLongPoll,
259 TaskLongPoll,
261}
262
263impl CallType {
264 pub(crate) fn is_long(&self) -> bool {
265 matches!(self, Self::UserLongPoll | Self::TaskLongPoll)
266 }
267}
268
269impl ErrorHandler<tonic::Status> for TonicErrorHandler {
270 type OutError = tonic::Status;
271
272 fn handle(
273 &mut self,
274 current_attempt: usize,
275 mut e: tonic::Status,
276 ) -> RetryPolicy<tonic::Status> {
277 if self.max_retries > 0 && current_attempt >= self.max_retries {
279 return RetryPolicy::ForwardError(e);
280 }
281
282 if let Some(sc) = self.retry_short_circuit.as_ref()
283 && (sc.predicate)(&e)
284 {
285 e.metadata_mut().insert(
286 ERROR_RETURNED_DUE_TO_SHORT_CIRCUIT,
287 tonic::metadata::MetadataValue::from(0),
288 );
289 return RetryPolicy::ForwardError(e);
290 }
291
292 if e.code() == Code::ResourceExhausted
294 && (e
295 .message()
296 .starts_with("grpc: received message larger than max")
297 || e.message()
298 .starts_with("grpc: message after decompression larger than max")
299 || e.message()
300 .starts_with("grpc: received message after decompression larger than max"))
301 {
302 e.metadata_mut().insert(
304 MESSAGE_TOO_LARGE_KEY,
305 tonic::metadata::MetadataValue::from(0),
306 );
307 return RetryPolicy::ForwardError(e);
308 }
309
310 let long_poll_allowed = self.call_type == CallType::TaskLongPoll
313 && [Code::Cancelled, Code::DeadlineExceeded].contains(&e.code());
314
315 let transport_cancel_retry_allowed =
320 e.code() == Code::Cancelled && is_transport_cancelled(&e);
321
322 if RETRYABLE_ERROR_CODES.contains(&e.code())
323 || long_poll_allowed
324 || transport_cancel_retry_allowed
325 {
326 if current_attempt == 1 {
327 debug!(error=?e, "gRPC call {} failed on first attempt", self.call_name);
328 } else {
329 self.maybe_log_retry(current_attempt, &e);
330 }
331
332 match self.backoff.next_backoff() {
333 None => RetryPolicy::ForwardError(e), Some(backoff) => {
335 if e.code() == Code::ResourceExhausted {
338 let extended_backoff =
339 backoff.max(self.throttle_backoff.next_backoff().unwrap_or_default());
340 RetryPolicy::WaitRetry(extended_backoff)
341 } else {
342 RetryPolicy::WaitRetry(backoff)
343 }
344 }
345 }
346 } else if self.call_type == CallType::TaskLongPoll
347 && self.retry_started_at.elapsed() <= LONG_POLL_FATAL_GRACE
348 {
349 RetryPolicy::WaitRetry(self.max_interval)
352 } else {
353 RetryPolicy::ForwardError(e)
354 }
355 }
356}
357
358fn is_transport_cancelled(status: &tonic::Status) -> bool {
363 status
364 .source()
365 .and_then(|e| e.downcast_ref::<tonic::transport::Error>())
366 .and_then(|te| te.source())
367 .and_then(|tec| tec.downcast_ref::<hyper::Error>())
368 .is_some()
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use crate::{
375 Client, ClientOptions, Connection, ConnectionOptions,
376 callback_based::{CallbackBasedGrpcService, GrpcSuccessResponse},
377 };
378 use assert_matches::assert_matches;
379 use prost::Message;
380 use std::{
381 sync::{
382 Arc,
383 atomic::{AtomicUsize, Ordering},
384 },
385 time::Instant,
386 };
387 use temporalio_common::protos::temporal::api::workflowservice::v1::{
388 CountWorkflowExecutionsResponse, PollActivityTaskQueueRequest, PollNexusTaskQueueRequest,
389 PollWorkflowTaskQueueRequest,
390 };
391 use tonic::{IntoRequest, Status};
392 use url::Url;
393
394 const TEST_RETRY_CONFIG: RetryOptions = RetryOptions {
396 initial_interval: Duration::from_millis(1),
397 randomization_factor: 0.0,
398 multiplier: 1.1,
399 max_interval: Duration::from_millis(2),
400 max_elapsed_time: None,
401 max_retries: 10,
402 };
403
404 const POLL_WORKFLOW_METH_NAME: &str = "poll_workflow_task_queue";
405 const POLL_ACTIVITY_METH_NAME: &str = "poll_activity_task_queue";
406 const POLL_NEXUS_METH_NAME: &str = "poll_nexus_task_queue";
407
408 #[tokio::test]
409 async fn retryable_errors() {
410 for code in RETRYABLE_ERROR_CODES
412 .iter()
413 .copied()
414 .filter(|code| code != &Code::ResourceExhausted)
415 {
416 let attempts = Arc::new(AtomicUsize::new(0));
417 let callback_attempts = attempts.clone();
418 let service_override = CallbackBasedGrpcService {
419 callback: Arc::new(move |request| {
420 assert_eq!(request.rpc, "CountWorkflowExecutions");
421 let callback_attempts = callback_attempts.clone();
422 Box::pin(async move {
423 if callback_attempts.fetch_add(1, Ordering::Relaxed) < 3 {
424 Err(Status::new(code, "retryable"))
425 } else {
426 Ok(GrpcSuccessResponse {
427 headers: Default::default(),
428 proto: CountWorkflowExecutionsResponse::default().encode_to_vec(),
429 })
430 }
431 })
432 }),
433 };
434 let connection_options =
435 ConnectionOptions::new(Url::parse("http://localhost:7233").unwrap())
436 .retry_options(TEST_RETRY_CONFIG)
437 .skip_get_system_info(true)
438 .service_override(service_override)
439 .dns_load_balancing(None)
440 .build();
441 let connection = Connection::connect(connection_options).await.unwrap();
442 let client = Client::new(connection, ClientOptions::new("ns").build()).unwrap();
443
444 let result = client.count_workflows("whatever", Default::default()).await;
445
446 assert!(result.is_ok(), "{result:?}");
447 assert_eq!(attempts.load(Ordering::Relaxed), 4);
448 }
449 }
450
451 #[tokio::test]
452 async fn long_poll_non_retryable_errors() {
453 for code in [
454 Code::InvalidArgument,
455 Code::NotFound,
456 Code::AlreadyExists,
457 Code::PermissionDenied,
458 Code::FailedPrecondition,
459 Code::Unauthenticated,
460 Code::Unimplemented,
461 ] {
462 for call_name in [POLL_WORKFLOW_METH_NAME, POLL_ACTIVITY_METH_NAME] {
463 let mut err_handler = TonicErrorHandler::new(
464 CallInfo {
465 call_type: CallType::TaskLongPoll,
466 call_name,
467 retry_cfg: TEST_RETRY_CONFIG,
468 retry_short_circuit: None,
469 },
470 TEST_RETRY_CONFIG,
471 );
472 let result = err_handler.handle(1, Status::new(code, "Ahh"));
473 assert_matches!(result, RetryPolicy::WaitRetry(_));
474 err_handler.retry_started_at =
475 Instant::now() - LONG_POLL_FATAL_GRACE - Duration::from_secs(1);
476 let result = err_handler.handle(2, Status::new(code, "Ahh"));
477 assert_matches!(result, RetryPolicy::ForwardError(_));
478 }
479 }
480 }
481
482 #[tokio::test]
483 async fn long_poll_retryable_errors_never_fatal() {
484 for code in RETRYABLE_ERROR_CODES {
485 for call_name in [POLL_WORKFLOW_METH_NAME, POLL_ACTIVITY_METH_NAME] {
486 let mut err_handler = TonicErrorHandler::new(
487 CallInfo {
488 call_type: CallType::TaskLongPoll,
489 call_name,
490 retry_cfg: TEST_RETRY_CONFIG,
491 retry_short_circuit: None,
492 },
493 TEST_RETRY_CONFIG,
494 );
495 let result = err_handler.handle(1, Status::new(code, "Ahh"));
496 assert_matches!(result, RetryPolicy::WaitRetry(_));
497 err_handler.retry_started_at =
498 Instant::now() - LONG_POLL_FATAL_GRACE - Duration::from_secs(1);
499 let result = err_handler.handle(2, Status::new(code, "Ahh"));
500 assert_matches!(result, RetryPolicy::WaitRetry(_));
501 }
502 }
503 }
504
505 #[tokio::test]
506 async fn retry_resource_exhausted() {
507 let mut err_handler = TonicErrorHandler::new(
508 CallInfo {
509 call_type: CallType::TaskLongPoll,
510 call_name: POLL_WORKFLOW_METH_NAME,
511 retry_cfg: TEST_RETRY_CONFIG,
512 retry_short_circuit: None,
513 },
514 RetryOptions {
515 initial_interval: Duration::from_millis(2),
516 randomization_factor: 0.0,
517 multiplier: 4.0,
518 max_interval: Duration::from_millis(10),
519 max_elapsed_time: None,
520 max_retries: 10,
521 },
522 );
523 let result = err_handler.handle(1, Status::new(Code::ResourceExhausted, "leave me alone"));
524 match result {
525 RetryPolicy::WaitRetry(duration) => assert_eq!(duration, Duration::from_millis(2)),
526 _ => panic!(),
527 }
528 let result = err_handler.handle(2, Status::new(Code::ResourceExhausted, "leave me alone"));
529 match result {
530 RetryPolicy::WaitRetry(duration) => assert_eq!(duration, Duration::from_millis(8)),
531 _ => panic!(),
532 }
533 }
534
535 #[tokio::test]
536 async fn retry_short_circuit() {
537 let mut err_handler = TonicErrorHandler::new(
538 CallInfo {
539 call_type: CallType::TaskLongPoll,
540 call_name: POLL_WORKFLOW_METH_NAME,
541 retry_cfg: TEST_RETRY_CONFIG,
542 retry_short_circuit: Some(NoRetryOnMatching {
543 predicate: |s: &Status| s.code() == Code::ResourceExhausted,
544 }),
545 },
546 TEST_RETRY_CONFIG,
547 );
548 let result = err_handler.handle(1, Status::new(Code::ResourceExhausted, "leave me alone"));
549 let e = assert_matches!(result, RetryPolicy::ForwardError(e) => e);
550 assert!(
551 e.metadata()
552 .get(ERROR_RETURNED_DUE_TO_SHORT_CIRCUIT)
553 .is_some()
554 );
555 }
556
557 #[tokio::test]
558 async fn message_too_large_not_retried() {
559 let mut err_handler = TonicErrorHandler::new(
560 CallInfo {
561 call_type: CallType::TaskLongPoll,
562 call_name: POLL_WORKFLOW_METH_NAME,
563 retry_cfg: TEST_RETRY_CONFIG,
564 retry_short_circuit: None,
565 },
566 TEST_RETRY_CONFIG,
567 );
568 let result = err_handler.handle(
569 1,
570 Status::new(
571 Code::ResourceExhausted,
572 "grpc: received message larger than max",
573 ),
574 );
575 assert_matches!(result, RetryPolicy::ForwardError(_));
576
577 let result = err_handler.handle(
578 1,
579 Status::new(
580 Code::ResourceExhausted,
581 "grpc: message after decompression larger than max",
582 ),
583 );
584 assert_matches!(result, RetryPolicy::ForwardError(_));
585
586 let result = err_handler.handle(
587 1,
588 Status::new(
589 Code::ResourceExhausted,
590 "grpc: received message after decompression larger than max",
591 ),
592 );
593 assert_matches!(result, RetryPolicy::ForwardError(_));
594 }
595
596 #[rstest::rstest]
597 #[tokio::test]
598 async fn task_poll_retries_forever<R>(
599 #[values(
600 (
601 POLL_WORKFLOW_METH_NAME,
602 PollWorkflowTaskQueueRequest::default(),
603 ),
604 (
605 POLL_ACTIVITY_METH_NAME,
606 PollActivityTaskQueueRequest::default(),
607 ),
608 (
609 POLL_NEXUS_METH_NAME,
610 PollNexusTaskQueueRequest::default(),
611 ),
612 )]
613 (call_name, req): (&'static str, R),
614 ) {
615 let mut req = req.into_request();
618 req.extensions_mut().insert(IsWorkerTaskLongPoll);
619 for i in 1..=50 {
620 let mut err_handler = TonicErrorHandler::new(
621 TEST_RETRY_CONFIG.get_call_info::<R>(call_name, Some(&req)),
622 RetryOptions::throttle_retry_policy(),
623 );
624 let result = err_handler.handle(i, Status::new(Code::Unknown, "Ahh"));
625 assert_matches!(result, RetryPolicy::WaitRetry(_));
626 }
627 }
628
629 #[rstest::rstest]
630 #[tokio::test]
631 async fn task_poll_retries_deadline_exceeded<R>(
632 #[values(
633 (
634 POLL_WORKFLOW_METH_NAME,
635 PollWorkflowTaskQueueRequest::default(),
636 ),
637 (
638 POLL_ACTIVITY_METH_NAME,
639 PollActivityTaskQueueRequest::default(),
640 ),
641 (
642 POLL_NEXUS_METH_NAME,
643 PollNexusTaskQueueRequest::default(),
644 ),
645 )]
646 (call_name, req): (&'static str, R),
647 ) {
648 let mut req = req.into_request();
649 req.extensions_mut().insert(IsWorkerTaskLongPoll);
650 for code in [Code::Cancelled, Code::DeadlineExceeded] {
652 let mut err_handler = TonicErrorHandler::new(
653 TEST_RETRY_CONFIG.get_call_info::<R>(call_name, Some(&req)),
654 RetryOptions::throttle_retry_policy(),
655 );
656 for i in 1..=5 {
657 let result = err_handler.handle(i, Status::new(code, "retryable failure"));
658 assert_matches!(result, RetryPolicy::WaitRetry(_));
659 }
660 }
661 }
662
663 #[tokio::test]
664 async fn plain_cancelled_not_retried_on_normal_call() {
665 let mut err_handler = TonicErrorHandler::new(
668 CallInfo {
669 call_type: CallType::Normal,
670 call_name: "respond_activity_task_completed",
671 retry_cfg: TEST_RETRY_CONFIG,
672 retry_short_circuit: None,
673 },
674 TEST_RETRY_CONFIG,
675 );
676 let result = err_handler.handle(1, Status::new(Code::Cancelled, "caller cancelled"));
677 assert_matches!(result, RetryPolicy::ForwardError(_));
678 }
679
680 #[tokio::test]
681 async fn is_transport_cancelled_false_for_plain_status() {
682 let status = Status::new(Code::Cancelled, "caller cancelled");
685 assert!(!is_transport_cancelled(&status));
686 }
687
688 #[tokio::test]
689 async fn transport_sourced_cancelled_retried_on_full_budget() {
690 let mut err_handler = TonicErrorHandler::new(
701 CallInfo {
702 call_type: CallType::Normal,
703 call_name: "respond_activity_task_completed",
704 retry_cfg: TEST_RETRY_CONFIG,
705 retry_short_circuit: None,
706 },
707 TEST_RETRY_CONFIG,
708 );
709
710 for i in 1..=5 {
713 let endpoint = tonic::transport::Endpoint::from_static("http://[::1]:1")
714 .connect_timeout(Duration::from_millis(1));
715 let transport_err = endpoint.connect().await.unwrap_err();
716 let status = Status::from_error(Box::new(transport_err));
717
718 let result = err_handler.handle(i, status);
719 assert_matches!(
720 result,
721 RetryPolicy::WaitRetry(_),
722 "Transport error should be retried on attempt {i}"
723 );
724 }
725 }
726}