nextest_runner/config/
retry_policy.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use serde::Deserialize;
5use std::{cmp::Ordering, fmt, time::Duration};
6
7/// Type for the retry config key.
8#[derive(Debug, Copy, Clone, Deserialize, PartialEq, Eq)]
9#[serde(tag = "backoff", rename_all = "kebab-case", deny_unknown_fields)]
10pub enum RetryPolicy {
11    /// Fixed backoff.
12    #[serde(rename_all = "kebab-case")]
13    Fixed {
14        /// Maximum retry count.
15        count: usize,
16
17        /// Delay between retries.
18        #[serde(default, with = "humantime_serde")]
19        delay: Duration,
20
21        /// If set to true, randomness will be added to the delay on each retry attempt.
22        #[serde(default)]
23        jitter: bool,
24    },
25
26    /// Exponential backoff.
27    #[serde(rename_all = "kebab-case")]
28    Exponential {
29        /// Maximum retry count.
30        count: usize,
31
32        /// Delay between retries. Not optional for exponential backoff.
33        #[serde(with = "humantime_serde")]
34        delay: Duration,
35
36        /// If set to true, randomness will be added to the delay on each retry attempt.
37        #[serde(default)]
38        jitter: bool,
39
40        /// If set, limits the delay between retries.
41        #[serde(default, with = "humantime_serde")]
42        max_delay: Option<Duration>,
43    },
44}
45
46impl Default for RetryPolicy {
47    #[inline]
48    fn default() -> Self {
49        Self::new_without_delay(0)
50    }
51}
52
53impl RetryPolicy {
54    /// Create new policy with no delay between retries.
55    pub fn new_without_delay(count: usize) -> Self {
56        Self::Fixed {
57            count,
58            delay: Duration::ZERO,
59            jitter: false,
60        }
61    }
62
63    /// Returns the number of retries.
64    pub fn count(&self) -> usize {
65        match self {
66            Self::Fixed { count, .. } | Self::Exponential { count, .. } => *count,
67        }
68    }
69}
70
71pub(super) fn deserialize_retry_policy<'de, D>(
72    deserializer: D,
73) -> Result<Option<RetryPolicy>, D::Error>
74where
75    D: serde::Deserializer<'de>,
76{
77    struct V;
78
79    impl<'de2> serde::de::Visitor<'de2> for V {
80        type Value = Option<RetryPolicy>;
81
82        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
83            write!(
84                formatter,
85                "a table ({{ count = 5, backoff = \"exponential\", delay = \"1s\", max-delay = \"10s\", jitter = true }}) or a number (5)"
86            )
87        }
88
89        // Note that TOML uses i64, not u64.
90        fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
91        where
92            E: serde::de::Error,
93        {
94            match v.cmp(&0) {
95                Ordering::Greater | Ordering::Equal => {
96                    Ok(Some(RetryPolicy::new_without_delay(v as usize)))
97                }
98                Ordering::Less => Err(serde::de::Error::invalid_value(
99                    serde::de::Unexpected::Signed(v),
100                    &self,
101                )),
102            }
103        }
104
105        fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
106        where
107            A: serde::de::MapAccess<'de2>,
108        {
109            RetryPolicy::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(Some)
110        }
111    }
112
113    // Post-deserialize validation of retry policy.
114    let retry_policy = deserializer.deserialize_any(V)?;
115    match &retry_policy {
116        Some(RetryPolicy::Fixed {
117            count: _,
118            delay,
119            jitter,
120        }) => {
121            // Jitter can't be specified if delay is 0.
122            if delay.is_zero() && *jitter {
123                return Err(serde::de::Error::custom(
124                    "`jitter` cannot be true if `delay` isn't specified or is zero",
125                ));
126            }
127        }
128        Some(RetryPolicy::Exponential {
129            count,
130            delay,
131            jitter: _,
132            max_delay,
133        }) => {
134            // Count can't be zero.
135            if *count == 0 {
136                return Err(serde::de::Error::custom(
137                    "`count` cannot be zero with exponential backoff",
138                ));
139            }
140            // Delay can't be zero.
141            if delay.is_zero() {
142                return Err(serde::de::Error::custom(
143                    "`delay` cannot be zero with exponential backoff",
144                ));
145            }
146            // Max delay, if specified, can't be zero.
147            if max_delay.is_some_and(|f| f.is_zero()) {
148                return Err(serde::de::Error::custom(
149                    "`max-delay` cannot be zero with exponential backoff",
150                ));
151            }
152            // Max delay can't be less than delay.
153            if max_delay.is_some_and(|max_delay| max_delay < *delay) {
154                return Err(serde::de::Error::custom(
155                    "`max-delay` cannot be less than delay with exponential backoff",
156                ));
157            }
158        }
159        None => {}
160    }
161
162    Ok(retry_policy)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::{
169        config::{
170            NextestConfig,
171            test_helpers::{binary_query, build_platforms, temp_workspace},
172        },
173        errors::ConfigParseErrorKind,
174    };
175    use camino::Utf8Path;
176    use camino_tempfile::tempdir;
177    use config::ConfigError;
178    use guppy::graph::cargo::BuildPlatform;
179    use indoc::indoc;
180    use nextest_filtering::{ParseContext, TestQuery};
181    use test_case::test_case;
182
183    #[test]
184    fn parse_retries_valid() {
185        let config_contents = indoc! {r#"
186            [profile.default]
187            retries = { backoff = "fixed", count = 3 }
188
189            [profile.no-retries]
190            retries = 0
191
192            [profile.fixed-with-delay]
193            retries = { backoff = "fixed", count = 3, delay = "1s" }
194
195            [profile.exp]
196            retries = { backoff = "exponential", count = 4, delay = "2s" }
197
198            [profile.exp-with-max-delay]
199            retries = { backoff = "exponential", count = 5, delay = "3s", max-delay = "10s" }
200
201            [profile.exp-with-max-delay-and-jitter]
202            retries = { backoff = "exponential", count = 6, delay = "4s", max-delay = "1m", jitter = true }
203        "#};
204
205        let workspace_dir = tempdir().unwrap();
206
207        let graph = temp_workspace(workspace_dir.path(), config_contents);
208        let pcx = ParseContext::new(&graph);
209
210        let config = NextestConfig::from_sources(
211            graph.workspace().root(),
212            &pcx,
213            None,
214            [],
215            &Default::default(),
216        )
217        .expect("config is valid");
218        assert_eq!(
219            config
220                .profile("default")
221                .expect("default profile exists")
222                .apply_build_platforms(&build_platforms())
223                .retries(),
224            RetryPolicy::Fixed {
225                count: 3,
226                delay: Duration::ZERO,
227                jitter: false,
228            },
229            "default retries matches"
230        );
231
232        assert_eq!(
233            config
234                .profile("no-retries")
235                .expect("profile exists")
236                .apply_build_platforms(&build_platforms())
237                .retries(),
238            RetryPolicy::new_without_delay(0),
239            "no-retries retries matches"
240        );
241
242        assert_eq!(
243            config
244                .profile("fixed-with-delay")
245                .expect("profile exists")
246                .apply_build_platforms(&build_platforms())
247                .retries(),
248            RetryPolicy::Fixed {
249                count: 3,
250                delay: Duration::from_secs(1),
251                jitter: false,
252            },
253            "fixed-with-delay retries matches"
254        );
255
256        assert_eq!(
257            config
258                .profile("exp")
259                .expect("profile exists")
260                .apply_build_platforms(&build_platforms())
261                .retries(),
262            RetryPolicy::Exponential {
263                count: 4,
264                delay: Duration::from_secs(2),
265                jitter: false,
266                max_delay: None,
267            },
268            "exp retries matches"
269        );
270
271        assert_eq!(
272            config
273                .profile("exp-with-max-delay")
274                .expect("profile exists")
275                .apply_build_platforms(&build_platforms())
276                .retries(),
277            RetryPolicy::Exponential {
278                count: 5,
279                delay: Duration::from_secs(3),
280                jitter: false,
281                max_delay: Some(Duration::from_secs(10)),
282            },
283            "exp-with-max-delay retries matches"
284        );
285
286        assert_eq!(
287            config
288                .profile("exp-with-max-delay-and-jitter")
289                .expect("profile exists")
290                .apply_build_platforms(&build_platforms())
291                .retries(),
292            RetryPolicy::Exponential {
293                count: 6,
294                delay: Duration::from_secs(4),
295                jitter: true,
296                max_delay: Some(Duration::from_secs(60)),
297            },
298            "exp-with-max-delay-and-jitter retries matches"
299        );
300    }
301
302    #[test_case(
303        indoc!{r#"
304            [profile.default]
305            retries = { backoff = "foo" }
306        "#},
307        "unknown variant `foo`, expected `fixed` or `exponential`"
308        ; "invalid value for backoff")]
309    #[test_case(
310        indoc!{r#"
311            [profile.default]
312            retries = { backoff = "fixed" }
313        "#},
314        "missing field `count`"
315        ; "fixed specified without count")]
316    #[test_case(
317        indoc!{r#"
318            [profile.default]
319            retries = { backoff = "fixed", count = 1, delay = "foobar" }
320        "#},
321        "invalid value: string \"foobar\", expected a duration"
322        ; "delay is not a valid duration")]
323    #[test_case(
324        indoc!{r#"
325            [profile.default]
326            retries = { backoff = "fixed", count = 1, jitter = true }
327        "#},
328        "`jitter` cannot be true if `delay` isn't specified or is zero"
329        ; "jitter specified without delay")]
330    #[test_case(
331        indoc!{r#"
332            [profile.default]
333            retries = { backoff = "fixed", count = 1, max-delay = "10s" }
334        "#},
335        "unknown field `max-delay`, expected one of `count`, `delay`, `jitter`"
336        ; "max-delay is incompatible with fixed backoff")]
337    #[test_case(
338        indoc!{r#"
339            [profile.default]
340            retries = { backoff = "exponential", count = 1 }
341        "#},
342        "missing field `delay`"
343        ; "exponential backoff must specify delay")]
344    #[test_case(
345        indoc!{r#"
346            [profile.default]
347            retries = { backoff = "exponential", delay = "1s" }
348        "#},
349        "missing field `count`"
350        ; "exponential backoff must specify count")]
351    #[test_case(
352        indoc!{r#"
353            [profile.default]
354            retries = { backoff = "exponential", count = 0, delay = "1s" }
355        "#},
356        "`count` cannot be zero with exponential backoff"
357        ; "exponential backoff must have a non-zero count")]
358    #[test_case(
359        indoc!{r#"
360            [profile.default]
361            retries = { backoff = "exponential", count = 1, delay = "0s" }
362        "#},
363        "`delay` cannot be zero with exponential backoff"
364        ; "exponential backoff must have a non-zero delay")]
365    #[test_case(
366        indoc!{r#"
367            [profile.default]
368            retries = { backoff = "exponential", count = 1, delay = "1s", max-delay = "0s" }
369        "#},
370        "`max-delay` cannot be zero with exponential backoff"
371        ; "exponential backoff must have a non-zero max delay")]
372    #[test_case(
373        indoc!{r#"
374            [profile.default]
375            retries = { backoff = "exponential", count = 1, delay = "4s", max-delay = "2s", jitter = true }
376        "#},
377        "`max-delay` cannot be less than delay"
378        ; "max-delay greater than delay")]
379    fn parse_retries_invalid(config_contents: &str, expected_message: &str) {
380        let workspace_dir = tempdir().unwrap();
381        let workspace_path: &Utf8Path = workspace_dir.path();
382
383        let graph = temp_workspace(workspace_path, config_contents);
384        let pcx = ParseContext::new(&graph);
385
386        let config_err = NextestConfig::from_sources(
387            graph.workspace().root(),
388            &pcx,
389            None,
390            [],
391            &Default::default(),
392        )
393        .expect_err("config expected to be invalid");
394
395        let message = match config_err.kind() {
396            ConfigParseErrorKind::DeserializeError(path_error) => match path_error.inner() {
397                ConfigError::Message(message) => message,
398                other => {
399                    panic!(
400                        "for config error {config_err:?}, expected ConfigError::Message for inner error {other:?}"
401                    );
402                }
403            },
404            other => {
405                panic!(
406                    "for config error {other:?}, expected ConfigParseErrorKind::DeserializeError"
407                );
408            }
409        };
410
411        assert!(
412            message.contains(expected_message),
413            "expected message \"{message}\" to contain \"{expected_message}\""
414        );
415    }
416
417    #[test_case(
418        indoc! {r#"
419            [[profile.default.overrides]]
420            filter = "test(=my_test)"
421            retries = 2
422
423            [profile.ci]
424        "#},
425        BuildPlatform::Target,
426        RetryPolicy::new_without_delay(2)
427
428        ; "my_test matches exactly"
429    )]
430    #[test_case(
431        indoc! {r#"
432            [[profile.default.overrides]]
433            filter = "!test(=my_test)"
434            retries = 2
435
436            [profile.ci]
437        "#},
438        BuildPlatform::Target,
439        RetryPolicy::new_without_delay(0)
440
441        ; "not match"
442    )]
443    #[test_case(
444        indoc! {r#"
445            [[profile.default.overrides]]
446            filter = "test(=my_test)"
447
448            [profile.ci]
449        "#},
450        BuildPlatform::Target,
451        RetryPolicy::new_without_delay(0)
452
453        ; "no retries specified"
454    )]
455    #[test_case(
456        indoc! {r#"
457            [[profile.default.overrides]]
458            filter = "test(test)"
459            retries = 2
460
461            [[profile.default.overrides]]
462            filter = "test(=my_test)"
463            retries = 3
464
465            [profile.ci]
466        "#},
467        BuildPlatform::Target,
468        RetryPolicy::new_without_delay(2)
469
470        ; "earlier configs override later ones"
471    )]
472    #[test_case(
473        indoc! {r#"
474            [[profile.default.overrides]]
475            filter = "test(test)"
476            retries = 2
477
478            [profile.ci]
479
480            [[profile.ci.overrides]]
481            filter = "test(=my_test)"
482            retries = 3
483        "#},
484        BuildPlatform::Target,
485        RetryPolicy::new_without_delay(3)
486
487        ; "profile-specific configs override default ones"
488    )]
489    #[test_case(
490        indoc! {r#"
491            [[profile.default.overrides]]
492            filter = "(!package(test-package)) and test(test)"
493            retries = 2
494
495            [profile.ci]
496
497            [[profile.ci.overrides]]
498            filter = "!test(=my_test_2)"
499            retries = 3
500        "#},
501        BuildPlatform::Target,
502        RetryPolicy::new_without_delay(3)
503
504        ; "no overrides match my_test exactly"
505    )]
506    #[test_case(
507        indoc! {r#"
508            [[profile.default.overrides]]
509            platform = "x86_64-unknown-linux-gnu"
510            filter = "test(test)"
511            retries = 2
512
513            [[profile.default.overrides]]
514            filter = "test(=my_test)"
515            retries = 3
516
517            [profile.ci]
518        "#},
519        BuildPlatform::Host,
520        RetryPolicy::new_without_delay(2)
521
522        ; "earlier config applied because it matches host triple"
523    )]
524    #[test_case(
525        indoc! {r#"
526            [[profile.default.overrides]]
527            platform = "aarch64-apple-darwin"
528            filter = "test(test)"
529            retries = 2
530
531            [[profile.default.overrides]]
532            filter = "test(=my_test)"
533            retries = 3
534
535            [profile.ci]
536        "#},
537        BuildPlatform::Host,
538        RetryPolicy::new_without_delay(3)
539
540        ; "earlier config ignored because it doesn't match host triple"
541    )]
542    #[test_case(
543        indoc! {r#"
544            [[profile.default.overrides]]
545            platform = "aarch64-apple-darwin"
546            filter = "test(test)"
547            retries = 2
548
549            [[profile.default.overrides]]
550            filter = "test(=my_test)"
551            retries = 3
552
553            [profile.ci]
554        "#},
555        BuildPlatform::Target,
556        RetryPolicy::new_without_delay(2)
557
558        ; "earlier config applied because it matches target triple"
559    )]
560    #[test_case(
561        indoc! {r#"
562            [[profile.default.overrides]]
563            platform = "x86_64-unknown-linux-gnu"
564            filter = "test(test)"
565            retries = 2
566
567            [[profile.default.overrides]]
568            filter = "test(=my_test)"
569            retries = 3
570
571            [profile.ci]
572        "#},
573        BuildPlatform::Target,
574        RetryPolicy::new_without_delay(3)
575
576        ; "earlier config ignored because it doesn't match target triple"
577    )]
578    #[test_case(
579        indoc! {r#"
580            [[profile.default.overrides]]
581            platform = 'cfg(target_os = "macos")'
582            filter = "test(test)"
583            retries = 2
584
585            [[profile.default.overrides]]
586            filter = "test(=my_test)"
587            retries = 3
588
589            [profile.ci]
590        "#},
591        BuildPlatform::Target,
592        RetryPolicy::new_without_delay(2)
593
594        ; "earlier config applied because it matches target cfg expr"
595    )]
596    #[test_case(
597        indoc! {r#"
598            [[profile.default.overrides]]
599            platform = 'cfg(target_arch = "x86_64")'
600            filter = "test(test)"
601            retries = 2
602
603            [[profile.default.overrides]]
604            filter = "test(=my_test)"
605            retries = 3
606
607            [profile.ci]
608        "#},
609        BuildPlatform::Target,
610        RetryPolicy::new_without_delay(3)
611
612        ; "earlier config ignored because it doesn't match target cfg expr"
613    )]
614    fn overrides_retries(
615        config_contents: &str,
616        build_platform: BuildPlatform,
617        retries: RetryPolicy,
618    ) {
619        let workspace_dir = tempdir().unwrap();
620        let workspace_path: &Utf8Path = workspace_dir.path();
621
622        let graph = temp_workspace(workspace_path, config_contents);
623        let package_id = graph.workspace().iter().next().unwrap().id();
624        let pcx = ParseContext::new(&graph);
625
626        let config = NextestConfig::from_sources(
627            graph.workspace().root(),
628            &pcx,
629            None,
630            &[][..],
631            &Default::default(),
632        )
633        .unwrap();
634        let binary_query = binary_query(&graph, package_id, "lib", "my-binary", build_platform);
635        let query = TestQuery {
636            binary_query: binary_query.to_query(),
637            test_name: "my_test",
638        };
639        let profile = config
640            .profile("ci")
641            .expect("ci profile is defined")
642            .apply_build_platforms(&build_platforms());
643        let settings_for = profile.settings_for(&query);
644        assert_eq!(
645            settings_for.retries(),
646            retries,
647            "actual retries don't match expected retries"
648        );
649    }
650}