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(in crate::config) 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::{core::NextestConfig, utils::test_helpers::*},
170 errors::ConfigParseErrorKind,
171 };
172 use camino_tempfile::tempdir;
173 use config::ConfigError;
174 use guppy::graph::cargo::BuildPlatform;
175 use indoc::indoc;
176 use nextest_filtering::{ParseContext, TestQuery};
177 use test_case::test_case;
178
179 #[test]
180 fn parse_retries_valid() {
181 let config_contents = indoc! {r#"
182 [profile.default]
183 retries = { backoff = "fixed", count = 3 }
184
185 [profile.no-retries]
186 retries = 0
187
188 [profile.fixed-with-delay]
189 retries = { backoff = "fixed", count = 3, delay = "1s" }
190
191 [profile.exp]
192 retries = { backoff = "exponential", count = 4, delay = "2s" }
193
194 [profile.exp-with-max-delay]
195 retries = { backoff = "exponential", count = 5, delay = "3s", max-delay = "10s" }
196
197 [profile.exp-with-max-delay-and-jitter]
198 retries = { backoff = "exponential", count = 6, delay = "4s", max-delay = "1m", jitter = true }
199 "#};
200
201 let workspace_dir = tempdir().unwrap();
202
203 let graph = temp_workspace(&workspace_dir, config_contents);
204 let pcx = ParseContext::new(&graph);
205
206 let config = NextestConfig::from_sources(
207 graph.workspace().root(),
208 &pcx,
209 None,
210 [],
211 &Default::default(),
212 )
213 .expect("config is valid");
214 assert_eq!(
215 config
216 .profile("default")
217 .expect("default profile exists")
218 .apply_build_platforms(&build_platforms())
219 .retries(),
220 RetryPolicy::Fixed {
221 count: 3,
222 delay: Duration::ZERO,
223 jitter: false,
224 },
225 "default retries matches"
226 );
227
228 assert_eq!(
229 config
230 .profile("no-retries")
231 .expect("profile exists")
232 .apply_build_platforms(&build_platforms())
233 .retries(),
234 RetryPolicy::new_without_delay(0),
235 "no-retries retries matches"
236 );
237
238 assert_eq!(
239 config
240 .profile("fixed-with-delay")
241 .expect("profile exists")
242 .apply_build_platforms(&build_platforms())
243 .retries(),
244 RetryPolicy::Fixed {
245 count: 3,
246 delay: Duration::from_secs(1),
247 jitter: false,
248 },
249 "fixed-with-delay retries matches"
250 );
251
252 assert_eq!(
253 config
254 .profile("exp")
255 .expect("profile exists")
256 .apply_build_platforms(&build_platforms())
257 .retries(),
258 RetryPolicy::Exponential {
259 count: 4,
260 delay: Duration::from_secs(2),
261 jitter: false,
262 max_delay: None,
263 },
264 "exp retries matches"
265 );
266
267 assert_eq!(
268 config
269 .profile("exp-with-max-delay")
270 .expect("profile exists")
271 .apply_build_platforms(&build_platforms())
272 .retries(),
273 RetryPolicy::Exponential {
274 count: 5,
275 delay: Duration::from_secs(3),
276 jitter: false,
277 max_delay: Some(Duration::from_secs(10)),
278 },
279 "exp-with-max-delay retries matches"
280 );
281
282 assert_eq!(
283 config
284 .profile("exp-with-max-delay-and-jitter")
285 .expect("profile exists")
286 .apply_build_platforms(&build_platforms())
287 .retries(),
288 RetryPolicy::Exponential {
289 count: 6,
290 delay: Duration::from_secs(4),
291 jitter: true,
292 max_delay: Some(Duration::from_secs(60)),
293 },
294 "exp-with-max-delay-and-jitter retries matches"
295 );
296 }
297
298 #[test_case(
299 indoc!{r#"
300 [profile.default]
301 retries = { backoff = "foo" }
302 "#},
303 "unknown variant `foo`, expected `fixed` or `exponential`"
304 ; "invalid value for backoff")]
305 #[test_case(
306 indoc!{r#"
307 [profile.default]
308 retries = { backoff = "fixed" }
309 "#},
310 "missing field `count`"
311 ; "fixed specified without count")]
312 #[test_case(
313 indoc!{r#"
314 [profile.default]
315 retries = { backoff = "fixed", count = 1, delay = "foobar" }
316 "#},
317 "invalid value: string \"foobar\", expected a duration"
318 ; "delay is not a valid duration")]
319 #[test_case(
320 indoc!{r#"
321 [profile.default]
322 retries = { backoff = "fixed", count = 1, jitter = true }
323 "#},
324 "`jitter` cannot be true if `delay` isn't specified or is zero"
325 ; "jitter specified without delay")]
326 #[test_case(
327 indoc!{r#"
328 [profile.default]
329 retries = { backoff = "fixed", count = 1, max-delay = "10s" }
330 "#},
331 "unknown field `max-delay`, expected one of `count`, `delay`, `jitter`"
332 ; "max-delay is incompatible with fixed backoff")]
333 #[test_case(
334 indoc!{r#"
335 [profile.default]
336 retries = { backoff = "exponential", count = 1 }
337 "#},
338 "missing field `delay`"
339 ; "exponential backoff must specify delay")]
340 #[test_case(
341 indoc!{r#"
342 [profile.default]
343 retries = { backoff = "exponential", delay = "1s" }
344 "#},
345 "missing field `count`"
346 ; "exponential backoff must specify count")]
347 #[test_case(
348 indoc!{r#"
349 [profile.default]
350 retries = { backoff = "exponential", count = 0, delay = "1s" }
351 "#},
352 "`count` cannot be zero with exponential backoff"
353 ; "exponential backoff must have a non-zero count")]
354 #[test_case(
355 indoc!{r#"
356 [profile.default]
357 retries = { backoff = "exponential", count = 1, delay = "0s" }
358 "#},
359 "`delay` cannot be zero with exponential backoff"
360 ; "exponential backoff must have a non-zero delay")]
361 #[test_case(
362 indoc!{r#"
363 [profile.default]
364 retries = { backoff = "exponential", count = 1, delay = "1s", max-delay = "0s" }
365 "#},
366 "`max-delay` cannot be zero with exponential backoff"
367 ; "exponential backoff must have a non-zero max delay")]
368 #[test_case(
369 indoc!{r#"
370 [profile.default]
371 retries = { backoff = "exponential", count = 1, delay = "4s", max-delay = "2s", jitter = true }
372 "#},
373 "`max-delay` cannot be less than delay"
374 ; "max-delay greater than delay")]
375 fn parse_retries_invalid(config_contents: &str, expected_message: &str) {
376 let workspace_dir = tempdir().unwrap();
377
378 let graph = temp_workspace(&workspace_dir, config_contents);
379 let pcx = ParseContext::new(&graph);
380
381 let config_err = NextestConfig::from_sources(
382 graph.workspace().root(),
383 &pcx,
384 None,
385 [],
386 &Default::default(),
387 )
388 .expect_err("config expected to be invalid");
389
390 let message = match config_err.kind() {
391 ConfigParseErrorKind::DeserializeError(path_error) => match path_error.inner() {
392 ConfigError::Message(message) => message,
393 other => {
394 panic!(
395 "for config error {config_err:?}, expected ConfigError::Message for inner error {other:?}"
396 );
397 }
398 },
399 other => {
400 panic!(
401 "for config error {other:?}, expected ConfigParseErrorKind::DeserializeError"
402 );
403 }
404 };
405
406 assert!(
407 message.contains(expected_message),
408 "expected message \"{message}\" to contain \"{expected_message}\""
409 );
410 }
411
412 #[test_case(
413 indoc! {r#"
414 [[profile.default.overrides]]
415 filter = "test(=my_test)"
416 retries = 2
417
418 [profile.ci]
419 "#},
420 BuildPlatform::Target,
421 RetryPolicy::new_without_delay(2)
422
423 ; "my_test matches exactly"
424 )]
425 #[test_case(
426 indoc! {r#"
427 [[profile.default.overrides]]
428 filter = "!test(=my_test)"
429 retries = 2
430
431 [profile.ci]
432 "#},
433 BuildPlatform::Target,
434 RetryPolicy::new_without_delay(0)
435
436 ; "not match"
437 )]
438 #[test_case(
439 indoc! {r#"
440 [[profile.default.overrides]]
441 filter = "test(=my_test)"
442
443 [profile.ci]
444 "#},
445 BuildPlatform::Target,
446 RetryPolicy::new_without_delay(0)
447
448 ; "no retries specified"
449 )]
450 #[test_case(
451 indoc! {r#"
452 [[profile.default.overrides]]
453 filter = "test(test)"
454 retries = 2
455
456 [[profile.default.overrides]]
457 filter = "test(=my_test)"
458 retries = 3
459
460 [profile.ci]
461 "#},
462 BuildPlatform::Target,
463 RetryPolicy::new_without_delay(2)
464
465 ; "earlier configs override later ones"
466 )]
467 #[test_case(
468 indoc! {r#"
469 [[profile.default.overrides]]
470 filter = "test(test)"
471 retries = 2
472
473 [profile.ci]
474
475 [[profile.ci.overrides]]
476 filter = "test(=my_test)"
477 retries = 3
478 "#},
479 BuildPlatform::Target,
480 RetryPolicy::new_without_delay(3)
481
482 ; "profile-specific configs override default ones"
483 )]
484 #[test_case(
485 indoc! {r#"
486 [[profile.default.overrides]]
487 filter = "(!package(test-package)) and test(test)"
488 retries = 2
489
490 [profile.ci]
491
492 [[profile.ci.overrides]]
493 filter = "!test(=my_test_2)"
494 retries = 3
495 "#},
496 BuildPlatform::Target,
497 RetryPolicy::new_without_delay(3)
498
499 ; "no overrides match my_test exactly"
500 )]
501 #[test_case(
502 indoc! {r#"
503 [[profile.default.overrides]]
504 platform = "x86_64-unknown-linux-gnu"
505 filter = "test(test)"
506 retries = 2
507
508 [[profile.default.overrides]]
509 filter = "test(=my_test)"
510 retries = 3
511
512 [profile.ci]
513 "#},
514 BuildPlatform::Host,
515 RetryPolicy::new_without_delay(2)
516
517 ; "earlier config applied because it matches host triple"
518 )]
519 #[test_case(
520 indoc! {r#"
521 [[profile.default.overrides]]
522 platform = "aarch64-apple-darwin"
523 filter = "test(test)"
524 retries = 2
525
526 [[profile.default.overrides]]
527 filter = "test(=my_test)"
528 retries = 3
529
530 [profile.ci]
531 "#},
532 BuildPlatform::Host,
533 RetryPolicy::new_without_delay(3)
534
535 ; "earlier config ignored because it doesn't match host triple"
536 )]
537 #[test_case(
538 indoc! {r#"
539 [[profile.default.overrides]]
540 platform = "aarch64-apple-darwin"
541 filter = "test(test)"
542 retries = 2
543
544 [[profile.default.overrides]]
545 filter = "test(=my_test)"
546 retries = 3
547
548 [profile.ci]
549 "#},
550 BuildPlatform::Target,
551 RetryPolicy::new_without_delay(2)
552
553 ; "earlier config applied because it matches target triple"
554 )]
555 #[test_case(
556 indoc! {r#"
557 [[profile.default.overrides]]
558 platform = "x86_64-unknown-linux-gnu"
559 filter = "test(test)"
560 retries = 2
561
562 [[profile.default.overrides]]
563 filter = "test(=my_test)"
564 retries = 3
565
566 [profile.ci]
567 "#},
568 BuildPlatform::Target,
569 RetryPolicy::new_without_delay(3)
570
571 ; "earlier config ignored because it doesn't match target triple"
572 )]
573 #[test_case(
574 indoc! {r#"
575 [[profile.default.overrides]]
576 platform = 'cfg(target_os = "macos")'
577 filter = "test(test)"
578 retries = 2
579
580 [[profile.default.overrides]]
581 filter = "test(=my_test)"
582 retries = 3
583
584 [profile.ci]
585 "#},
586 BuildPlatform::Target,
587 RetryPolicy::new_without_delay(2)
588
589 ; "earlier config applied because it matches target cfg expr"
590 )]
591 #[test_case(
592 indoc! {r#"
593 [[profile.default.overrides]]
594 platform = 'cfg(target_arch = "x86_64")'
595 filter = "test(test)"
596 retries = 2
597
598 [[profile.default.overrides]]
599 filter = "test(=my_test)"
600 retries = 3
601
602 [profile.ci]
603 "#},
604 BuildPlatform::Target,
605 RetryPolicy::new_without_delay(3)
606
607 ; "earlier config ignored because it doesn't match target cfg expr"
608 )]
609 fn overrides_retries(
610 config_contents: &str,
611 build_platform: BuildPlatform,
612 retries: RetryPolicy,
613 ) {
614 let workspace_dir = tempdir().unwrap();
615
616 let graph = temp_workspace(&workspace_dir, config_contents);
617 let package_id = graph.workspace().iter().next().unwrap().id();
618 let pcx = ParseContext::new(&graph);
619
620 let config = NextestConfig::from_sources(
621 graph.workspace().root(),
622 &pcx,
623 None,
624 &[][..],
625 &Default::default(),
626 )
627 .unwrap();
628 let binary_query = binary_query(&graph, package_id, "lib", "my-binary", build_platform);
629 let query = TestQuery {
630 binary_query: binary_query.to_query(),
631 test_name: "my_test",
632 };
633 let profile = config
634 .profile("ci")
635 .expect("ci profile is defined")
636 .apply_build_platforms(&build_platforms());
637 let settings_for = profile.settings_for(&query);
638 assert_eq!(
639 settings_for.retries(),
640 retries,
641 "actual retries don't match expected retries"
642 );
643 }
644}