chrono_machines 0.9.2 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b11004f5b8e42aefeb73fc0a754d18af5503163e75ba23b2e55f15f767c9b39
4
- data.tar.gz: af89d525918c2164ef919dbafc840fd8f34c91cf3d97c9ff1067e4f8a07d0d4e
3
+ metadata.gz: e4195a7a57a7069769ba6b45dec7b2187ffd78be09e03d79c6b744881306423d
4
+ data.tar.gz: 17d103b281d335cd9b0d2d78590a57f9eb5f5e68eaf3910aa07b51ccfe1a7cb3
5
5
  SHA512:
6
- metadata.gz: b6eac8b9834568cbe4eef64945fe6c18ef572a808936a280f3b615ae1db8694fa449fa659a042ff5fc97b20d20e94513e33bb367e3cdd55b85595ba4cedf162e
7
- data.tar.gz: ac0dda8c7a9957e081ed952a9e2aed1d445f5a1ac80f4bf23ba31e07c5ef01fa1b1d54d49ad11df080d27af24da3d4049642b13d9cf5093b193951e3663f00b1
6
+ metadata.gz: 9d8638c3dbdfa33e746beecba8e3e5cfc92a75cce7c315a604d07a5e108188dcff339a03406c66e220a2dcaa8ec28115d9b3047179fdbe214e9ba95b1115f0ce
7
+ data.tar.gz: 167d0237c2ff6ea4e0958befc179f60b5a518b44ac86106a3409f5a0ae611809b1a43520a928d3a5026cd1389429aeab856f7d15522df3703259767d23100020
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.11.0](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.10.0...chrono_machines/v0.11.0) (2026-10-14)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **core:** RetryError::cause() returns &E and into_cause() returns E instead of Option.
9
+
10
+ ### Features
11
+
12
+ * **core:** RetryError always carries its cause ([e5222a3](https://github.com/seuros/chrono_machines/commit/e5222a3c394974ba86605944f02d820345cbae8e))
13
+
14
+ ## [0.10.0](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.9.2...chrono_machines/v0.10.0) (2026-10-11)
15
+
16
+
17
+ ### Features
18
+
19
+ * 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))
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * reject a jitter random source without #rand ([50734ee](https://github.com/seuros/chrono_machines/commit/50734eee35608d8a8324b38cf38f9ab1cc101c65))
25
+
3
26
  ## [0.9.2](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.9.1...chrono_machines/v0.9.2) (2026-10-10)
4
27
 
5
28
 
data/Cargo.lock CHANGED
@@ -69,7 +69,7 @@ dependencies = [
69
69
 
70
70
  [[package]]
71
71
  name = "chrono-machines"
72
- version = "0.8.0"
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.0"
80
+ version = "0.9.1"
81
81
  dependencies = [
82
82
  "chrono-machines",
83
83
  "magnus",
data/README.md CHANGED
@@ -221,6 +221,41 @@ 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
+ See the [Rails integration guide](docs/RAILS_INTEGRATION.md) for Active Job patterns, policy callbacks, and safe retries.
227
+
228
+ `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.
229
+
230
+ For Active Job's `retry_on`, keep the attempt limit in sync with the policy:
231
+
232
+ ```ruby
233
+ class FetchJob < ApplicationJob
234
+ RETRY_POLICY = ChronoMachines::Executor.new(max_attempts: 5, base_delay: 1)
235
+
236
+ retry_on IOError, attempts: 5,
237
+ wait: ->(executions) { RETRY_POLICY.next_delay(attempt: executions) }
238
+ end
239
+ ```
240
+
241
+ `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`.
242
+
243
+ To use exception hints with durable jobs, pass the attempt count to the next job:
244
+
245
+ ```ruby
246
+ def perform(record_id, attempt: 1)
247
+ fetch_record(record_id)
248
+ rescue IOError => error
249
+ delay = RETRY_POLICY.next_delay(attempt: attempt, exception: error)
250
+ raise unless delay
251
+
252
+ self.class.set(wait_until: Time.current + delay)
253
+ .perform_later(record_id, attempt: attempt + 1)
254
+ end
255
+ ```
256
+
257
+ 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.
258
+
224
259
  ## The Science of Temporal Jitter
225
260
 
226
261
  ChronoMachines implements **full jitter** exponential backoff:
@@ -239,16 +274,19 @@ ChronoMachines implements **full jitter** exponential backoff:
239
274
 
240
275
  This prevents the "thundering herd" problem where multiple clients retry simultaneously, overwhelming recovering services.
241
276
 
277
+ 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.
278
+
242
279
  ## Configuration Reference
243
280
 
244
281
  ### Policy Options
245
282
 
246
283
  | Option | Default | Description |
247
284
  |--------|---------|-------------|
248
- | `max_attempts` | `3` | Maximum number of retry attempts |
285
+ | `max_attempts` | `3` | Maximum number of attempts, including the initial call |
249
286
  | `base_delay` | `0.1` | Initial delay in seconds |
250
287
  | `multiplier` | `2` | Exponential backoff multiplier |
251
288
  | `max_delay` | `10` | Maximum delay cap in seconds |
289
+ | `random` | `Random` | Random source for jitter; accepts `Random.new(seed)` |
252
290
  | `retryable_exceptions` | `[StandardError]` | Array of exception classes to retry |
253
291
  | `on_success` | `nil` | Success callback: `(result:, attempts:)` |
254
292
  | `on_retry` | `nil` | Retry callback: `(exception:, attempt:, next_delay:)` |
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "chrono-machines"
3
- version = "0.8.1"
3
+ version = "0.9.0"
4
4
  edition.workspace = true
5
5
  rust-version.workspace = true
6
6
  authors = ["Abdelkader Boudih <terminale@gmail.com>"]
@@ -51,13 +51,7 @@ fn finalize_failure<E>(
51
51
  cumulative_delay_ms: u64,
52
52
  error: E,
53
53
  ) -> RetryError<E> {
54
- let retry_error = RetryError::new(
55
- kind,
56
- attempt,
57
- max_attempts,
58
- cumulative_delay_ms,
59
- Some(error),
60
- );
54
+ let retry_error = RetryError::new(kind, attempt, max_attempts, cumulative_delay_ms, error);
61
55
  if let Some(callback) = on_failure {
62
56
  callback(&retry_error);
63
57
  }
@@ -99,7 +93,7 @@ pub struct RetryError<E> {
99
93
  attempts: u8,
100
94
  max_attempts: u8,
101
95
  cumulative_delay_ms: u64,
102
- cause: Option<E>,
96
+ cause: E,
103
97
  }
104
98
 
105
99
  impl<E> RetryError<E> {
@@ -108,7 +102,7 @@ impl<E> RetryError<E> {
108
102
  attempts: u8,
109
103
  max_attempts: u8,
110
104
  cumulative_delay_ms: u64,
111
- cause: Option<E>,
105
+ cause: E,
112
106
  ) -> Self {
113
107
  Self {
114
108
  kind,
@@ -119,13 +113,13 @@ impl<E> RetryError<E> {
119
113
  }
120
114
  }
121
115
 
122
- /// Retrieve the underlying cause when available.
123
- pub const fn cause(&self) -> Option<&E> {
124
- self.cause.as_ref()
116
+ /// The error from the final attempt.
117
+ pub const fn cause(&self) -> &E {
118
+ &self.cause
125
119
  }
126
120
 
127
- /// Consume the error and return the underlying cause when available.
128
- pub fn into_cause(self) -> Option<E> {
121
+ /// Consume the error and return the error from the final attempt.
122
+ pub fn into_cause(self) -> E {
129
123
  self.cause
130
124
  }
131
125
 
@@ -175,13 +169,11 @@ where
175
169
  }
176
170
  }
177
171
 
178
- write!(f, " (cumulative delay {}ms)", self.cumulative_delay_ms)?;
179
-
180
- if let Some(cause) = self.cause.as_ref() {
181
- write!(f, ": {cause}")?;
182
- }
183
-
184
- Ok(())
172
+ write!(
173
+ f,
174
+ " (cumulative delay {}ms): {}",
175
+ self.cumulative_delay_ms, self.cause
176
+ )
185
177
  }
186
178
  }
187
179
 
@@ -15,6 +15,8 @@ module ChronoMachines
15
15
  @multiplier = policy_options[:multiplier] || 2
16
16
  @max_delay = policy_options[:max_delay]
17
17
  @jitter_factor = policy_options[:jitter_factor]
18
+ @random = policy_options[:random] || Random
19
+ raise ArgumentError, "random must respond to #rand; got #{@random.inspect}" unless @random.respond_to?(:rand)
18
20
  @retryable_exceptions = policy_options[:retryable_exceptions]
19
21
  @delay_from = policy_options[:delay_from]
20
22
  @on_failure = policy_options[:on_failure]
@@ -22,6 +24,14 @@ module ChronoMachines
22
24
  @on_success = policy_options[:on_success]
23
25
  end
24
26
 
27
+ def next_delay(attempt:, exception: nil)
28
+ raise ArgumentError, 'attempt must be a positive Integer' unless attempt.is_a?(Integer) && attempt.positive?
29
+ return if exception && !retryable_exception?(exception)
30
+ return if attempt >= @max_attempts
31
+
32
+ resolve_delay(exception, attempt)
33
+ end
34
+
25
35
  def call
26
36
  attempts = 0
27
37
 
@@ -34,22 +44,14 @@ module ChronoMachines
34
44
 
35
45
  result
36
46
  rescue StandardError => e
37
- # Check if exception is retryable
38
- unless @retryable_exceptions.any? { |ex| e.is_a?(ex) }
39
- # Non-retryable exception - call failure callback and re-raise
47
+ delay = next_delay(attempt: attempts, exception: e)
48
+ unless delay
40
49
  handle_final_failure(e, attempts)
41
- raise e
42
- end
50
+ raise MaxRetriesExceededError.new(e, attempts) if retryable_exception?(e) && attempts >= @max_attempts
43
51
 
44
- # Check if we've exhausted all attempts
45
- if attempts >= @max_attempts
46
- handle_final_failure(e, attempts)
47
- raise MaxRetriesExceededError.new(e, attempts)
52
+ raise e
48
53
  end
49
54
 
50
- # Calculate delay
51
- delay = resolve_delay(e, attempts)
52
-
53
55
  # Call retry callback if defined
54
56
  @on_retry&.call(exception: e, attempt: attempts, next_delay: delay)
55
57
 
@@ -61,24 +63,25 @@ module ChronoMachines
61
63
 
62
64
  private
63
65
 
66
+ def retryable_exception?(exception)
67
+ @retryable_exceptions.any? { |type| exception.is_a?(type) }
68
+ end
69
+
64
70
  # delay_from(exception:, attempt:) overrides the backoff: nil -> backoff,
65
- # Numeric -> verbatim (beyond max_delay: halt), :halt/false -> re-raise.
71
+ # Numeric -> verbatim (beyond max_delay: halt), :halt/false -> stop.
66
72
  def resolve_delay(exception, attempts)
67
- return calculate_delay(attempts) unless @delay_from
73
+ return calculate_delay(attempts) unless exception && @delay_from
68
74
 
69
75
  hint = @delay_from.call(exception: exception, attempt: attempts)
70
76
  case hint
71
77
  when nil
72
78
  calculate_delay(attempts)
73
79
  when Numeric
74
- if @max_delay && hint > @max_delay
75
- handle_final_failure(exception, attempts)
76
- raise exception
77
- end
80
+ return if @max_delay && hint > @max_delay
81
+
78
82
  hint
79
83
  when :halt, false
80
- handle_final_failure(exception, attempts)
81
- raise exception
84
+ nil
82
85
  else
83
86
  raise ArgumentError, "delay_from must return nil, a Numeric, :halt or false; got #{hint.inspect}"
84
87
  end
@@ -87,22 +90,19 @@ module ChronoMachines
87
90
  # Pure Ruby implementation of delay calculation (exponential backoff)
88
91
  def ruby_calculate_delay_exponential(attempts)
89
92
  base_exponential_delay = [@base_delay * (@multiplier**(attempts - 1)), @max_delay].min
90
- jitter_factor = normalized_jitter_factor
91
- base_exponential_delay * (1 - jitter_factor + (rand * jitter_factor))
93
+ apply_jitter(base_exponential_delay)
92
94
  end
93
95
 
94
96
  # Pure Ruby implementation of constant backoff
95
97
  def ruby_calculate_delay_constant(_attempts)
96
- jitter_factor = normalized_jitter_factor
97
- @base_delay * (1 - jitter_factor + (rand * jitter_factor))
98
+ apply_jitter(@base_delay)
98
99
  end
99
100
 
100
101
  # Pure Ruby implementation of Fibonacci backoff
101
102
  def ruby_calculate_delay_fibonacci(attempts)
102
103
  fib = fibonacci(attempts)
103
104
  base_delay = [@base_delay * fib, @max_delay].min
104
- jitter_factor = normalized_jitter_factor
105
- base_delay * (1 - jitter_factor + (rand * jitter_factor))
105
+ apply_jitter(base_delay)
106
106
  end
107
107
 
108
108
  # Calculate Fibonacci number (1-indexed)
@@ -134,6 +134,11 @@ module ChronoMachines
134
134
  # By default, use Ruby implementation (may be overridden by native extension)
135
135
  alias_method :calculate_delay, :ruby_calculate_delay
136
136
 
137
+ def apply_jitter(delay)
138
+ jitter_factor = normalized_jitter_factor
139
+ delay * (1 - jitter_factor + (@random.rand * jitter_factor))
140
+ end
141
+
137
142
  def robust_sleep(delay)
138
143
  # Handle potential interruptions to sleep
139
144
  # In Ruby 3.2+, Kernel.sleep is fiber-aware
@@ -22,32 +22,34 @@ begin
22
22
 
23
23
  # Override calculate_delay to dispatch to native implementations
24
24
  def calculate_delay(attempts)
25
- case @backoff_strategy
26
- when :exponential
27
- ChronoMachinesNative.exponential_delay(
28
- attempts,
29
- @base_delay,
30
- @multiplier,
31
- @max_delay,
32
- normalized_jitter_factor
33
- )
34
- when :constant
35
- ChronoMachinesNative.constant_delay(
36
- attempts,
37
- @base_delay,
38
- normalized_jitter_factor
39
- )
40
- when :fibonacci
41
- ChronoMachinesNative.fibonacci_delay(
42
- attempts,
43
- @base_delay,
44
- @max_delay,
45
- normalized_jitter_factor
46
- )
47
- else
48
- # Unknown strategy, fall back to Ruby
49
- super
50
- end
25
+ delay = case @backoff_strategy
26
+ when :exponential
27
+ ChronoMachinesNative.exponential_delay(
28
+ attempts,
29
+ @base_delay,
30
+ @multiplier,
31
+ @max_delay,
32
+ 0.0
33
+ )
34
+ when :constant
35
+ ChronoMachinesNative.constant_delay(
36
+ attempts,
37
+ @base_delay,
38
+ 0.0
39
+ )
40
+ when :fibonacci
41
+ ChronoMachinesNative.fibonacci_delay(
42
+ attempts,
43
+ @base_delay,
44
+ @max_delay,
45
+ 0.0
46
+ )
47
+ else
48
+ # Unknown strategy, fall back to Ruby
49
+ return super
50
+ end
51
+
52
+ apply_jitter(delay)
51
53
  end
52
54
  end
53
55
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChronoMachines
4
- VERSION = '0.9.2'
4
+ VERSION = '0.11.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chrono_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.2
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih