chrono_machines 0.0.0 → 0.2.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 +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +618 -0
- data/lib/chrono_machines/async_support.rb +37 -0
- data/lib/chrono_machines/configuration.rb +34 -0
- data/lib/chrono_machines/dsl.rb +21 -0
- data/lib/chrono_machines/errors.rb +15 -0
- data/lib/chrono_machines/executor.rb +97 -0
- data/lib/chrono_machines/test_helper.rb +22 -0
- data/lib/chrono_machines/version.rb +1 -1
- data/lib/chrono_machines.rb +31 -3
- data/sig/chrono_machines.rbs +4 -0
- metadata +35 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8617cc9e32d231563c1f075a8446728ff851ec0ec6c755aa00e807de5adde85d
|
4
|
+
data.tar.gz: de5c482f0bb089ce720f5cd3f3019a88fa6b149e83061b729d4d6287161601a3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 482988d7dd362bb4159af9e9651312b84c3a4e2633d9f4e48f00d9208991328d89ff66bcabd9001c3144c29a347b98f7476849499e310cd6530c08066e0b8f4a
|
7
|
+
data.tar.gz: 99c5ed6434f2e18236257efb87a652094354c24711d3c7aed651dcfd20b0a0fade2ae15214fbf3ea002730e814f696951dfd7f8af964942937ff0d8b1d91f723
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
## [Unreleased]
|
2
|
+
|
3
|
+
## [0.2.0](https://github.com/seuros/chrono_machines/compare/chrono_machines-v0.1.0...chrono_machines/v0.2.0) (2025-07-20)
|
4
|
+
|
5
|
+
|
6
|
+
### Features
|
7
|
+
|
8
|
+
* migrate to Zeitwerk autoloading and improve Ruby compatibility ([#1](https://github.com/seuros/chrono_machines/issues/1)) ([162a1cb](https://github.com/seuros/chrono_machines/commit/162a1cb6ad81c3c59778dcd325cebdfe637c22cb))
|
9
|
+
|
10
|
+
## [0.1.0] - 2025-07-12
|
11
|
+
|
12
|
+
- Initial release
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2025 Abdelkader Boudih
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,618 @@
|
|
1
|
+
# ChronoMachines
|
2
|
+
|
3
|
+
> The temporal manipulation engine that rewrites the rules of retry!
|
4
|
+
|
5
|
+
A sophisticated Ruby implementation of exponential backoff and retry mechanisms, built for temporal precision in distributed systems where time itself is your greatest ally.
|
6
|
+
|
7
|
+
## Quick Start
|
8
|
+
|
9
|
+
```bash
|
10
|
+
gem 'chrono_machines'
|
11
|
+
```
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
class PaymentService
|
15
|
+
include ChronoMachines::DSL
|
16
|
+
|
17
|
+
chrono_policy :stripe_payment, max_attempts: 5, base_delay: 0.1, multiplier: 2
|
18
|
+
|
19
|
+
def charge(amount)
|
20
|
+
with_chrono_policy(:stripe_payment) do
|
21
|
+
Stripe::Charge.create(amount: amount)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Or use it directly
|
27
|
+
result = ChronoMachines.retry(max_attempts: 3) do
|
28
|
+
perform_risky_operation
|
29
|
+
end
|
30
|
+
```
|
31
|
+
|
32
|
+
## A Message from the Time Lords
|
33
|
+
|
34
|
+
So your microservices are failing faster than your deployment pipeline can recover, and you're stuck in an infinite loop of "let's just add more retries"?
|
35
|
+
|
36
|
+
Welcome to the temporal wasteland—where every millisecond matters, exponential backoff is law, and jitter isn't just a feeling you get when watching your error rates spike.
|
37
|
+
|
38
|
+
Still here? Excellent. Because in the fabric of spacetime, nobody can hear your servers screaming about cascading failures. It's all just timing and patience.
|
39
|
+
|
40
|
+
### The Pattern Time Forgot
|
41
|
+
|
42
|
+
Built for Ruby 3.2+ with fiber-aware sleep and full jitter implementation, because when you're manipulating time itself, precision matters.
|
43
|
+
|
44
|
+
## Features
|
45
|
+
|
46
|
+
- **Temporal Precision** - Full jitter exponential backoff with microsecond accuracy
|
47
|
+
- **Advanced Retry Logic** - Configurable retryable exceptions and intelligent failure handling
|
48
|
+
- **Rich Instrumentation** - Success, retry, and failure callbacks with contextual data
|
49
|
+
- **Fallback Mechanisms** - Execute alternative logic when all retries are exhausted
|
50
|
+
- **Declarative DSL** - Clean policy definitions with builder patterns
|
51
|
+
- **Fiber-Safe Operations** - Async-aware sleep handling for modern Ruby
|
52
|
+
- **Custom Exceptions** - MaxRetriesExceededError with original exception context
|
53
|
+
- **Policy Management** - Named retry policies with inheritance and overrides
|
54
|
+
- **Robust Error Handling** - Interrupt-preserving sleep with graceful degradation
|
55
|
+
|
56
|
+
## The Temporal Manifesto
|
57
|
+
|
58
|
+
### You Think: "I'll just add `retry` and call it resilience!"
|
59
|
+
### Reality: You're creating a time paradox that crashes your entire fleet
|
60
|
+
|
61
|
+
When your payment service fails, you don't want to hammer it into submission. You want to approach it like a time traveler—carefully, with exponential patience, and a healthy respect for the butterfly effect.
|
62
|
+
|
63
|
+
## Core Usage Patterns
|
64
|
+
|
65
|
+
### Direct Retry with Options
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
# Simple retry with exponential backoff
|
69
|
+
result = ChronoMachines.retry(max_attempts: 3, base_delay: 0.1) do
|
70
|
+
fetch_external_data
|
71
|
+
end
|
72
|
+
|
73
|
+
# Advanced configuration
|
74
|
+
result = ChronoMachines.retry(
|
75
|
+
max_attempts: 5,
|
76
|
+
base_delay: 0.2,
|
77
|
+
multiplier: 3,
|
78
|
+
max_delay: 30,
|
79
|
+
retryable_exceptions: [Net::TimeoutError, HTTPError],
|
80
|
+
on_retry: ->(exception:, attempt:, next_delay:) {
|
81
|
+
Rails.logger.warn "Retry #{attempt}: #{exception.message}, waiting #{next_delay}s"
|
82
|
+
},
|
83
|
+
on_failure: ->(exception:, attempts:) {
|
84
|
+
Metrics.increment('api.retry.exhausted', tags: ["attempts:#{attempts}"])
|
85
|
+
}
|
86
|
+
) do
|
87
|
+
external_api_call
|
88
|
+
end
|
89
|
+
```
|
90
|
+
|
91
|
+
### Policy-Based Configuration
|
92
|
+
|
93
|
+
```ruby
|
94
|
+
# Configure global policies
|
95
|
+
ChronoMachines.configure do |config|
|
96
|
+
config.define_policy(:aggressive, max_attempts: 10, base_delay: 0.01, multiplier: 1.5)
|
97
|
+
config.define_policy(:conservative, max_attempts: 3, base_delay: 1.0, multiplier: 2)
|
98
|
+
config.define_policy(:database, max_attempts: 5, retryable_exceptions: [ActiveRecord::ConnectionError])
|
99
|
+
end
|
100
|
+
|
101
|
+
# Use named policies
|
102
|
+
result = ChronoMachines.retry(:database) do
|
103
|
+
User.find(user_id)
|
104
|
+
end
|
105
|
+
```
|
106
|
+
|
107
|
+
### DSL Integration
|
108
|
+
|
109
|
+
```ruby
|
110
|
+
class ApiClient
|
111
|
+
include ChronoMachines::DSL
|
112
|
+
|
113
|
+
# Define policies at class level
|
114
|
+
chrono_policy :standard_api, max_attempts: 5, base_delay: 0.1, multiplier: 2
|
115
|
+
chrono_policy :critical_api, max_attempts: 10, base_delay: 0.05, max_delay: 5
|
116
|
+
|
117
|
+
def fetch_user_data(id)
|
118
|
+
with_chrono_policy(:standard_api) do
|
119
|
+
api_request("/users/#{id}")
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def emergency_shutdown
|
124
|
+
# Use inline options for one-off scenarios
|
125
|
+
with_chrono_policy(max_attempts: 1, base_delay: 0) do
|
126
|
+
shutdown_api_call
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
```
|
131
|
+
|
132
|
+
## Advanced Temporal Mechanics
|
133
|
+
|
134
|
+
### Callback Instrumentation
|
135
|
+
|
136
|
+
```ruby
|
137
|
+
# Monitor retry patterns
|
138
|
+
policy_options = {
|
139
|
+
max_attempts: 5,
|
140
|
+
on_success: ->(result:, attempts:) {
|
141
|
+
Metrics.histogram('operation.attempts', attempts)
|
142
|
+
Rails.logger.info "Operation succeeded after #{attempts} attempts"
|
143
|
+
},
|
144
|
+
|
145
|
+
on_retry: ->(exception:, attempt:, next_delay:) {
|
146
|
+
Metrics.increment('operation.retry', tags: ["attempt:#{attempt}"])
|
147
|
+
Honeybadger.notify(exception, context: { attempt: attempt, next_delay: next_delay })
|
148
|
+
},
|
149
|
+
|
150
|
+
on_failure: ->(exception:, attempts:) {
|
151
|
+
Metrics.increment('operation.failure', tags: ["final_attempts:#{attempts}"])
|
152
|
+
PagerDuty.trigger("Operation failed after #{attempts} attempts: #{exception.message}")
|
153
|
+
}
|
154
|
+
}
|
155
|
+
|
156
|
+
ChronoMachines.retry(policy_options) do
|
157
|
+
critical_operation
|
158
|
+
end
|
159
|
+
```
|
160
|
+
|
161
|
+
### Exception Handling
|
162
|
+
|
163
|
+
```ruby
|
164
|
+
begin
|
165
|
+
ChronoMachines.retry(max_attempts: 3) do
|
166
|
+
risky_operation
|
167
|
+
end
|
168
|
+
rescue ChronoMachines::MaxRetriesExceededError => e
|
169
|
+
# Access the original exception and retry context
|
170
|
+
Rails.logger.error "Failed after #{e.attempts} attempts: #{e.original_exception.message}"
|
171
|
+
|
172
|
+
# The original exception is preserved
|
173
|
+
case e.original_exception
|
174
|
+
when Net::TimeoutError
|
175
|
+
handle_timeout_failure
|
176
|
+
when HTTPError
|
177
|
+
handle_http_failure
|
178
|
+
end
|
179
|
+
end
|
180
|
+
```
|
181
|
+
|
182
|
+
### Fallback Mechanisms
|
183
|
+
|
184
|
+
```ruby
|
185
|
+
# Execute fallback logic when retries are exhausted
|
186
|
+
ChronoMachines.retry(
|
187
|
+
max_attempts: 3,
|
188
|
+
on_failure: ->(exception:, attempts:) {
|
189
|
+
# Fallback doesn't throw - original exception is still raised
|
190
|
+
Rails.cache.write("fallback_data_#{user_id}", cached_response, expires_in: 5.minutes)
|
191
|
+
SlackNotifier.notify("API down, serving cached data for user #{user_id}")
|
192
|
+
}
|
193
|
+
) do
|
194
|
+
fetch_fresh_user_data
|
195
|
+
end
|
196
|
+
```
|
197
|
+
|
198
|
+
## The Science of Temporal Jitter
|
199
|
+
|
200
|
+
ChronoMachines implements **full jitter** exponential backoff:
|
201
|
+
|
202
|
+
```ruby
|
203
|
+
# Instead of predictable delays that create thundering herds:
|
204
|
+
# Attempt 1: 100ms
|
205
|
+
# Attempt 2: 200ms
|
206
|
+
# Attempt 3: 400ms
|
207
|
+
|
208
|
+
# ChronoMachines uses full jitter:
|
209
|
+
# Attempt 1: random(0, 100ms)
|
210
|
+
# Attempt 2: random(0, 200ms)
|
211
|
+
# Attempt 3: random(0, 400ms)
|
212
|
+
```
|
213
|
+
|
214
|
+
This prevents the "thundering herd" problem where multiple clients retry simultaneously, overwhelming recovering services.
|
215
|
+
|
216
|
+
## Configuration Reference
|
217
|
+
|
218
|
+
### Policy Options
|
219
|
+
|
220
|
+
| Option | Default | Description |
|
221
|
+
|--------|---------|-------------|
|
222
|
+
| `max_attempts` | `3` | Maximum number of retry attempts |
|
223
|
+
| `base_delay` | `0.1` | Initial delay in seconds |
|
224
|
+
| `multiplier` | `2` | Exponential backoff multiplier |
|
225
|
+
| `max_delay` | `10` | Maximum delay cap in seconds |
|
226
|
+
| `retryable_exceptions` | `[StandardError]` | Array of exception classes to retry |
|
227
|
+
| `on_success` | `nil` | Success callback: `(result:, attempts:)` |
|
228
|
+
| `on_retry` | `nil` | Retry callback: `(exception:, attempt:, next_delay:)` |
|
229
|
+
| `on_failure` | `nil` | Failure callback: `(exception:, attempts:)` |
|
230
|
+
|
231
|
+
### DSL Methods
|
232
|
+
|
233
|
+
| Method | Scope | Description |
|
234
|
+
|--------|-------|-------------|
|
235
|
+
| `chrono_policy(name, options)` | Class | Define a named retry policy |
|
236
|
+
| `with_chrono_policy(policy_or_options, &block)` | Instance | Execute block with retry policy |
|
237
|
+
|
238
|
+
## Real-World Examples
|
239
|
+
|
240
|
+
### Database Connection Resilience
|
241
|
+
|
242
|
+
```ruby
|
243
|
+
class DatabaseService
|
244
|
+
include ChronoMachines::DSL
|
245
|
+
|
246
|
+
chrono_policy :db_connection,
|
247
|
+
max_attempts: 5,
|
248
|
+
base_delay: 0.1,
|
249
|
+
retryable_exceptions: [
|
250
|
+
ActiveRecord::ConnectionTimeoutError,
|
251
|
+
ActiveRecord::DisconnectedError,
|
252
|
+
PG::ConnectionBad
|
253
|
+
],
|
254
|
+
on_retry: ->(exception:, attempt:, next_delay:) {
|
255
|
+
Rails.logger.warn "DB retry #{attempt}: #{exception.class}"
|
256
|
+
}
|
257
|
+
|
258
|
+
def find_user(id)
|
259
|
+
with_chrono_policy(:db_connection) do
|
260
|
+
User.find(id)
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
264
|
+
```
|
265
|
+
|
266
|
+
### HTTP API Integration
|
267
|
+
|
268
|
+
```ruby
|
269
|
+
class WeatherService
|
270
|
+
include ChronoMachines::DSL
|
271
|
+
|
272
|
+
chrono_policy :weather_api,
|
273
|
+
max_attempts: 4,
|
274
|
+
base_delay: 0.2,
|
275
|
+
max_delay: 10,
|
276
|
+
retryable_exceptions: [Net::TimeoutError, Net::HTTPServerError],
|
277
|
+
on_failure: ->(exception:, attempts:) {
|
278
|
+
# Serve stale data when API is completely down
|
279
|
+
Rails.cache.write("weather_service_down", true, expires_in: 5.minutes)
|
280
|
+
}
|
281
|
+
|
282
|
+
def current_weather(location)
|
283
|
+
with_chrono_policy(:weather_api) do
|
284
|
+
response = HTTP.timeout(connect: 2, read: 5)
|
285
|
+
.get("https://api.weather.com/#{location}")
|
286
|
+
JSON.parse(response.body)
|
287
|
+
end
|
288
|
+
rescue ChronoMachines::MaxRetriesExceededError
|
289
|
+
# Return cached data if available
|
290
|
+
Rails.cache.fetch("weather_#{location}", expires_in: 1.hour) do
|
291
|
+
{ temperature: "Unknown", status: "Service Unavailable" }
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
```
|
296
|
+
|
297
|
+
### Background Job Retry Logic
|
298
|
+
|
299
|
+
```ruby
|
300
|
+
class EmailDeliveryJob
|
301
|
+
include ChronoMachines::DSL
|
302
|
+
|
303
|
+
chrono_policy :email_delivery,
|
304
|
+
max_attempts: 8,
|
305
|
+
base_delay: 1,
|
306
|
+
multiplier: 1.5,
|
307
|
+
max_delay: 300, # 5 minutes max
|
308
|
+
retryable_exceptions: [Net::SMTPServerBusy, Net::SMTPTemporaryError],
|
309
|
+
on_failure: ->(exception:, attempts:) {
|
310
|
+
# Move to dead letter queue after all retries
|
311
|
+
DeadLetterQueue.push(job_data, reason: exception.message)
|
312
|
+
}
|
313
|
+
|
314
|
+
def perform(email_data)
|
315
|
+
with_chrono_policy(:email_delivery) do
|
316
|
+
EmailService.deliver(email_data)
|
317
|
+
end
|
318
|
+
end
|
319
|
+
end
|
320
|
+
```
|
321
|
+
|
322
|
+
## Testing Strategies
|
323
|
+
|
324
|
+
### Mocking Time and Retries
|
325
|
+
|
326
|
+
```ruby
|
327
|
+
require "minitest/autorun"
|
328
|
+
require "mocha/minitest"
|
329
|
+
|
330
|
+
class PaymentServiceTest < Minitest::Test
|
331
|
+
def setup
|
332
|
+
@service = PaymentService.new
|
333
|
+
end
|
334
|
+
|
335
|
+
def test_retries_payment_on_timeout
|
336
|
+
charge_response = { id: "ch_123", amount: 100 }
|
337
|
+
|
338
|
+
Stripe::Charge.expects(:create)
|
339
|
+
.raises(Net::TimeoutError).once
|
340
|
+
.then.returns(charge_response)
|
341
|
+
|
342
|
+
# Mock sleep to avoid test delays
|
343
|
+
ChronoMachines::Executor.any_instance.expects(:robust_sleep).at_least_once
|
344
|
+
|
345
|
+
result = @service.charge(100)
|
346
|
+
assert_equal charge_response, result
|
347
|
+
end
|
348
|
+
|
349
|
+
def test_respects_max_attempts
|
350
|
+
Stripe::Charge.expects(:create)
|
351
|
+
.raises(Net::TimeoutError).times(3)
|
352
|
+
|
353
|
+
assert_raises(ChronoMachines::MaxRetriesExceededError) do
|
354
|
+
@service.charge(100)
|
355
|
+
end
|
356
|
+
end
|
357
|
+
|
358
|
+
def test_preserves_original_exception
|
359
|
+
original_error = Net::TimeoutError.new("Connection timed out")
|
360
|
+
Stripe::Charge.expects(:create).raises(original_error).times(3)
|
361
|
+
|
362
|
+
begin
|
363
|
+
@service.charge(100)
|
364
|
+
flunk "Expected MaxRetriesExceededError to be raised"
|
365
|
+
rescue ChronoMachines::MaxRetriesExceededError => e
|
366
|
+
assert_equal 3, e.attempts
|
367
|
+
assert_equal original_error, e.original_exception
|
368
|
+
assert_equal "Connection timed out", e.original_exception.message
|
369
|
+
end
|
370
|
+
end
|
371
|
+
end
|
372
|
+
```
|
373
|
+
|
374
|
+
### Testing Callbacks
|
375
|
+
|
376
|
+
```ruby
|
377
|
+
class CallbackTest < Minitest::Test
|
378
|
+
def test_calls_retry_callback_with_correct_context
|
379
|
+
retry_calls = []
|
380
|
+
call_count = 0
|
381
|
+
|
382
|
+
result = ChronoMachines.retry(
|
383
|
+
max_attempts: 3,
|
384
|
+
base_delay: 0.001, # Short delay for tests
|
385
|
+
on_retry: ->(exception:, attempt:, next_delay:) {
|
386
|
+
retry_calls << {
|
387
|
+
attempt: attempt,
|
388
|
+
delay: next_delay,
|
389
|
+
exception_message: exception.message
|
390
|
+
}
|
391
|
+
}
|
392
|
+
) do
|
393
|
+
call_count += 1
|
394
|
+
raise "Fail" if call_count < 2
|
395
|
+
"Success"
|
396
|
+
end
|
397
|
+
|
398
|
+
assert_equal "Success", result
|
399
|
+
assert_equal 1, retry_calls.length
|
400
|
+
assert_equal 1, retry_calls.first[:attempt]
|
401
|
+
assert retry_calls.first[:delay] > 0
|
402
|
+
assert_equal "Fail", retry_calls.first[:exception_message]
|
403
|
+
end
|
404
|
+
|
405
|
+
def test_calls_success_callback
|
406
|
+
success_called = false
|
407
|
+
result_captured = nil
|
408
|
+
attempts_captured = nil
|
409
|
+
|
410
|
+
result = ChronoMachines.retry(
|
411
|
+
on_success: ->(result:, attempts:) {
|
412
|
+
success_called = true
|
413
|
+
result_captured = result
|
414
|
+
attempts_captured = attempts
|
415
|
+
}
|
416
|
+
) do
|
417
|
+
"Operation succeeded"
|
418
|
+
end
|
419
|
+
|
420
|
+
assert success_called
|
421
|
+
assert_equal "Operation succeeded", result_captured
|
422
|
+
assert_equal 1, attempts_captured
|
423
|
+
end
|
424
|
+
|
425
|
+
def test_calls_failure_callback
|
426
|
+
failure_called = false
|
427
|
+
exception_captured = nil
|
428
|
+
|
429
|
+
assert_raises(ChronoMachines::MaxRetriesExceededError) do
|
430
|
+
ChronoMachines.retry(
|
431
|
+
max_attempts: 2,
|
432
|
+
on_failure: ->(exception:, attempts:) {
|
433
|
+
failure_called = true
|
434
|
+
exception_captured = exception
|
435
|
+
}
|
436
|
+
) do
|
437
|
+
raise "Always fails"
|
438
|
+
end
|
439
|
+
end
|
440
|
+
|
441
|
+
assert failure_called
|
442
|
+
assert_equal "Always fails", exception_captured.message
|
443
|
+
end
|
444
|
+
end
|
445
|
+
```
|
446
|
+
|
447
|
+
### Testing DSL Integration
|
448
|
+
|
449
|
+
```ruby
|
450
|
+
class DSLTestExample < Minitest::Test
|
451
|
+
class TestService
|
452
|
+
include ChronoMachines::DSL
|
453
|
+
|
454
|
+
chrono_policy :test_policy, max_attempts: 2, base_delay: 0.001
|
455
|
+
|
456
|
+
def risky_operation
|
457
|
+
with_chrono_policy(:test_policy) do
|
458
|
+
# Simulated operation
|
459
|
+
yield if block_given?
|
460
|
+
end
|
461
|
+
end
|
462
|
+
end
|
463
|
+
|
464
|
+
def test_dsl_policy_definition
|
465
|
+
service = TestService.new
|
466
|
+
|
467
|
+
call_count = 0
|
468
|
+
result = service.risky_operation do
|
469
|
+
call_count += 1
|
470
|
+
raise "Fail" if call_count < 2
|
471
|
+
"Success"
|
472
|
+
end
|
473
|
+
|
474
|
+
assert_equal "Success", result
|
475
|
+
assert_equal 2, call_count
|
476
|
+
end
|
477
|
+
|
478
|
+
def test_dsl_with_inline_options
|
479
|
+
service = TestService.new
|
480
|
+
|
481
|
+
assert_raises(ChronoMachines::MaxRetriesExceededError) do
|
482
|
+
service.with_chrono_policy(max_attempts: 1) do
|
483
|
+
raise "Always fails"
|
484
|
+
end
|
485
|
+
end
|
486
|
+
end
|
487
|
+
end
|
488
|
+
```
|
489
|
+
|
490
|
+
## TestHelper for Library Authors
|
491
|
+
|
492
|
+
ChronoMachines provides a test helper module for library authors who want to integrate ChronoMachines testing utilities into their own test suites.
|
493
|
+
|
494
|
+
### Setup
|
495
|
+
|
496
|
+
```ruby
|
497
|
+
require 'chrono_machines/test_helper'
|
498
|
+
|
499
|
+
class MyLibraryTest < Minitest::Test
|
500
|
+
include ChronoMachines::TestHelper
|
501
|
+
|
502
|
+
def setup
|
503
|
+
super # Important: calls ChronoMachines config reset
|
504
|
+
# Your setup code here
|
505
|
+
end
|
506
|
+
end
|
507
|
+
```
|
508
|
+
|
509
|
+
### Features
|
510
|
+
|
511
|
+
**Configuration Reset**: The TestHelper automatically resets ChronoMachines configuration before each test, ensuring test isolation.
|
512
|
+
|
513
|
+
**Custom Assertions**: Provides specialized assertions for testing delay ranges:
|
514
|
+
|
515
|
+
```ruby
|
516
|
+
def test_delay_calculation
|
517
|
+
executor = ChronoMachines::Executor.new(base_delay: 0.1, multiplier: 2)
|
518
|
+
delay = executor.send(:calculate_delay, 1)
|
519
|
+
|
520
|
+
# Assert delay is within expected jitter range
|
521
|
+
assert_cm_delay_range(delay, 0.0, 0.1, "First attempt delay out of range")
|
522
|
+
end
|
523
|
+
```
|
524
|
+
|
525
|
+
**Available Assertions**:
|
526
|
+
- `assert_cm_delay_range(delay, min, max, message = nil)` - Assert delay falls within expected range
|
527
|
+
|
528
|
+
### Integration Example
|
529
|
+
|
530
|
+
```ruby
|
531
|
+
# In your gem's test_helper.rb
|
532
|
+
require 'minitest/autorun'
|
533
|
+
require 'chrono_machines/test_helper'
|
534
|
+
|
535
|
+
class TestBase < Minitest::Test
|
536
|
+
include ChronoMachines::TestHelper
|
537
|
+
|
538
|
+
def setup
|
539
|
+
super
|
540
|
+
# Reset any additional state
|
541
|
+
end
|
542
|
+
end
|
543
|
+
|
544
|
+
# In your specific tests
|
545
|
+
class RetryServiceTest < TestBase
|
546
|
+
def test_retry_with_custom_policy
|
547
|
+
# ChronoMachines config is automatically reset
|
548
|
+
# You can safely define test-specific policies
|
549
|
+
|
550
|
+
ChronoMachines.configure do |config|
|
551
|
+
config.define_policy(:test_policy, max_attempts: 2)
|
552
|
+
end
|
553
|
+
|
554
|
+
result = ChronoMachines.retry(:test_policy) do
|
555
|
+
"success"
|
556
|
+
end
|
557
|
+
|
558
|
+
assert_equal "success", result
|
559
|
+
end
|
560
|
+
end
|
561
|
+
```
|
562
|
+
|
563
|
+
## Why ChronoMachines?
|
564
|
+
|
565
|
+
### Built for Modern Ruby
|
566
|
+
- **Ruby 3.2+ Support**: Fiber-aware sleep handling
|
567
|
+
- **Clean Architecture**: Separation of concerns with configurable policies
|
568
|
+
- **Rich Instrumentation**: Comprehensive callback system for monitoring
|
569
|
+
- **Battle-Tested**: Full jitter implementation prevents thundering herds
|
570
|
+
|
571
|
+
### Time-Tested Patterns
|
572
|
+
- **Exponential Backoff**: Industry-standard retry timing
|
573
|
+
- **Circuit Breaking**: Fail-fast when upstream is down
|
574
|
+
- **Fallback Support**: Graceful degradation strategies
|
575
|
+
- **Exception Preservation**: Original errors aren't lost in retry logic
|
576
|
+
|
577
|
+
## A Word from the Time Corps Engineering Division
|
578
|
+
|
579
|
+
*The Temporal Commentary Engine activates:*
|
580
|
+
|
581
|
+
"Time isn't linear—especially when your payment processor is having 'a moment.'
|
582
|
+
|
583
|
+
The universe doesn't care about your startup's burn rate or your post on X about 'building in public.' It cares about one immutable law:
|
584
|
+
|
585
|
+
**Does your system handle failure gracefully across the fourth dimension?**
|
586
|
+
|
587
|
+
If not, welcome to the Time Corps. We have exponential backoff.
|
588
|
+
|
589
|
+
Remember: The goal isn't to prevent temporal anomalies—it's to fail fast, fail smart, and retry with mathematical precision.
|
590
|
+
|
591
|
+
As I always say when debugging production: 'Time heals all wounds, but jitter prevents thundering herds.'"
|
592
|
+
|
593
|
+
*— Temporal Commentary Engine, Log Entry ∞*
|
594
|
+
|
595
|
+
## Contributing to the Timeline
|
596
|
+
|
597
|
+
1. Fork it (like it's 2005, but with better temporal mechanics)
|
598
|
+
2. Create your feature branch (`git checkout -b feature/quantum-retries`)
|
599
|
+
3. Commit your changes (`git commit -am 'Add temporal stabilization'`)
|
600
|
+
4. Push to the branch (`git push origin feature/quantum-retries`)
|
601
|
+
5. Create a new Pull Request (and wait for the Time Lords to review)
|
602
|
+
|
603
|
+
## License
|
604
|
+
|
605
|
+
MIT License. See [LICENSE](LICENSE) file for details.
|
606
|
+
|
607
|
+
## Acknowledgments
|
608
|
+
|
609
|
+
- The Ruby community - For building a language worth retrying for
|
610
|
+
- Every timeout that ever taught us patience - You made us stronger
|
611
|
+
- The Time Corps - For maintaining temporal stability
|
612
|
+
- The universe - For being deterministically random
|
613
|
+
|
614
|
+
## Author
|
615
|
+
|
616
|
+
Built with time and coffee by temporal engineers fighting entropy one retry at a time.
|
617
|
+
|
618
|
+
**Remember: In the fabric of spacetime, nobody can hear your API timeout. But they can feel your exponential backoff working as intended.**
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This file provides optional integration with the Async gem.
|
4
|
+
# It will only patch if Async is available.
|
5
|
+
|
6
|
+
async_available = defined?(Async) || begin
|
7
|
+
require 'async'
|
8
|
+
true
|
9
|
+
rescue LoadError
|
10
|
+
false
|
11
|
+
end
|
12
|
+
|
13
|
+
if async_available
|
14
|
+
module ChronoMachines
|
15
|
+
# Patch the Executor's robust_sleep method to use Async's non-blocking sleep
|
16
|
+
# if an Async task is currently running.
|
17
|
+
class Executor
|
18
|
+
alias original_robust_sleep robust_sleep
|
19
|
+
|
20
|
+
def robust_sleep(delay)
|
21
|
+
# Check if we're in an Async context and safely call current
|
22
|
+
current_task = begin
|
23
|
+
Async::Task.current
|
24
|
+
rescue RuntimeError
|
25
|
+
# No async task available
|
26
|
+
nil
|
27
|
+
end
|
28
|
+
|
29
|
+
if current_task
|
30
|
+
current_task.sleep(delay)
|
31
|
+
else
|
32
|
+
original_robust_sleep(delay)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ChronoMachines
|
4
|
+
class Configuration
|
5
|
+
DEFAULT_POLICY_NAME = :default
|
6
|
+
|
7
|
+
attr_reader :policies
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@policies = {
|
11
|
+
DEFAULT_POLICY_NAME => {
|
12
|
+
max_attempts: 3,
|
13
|
+
base_delay: 0.1, # seconds
|
14
|
+
multiplier: 2,
|
15
|
+
max_delay: 10, # seconds
|
16
|
+
jitter_factor: 0.1, # 10% jitter (though full jitter makes this less direct)
|
17
|
+
retryable_exceptions: [StandardError],
|
18
|
+
on_failure: nil, # Fallback block when all retries are exhausted
|
19
|
+
on_retry: nil, # Callback block when a retry occurs
|
20
|
+
on_success: nil # Callback block when operation succeeds
|
21
|
+
}
|
22
|
+
}
|
23
|
+
end
|
24
|
+
|
25
|
+
def define_policy(name, options)
|
26
|
+
@policies[name.to_sym] = @policies[DEFAULT_POLICY_NAME].merge(options)
|
27
|
+
end
|
28
|
+
|
29
|
+
def get_policy(name)
|
30
|
+
@policies[name.to_sym] || raise(ArgumentError, "Policy '#{name}' not found.")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ChronoMachines
|
4
|
+
module DSL
|
5
|
+
def self.included(base)
|
6
|
+
base.extend(ClassMethods)
|
7
|
+
end
|
8
|
+
|
9
|
+
module ClassMethods
|
10
|
+
def chrono_policy(name, options = {})
|
11
|
+
ChronoMachines.configure do |config|
|
12
|
+
config.define_policy(name, options)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def with_chrono_policy(policy_name_or_options, &)
|
18
|
+
ChronoMachines.retry(policy_name_or_options, &)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ChronoMachines
|
4
|
+
class Error < StandardError; end
|
5
|
+
|
6
|
+
class MaxRetriesExceededError < Error
|
7
|
+
attr_reader :original_exception, :attempts
|
8
|
+
|
9
|
+
def initialize(original_exception, attempts)
|
10
|
+
@original_exception = original_exception
|
11
|
+
@attempts = attempts
|
12
|
+
super("Max retries (#{attempts}) exceeded. Original error: #{original_exception.class}: #{original_exception.message}")
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ChronoMachines
|
4
|
+
class Executor
|
5
|
+
def initialize(policy_or_options = {})
|
6
|
+
policy_options = if policy_or_options.is_a?(Symbol)
|
7
|
+
ChronoMachines.config.get_policy(policy_or_options)
|
8
|
+
else
|
9
|
+
ChronoMachines.config.get_policy(Configuration::DEFAULT_POLICY_NAME).merge(policy_or_options)
|
10
|
+
end
|
11
|
+
|
12
|
+
@max_attempts = policy_options[:max_attempts]
|
13
|
+
@base_delay = policy_options[:base_delay]
|
14
|
+
@multiplier = policy_options[:multiplier]
|
15
|
+
@max_delay = policy_options[:max_delay]
|
16
|
+
@jitter_factor = policy_options[:jitter_factor]
|
17
|
+
@retryable_exceptions = policy_options[:retryable_exceptions]
|
18
|
+
@on_failure = policy_options[:on_failure]
|
19
|
+
@on_retry = policy_options[:on_retry]
|
20
|
+
@on_success = policy_options[:on_success]
|
21
|
+
end
|
22
|
+
|
23
|
+
def call
|
24
|
+
attempts = 0
|
25
|
+
|
26
|
+
begin
|
27
|
+
attempts += 1
|
28
|
+
result = yield
|
29
|
+
|
30
|
+
# Call success callback if defined
|
31
|
+
@on_success&.call(result: result, attempts: attempts)
|
32
|
+
|
33
|
+
result
|
34
|
+
rescue StandardError => e
|
35
|
+
# Check if exception is retryable
|
36
|
+
unless @retryable_exceptions.any? { |ex| e.is_a?(ex) }
|
37
|
+
# Non-retryable exception - call failure callback and re-raise
|
38
|
+
handle_final_failure(e, attempts)
|
39
|
+
raise e
|
40
|
+
end
|
41
|
+
|
42
|
+
# Check if we've exhausted all attempts
|
43
|
+
if attempts >= @max_attempts
|
44
|
+
handle_final_failure(e, attempts)
|
45
|
+
raise MaxRetriesExceededError.new(e, attempts)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Call retry callback if defined
|
49
|
+
@on_retry&.call(exception: e, attempt: attempts, next_delay: calculate_delay(attempts))
|
50
|
+
|
51
|
+
# Calculate and execute delay with robust sleep
|
52
|
+
delay = calculate_delay(attempts)
|
53
|
+
robust_sleep(delay)
|
54
|
+
retry
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def calculate_delay(attempts)
|
61
|
+
# Calculate the base exponential backoff delay
|
62
|
+
# Ensure it doesn't exceed max_delay
|
63
|
+
base_exponential_delay = [@base_delay * (@multiplier**(attempts - 1)), @max_delay].min
|
64
|
+
|
65
|
+
# Apply full jitter: random value between 0 and the base_exponential_delay
|
66
|
+
rand * base_exponential_delay
|
67
|
+
end
|
68
|
+
|
69
|
+
def robust_sleep(delay)
|
70
|
+
# Handle potential interruptions to sleep
|
71
|
+
# In Ruby 3.2+, Kernel.sleep is fiber-aware
|
72
|
+
return if delay <= 0
|
73
|
+
|
74
|
+
begin
|
75
|
+
sleep(delay)
|
76
|
+
rescue Interrupt
|
77
|
+
# Re-raise interrupt signals
|
78
|
+
raise
|
79
|
+
rescue StandardError
|
80
|
+
# Log or handle other sleep interruptions, but continue
|
81
|
+
# In most cases, we want to proceed with the retry
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def handle_final_failure(exception, attempts)
|
86
|
+
# Execute fallback block if defined
|
87
|
+
return unless @on_failure
|
88
|
+
|
89
|
+
begin
|
90
|
+
@on_failure.call(exception: exception, attempts: attempts)
|
91
|
+
rescue StandardError
|
92
|
+
# Don't let fallback errors mask the original error
|
93
|
+
# Could log this or handle as needed
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'minitest/autorun'
|
4
|
+
|
5
|
+
module ChronoMachines
|
6
|
+
module TestHelper
|
7
|
+
def setup
|
8
|
+
super
|
9
|
+
# Reset configuration before each test
|
10
|
+
ChronoMachines.instance_variable_set(:@config, ChronoMachines::Configuration.new)
|
11
|
+
end
|
12
|
+
|
13
|
+
module Assertions
|
14
|
+
def assert_cm_delay_range(delay, expected_min, expected_max, message = nil)
|
15
|
+
assert_operator(delay, :>=, expected_min, "Expected delay #{delay} to be >= #{expected_min}. #{message}")
|
16
|
+
assert_operator(delay, :<=, expected_max, "Expected delay #{delay} to be <= #{expected_max}. #{message}")
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
include Assertions
|
21
|
+
end
|
22
|
+
end
|
data/lib/chrono_machines.rb
CHANGED
@@ -1,8 +1,36 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
require 'zeitwerk'
|
4
|
+
|
5
|
+
loader = Zeitwerk::Loader.for_gem
|
6
|
+
loader.ignore("#{__dir__}/chrono_machines/errors.rb")
|
7
|
+
loader.ignore("#{__dir__}/chrono_machines/async_support.rb")
|
8
|
+
loader.ignore("#{__dir__}/chrono_machines/test_helper.rb")
|
9
|
+
loader.inflector.inflect("dsl" => "DSL")
|
10
|
+
loader.setup
|
11
|
+
|
12
|
+
require_relative 'chrono_machines/errors'
|
4
13
|
|
5
14
|
module ChronoMachines
|
6
|
-
|
7
|
-
|
15
|
+
# Global configuration instance
|
16
|
+
@config = Configuration.new
|
17
|
+
|
18
|
+
def self.configure
|
19
|
+
yield @config
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.config
|
23
|
+
@config
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.retry(policy_name_or_options = {}, &)
|
27
|
+
Executor.new(policy_name_or_options).call(&)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Load optional async support after core is loaded
|
32
|
+
begin
|
33
|
+
require_relative 'chrono_machines/async_support'
|
34
|
+
rescue LoadError
|
35
|
+
# Async gem not available, skip async support
|
8
36
|
end
|
metadata
CHANGED
@@ -1,25 +1,51 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: chrono_machines
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Abdelkader Boudih
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
11
|
-
dependencies:
|
12
|
-
|
13
|
-
|
14
|
-
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: zeitwerk
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '2.7'
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - "~>"
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '2.7'
|
26
|
+
description: ChronoMachines offers a flexible and configurable solution for handling
|
27
|
+
transient failures in distributed Ruby applications. It provides powerful retry
|
28
|
+
strategies, including exponential backoff and full jitter, along with customizable
|
29
|
+
callbacks for success, retry, and failure events. Define and manage retry policies
|
30
|
+
with a clean DSL for seamless integration.
|
15
31
|
email:
|
16
32
|
- terminale@gmail.com
|
17
33
|
executables: []
|
18
34
|
extensions: []
|
19
35
|
extra_rdoc_files: []
|
20
36
|
files:
|
37
|
+
- CHANGELOG.md
|
38
|
+
- LICENSE.txt
|
39
|
+
- README.md
|
21
40
|
- lib/chrono_machines.rb
|
41
|
+
- lib/chrono_machines/async_support.rb
|
42
|
+
- lib/chrono_machines/configuration.rb
|
43
|
+
- lib/chrono_machines/dsl.rb
|
44
|
+
- lib/chrono_machines/errors.rb
|
45
|
+
- lib/chrono_machines/executor.rb
|
46
|
+
- lib/chrono_machines/test_helper.rb
|
22
47
|
- lib/chrono_machines/version.rb
|
48
|
+
- sig/chrono_machines.rbs
|
23
49
|
homepage: https://github.com/seuros/chrono_machines
|
24
50
|
licenses:
|
25
51
|
- MIT
|
@@ -28,6 +54,7 @@ metadata:
|
|
28
54
|
homepage_uri: https://github.com/seuros/chrono_machines
|
29
55
|
source_code_uri: https://github.com/seuros/chrono_machines
|
30
56
|
changelog_uri: https://github.com/seuros/chrono_machines/blob/main/CHANGELOG.md
|
57
|
+
rubygems_mfa_required: 'true'
|
31
58
|
rdoc_options: []
|
32
59
|
require_paths:
|
33
60
|
- lib
|
@@ -35,7 +62,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
35
62
|
requirements:
|
36
63
|
- - ">="
|
37
64
|
- !ruby/object:Gem::Version
|
38
|
-
version: 3.
|
65
|
+
version: 3.3.0
|
39
66
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
40
67
|
requirements:
|
41
68
|
- - ">="
|
@@ -44,5 +71,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
44
71
|
requirements: []
|
45
72
|
rubygems_version: 3.6.9
|
46
73
|
specification_version: 4
|
47
|
-
summary: A
|
74
|
+
summary: A robust Ruby gem for implementing retry mechanisms with exponential backoff
|
75
|
+
and jitter.
|
48
76
|
test_files: []
|