chrono_machines 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/Cargo.lock +2 -2
- data/Cargo.toml +14 -1
- data/README.md +37 -1
- data/ext/chrono_machines_native/core/Cargo.toml +6 -2
- data/ext/chrono_machines_native/core/src/backoff.rs +132 -64
- data/ext/chrono_machines_native/core/src/dsl.rs +14 -3
- data/ext/chrono_machines_native/core/src/lib.rs +38 -32
- data/ext/chrono_machines_native/core/src/policy.rs +43 -68
- data/ext/chrono_machines_native/core/src/retry.rs +60 -27
- data/ext/chrono_machines_native/core/src/sleep.rs +1 -1
- data/ext/chrono_machines_native/core/src/test_support.rs +16 -0
- data/ext/chrono_machines_native/ffi/Cargo.toml +6 -2
- data/ext/chrono_machines_native/ffi/src/lib.rs +35 -19
- data/lib/chrono_machines/executor.rb +31 -26
- data/lib/chrono_machines/native_speedup.rb +28 -26
- data/lib/chrono_machines/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 520c4b33c24d8122f82758ed724886bfdd7955b427a98de6432bfd2b85f8355e
|
|
4
|
+
data.tar.gz: 7f31ccc205a392a0f26f932cdebdd13efb56204137598d38e818768f6116da53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 51dbb600f208195e5c42f6cc8eec21069aa361f1c1056df5c960ea331a4335c1f18fb328fb27748d5535d9477fea99ae20f1a5961b1cd53ced563b336bc96542
|
|
7
|
+
data.tar.gz: 7a82f044a7fe6e185399021cb09e141a51e0370a468767df79058cf78567fda1ecc96dc0b916de6154d02b828a4b5b4ac803a5a0aa7e78078c147220383c0bfc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.10.0](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.9.2...chrono_machines/v0.10.0) (2026-10-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* expose retry delays without executing or sleeping ([#30](https://github.com/seuros/chrono_machines/issues/30)) ([2f6ce19](https://github.com/seuros/chrono_machines/commit/2f6ce1999c7d2056d3adc8e57e0025c22087ca01))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* reject a jitter random source without #rand ([50734ee](https://github.com/seuros/chrono_machines/commit/50734eee35608d8a8324b38cf38f9ab1cc101c65))
|
|
14
|
+
|
|
15
|
+
## [0.9.2](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.9.1...chrono_machines/v0.9.2) (2026-10-10)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **ci:** attach release gems with gh release upload ([87466ea](https://github.com/seuros/chrono_machines/commit/87466eafe0126e5473ff49f176180eae08337657))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Performance Improvements
|
|
24
|
+
|
|
25
|
+
* allocate outside the policy registry lock; adopt clippy pedantic and nursery ([80b40d8](https://github.com/seuros/chrono_machines/commit/80b40d87e5869c74b356a71425639ebf560cec4e))
|
|
26
|
+
|
|
3
27
|
## [0.9.1](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.9.0...chrono_machines/v0.9.1) (2026-10-05)
|
|
4
28
|
|
|
5
29
|
|
data/Cargo.lock
CHANGED
|
@@ -69,7 +69,7 @@ dependencies = [
|
|
|
69
69
|
|
|
70
70
|
[[package]]
|
|
71
71
|
name = "chrono-machines"
|
|
72
|
-
version = "0.8.
|
|
72
|
+
version = "0.8.1"
|
|
73
73
|
dependencies = [
|
|
74
74
|
"rand",
|
|
75
75
|
"tokio",
|
|
@@ -77,7 +77,7 @@ dependencies = [
|
|
|
77
77
|
|
|
78
78
|
[[package]]
|
|
79
79
|
name = "chrono_machines_native"
|
|
80
|
-
version = "0.9.
|
|
80
|
+
version = "0.9.1"
|
|
81
81
|
dependencies = [
|
|
82
82
|
"chrono-machines",
|
|
83
83
|
"magnus",
|
data/Cargo.toml
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
[workspace]
|
|
2
2
|
members = ["ext/chrono_machines_native/core", "ext/chrono_machines_native/ffi"]
|
|
3
|
-
resolver = "
|
|
3
|
+
resolver = "3"
|
|
4
|
+
|
|
5
|
+
[workspace.package]
|
|
6
|
+
edition = "2024"
|
|
7
|
+
rust-version = "1.99"
|
|
4
8
|
|
|
5
9
|
[workspace.dependencies]
|
|
6
10
|
chrono-machines = { path = "ext/chrono_machines_native/core" }
|
|
7
11
|
rand = { version = "0.10", default-features = false, features = ["std_rng", "sys_rng"] }
|
|
12
|
+
|
|
13
|
+
[workspace.lints.rust]
|
|
14
|
+
unsafe_code = "deny"
|
|
15
|
+
unused_must_use = "warn"
|
|
16
|
+
|
|
17
|
+
[workspace.lints.clippy]
|
|
18
|
+
all = "warn"
|
|
19
|
+
pedantic = "warn"
|
|
20
|
+
nursery = "warn"
|
data/README.md
CHANGED
|
@@ -221,6 +221,39 @@ The hook's return value controls the retry:
|
|
|
221
221
|
|
|
222
222
|
Anything else raises `ArgumentError` - a broken hook fails loudly instead of silently mangling your retry cadence.
|
|
223
223
|
|
|
224
|
+
### Scheduling retries without sleeping
|
|
225
|
+
|
|
226
|
+
`Executor#next_delay(attempt:, exception: nil)` returns seconds to wait, or `nil` to stop. `attempt: 1` means the initial call has failed; `max_attempts` includes that initial call. It uses the same backoff, jitter, exception rules, and `delay_from` hints as `call`, without executing work, sleeping, or running lifecycle callbacks. The caller owns the attempt count and scheduling. Without an exception, `delay_from` and exception filtering are skipped.
|
|
227
|
+
|
|
228
|
+
For Active Job's `retry_on`, keep the attempt limit in sync with the policy:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
class FetchJob < ApplicationJob
|
|
232
|
+
RETRY_POLICY = ChronoMachines::Executor.new(max_attempts: 5, base_delay: 1)
|
|
233
|
+
|
|
234
|
+
retry_on IOError, attempts: 5,
|
|
235
|
+
wait: ->(executions) { RETRY_POLICY.next_delay(attempt: executions) }
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`retry_on` calls `wait` only while `executions < attempts`. If the two limits drift apart, `next_delay` returns `nil` and Active Job retries immediately, so keep `attempts:` equal to `max_attempts`.
|
|
240
|
+
|
|
241
|
+
To use exception hints with durable jobs, pass the attempt count to the next job:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
def perform(record_id, attempt: 1)
|
|
245
|
+
fetch_record(record_id)
|
|
246
|
+
rescue IOError => error
|
|
247
|
+
delay = RETRY_POLICY.next_delay(attempt: attempt, exception: error)
|
|
248
|
+
raise unless delay
|
|
249
|
+
|
|
250
|
+
self.class.set(wait_until: Time.current + delay)
|
|
251
|
+
.perform_later(record_id, attempt: attempt + 1)
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
A hint beyond `max_delay` returns `nil`; it is never shortened to the cap. Use `random: Random.new(seed)` for deterministic jitter in tests, with either the Ruby or native backend.
|
|
256
|
+
|
|
224
257
|
## The Science of Temporal Jitter
|
|
225
258
|
|
|
226
259
|
ChronoMachines implements **full jitter** exponential backoff:
|
|
@@ -239,16 +272,19 @@ ChronoMachines implements **full jitter** exponential backoff:
|
|
|
239
272
|
|
|
240
273
|
This prevents the "thundering herd" problem where multiple clients retry simultaneously, overwhelming recovering services.
|
|
241
274
|
|
|
275
|
+
Jitter is drawn from the `random:` option (Ruby's `Random` by default) on both backends; the native extension computes only the un-jittered base delay.
|
|
276
|
+
|
|
242
277
|
## Configuration Reference
|
|
243
278
|
|
|
244
279
|
### Policy Options
|
|
245
280
|
|
|
246
281
|
| Option | Default | Description |
|
|
247
282
|
|--------|---------|-------------|
|
|
248
|
-
| `max_attempts` | `3` | Maximum number of
|
|
283
|
+
| `max_attempts` | `3` | Maximum number of attempts, including the initial call |
|
|
249
284
|
| `base_delay` | `0.1` | Initial delay in seconds |
|
|
250
285
|
| `multiplier` | `2` | Exponential backoff multiplier |
|
|
251
286
|
| `max_delay` | `10` | Maximum delay cap in seconds |
|
|
287
|
+
| `random` | `Random` | Random source for jitter; accepts `Random.new(seed)` |
|
|
252
288
|
| `retryable_exceptions` | `[StandardError]` | Array of exception classes to retry |
|
|
253
289
|
| `on_success` | `nil` | Success callback: `(result:, attempts:)` |
|
|
254
290
|
| `on_retry` | `nil` | Retry callback: `(exception:, attempt:, next_delay:)` |
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "chrono-machines"
|
|
3
|
-
version = "0.8.
|
|
4
|
-
edition =
|
|
3
|
+
version = "0.8.1"
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
rust-version.workspace = true
|
|
5
6
|
authors = ["Abdelkader Boudih <terminale@gmail.com>"]
|
|
6
7
|
license = "MIT"
|
|
7
8
|
description = "Exponential, constant, and Fibonacci backoff retry library with full jitter support - no_std compatible"
|
|
@@ -29,3 +30,6 @@ rand = { version = "0.10", default-features = false, features = ["std_rng"] }
|
|
|
29
30
|
|
|
30
31
|
[dev-dependencies]
|
|
31
32
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "time"] }
|
|
33
|
+
|
|
34
|
+
[lints]
|
|
35
|
+
workspace = true
|
|
@@ -8,7 +8,7 @@ use rand::RngExt;
|
|
|
8
8
|
|
|
9
9
|
/// `f64::powi`, implemented by hand so it works in `core` (`no_std`).
|
|
10
10
|
#[inline]
|
|
11
|
-
|
|
11
|
+
fn powi_f64(base: f64, exp: i32) -> f64 {
|
|
12
12
|
if exp == 0 {
|
|
13
13
|
return 1.0;
|
|
14
14
|
}
|
|
@@ -25,7 +25,36 @@ pub(crate) fn powi_f64(base: f64, exp: i32) -> f64 {
|
|
|
25
25
|
acc
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/// Widen a delay (or Fibonacci factor) to `f64` for the backoff arithmetic.
|
|
29
|
+
///
|
|
30
|
+
/// There is no lossless `u64 -> f64` conversion. Rounding only starts above
|
|
31
|
+
/// 2^53 (2^53 ms is ~285,000 years), so every delay that can actually be slept
|
|
32
|
+
/// converts exactly; larger values only ever lose to the `max_delay_ms` cap.
|
|
33
|
+
#[inline]
|
|
34
|
+
#[expect(
|
|
35
|
+
clippy::cast_precision_loss,
|
|
36
|
+
reason = "inherent to u64 -> f64; exact below 2^53"
|
|
37
|
+
)]
|
|
38
|
+
const fn lossy_f64(n: u64) -> f64 {
|
|
39
|
+
n as f64
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Un-jittered exponential delay: `base_delay_ms * multiplier^(attempt - 1)`,
|
|
43
|
+
/// capped at `max_delay_ms`.
|
|
44
|
+
#[inline]
|
|
45
|
+
pub(crate) fn exponential_ms(
|
|
46
|
+
base_delay_ms: u64,
|
|
47
|
+
multiplier: f64,
|
|
48
|
+
max_delay_ms: u64,
|
|
49
|
+
attempt: u8,
|
|
50
|
+
) -> f64 {
|
|
51
|
+
let exponent = i32::from(attempt.saturating_sub(1));
|
|
52
|
+
let base_exponential = lossy_f64(base_delay_ms) * powi_f64(multiplier, exponent);
|
|
53
|
+
base_exponential.min(lossy_f64(max_delay_ms))
|
|
54
|
+
}
|
|
55
|
+
|
|
28
56
|
/// Calculate the nth Fibonacci number (1-indexed): 1, 1, 2, 3, 5, 8, 13, ...
|
|
57
|
+
#[must_use]
|
|
29
58
|
pub fn fibonacci(n: u8) -> u64 {
|
|
30
59
|
match n {
|
|
31
60
|
0 => 0,
|
|
@@ -47,7 +76,22 @@ pub fn fibonacci(n: u8) -> u64 {
|
|
|
47
76
|
///
|
|
48
77
|
/// `jitter_factor` is clamped to `[0.0, 1.0]`: `0.0` returns `base` unchanged,
|
|
49
78
|
/// `1.0` yields a uniform value in `[0, base]`.
|
|
50
|
-
|
|
79
|
+
// Clippy only suggests `mul_add` when `std` provides it; `core` has no
|
|
80
|
+
// `f64::mul_add`, so the suggestion would break the `no_std` build.
|
|
81
|
+
#[cfg_attr(
|
|
82
|
+
feature = "std",
|
|
83
|
+
expect(
|
|
84
|
+
clippy::suboptimal_flops,
|
|
85
|
+
reason = "`f64::mul_add` is std-only (breaks no_std) and would change the rounding"
|
|
86
|
+
)
|
|
87
|
+
)]
|
|
88
|
+
#[expect(
|
|
89
|
+
clippy::cast_possible_truncation,
|
|
90
|
+
clippy::cast_sign_loss,
|
|
91
|
+
reason = "`as` is the saturating float -> int conversion: NaN and negatives \
|
|
92
|
+
become 0, overflow becomes u64::MAX, which is the clamp we want"
|
|
93
|
+
)]
|
|
94
|
+
pub(crate) fn apply_jitter<R: Rng>(base: f64, jitter_factor: f64, rng: &mut R) -> u64 {
|
|
51
95
|
let jitter_factor = jitter_factor.clamp(0.0, 1.0);
|
|
52
96
|
let random_scalar: f64 = rng.random_range(0.0..=1.0);
|
|
53
97
|
let jitter_blend = 1.0 - jitter_factor + random_scalar * jitter_factor;
|
|
@@ -126,7 +170,7 @@ pub trait BackoffStrategy {
|
|
|
126
170
|
|
|
127
171
|
/// Exponential backoff strategy with configurable jitter
|
|
128
172
|
///
|
|
129
|
-
/// Delays grow exponentially:
|
|
173
|
+
/// Delays grow exponentially: `base_delay_ms * multiplier^(attempt - 1)`
|
|
130
174
|
///
|
|
131
175
|
/// # Example
|
|
132
176
|
///
|
|
@@ -156,36 +200,48 @@ pub struct ExponentialBackoff {
|
|
|
156
200
|
|
|
157
201
|
impl ExponentialBackoff {
|
|
158
202
|
/// Create a new exponential backoff builder with default values
|
|
159
|
-
|
|
160
|
-
|
|
203
|
+
#[must_use]
|
|
204
|
+
pub const fn new() -> Self {
|
|
205
|
+
Self {
|
|
206
|
+
max_attempts: 3,
|
|
207
|
+
base_delay_ms: 100,
|
|
208
|
+
multiplier: 2.0,
|
|
209
|
+
max_delay_ms: 10_000,
|
|
210
|
+
jitter_factor: 1.0, // Full jitter by default
|
|
211
|
+
}
|
|
161
212
|
}
|
|
162
213
|
|
|
163
214
|
/// Set the base delay in milliseconds
|
|
164
|
-
|
|
215
|
+
#[must_use]
|
|
216
|
+
pub const fn base_delay_ms(mut self, ms: u64) -> Self {
|
|
165
217
|
self.base_delay_ms = ms;
|
|
166
218
|
self
|
|
167
219
|
}
|
|
168
220
|
|
|
169
221
|
/// Set the exponential multiplier
|
|
170
|
-
|
|
222
|
+
#[must_use]
|
|
223
|
+
pub const fn multiplier(mut self, multiplier: f64) -> Self {
|
|
171
224
|
self.multiplier = multiplier;
|
|
172
225
|
self
|
|
173
226
|
}
|
|
174
227
|
|
|
175
228
|
/// Set the maximum delay cap in milliseconds
|
|
176
|
-
|
|
229
|
+
#[must_use]
|
|
230
|
+
pub const fn max_delay_ms(mut self, ms: u64) -> Self {
|
|
177
231
|
self.max_delay_ms = ms;
|
|
178
232
|
self
|
|
179
233
|
}
|
|
180
234
|
|
|
181
235
|
/// Set the maximum number of attempts
|
|
182
|
-
|
|
236
|
+
#[must_use]
|
|
237
|
+
pub const fn max_attempts(mut self, attempts: u8) -> Self {
|
|
183
238
|
self.max_attempts = attempts;
|
|
184
239
|
self
|
|
185
240
|
}
|
|
186
241
|
|
|
187
242
|
/// Set the jitter factor (0.0 = no jitter, 1.0 = full jitter)
|
|
188
|
-
|
|
243
|
+
#[must_use]
|
|
244
|
+
pub const fn jitter_factor(mut self, factor: f64) -> Self {
|
|
189
245
|
self.jitter_factor = factor.clamp(0.0, 1.0);
|
|
190
246
|
self
|
|
191
247
|
}
|
|
@@ -193,13 +249,7 @@ impl ExponentialBackoff {
|
|
|
193
249
|
|
|
194
250
|
impl Default for ExponentialBackoff {
|
|
195
251
|
fn default() -> Self {
|
|
196
|
-
Self
|
|
197
|
-
max_attempts: 3,
|
|
198
|
-
base_delay_ms: 100,
|
|
199
|
-
multiplier: 2.0,
|
|
200
|
-
max_delay_ms: 10_000,
|
|
201
|
-
jitter_factor: 1.0, // Full jitter by default
|
|
202
|
-
}
|
|
252
|
+
Self::new()
|
|
203
253
|
}
|
|
204
254
|
}
|
|
205
255
|
|
|
@@ -209,9 +259,12 @@ impl BackoffStrategy for ExponentialBackoff {
|
|
|
209
259
|
return None;
|
|
210
260
|
}
|
|
211
261
|
|
|
212
|
-
let
|
|
213
|
-
|
|
214
|
-
|
|
262
|
+
let capped = exponential_ms(
|
|
263
|
+
self.base_delay_ms,
|
|
264
|
+
self.multiplier,
|
|
265
|
+
self.max_delay_ms,
|
|
266
|
+
attempt,
|
|
267
|
+
);
|
|
215
268
|
|
|
216
269
|
Some(apply_jitter(capped, self.jitter_factor, rng))
|
|
217
270
|
}
|
|
@@ -255,24 +308,32 @@ pub struct ConstantBackoff {
|
|
|
255
308
|
|
|
256
309
|
impl ConstantBackoff {
|
|
257
310
|
/// Create a new constant backoff builder with default values
|
|
258
|
-
|
|
259
|
-
|
|
311
|
+
#[must_use]
|
|
312
|
+
pub const fn new() -> Self {
|
|
313
|
+
Self {
|
|
314
|
+
delay_ms: 100,
|
|
315
|
+
max_attempts: 3,
|
|
316
|
+
jitter_factor: 0.0, // No jitter for constant by default
|
|
317
|
+
}
|
|
260
318
|
}
|
|
261
319
|
|
|
262
320
|
/// Set the constant delay in milliseconds
|
|
263
|
-
|
|
321
|
+
#[must_use]
|
|
322
|
+
pub const fn delay_ms(mut self, ms: u64) -> Self {
|
|
264
323
|
self.delay_ms = ms;
|
|
265
324
|
self
|
|
266
325
|
}
|
|
267
326
|
|
|
268
327
|
/// Set the maximum number of attempts
|
|
269
|
-
|
|
328
|
+
#[must_use]
|
|
329
|
+
pub const fn max_attempts(mut self, attempts: u8) -> Self {
|
|
270
330
|
self.max_attempts = attempts;
|
|
271
331
|
self
|
|
272
332
|
}
|
|
273
333
|
|
|
274
334
|
/// Set the jitter factor (0.0 = no jitter, 1.0 = full jitter)
|
|
275
|
-
|
|
335
|
+
#[must_use]
|
|
336
|
+
pub const fn jitter_factor(mut self, factor: f64) -> Self {
|
|
276
337
|
self.jitter_factor = factor.clamp(0.0, 1.0);
|
|
277
338
|
self
|
|
278
339
|
}
|
|
@@ -280,11 +341,7 @@ impl ConstantBackoff {
|
|
|
280
341
|
|
|
281
342
|
impl Default for ConstantBackoff {
|
|
282
343
|
fn default() -> Self {
|
|
283
|
-
Self
|
|
284
|
-
delay_ms: 100,
|
|
285
|
-
max_attempts: 3,
|
|
286
|
-
jitter_factor: 0.0, // No jitter for constant by default
|
|
287
|
-
}
|
|
344
|
+
Self::new()
|
|
288
345
|
}
|
|
289
346
|
}
|
|
290
347
|
|
|
@@ -294,7 +351,11 @@ impl BackoffStrategy for ConstantBackoff {
|
|
|
294
351
|
return None;
|
|
295
352
|
}
|
|
296
353
|
|
|
297
|
-
Some(apply_jitter(
|
|
354
|
+
Some(apply_jitter(
|
|
355
|
+
lossy_f64(self.delay_ms),
|
|
356
|
+
self.jitter_factor,
|
|
357
|
+
rng,
|
|
358
|
+
))
|
|
298
359
|
}
|
|
299
360
|
|
|
300
361
|
fn should_retry(&self, attempt: u8) -> bool {
|
|
@@ -309,7 +370,7 @@ impl BackoffStrategy for ConstantBackoff {
|
|
|
309
370
|
/// Fibonacci backoff strategy
|
|
310
371
|
///
|
|
311
372
|
/// Delays follow the Fibonacci sequence: 1, 1, 2, 3, 5, 8, 13, ...
|
|
312
|
-
/// Each delay is base_delay_ms * fibonacci(attempt)
|
|
373
|
+
/// Each delay is `base_delay_ms * fibonacci(attempt)`.
|
|
313
374
|
///
|
|
314
375
|
/// # Example
|
|
315
376
|
///
|
|
@@ -336,30 +397,40 @@ pub struct FibonacciBackoff {
|
|
|
336
397
|
|
|
337
398
|
impl FibonacciBackoff {
|
|
338
399
|
/// Create a new Fibonacci backoff builder with default values
|
|
339
|
-
|
|
340
|
-
|
|
400
|
+
#[must_use]
|
|
401
|
+
pub const fn new() -> Self {
|
|
402
|
+
Self {
|
|
403
|
+
base_delay_ms: 100,
|
|
404
|
+
max_delay_ms: 10_000,
|
|
405
|
+
max_attempts: 8,
|
|
406
|
+
jitter_factor: 1.0, // Full jitter by default
|
|
407
|
+
}
|
|
341
408
|
}
|
|
342
409
|
|
|
343
410
|
/// Set the base delay in milliseconds
|
|
344
|
-
|
|
411
|
+
#[must_use]
|
|
412
|
+
pub const fn base_delay_ms(mut self, ms: u64) -> Self {
|
|
345
413
|
self.base_delay_ms = ms;
|
|
346
414
|
self
|
|
347
415
|
}
|
|
348
416
|
|
|
349
417
|
/// Set the maximum delay cap in milliseconds
|
|
350
|
-
|
|
418
|
+
#[must_use]
|
|
419
|
+
pub const fn max_delay_ms(mut self, ms: u64) -> Self {
|
|
351
420
|
self.max_delay_ms = ms;
|
|
352
421
|
self
|
|
353
422
|
}
|
|
354
423
|
|
|
355
424
|
/// Set the maximum number of attempts
|
|
356
|
-
|
|
425
|
+
#[must_use]
|
|
426
|
+
pub const fn max_attempts(mut self, attempts: u8) -> Self {
|
|
357
427
|
self.max_attempts = attempts;
|
|
358
428
|
self
|
|
359
429
|
}
|
|
360
430
|
|
|
361
431
|
/// Set the jitter factor (0.0 = no jitter, 1.0 = full jitter)
|
|
362
|
-
|
|
432
|
+
#[must_use]
|
|
433
|
+
pub const fn jitter_factor(mut self, factor: f64) -> Self {
|
|
363
434
|
self.jitter_factor = factor.clamp(0.0, 1.0);
|
|
364
435
|
self
|
|
365
436
|
}
|
|
@@ -367,12 +438,7 @@ impl FibonacciBackoff {
|
|
|
367
438
|
|
|
368
439
|
impl Default for FibonacciBackoff {
|
|
369
440
|
fn default() -> Self {
|
|
370
|
-
Self
|
|
371
|
-
base_delay_ms: 100,
|
|
372
|
-
max_delay_ms: 10_000,
|
|
373
|
-
max_attempts: 8,
|
|
374
|
-
jitter_factor: 1.0, // Full jitter by default
|
|
375
|
-
}
|
|
441
|
+
Self::new()
|
|
376
442
|
}
|
|
377
443
|
}
|
|
378
444
|
|
|
@@ -383,7 +449,8 @@ impl BackoffStrategy for FibonacciBackoff {
|
|
|
383
449
|
}
|
|
384
450
|
|
|
385
451
|
let fib = fibonacci(attempt);
|
|
386
|
-
let base =
|
|
452
|
+
let base =
|
|
453
|
+
(lossy_f64(self.base_delay_ms) * lossy_f64(fib)).min(lossy_f64(self.max_delay_ms));
|
|
387
454
|
|
|
388
455
|
Some(apply_jitter(base, self.jitter_factor, rng))
|
|
389
456
|
}
|
|
@@ -417,11 +484,12 @@ pub enum BackoffPolicy {
|
|
|
417
484
|
|
|
418
485
|
impl BackoffPolicy {
|
|
419
486
|
/// Return the maximum retry attempts for the wrapped strategy.
|
|
420
|
-
|
|
487
|
+
#[must_use]
|
|
488
|
+
pub const fn max_attempts(&self) -> u8 {
|
|
421
489
|
match self {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
490
|
+
Self::Exponential(policy) => policy.max_attempts,
|
|
491
|
+
Self::Constant(policy) => policy.max_attempts,
|
|
492
|
+
Self::Fibonacci(policy) => policy.max_attempts,
|
|
425
493
|
}
|
|
426
494
|
}
|
|
427
495
|
}
|
|
@@ -429,52 +497,52 @@ impl BackoffPolicy {
|
|
|
429
497
|
impl BackoffStrategy for BackoffPolicy {
|
|
430
498
|
fn delay<R: Rng>(&self, attempt: u8, rng: &mut R) -> Option<u64> {
|
|
431
499
|
match self {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
500
|
+
Self::Exponential(policy) => policy.delay(attempt, rng),
|
|
501
|
+
Self::Constant(policy) => policy.delay(attempt, rng),
|
|
502
|
+
Self::Fibonacci(policy) => policy.delay(attempt, rng),
|
|
435
503
|
}
|
|
436
504
|
}
|
|
437
505
|
|
|
438
506
|
fn should_retry(&self, attempt: u8) -> bool {
|
|
439
507
|
match self {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
508
|
+
Self::Exponential(policy) => policy.should_retry(attempt),
|
|
509
|
+
Self::Constant(policy) => policy.should_retry(attempt),
|
|
510
|
+
Self::Fibonacci(policy) => policy.should_retry(attempt),
|
|
443
511
|
}
|
|
444
512
|
}
|
|
445
513
|
|
|
446
514
|
fn max_attempts(&self) -> u8 {
|
|
447
515
|
match self {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
516
|
+
Self::Exponential(policy) => policy.max_attempts(),
|
|
517
|
+
Self::Constant(policy) => policy.max_attempts(),
|
|
518
|
+
Self::Fibonacci(policy) => policy.max_attempts(),
|
|
451
519
|
}
|
|
452
520
|
}
|
|
453
521
|
|
|
454
522
|
fn max_delay_ms(&self) -> Option<u64> {
|
|
455
523
|
match self {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
524
|
+
Self::Exponential(policy) => policy.max_delay_ms(),
|
|
525
|
+
Self::Constant(policy) => policy.max_delay_ms(),
|
|
526
|
+
Self::Fibonacci(policy) => policy.max_delay_ms(),
|
|
459
527
|
}
|
|
460
528
|
}
|
|
461
529
|
}
|
|
462
530
|
|
|
463
531
|
impl From<ExponentialBackoff> for BackoffPolicy {
|
|
464
532
|
fn from(value: ExponentialBackoff) -> Self {
|
|
465
|
-
|
|
533
|
+
Self::Exponential(value)
|
|
466
534
|
}
|
|
467
535
|
}
|
|
468
536
|
|
|
469
537
|
impl From<ConstantBackoff> for BackoffPolicy {
|
|
470
538
|
fn from(value: ConstantBackoff) -> Self {
|
|
471
|
-
|
|
539
|
+
Self::Constant(value)
|
|
472
540
|
}
|
|
473
541
|
}
|
|
474
542
|
|
|
475
543
|
impl From<FibonacciBackoff> for BackoffPolicy {
|
|
476
544
|
fn from(value: FibonacciBackoff) -> Self {
|
|
477
|
-
|
|
545
|
+
Self::Fibonacci(value)
|
|
478
546
|
}
|
|
479
547
|
}
|
|
480
548
|
|
|
@@ -22,7 +22,7 @@ pub enum DslError<E> {
|
|
|
22
22
|
|
|
23
23
|
impl<E> From<RetryError<E>> for DslError<E> {
|
|
24
24
|
fn from(value: RetryError<E>) -> Self {
|
|
25
|
-
|
|
25
|
+
Self::Execution(value)
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -32,8 +32,8 @@ where
|
|
|
32
32
|
{
|
|
33
33
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
34
34
|
match self {
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
Self::PolicyMissing(name) => write!(f, "retry policy '{name}' is not registered"),
|
|
36
|
+
Self::Execution(err) => write!(f, "{err}"),
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -41,6 +41,11 @@ where
|
|
|
41
41
|
impl<E> std::error::Error for DslError<E> where E: fmt::Display + std::error::Error {}
|
|
42
42
|
|
|
43
43
|
/// Construct a [`RetryBuilder`] using a named policy from the global registry.
|
|
44
|
+
///
|
|
45
|
+
/// # Errors
|
|
46
|
+
///
|
|
47
|
+
/// Returns [`DslError::PolicyMissing`] when no policy is registered under
|
|
48
|
+
/// `policy_name`.
|
|
44
49
|
pub fn builder_for_policy<F, T, E>(
|
|
45
50
|
policy_name: &str,
|
|
46
51
|
operation: F,
|
|
@@ -55,6 +60,12 @@ where
|
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/// Execute an operation using a named policy from the global registry.
|
|
63
|
+
///
|
|
64
|
+
/// # Errors
|
|
65
|
+
///
|
|
66
|
+
/// Returns [`DslError::PolicyMissing`] when no policy is registered under
|
|
67
|
+
/// `policy_name`, or [`DslError::Execution`] wrapping the [`RetryError`] when
|
|
68
|
+
/// the retry gives up.
|
|
58
69
|
pub fn retry_with_policy<F, T, E>(
|
|
59
70
|
policy_name: &str,
|
|
60
71
|
operation: F,
|