1use serde::Deserialize;
5use std::{cmp::Ordering, fmt, time::Duration};
6
7#[derive(Debug, Copy, Clone, Deserialize, PartialEq, Eq)]
9#[serde(tag = "backoff", rename_all = "kebab-case", deny_unknown_fields)]
10pub enum RetryPolicy {
11 #[serde(rename_all = "kebab-case")]
13 Fixed {
14 count: usize,
16
17 #[serde(default, with = "humantime_serde")]
19 delay: Duration,
20
21 #[serde(default)]
23 jitter: bool,
24 },
25
26 #[serde(rename_all = "kebab-case")]
28 Exponential {
29 count: usize,
31
32 #[serde(with = "humantime_serde")]
34 delay: Duration,
35
36 #[serde(default)]
38 jitter: bool,
39
40 #[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 pub fn new_without_delay(count: usize) -> Self {
56 Self::Fixed {
57 count,
58 delay: Duration::ZERO,
59 jitter: false,
60 }
61 }
62
63 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 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 let retry_policy = deserializer.deserialize_any(V)?;
115 match &retry_policy {
116 Some(RetryPolicy::Fixed {
117 count: _,
118 delay,
119 jitter,
120 }) => {
121 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 if *count == 0 {
136 return Err(serde::de::Error::custom(
137 "`count` cannot be zero with exponential backoff",
138 ));
139 }
140 if delay.is_zero() {
142 return Err(serde::de::Error::custom(
143 "`delay` cannot be zero with exponential backoff",
144 ));
145 }
146 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 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}