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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 474f154ed5894dbea202693d26b61a75e4df4bdb1dbbfc0ef1823536a2f477dc
4
- data.tar.gz: 1c29822882e486842767c41e91d9c3dcdcbce464e7b75fe8ccf5bf0a224a7674
3
+ metadata.gz: 8617cc9e32d231563c1f075a8446728ff851ec0ec6c755aa00e807de5adde85d
4
+ data.tar.gz: de5c482f0bb089ce720f5cd3f3019a88fa6b149e83061b729d4d6287161601a3
5
5
  SHA512:
6
- metadata.gz: d7efe36430add656b675ead11255f00ac25e332deeb3b965521f74838c72585b2d0aa22cfe22dd23e83ca02a43a793f205480a89b1efcebce043e4b7c6ac0a03
7
- data.tar.gz: 9d8e72a5da90ccb7dac0c933440a23d19ca7b7c1e68f789c8de3103fd8b6358ee5d288b9b372b660658c08d8e1dc032d72724eb77ed37e0bcb2f17b8a0889cc3
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChronoMachines
4
- VERSION = "0.0.0"
4
+ VERSION = '0.2.0'
5
5
  end
@@ -1,8 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "chrono_machines/version"
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
- class Error < StandardError; end
7
- # Your code goes here...
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
@@ -0,0 +1,4 @@
1
+ module ChronoMachines
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ 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.0.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
- description: ChronoMachines provides a unique framework for managing temporal flows
13
- within Ruby systems, allowing for precise control over event sequencing and re-engagement.
14
- Explore the possibilities of time-based operations.
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.2.0
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 temporal manipulation engine for Ruby applications.
74
+ summary: A robust Ruby gem for implementing retry mechanisms with exponential backoff
75
+ and jitter.
48
76
  test_files: []