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.
17#[doc(hidden)]
18pub const RETRYABLE_ERROR_CODES: [Code; 7] = [
19    Code::DataLoss,
20    Code::Internal,
21    Code::Unknown,
22    Code::ResourceExhausted,
23    Code::Aborted,
24    Code::OutOfRange,
25    Code::Unavailable,
26];
27const LONG_POLL_FATAL_GRACE: Duration = Duration::from_secs(60);
28
29/// Configuration for retrying requests to the server
30#[derive(Clone, Debug, PartialEq)]
31pub struct RetryOptions {
32    /// initial wait time before the first retry.
33    pub initial_interval: Duration,
34    /// randomization jitter that is used as a multiplier for the current retry interval
35    /// and is added or subtracted from the interval length.
36    pub randomization_factor: f64,
37    /// rate at which retry time should be increased, until it reaches max_interval.
38    pub multiplier: f64,
39    /// maximum amount of time to wait between retries.
40    pub max_interval: Duration,
41    /// maximum total amount of time requests should be retried for, if None is set then no limit
42    /// will be used.
43    pub max_elapsed_time: Option<Duration>,
44    /// maximum number of retry attempts.
45    pub max_retries: usize,
46}
47
48impl Default for RetryOptions {
49    fn default() -> Self {
50        Self {
51            initial_interval: Duration::from_millis(100), // 100 ms wait by default.
52            randomization_factor: 0.2,                    // +-20% jitter.
53            multiplier: 1.7, // each next retry delay will increase by 70%
54            max_interval: Duration::from_secs(5), // until it reaches 5 seconds.
55            max_elapsed_time: Some(Duration::from_secs(10)), // 10 seconds total allocated time for all retries.
56            max_retries: 10,
57        }
58    }
59}
60
61impl RetryOptions {
62    pub(crate) const fn task_poll_retry_policy() -> Self {
63        Self {
64            initial_interval: Duration::from_millis(200),
65            randomization_factor: 0.2,
66            multiplier: 2.0,
67            max_interval: Duration::from_secs(10),
68            max_elapsed_time: None,
69            max_retries: 0,
70        }
71    }
72
73    pub(crate) const fn throttle_retry_policy() -> Self {
74        Self {
75            initial_interval: Duration::from_secs(1),
76            randomization_factor: 0.2,
77            multiplier: 2.0,
78            max_interval: Duration::from_secs(10),
79            max_elapsed_time: None,
80            max_retries: 0,
81        }
82    }
83
84    /// A retry policy that never retires
85    pub const fn no_retries() -> Self {
86        Self {
87            initial_interval: Duration::from_secs(0),
88            randomization_factor: 0.0,
89            multiplier: 1.0,
90            max_interval: Duration::from_secs(0),
91            max_elapsed_time: None,
92            max_retries: 1,
93        }
94    }
95
96    pub(crate) fn get_call_info<R>(
97        &self,
98        call_name: &'static str,
99        request: Option<&tonic::Request<R>>,
100    ) -> CallInfo {
101        let mut call_type = CallType::Normal;
102        let mut retry_short_circuit = None;
103        let mut retry_cfg_override = None;
104        if let Some(r) = request.as_ref() {
105            let ext = r.extensions();
106            if ext.get::<IsUserLongPoll>().is_some() {
107                call_type = CallType::UserLongPoll;
108            } else if ext.get::<IsWorkerTaskLongPoll>().is_some() {
109                call_type = CallType::TaskLongPoll;
110            }
111
112            retry_short_circuit = ext.get::<NoRetryOnMatching>().cloned();
113            retry_cfg_override = ext.get::<RetryConfigForCall>().cloned();
114        }
115        let retry_cfg = if let Some(ovr) = retry_cfg_override {
116            ovr.0
117        } else if call_type == CallType::TaskLongPoll {
118            RetryOptions::task_poll_retry_policy()
119        } else {
120            self.clone()
121        };
122        CallInfo {
123            call_type,
124            call_name,
125            retry_cfg,
126            retry_short_circuit,
127        }
128    }
129
130    fn jittered_backoff(&self) -> JitteredBackoff {
131        let inner = ExponentialBuilder::new()
132            .with_min_delay(self.initial_interval)
133            .with_factor(self.multiplier as f32)
134            .with_max_delay(self.max_interval)
135            .without_max_times()
136            .build();
137        JitteredBackoff {
138            inner,
139            randomization_factor: self.randomization_factor,
140            max_elapsed_time: self.max_elapsed_time,
141            started_at: Instant::now(),
142        }
143    }
144}
145
146pub(crate) fn make_future_retry<R, F, Fut>(
147    info: CallInfo,
148    factory: F,
149) -> FutureRetry<F, TonicErrorHandler>
150where
151    F: FnMut() -> Fut + Unpin,
152    Fut: Future<Output = Result<R, tonic::Status>>,
153{
154    FutureRetry::new(
155        factory,
156        TonicErrorHandler::new(info, RetryOptions::throttle_retry_policy()),
157    )
158}
159
160#[doc(hidden)]
161pub fn jittered(base: Duration, randomization_factor: f64) -> Duration {
162    if randomization_factor <= 0.0 {
163        return base;
164    }
165    // Reproduce the `backoff` crate's documented jitter for backward compatibility:
166    //   randomized interval = retry_interval * (random value in range [1 - randomization_factor, 1 + randomization_factor])
167    // Docs: https://github.com/ihrwein/backoff/blob/587e2da8fb2dcfc65ca544cb9249022c51f1406e/src/lib.rs#L4
168    // Algorithm (`get_random_value_from_interval`): https://github.com/ihrwein/backoff/blob/587e2da8fb2dcfc65ca544cb9249022c51f1406e/src/exponential.rs#L61
169    let base_secs = base.as_secs_f64();
170    let spread = randomization_factor * base_secs;
171    let offset = spread * (2.0 * rand::random::<f64>() - 1.0);
172    Duration::try_from_secs_f64((base_secs + offset).max(0.0)).unwrap_or(base)
173}
174
175#[derive(Debug)]
176struct JitteredBackoff {
177    inner: backon::ExponentialBackoff,
178    randomization_factor: f64,
179    max_elapsed_time: Option<Duration>,
180    started_at: Instant,
181}
182
183impl JitteredBackoff {
184    fn next_backoff(&mut self) -> Option<Duration> {
185        // `inner` never stops on its own; the total retry budget is enforced here
186        // against wall-clock time so the jittered delay we actually return is what
187        // counts toward `max_elapsed_time`.
188        let base = self.inner.next()?;
189        let delay = jittered(base, self.randomization_factor);
190        if let Some(max_elapsed_time) = self.max_elapsed_time
191            && self.started_at.elapsed() + delay > max_elapsed_time
192        {
193            return None;
194        }
195        Some(delay)
196    }
197}
198
199#[derive(Debug)]
200pub(crate) struct TonicErrorHandler {
201    backoff: JitteredBackoff,
202    throttle_backoff: JitteredBackoff,
203    max_interval: Duration,
204    retry_started_at: Instant,
205    max_retries: usize,
206    call_type: CallType,
207    call_name: &'static str,
208    retry_short_circuit: Option<NoRetryOnMatching>,
209}
210
211impl TonicErrorHandler {
212    fn new(call_info: CallInfo, throttle_cfg: RetryOptions) -> Self {
213        Self {
214            call_type: call_info.call_type,
215            call_name: call_info.call_name,
216            max_retries: call_info.retry_cfg.max_retries,
217            max_interval: call_info.retry_cfg.max_interval,
218            backoff: call_info.retry_cfg.jittered_backoff(),
219            throttle_backoff: throttle_cfg.jittered_backoff(),
220            retry_started_at: Instant::now(),
221            retry_short_circuit: call_info.retry_short_circuit,
222        }
223    }
224
225    fn maybe_log_retry(&self, cur_attempt: usize, err: &tonic::Status) {
226        let mut do_log = false;
227        // Warn on more than 5 retries for unlimited retrying
228        if self.max_retries == 0 && cur_attempt > 5 {
229            do_log = true;
230        }
231        // Warn if the attempts are more than 50% of max retries
232        if self.max_retries > 0 && cur_attempt * 2 >= self.max_retries {
233            do_log = true;
234        }
235
236        if do_log {
237            // Error if unlimited retries have been going on for a while
238            if self.max_retries == 0 && cur_attempt > 15 {
239                error!(error=?err, "gRPC call {} retried {} times", self.call_name, cur_attempt);
240            } else {
241                warn!(error=?err, "gRPC call {} retried {} times", self.call_name, cur_attempt);
242            }
243        }
244    }
245}
246
247#[derive(Clone, Debug)]
248pub(crate) struct CallInfo {
249    pub call_type: CallType,
250    call_name: &'static str,
251    retry_cfg: RetryOptions,
252    retry_short_circuit: Option<NoRetryOnMatching>,
253}
254
255#[doc(hidden)]
256#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
257pub enum CallType {
258    Normal,
259    // A long poll but won't always retry timeouts/cancels. EX: Get workflow history
260    UserLongPoll,
261    // A worker is polling for a task
262    TaskLongPoll,
263}
264
265impl CallType {
266    pub(crate) fn is_long(&self) -> bool {
267        matches!(self, Self::UserLongPoll | Self::TaskLongPoll)
268    }
269}
270
271impl ErrorHandler<tonic::Status> for TonicErrorHandler {
272    type OutError = tonic::Status;
273
274    fn handle(
275        &mut self,
276        current_attempt: usize,
277        mut e: tonic::Status,
278    ) -> RetryPolicy<tonic::Status> {
279        // 0 max retries means unlimited retries
280        if self.max_retries > 0 && current_attempt >= self.max_retries {
281            return RetryPolicy::ForwardError(e);
282        }
283
284        if let Some(sc) = self.retry_short_circuit.as_ref()
285            && (sc.predicate)(&e)
286        {
287            e.metadata_mut().insert(
288                ERROR_RETURNED_DUE_TO_SHORT_CIRCUIT,
289                tonic::metadata::MetadataValue::from(0),
290            );
291            return RetryPolicy::ForwardError(e);
292        }
293
294        // Short circuit if message is too large - this is not retryable
295        if e.code() == Code::ResourceExhausted
296            && (e
297                .message()
298                .starts_with("grpc: received message larger than max")
299                || e.message()
300                    .starts_with("grpc: message after decompression larger than max")
301                || e.message()
302                    .starts_with("grpc: received message after decompression larger than max"))
303        {
304            // Leave a marker so we don't have duplicate detection logic in the workflow
305            e.metadata_mut().insert(
306                MESSAGE_TOO_LARGE_KEY,
307                tonic::metadata::MetadataValue::from(0),
308            );
309            return RetryPolicy::ForwardError(e);
310        }
311
312        // Task polls are OK with being cancelled or running into the timeout because there's
313        // nothing to do but retry anyway
314        let long_poll_allowed = self.call_type == CallType::TaskLongPoll
315            && [Code::Cancelled, Code::DeadlineExceeded].contains(&e.code());
316
317        // When Code::Cancelled originates from a transport-level error (e.g. GOAWAY,
318        // connection closed during an AZ outage), it should be retried like Unavailable.
319        // We distinguish this from true application/caller-initiated cancellations by
320        // inspecting the error source chain for tonic::transport::Error → hyper::Error.
321        let transport_cancel_retry_allowed =
322            e.code() == Code::Cancelled && is_transport_cancelled(&e);
323
324        if RETRYABLE_ERROR_CODES.contains(&e.code())
325            || long_poll_allowed
326            || transport_cancel_retry_allowed
327        {
328            if current_attempt == 1 {
329                debug!(error=?e, "gRPC call {} failed on first attempt", self.call_name);
330            } else {
331                self.maybe_log_retry(current_attempt, &e);
332            }
333
334            match self.backoff.next_backoff() {
335                None => RetryPolicy::ForwardError(e), // None is returned when we've ran out of time
336                Some(backoff) => {
337                    // We treat ResourceExhausted as a special case and backoff more
338                    // so we don't overload the server
339                    if e.code() == Code::ResourceExhausted {
340                        let extended_backoff =
341                            backoff.max(self.throttle_backoff.next_backoff().unwrap_or_default());
342                        RetryPolicy::WaitRetry(extended_backoff)
343                    } else {
344                        RetryPolicy::WaitRetry(backoff)
345                    }
346                }
347            }
348        } else if self.call_type == CallType::TaskLongPoll
349            && self.retry_started_at.elapsed() <= LONG_POLL_FATAL_GRACE
350        {
351            // We permit "fatal" errors while long polling for a while, because some proxies return
352            // stupid error codes while getting ready, among other weird infra issues
353            RetryPolicy::WaitRetry(self.max_interval)
354        } else {
355            RetryPolicy::ForwardError(e)
356        }
357    }
358}
359
360/// Returns true if the given status is a `Code::Cancelled` that originated from a
361/// transport-level failure (tonic::transport::Error → hyper::Error) rather than
362/// an application/caller-initiated cancellation. These should be retried like
363/// `Code::Unavailable`.
364fn is_transport_cancelled(status: &tonic::Status) -> bool {
365    status
366        .source()
367        .and_then(|e| e.downcast_ref::<tonic::transport::Error>())
368        .and_then(|te| te.source())
369        .and_then(|tec| tec.downcast_ref::<hyper::Error>())
370        .is_some()
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use assert_matches::assert_matches;
377    use std::time::Instant;
378    use temporalio_common::protos::temporal::api::workflowservice::v1::{
379        PollActivityTaskQueueRequest, PollNexusTaskQueueRequest, PollWorkflowTaskQueueRequest,
380    };
381    use tonic::{IntoRequest, Status};
382
383    /// Predefined retry configs with low durations to make unit tests faster
384    const TEST_RETRY_CONFIG: RetryOptions = RetryOptions {
385        initial_interval: Duration::from_millis(1),
386        randomization_factor: 0.0,
387        multiplier: 1.1,
388        max_interval: Duration::from_millis(2),
389        max_elapsed_time: None,
390        max_retries: 10,
391    };
392
393    const POLL_WORKFLOW_METH_NAME: &str = "poll_workflow_task_queue";
394    const POLL_ACTIVITY_METH_NAME: &str = "poll_activity_task_queue";
395    const POLL_NEXUS_METH_NAME: &str = "poll_nexus_task_queue";
396
397    #[tokio::test]
398    async fn long_poll_non_retryable_errors() {
399        for code in [
400            Code::InvalidArgument,
401            Code::NotFound,
402            Code::AlreadyExists,
403            Code::PermissionDenied,
404            Code::FailedPrecondition,
405            Code::Unauthenticated,
406            Code::Unimplemented,
407        ] {
408            for call_name in [POLL_WORKFLOW_METH_NAME, POLL_ACTIVITY_METH_NAME] {
409                let mut err_handler = TonicErrorHandler::new(
410                    CallInfo {
411                        call_type: CallType::TaskLongPoll,
412                        call_name,
413                        retry_cfg: TEST_RETRY_CONFIG,
414                        retry_short_circuit: None,
415                    },
416                    TEST_RETRY_CONFIG,
417                );
418                let result = err_handler.handle(1, Status::new(code, "Ahh"));
419                assert_matches!(result, RetryPolicy::WaitRetry(_));
420                err_handler.retry_started_at =
421                    Instant::now() - LONG_POLL_FATAL_GRACE - Duration::from_secs(1);
422                let result = err_handler.handle(2, Status::new(code, "Ahh"));
423                assert_matches!(result, RetryPolicy::ForwardError(_));
424            }
425        }
426    }
427
428    #[tokio::test]
429    async fn long_poll_retryable_errors_never_fatal() {
430        for code in RETRYABLE_ERROR_CODES {
431            for call_name in [POLL_WORKFLOW_METH_NAME, POLL_ACTIVITY_METH_NAME] {
432                let mut err_handler = TonicErrorHandler::new(
433                    CallInfo {
434                        call_type: CallType::TaskLongPoll,
435                        call_name,
436                        retry_cfg: TEST_RETRY_CONFIG,
437                        retry_short_circuit: None,
438                    },
439                    TEST_RETRY_CONFIG,
440                );
441                let result = err_handler.handle(1, Status::new(code, "Ahh"));
442                assert_matches!(result, RetryPolicy::WaitRetry(_));
443                err_handler.retry_started_at =
444                    Instant::now() - LONG_POLL_FATAL_GRACE - Duration::from_secs(1);
445                let result = err_handler.handle(2, Status::new(code, "Ahh"));
446                assert_matches!(result, RetryPolicy::WaitRetry(_));
447            }
448        }
449    }
450
451    #[tokio::test]
452    async fn retry_resource_exhausted() {
453        let mut err_handler = TonicErrorHandler::new(
454            CallInfo {
455                call_type: CallType::TaskLongPoll,
456                call_name: POLL_WORKFLOW_METH_NAME,
457                retry_cfg: TEST_RETRY_CONFIG,
458                retry_short_circuit: None,
459            },
460            RetryOptions {
461                initial_interval: Duration::from_millis(2),
462                randomization_factor: 0.0,
463                multiplier: 4.0,
464                max_interval: Duration::from_millis(10),
465                max_elapsed_time: None,
466                max_retries: 10,
467            },
468        );
469        let result = err_handler.handle(1, Status::new(Code::ResourceExhausted, "leave me alone"));
470        match result {
471            RetryPolicy::WaitRetry(duration) => assert_eq!(duration, Duration::from_millis(2)),
472            _ => panic!(),
473        }
474        let result = err_handler.handle(2, Status::new(Code::ResourceExhausted, "leave me alone"));
475        match result {
476            RetryPolicy::WaitRetry(duration) => assert_eq!(duration, Duration::from_millis(8)),
477            _ => panic!(),
478        }
479    }
480
481    #[tokio::test]
482    async fn retry_short_circuit() {
483        let mut err_handler = TonicErrorHandler::new(
484            CallInfo {
485                call_type: CallType::TaskLongPoll,
486                call_name: POLL_WORKFLOW_METH_NAME,
487                retry_cfg: TEST_RETRY_CONFIG,
488                retry_short_circuit: Some(NoRetryOnMatching {
489                    predicate: |s: &Status| s.code() == Code::ResourceExhausted,
490                }),
491            },
492            TEST_RETRY_CONFIG,
493        );
494        let result = err_handler.handle(1, Status::new(Code::ResourceExhausted, "leave me alone"));
495        let e = assert_matches!(result, RetryPolicy::ForwardError(e) => e);
496        assert!(
497            e.metadata()
498                .get(ERROR_RETURNED_DUE_TO_SHORT_CIRCUIT)
499                .is_some()
500        );
501    }
502
503    #[tokio::test]
504    async fn message_too_large_not_retried() {
505        let mut err_handler = TonicErrorHandler::new(
506            CallInfo {
507                call_type: CallType::TaskLongPoll,
508                call_name: POLL_WORKFLOW_METH_NAME,
509                retry_cfg: TEST_RETRY_CONFIG,
510                retry_short_circuit: None,
511            },
512            TEST_RETRY_CONFIG,
513        );
514        let result = err_handler.handle(
515            1,
516            Status::new(
517                Code::ResourceExhausted,
518                "grpc: received message larger than max",
519            ),
520        );
521        assert_matches!(result, RetryPolicy::ForwardError(_));
522
523        let result = err_handler.handle(
524            1,
525            Status::new(
526                Code::ResourceExhausted,
527                "grpc: message after decompression larger than max",
528            ),
529        );
530        assert_matches!(result, RetryPolicy::ForwardError(_));
531
532        let result = err_handler.handle(
533            1,
534            Status::new(
535                Code::ResourceExhausted,
536                "grpc: received message after decompression larger than max",
537            ),
538        );
539        assert_matches!(result, RetryPolicy::ForwardError(_));
540    }
541
542    #[rstest::rstest]
543    #[tokio::test]
544    async fn task_poll_retries_forever<R>(
545        #[values(
546                (
547                    POLL_WORKFLOW_METH_NAME,
548                    PollWorkflowTaskQueueRequest::default(),
549                ),
550                (
551                    POLL_ACTIVITY_METH_NAME,
552                    PollActivityTaskQueueRequest::default(),
553                ),
554                (
555                    POLL_NEXUS_METH_NAME,
556                    PollNexusTaskQueueRequest::default(),
557                ),
558        )]
559        (call_name, req): (&'static str, R),
560    ) {
561        // A bit odd, but we don't need a real client to test the retry client passes through the
562        // correct retry config
563        let mut req = req.into_request();
564        req.extensions_mut().insert(IsWorkerTaskLongPoll);
565        for i in 1..=50 {
566            let mut err_handler = TonicErrorHandler::new(
567                TEST_RETRY_CONFIG.get_call_info::<R>(call_name, Some(&req)),
568                RetryOptions::throttle_retry_policy(),
569            );
570            let result = err_handler.handle(i, Status::new(Code::Unknown, "Ahh"));
571            assert_matches!(result, RetryPolicy::WaitRetry(_));
572        }
573    }
574
575    #[rstest::rstest]
576    #[tokio::test]
577    async fn task_poll_retries_deadline_exceeded<R>(
578        #[values(
579                (
580                    POLL_WORKFLOW_METH_NAME,
581                    PollWorkflowTaskQueueRequest::default(),
582                ),
583                (
584                    POLL_ACTIVITY_METH_NAME,
585                    PollActivityTaskQueueRequest::default(),
586                ),
587                (
588                    POLL_NEXUS_METH_NAME,
589                    PollNexusTaskQueueRequest::default(),
590                ),
591        )]
592        (call_name, req): (&'static str, R),
593    ) {
594        let mut req = req.into_request();
595        req.extensions_mut().insert(IsWorkerTaskLongPoll);
596        // For some reason we will get cancelled in these situations occasionally (always?) too
597        for code in [Code::Cancelled, Code::DeadlineExceeded] {
598            let mut err_handler = TonicErrorHandler::new(
599                TEST_RETRY_CONFIG.get_call_info::<R>(call_name, Some(&req)),
600                RetryOptions::throttle_retry_policy(),
601            );
602            for i in 1..=5 {
603                let result = err_handler.handle(i, Status::new(code, "retryable failure"));
604                assert_matches!(result, RetryPolicy::WaitRetry(_));
605            }
606        }
607    }
608
609    #[tokio::test]
610    async fn plain_cancelled_not_retried_on_normal_call() {
611        // A plain Code::Cancelled (no transport error in source chain) on a Normal call
612        // must NOT be retried — this is spec-correct behavior for application-level cancels.
613        let mut err_handler = TonicErrorHandler::new(
614            CallInfo {
615                call_type: CallType::Normal,
616                call_name: "respond_activity_task_completed",
617                retry_cfg: TEST_RETRY_CONFIG,
618                retry_short_circuit: None,
619            },
620            TEST_RETRY_CONFIG,
621        );
622        let result = err_handler.handle(1, Status::new(Code::Cancelled, "caller cancelled"));
623        assert_matches!(result, RetryPolicy::ForwardError(_));
624    }
625
626    #[tokio::test]
627    async fn is_transport_cancelled_false_for_plain_status() {
628        // A status without a transport error source chain should not be detected as
629        // transport-cancelled.
630        let status = Status::new(Code::Cancelled, "caller cancelled");
631        assert!(!is_transport_cancelled(&status));
632    }
633
634    #[tokio::test]
635    async fn transport_sourced_cancelled_retried_on_full_budget() {
636        // NOTE: tonic::Status's public API doesn't allow constructing a Status with both
637        // Code::Cancelled AND a transport error source chain. In production, tonic
638        // internally builds this when a GOAWAY/connection-close kills an in-flight RPC.
639        // We test the components separately:
640        //   1. is_transport_cancelled correctly detects transport errors (test above)
641        //   2. The retry handler correctly treats transport-cancelled as retryable (this test)
642        //
643        // For this test, we verify through the `handle` method that a transport-sourced
644        // Cancelled status (created via from_error, which sets Code::Unknown but preserves
645        // the transport source chain) IS retried multiple times on the standard budget.
646        let mut err_handler = TonicErrorHandler::new(
647            CallInfo {
648                call_type: CallType::Normal,
649                call_name: "respond_activity_task_completed",
650                retry_cfg: TEST_RETRY_CONFIG,
651                retry_short_circuit: None,
652            },
653            TEST_RETRY_CONFIG,
654        );
655
656        // Code::Unknown with a transport source IS retried (it's in RETRYABLE_ERROR_CODES)
657        // AND is_transport_cancelled would return true — both paths lead to retry.
658        for i in 1..=5 {
659            let endpoint = tonic::transport::Endpoint::from_static("http://[::1]:1")
660                .connect_timeout(Duration::from_millis(1));
661            let transport_err = endpoint.connect().await.unwrap_err();
662            let status = Status::from_error(Box::new(transport_err));
663
664            let result = err_handler.handle(i, status);
665            assert_matches!(
666                result,
667                RetryPolicy::WaitRetry(_),
668                "Transport error should be retried on attempt {i}"
669            );
670        }
671    }
672}