Skip to main content

temporalio_client/
retry.rs

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
16/// List of gRPC error codes that client will retry.
17const 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/// Configuration for retrying requests to the server
29#[derive(Clone, Debug, PartialEq, bon::Builder)]
30#[non_exhaustive]
31pub struct RetryOptions {
32    /// initial wait time before the first retry.
33    #[builder(default = Duration::from_millis(100))]
34    pub initial_interval: Duration,
35    /// randomization jitter that is used as a multiplier for the current retry interval
36    /// and is added or subtracted from the interval length.
37    #[builder(default = 0.2)]
38    pub randomization_factor: f64,
39    /// rate at which retry time should be increased, until it reaches max_interval.
40    #[builder(default = 1.7)]
41    pub multiplier: f64,
42    /// maximum amount of time to wait between retries.
43    #[builder(default = Duration::from_secs(5))]
44    pub max_interval: Duration,
45    /// maximum total amount of time requests should be retried for, if None is set then no limit
46    /// will be used.
47    #[builder(required, default = Some(Duration::from_secs(10)))]
48    pub max_elapsed_time: Option<Duration>,
49    /// maximum number of retry attempts.
50    #[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    /// A retry policy that never retires
84    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    // Reproduce the `backoff` crate's documented jitter for backward compatibility:
165    //   randomized interval = retry_interval * (random value in range [1 - randomization_factor, 1 + randomization_factor])
166    // Docs: https://github.com/ihrwein/backoff/blob/587e2da8fb2dcfc65ca544cb9249022c51f1406e/src/lib.rs#L4
167    // Algorithm (`get_random_value_from_interval`): https://github.com/ihrwein/backoff/blob/587e2da8fb2dcfc65ca544cb9249022c51f1406e/src/exponential.rs#L61
168    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        // `inner` never stops on its own; the total retry budget is enforced here
185        // against wall-clock time so the jittered delay we actually return is what
186        // counts toward `max_elapsed_time`.
187        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        // Warn on more than 5 retries for unlimited retrying
227        if self.max_retries == 0 && cur_attempt > 5 {
228            do_log = true;
229        }
230        // Warn if the attempts are more than 50% of max retries
231        if self.max_retries > 0 && cur_attempt * 2 >= self.max_retries {
232            do_log = true;
233        }
234
235        if do_log {
236            // Error if unlimited retries have been going on for a while
237            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    // A long poll but won't always retry timeouts/cancels. EX: Get workflow history
258    UserLongPoll,
259    // A worker is polling for a task
260    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        // 0 max retries means unlimited retries
278        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        // Short circuit if message is too large - this is not retryable
293        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            // Leave a marker so we don't have duplicate detection logic in the workflow
303            e.metadata_mut().insert(
304                MESSAGE_TOO_LARGE_KEY,
305                tonic::metadata::MetadataValue::from(0),
306            );
307            return RetryPolicy::ForwardError(e);
308        }
309
310        // Task polls are OK with being cancelled or running into the timeout because there's
311        // nothing to do but retry anyway
312        let long_poll_allowed = self.call_type == CallType::TaskLongPoll
313            && [Code::Cancelled, Code::DeadlineExceeded].contains(&e.code());
314
315        // When Code::Cancelled originates from a transport-level error (e.g. GOAWAY,
316        // connection closed during an AZ outage), it should be retried like Unavailable.
317        // We distinguish this from true application/caller-initiated cancellations by
318        // inspecting the error source chain for tonic::transport::Error → hyper::Error.
319        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), // None is returned when we've ran out of time
334                Some(backoff) => {
335                    // We treat ResourceExhausted as a special case and backoff more
336                    // so we don't overload the server
337                    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            // We permit "fatal" errors while long polling for a while, because some proxies return
350            // stupid error codes while getting ready, among other weird infra issues
351            RetryPolicy::WaitRetry(self.max_interval)
352        } else {
353            RetryPolicy::ForwardError(e)
354        }
355    }
356}
357
358/// Returns true if the given status is a `Code::Cancelled` that originated from a
359/// transport-level failure (tonic::transport::Error → hyper::Error) rather than
360/// an application/caller-initiated cancellation. These should be retried like
361/// `Code::Unavailable`.
362fn 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    /// Predefined retry configs with low durations to make unit tests faster
395    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        // Resource exhausted has a separate retry policy and is covered below.
411        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        // A bit odd, but we don't need a real client to test the retry client passes through the
616        // correct retry config
617        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 some reason we will get cancelled in these situations occasionally (always?) too
651        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        // A plain Code::Cancelled (no transport error in source chain) on a Normal call
666        // must NOT be retried — this is spec-correct behavior for application-level cancels.
667        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        // A status without a transport error source chain should not be detected as
683        // transport-cancelled.
684        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        // NOTE: tonic::Status's public API doesn't allow constructing a Status with both
691        // Code::Cancelled AND a transport error source chain. In production, tonic
692        // internally builds this when a GOAWAY/connection-close kills an in-flight RPC.
693        // We test the components separately:
694        //   1. is_transport_cancelled correctly detects transport errors (test above)
695        //   2. The retry handler correctly treats transport-cancelled as retryable (this test)
696        //
697        // For this test, we verify through the `handle` method that a transport-sourced
698        // Cancelled status (created via from_error, which sets Code::Unknown but preserves
699        // the transport source chain) IS retried multiple times on the standard budget.
700        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        // Code::Unknown with a transport source IS retried (it's in RETRYABLE_ERROR_CODES)
711        // AND is_transport_cancelled would return true — both paths lead to retry.
712        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}