attempt 0.6.3 → 0.8.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.
data/lib/attempt.rb CHANGED
@@ -1,18 +1,230 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if File::ALT_SEPARATOR
4
- require 'timeout'
5
- else
6
- require 'safe_timeout'
7
- end
8
-
3
+ require 'timeout'
9
4
  require 'structured_warnings'
10
5
 
6
+ # Custom timeout implementation that's more reliable than Ruby's Timeout module
7
+ class AttemptTimeout
8
+ class Error < StandardError; end
9
+
10
+ # More reliable timeout using Thread + sleep instead of Timeout.timeout
11
+ # This approach is safer as it doesn't use Thread#raise
12
+ def self.timeout(seconds, &block)
13
+ return yield if seconds.nil? || seconds <= 0
14
+
15
+ result = nil
16
+ exception = nil
17
+
18
+ thread = Thread.new do
19
+ begin
20
+ result = yield
21
+ rescue => e
22
+ exception = e
23
+ end
24
+ end
25
+
26
+ if thread.join(seconds)
27
+ # Thread completed within timeout
28
+ raise exception if exception
29
+ result
30
+ else
31
+ # Thread timed out
32
+ thread.kill # More graceful than Thread#raise
33
+ thread.join # Wait for cleanup
34
+ raise Error, "execution expired after #{seconds} seconds"
35
+ end
36
+ end
37
+
38
+ # Alternative: Fiber-based timeout (lightweight, cooperative)
39
+ # Note: This only works for code that yields control back to the main thread
40
+ def self.fiber_timeout(seconds, &block)
41
+ return yield if seconds.nil? || seconds <= 0
42
+
43
+ # For blocks that don't naturally yield, we need a different approach
44
+ # We'll use a hybrid fiber + thread approach for better compatibility
45
+ if fiber_compatible_block?(&block)
46
+ fiber_only_timeout(seconds, &block)
47
+ else
48
+ fiber_thread_hybrid_timeout(seconds, &block)
49
+ end
50
+ end
51
+
52
+ # Pure fiber-based timeout for cooperative code
53
+ def self.fiber_only_timeout(seconds, &block)
54
+ fiber = Fiber.new(&block)
55
+ start_time = Time.now
56
+
57
+ loop do
58
+ elapsed = Time.now - start_time
59
+ if elapsed > seconds
60
+ raise Error, "execution expired after #{seconds} seconds"
61
+ end
62
+
63
+ begin
64
+ result = fiber.resume
65
+ return result unless fiber.alive?
66
+ rescue FiberError
67
+ # Fiber is dead, which means it completed
68
+ break
69
+ end
70
+
71
+ # Small sleep to prevent busy waiting and allow other operations
72
+ sleep 0.001
73
+ end
74
+ end
75
+
76
+ # Hybrid approach: fiber in a thread with timeout
77
+ def self.fiber_thread_hybrid_timeout(seconds, &block)
78
+ result = nil
79
+ exception = nil
80
+
81
+ thread = Thread.new do
82
+ fiber = Fiber.new do
83
+ begin
84
+ result = yield
85
+ rescue => e
86
+ exception = e
87
+ end
88
+ end
89
+
90
+ # Resume the fiber until completion
91
+ while fiber.alive?
92
+ fiber.resume
93
+ Thread.pass # Allow other threads to run
94
+ end
95
+ end
96
+
97
+ if thread.join(seconds)
98
+ raise exception if exception
99
+ result
100
+ else
101
+ thread.kill
102
+ thread.join(0.1)
103
+ raise Error, "execution expired after #{seconds} seconds"
104
+ end
105
+ end
106
+
107
+ # Simple heuristic to determine if a block is likely to be fiber-compatible
108
+ # (This is a basic implementation - in practice, this is hard to determine)
109
+ def self.fiber_compatible_block?(&block)
110
+ # Try multiple detection strategies
111
+ detect_by_execution_pattern(&block) ||
112
+ detect_by_source_analysis(&block) ||
113
+ detect_by_timing_analysis(&block)
114
+ end
115
+
116
+ # Method 1: Execute in a test fiber and see if it yields naturally
117
+ def self.detect_by_execution_pattern(&block)
118
+ return false unless block_given?
119
+
120
+ test_fiber = Fiber.new(&block)
121
+ start_time = Time.now
122
+ yields_detected = 0
123
+
124
+ # Try to resume the fiber multiple times with short intervals
125
+ 3.times do
126
+ break unless test_fiber.alive?
127
+
128
+ begin
129
+ test_fiber.resume
130
+ yields_detected += 1 if (Time.now - start_time) < 0.01 # Quick yield
131
+ rescue FiberError, StandardError
132
+ break
133
+ end
134
+ end
135
+
136
+ yields_detected > 1 # Multiple quick yields suggest cooperative behavior
137
+ rescue
138
+ false # If anything goes wrong, assume non-cooperative
139
+ end
140
+
141
+ # Method 2: Analyze the block's source code for yield indicators
142
+ def self.detect_by_source_analysis(&block)
143
+ return false unless block_given?
144
+
145
+ source = extract_block_source(&block)
146
+ return false unless source
147
+
148
+ # Look for patterns that suggest yielding behavior
149
+ yielding_patterns = [
150
+ /\bFiber\.yield\b/, # Explicit fiber yields
151
+ /\bEM\.|EventMachine/, # EventMachine operations
152
+ /\bAsync\b/, # Async gem operations
153
+ /\.async\b/, # Async method calls
154
+ /\bawait\b/, # Await-style calls
155
+ /\bIO\.select\b/, # IO operations
156
+ /\bsocket\./i, # Socket operations
157
+ ]
158
+
159
+ blocking_patterns = [
160
+ /\bsleep\b/, # sleep() calls - actually blocking in fiber context!
161
+ /\bNet::HTTP\b/, # HTTP operations - can block
162
+ /\bwhile\s+true\b/, # Infinite loops
163
+ /\bloop\s+do\b/, # Loop blocks
164
+ /\d+\.times\s+do\b/, # Numeric iteration
165
+ /\bArray\.new\(/, # Large array creation
166
+ ]
167
+
168
+ has_yielding = yielding_patterns.any? { |pattern| source =~ pattern }
169
+ has_blocking = blocking_patterns.any? { |pattern| source =~ pattern }
170
+
171
+ has_yielding && !has_blocking
172
+ rescue
173
+ false
174
+ end
175
+
176
+ # Method 3: Time-based analysis - quick execution suggests yielding
177
+ def self.detect_by_timing_analysis(&block)
178
+ return false unless block_given?
179
+
180
+ # Test execution in a thread with very short timeout
181
+ start_time = Time.now
182
+ completed = false
183
+
184
+ test_thread = Thread.new do
185
+ begin
186
+ yield
187
+ completed = true
188
+ rescue
189
+ # Ignore errors for detection purposes
190
+ end
191
+ end
192
+
193
+ # If it completes very quickly or yields within 10ms, likely cooperative
194
+ test_thread.join(0.01)
195
+ execution_time = Time.now - start_time
196
+
197
+ test_thread.kill unless test_thread.status.nil?
198
+ test_thread.join(0.01)
199
+
200
+ # Quick completion suggests either very fast operation or yielding behavior
201
+ completed && execution_time < 0.005
202
+ rescue
203
+ false
204
+ end
205
+
206
+ private
207
+
208
+ # Extract source code from a block (Ruby 2.7+ method)
209
+ def self.extract_block_source(&block)
210
+ return nil unless block.respond_to?(:source_location)
211
+
212
+ file, line = block.source_location
213
+ return nil unless file && line && File.exist?(file)
214
+
215
+ lines = File.readlines(file)
216
+ # Simple extraction - in practice, you'd want more sophisticated parsing
217
+ lines[line - 1..line + 5].join
218
+ rescue
219
+ nil
220
+ end
221
+ end
222
+
11
223
  # The Attempt class encapsulates methods related to multiple attempts at
12
224
  # running the same method before actually failing.
13
225
  class Attempt
14
226
  # The version of the attempt library.
15
- VERSION = '0.6.3'
227
+ VERSION = '0.8.0'
16
228
 
17
229
  # Warning raised if an attempt fails before the maximum number of tries
18
230
  # has been reached.
@@ -39,6 +251,10 @@ class Attempt
39
251
  # If set, the code block is further wrapped in a timeout block.
40
252
  attr_accessor :timeout
41
253
 
254
+ # Strategy to use for timeout implementation
255
+ # Options: :auto, :custom, :thread, :process, :fiber, :ruby_timeout
256
+ attr_accessor :timeout_strategy
257
+
42
258
  # Determines which exception level to check when looking for errors to
43
259
  # retry. The default is 'Exception' (i.e. all errors).
44
260
  attr_accessor :level
@@ -49,29 +265,35 @@ class Attempt
49
265
  # Creates and returns a new +Attempt+ object. The supported keyword options
50
266
  # are as follows:
51
267
  #
52
- # * tries - The number of attempts to make before giving up. The default is 3.
53
- # * interval - The delay in seconds between each attempt. The default is 60.
268
+ # * tries - The number of attempts to make before giving up. Must be positive. The default is 3.
269
+ # * interval - The delay in seconds between each attempt. Must be non-negative. The default is 60.
54
270
  # * log - An IO handle or Logger instance where warnings/errors are logged to. The default is nil.
55
- # * increment - The amount to increment the interval between tries. The default is 0.
56
- # * level - The level of exception to be caught. The default is everything, i.e. Exception.
271
+ # * increment - The amount to increment the interval between tries. Must be non-negative. The default is 0.
272
+ # * level - The level of exception to be caught. The default is StandardError (recommended over Exception).
57
273
  # * warnings - Boolean value that indicates whether or not errors are treated as warnings
58
274
  # until the maximum number of attempts has been made. The default is true.
59
- # * timeout - Boolean value to indicate whether or not to automatically wrap your
60
- # proc in a Timeout/SafeTimeout block. The default is false.
275
+ # * timeout - Timeout in seconds to automatically wrap your proc in a Timeout block.
276
+ # Must be positive if provided. The default is nil (no timeout).
277
+ # * timeout_strategy - Strategy for timeout implementation. Options: :auto (default), :custom, :thread, :process, :fiber, :ruby_timeout
61
278
  #
62
279
  # Example:
63
280
  #
64
- # a = Attempt.new(tries: 5, increment: 10, timeout: true)
281
+ # a = Attempt.new(tries: 5, increment: 10, timeout: 30, timeout_strategy: :process)
65
282
  # a.attempt{ http.get("http://something.foo.com") }
66
283
  #
284
+ # Raises ArgumentError if any parameters are invalid.
285
+ #
67
286
  def initialize(**options)
68
- @tries = options[:tries] || 3 # Reasonable default
69
- @interval = options[:interval] || 60 # Reasonable default
70
- @log = options[:log] # Should be an IO handle, if provided
71
- @increment = options[:increment] || 0 # Should be an integer, if provided
72
- @timeout = options[:timeout] || false # Wrap the code in a timeout block if provided
73
- @level = options[:level] || Exception # Level of exception to be caught
74
- @warnings = options[:warnings] || true # Errors are sent to STDERR as warnings if true
287
+ @tries = validate_tries(options[:tries] || 3)
288
+ @interval = validate_interval(options[:interval] || 60)
289
+ @log = validate_log(options[:log])
290
+ @increment = validate_increment(options[:increment] || 0)
291
+ @timeout = validate_timeout(options[:timeout])
292
+ @timeout_strategy = options[:timeout_strategy] || :auto
293
+ @level = options[:level] || StandardError # More appropriate default than Exception
294
+ @warnings = options.fetch(:warnings, true) # More explicit than ||
295
+
296
+ freeze_configuration if options[:freeze_config]
75
297
  end
76
298
 
77
299
  # Attempt to perform the operation in the provided block up to +tries+
@@ -80,38 +302,345 @@ class Attempt
80
302
  # You will not typically use this method directly, but the Kernel#attempt
81
303
  # method instead.
82
304
  #
305
+ # Returns the result of the block if successful.
306
+ # Raises the last caught exception if all attempts fail.
307
+ #
83
308
  def attempt(&block)
84
- count = 1
309
+ raise ArgumentError, 'No block given' unless block_given?
310
+
311
+ attempts_made = 0
312
+ current_interval = @interval
313
+ max_tries = @tries
314
+
85
315
  begin
86
- if @timeout
87
- File::ALT_SEPARATOR ? Timeout.timeout(@timeout, &block) : SafeTimeout.timeout(@timeout, &block)
316
+ attempts_made += 1
317
+
318
+ result = if timeout_enabled?
319
+ execute_with_timeout(&block)
88
320
  else
89
321
  yield
90
322
  end
323
+
324
+ return result
325
+
91
326
  rescue @level => err
92
- @tries -= 1
93
- if @tries > 0
94
- msg = "Error on attempt # #{count}: #{err}; retrying"
95
- count += 1
96
- warn Warning, msg if @warnings
97
-
98
- if @log # Accept an IO or Logger object
99
- @log.respond_to?(:puts) ? @log.puts(msg) : @log.warn(msg)
100
- end
327
+ remaining_tries = max_tries - attempts_made
101
328
 
102
- @interval += @increment if @increment
103
- sleep @interval
329
+ if remaining_tries > 0
330
+ log_retry_attempt(attempts_made, err)
331
+ sleep current_interval if current_interval > 0
332
+ current_interval += @increment if @increment && @increment > 0
104
333
  retry
334
+ else
335
+ log_final_failure(attempts_made, err)
336
+ raise
337
+ end
338
+ end
339
+ end
340
+
341
+ # Returns true if this attempt instance has been configured to use timeouts
342
+ def timeout_enabled?
343
+ !@timeout.nil? && @timeout != false
344
+ end
345
+
346
+ # Returns the effective timeout value (handles both boolean and numeric values)
347
+ def effective_timeout
348
+ return nil unless timeout_enabled?
349
+ @timeout.is_a?(Numeric) ? @timeout : 10 # Default timeout if true was passed
350
+ end
351
+
352
+ # Returns a summary of the current configuration
353
+ def configuration
354
+ {
355
+ tries: @tries,
356
+ interval: @interval,
357
+ increment: @increment,
358
+ timeout: @timeout,
359
+ timeout_strategy: @timeout_strategy,
360
+ level: @level,
361
+ warnings: @warnings,
362
+ log: @log&.class&.name
363
+ }
364
+ end
365
+
366
+ private
367
+
368
+ # Execute the block with appropriate timeout mechanism
369
+ # Uses multiple strategies for better reliability
370
+ def execute_with_timeout(&block)
371
+ timeout_value = effective_timeout
372
+ return yield unless timeout_value
373
+
374
+ case @timeout_strategy
375
+ when :custom
376
+ execute_with_custom_timeout(timeout_value, &block)
377
+ when :thread
378
+ execute_with_thread_timeout(timeout_value, &block)
379
+ when :process
380
+ execute_with_process_timeout(timeout_value, &block)
381
+ when :fiber
382
+ execute_with_fiber_timeout(timeout_value, &block)
383
+ when :ruby_timeout
384
+ Timeout.timeout(timeout_value, &block)
385
+ else # :auto
386
+ execute_with_auto_timeout(timeout_value, &block)
387
+ end
388
+ end
389
+
390
+ # Automatic timeout strategy selection
391
+ def execute_with_auto_timeout(timeout_value, &block)
392
+ # Detect the optimal strategy based on the block
393
+ strategy = detect_optimal_strategy(&block)
394
+
395
+ case strategy
396
+ when :fiber
397
+ execute_with_fiber_timeout(timeout_value, &block)
398
+ when :thread
399
+ execute_with_thread_timeout(timeout_value, &block)
400
+ when :process
401
+ execute_with_process_timeout(timeout_value, &block)
402
+ else
403
+ execute_with_custom_timeout(timeout_value, &block)
404
+ end
405
+ rescue NameError, NoMethodError
406
+ # Fall back to other strategies if preferred strategy fails
407
+ execute_with_fallback_timeout(timeout_value, &block)
408
+ end
409
+
410
+ # Custom timeout using our AttemptTimeout class
411
+ def execute_with_custom_timeout(timeout_value, &block)
412
+ begin
413
+ return AttemptTimeout.timeout(timeout_value, &block)
414
+ rescue AttemptTimeout::Error => e
415
+ raise Timeout::Error, e.message # Convert to expected exception type
416
+ end
417
+ end
418
+
419
+ # Fallback timeout implementation using multiple strategies
420
+ def execute_with_fallback_timeout(timeout_value, &block)
421
+ # Strategy 2: Process-based timeout (most reliable for blocking operations)
422
+ if respond_to?(:system) && (!defined?(RUBY_ENGINE) || RUBY_ENGINE != 'jruby')
423
+ return execute_with_process_timeout(timeout_value, &block)
424
+ end
425
+
426
+ # Strategy 3: Fiber-based timeout (lightweight alternative)
427
+ begin
428
+ return execute_with_fiber_timeout(timeout_value, &block)
429
+ rescue NameError, NoMethodError
430
+ # Fiber support may not be available in all Ruby versions
431
+ end
432
+
433
+ # Strategy 4: Thread-based timeout with better error handling
434
+ return execute_with_thread_timeout(timeout_value, &block)
435
+ rescue
436
+ # Strategy 5: Last resort - use Ruby's Timeout (least reliable)
437
+ Timeout.timeout(timeout_value, &block)
438
+ end
439
+
440
+ # Process-based timeout - most reliable for I/O operations
441
+ def execute_with_process_timeout(timeout_value, &block)
442
+ reader, writer = IO.pipe
443
+
444
+ pid = fork do
445
+ reader.close
446
+ begin
447
+ result = yield
448
+ Marshal.dump(result, writer)
449
+ rescue => e
450
+ Marshal.dump({error: e}, writer)
451
+ ensure
452
+ writer.close
453
+ end
454
+ end
455
+
456
+ writer.close
457
+
458
+ if Process.waitpid(pid, Process::WNOHANG)
459
+ # Process completed immediately
460
+ result = Marshal.load(reader)
461
+ else
462
+ # Wait for timeout
463
+ if IO.select([reader], nil, nil, timeout_value)
464
+ Process.waitpid(pid)
465
+ result = Marshal.load(reader)
466
+ else
467
+ Process.kill('TERM', pid)
468
+ Process.waitpid(pid)
469
+ raise Timeout::Error, "execution expired after #{timeout_value} seconds"
470
+ end
471
+ end
472
+
473
+ reader.close
474
+
475
+ if result.is_a?(Hash) && result[:error]
476
+ raise result[:error]
477
+ end
478
+
479
+ result
480
+ rescue Errno::ECHILD, NotImplementedError
481
+ # Fork not available, fall back to thread-based
482
+ execute_with_thread_timeout(timeout_value, &block)
483
+ end
484
+
485
+ # Improved thread-based timeout
486
+ def execute_with_thread_timeout(timeout_value, &block)
487
+ result = nil
488
+ exception = nil
489
+ completed = false
490
+
491
+ thread = Thread.new do
492
+ begin
493
+ result = yield
494
+ rescue => e
495
+ exception = e
496
+ ensure
497
+ completed = true
105
498
  end
106
- raise
107
499
  end
500
+
501
+ # Wait for completion or timeout
502
+ unless thread.join(timeout_value)
503
+ thread.kill
504
+ thread.join(0.1) # Give thread time to clean up
505
+ raise Timeout::Error, "execution expired after #{timeout_value} seconds"
506
+ end
507
+
508
+ raise exception if exception
509
+ result
510
+ end
511
+
512
+ # Fiber-based timeout - lightweight alternative
513
+ def execute_with_fiber_timeout(timeout_value, &block)
514
+ begin
515
+ return AttemptTimeout.fiber_timeout(timeout_value, &block)
516
+ rescue AttemptTimeout::Error => e
517
+ raise Timeout::Error, e.message # Convert to expected exception type
518
+ end
519
+ end
520
+
521
+ # Log retry attempt information
522
+ def log_retry_attempt(attempt_number, error)
523
+ msg = "Attempt #{attempt_number} failed: #{error.class}: #{error.message}; retrying"
524
+
525
+ warn Warning, msg if @warnings
526
+ log_message(msg)
527
+ end
528
+
529
+ # Log final failure information
530
+ def log_final_failure(total_attempts, error)
531
+ msg = "All #{total_attempts} attempts failed. Final error: #{error.class}: #{error.message}"
532
+ log_message(msg)
533
+ end
534
+
535
+ # Helper method to handle logging to various output types
536
+ def log_message(message)
537
+ return unless @log
538
+
539
+ if @log.respond_to?(:warn)
540
+ @log.warn(message)
541
+ elsif @log.respond_to?(:puts)
542
+ @log.puts(message)
543
+ elsif @log.respond_to?(:write)
544
+ @log.write("#{message}\n")
545
+ end
546
+ end
547
+
548
+ # Validation methods for better error handling
549
+ def validate_tries(tries)
550
+ unless tries.is_a?(Integer) && tries > 0
551
+ raise ArgumentError, "tries must be a positive integer, got: #{tries.inspect}"
552
+ end
553
+ tries
554
+ end
555
+
556
+ def validate_interval(interval)
557
+ unless interval.is_a?(Numeric) && interval >= 0
558
+ raise ArgumentError, "interval must be a non-negative number, got: #{interval.inspect}"
559
+ end
560
+ interval
561
+ end
562
+
563
+ def validate_increment(increment)
564
+ unless increment.is_a?(Numeric) && increment >= 0
565
+ raise ArgumentError, "increment must be a non-negative number, got: #{increment.inspect}"
566
+ end
567
+ increment
568
+ end
569
+
570
+ def validate_timeout(timeout)
571
+ return nil if timeout.nil?
572
+ return false if timeout == false
573
+
574
+ unless timeout.is_a?(Numeric) && timeout > 0
575
+ raise ArgumentError, "timeout must be a positive number or nil, got: #{timeout.inspect}"
576
+ end
577
+ timeout
578
+ end
579
+
580
+ def validate_log(log)
581
+ return nil if log.nil?
582
+
583
+ unless log.respond_to?(:puts) || log.respond_to?(:warn) || log.respond_to?(:write)
584
+ raise ArgumentError, "log must respond to :puts, :warn, or :write methods"
585
+ end
586
+ log
587
+ end
588
+
589
+ def freeze_configuration
590
+ instance_variables.each { |var| instance_variable_get(var).freeze }
591
+ freeze
592
+ end
593
+
594
+ # Detect the optimal timeout strategy based on block characteristics
595
+ def detect_optimal_strategy(&block)
596
+ # Quick heuristics for strategy selection
597
+ source = extract_block_source(&block) if block.source_location
598
+
599
+ if source
600
+ # I/O operations - process strategy is most reliable
601
+ return :process if source =~ /Net::HTTP|Socket|File\.|IO\.|system|`|Process\./
602
+
603
+ # Sleep operations - thread strategy is better than fiber for blocking sleep
604
+ return :thread if source =~ /\bsleep\b/
605
+
606
+ # Event-driven code - fiber strategy works well
607
+ return :fiber if source =~ /EM\.|EventMachine|Async|\.async|Fiber\.yield/
608
+
609
+ # CPU-intensive - thread strategy
610
+ return :thread if source =~ /\d+\.times|while|loop|Array\.new\(\d+\)/
611
+ end
612
+
613
+ # Use fiber detection if available (but be conservative)
614
+ if AttemptTimeout.respond_to?(:fiber_compatible_block?) &&
615
+ AttemptTimeout.fiber_compatible_block?(&block)
616
+ return :fiber
617
+ end
618
+
619
+ # Default: custom timeout (safest general-purpose option)
620
+ :custom
621
+ end
622
+
623
+ # Extract source code from a block for analysis
624
+ def extract_block_source(&block)
625
+ return nil unless block.respond_to?(:source_location)
626
+
627
+ file, line = block.source_location
628
+ return nil unless file && line && File.exist?(file)
629
+
630
+ lines = File.readlines(file)
631
+ # Simple extraction - get a few lines around the block
632
+ start_line = [line - 1, 0].max
633
+ end_line = [line + 3, lines.length - 1].min
634
+ lines[start_line..end_line].join
635
+ rescue
636
+ nil
108
637
  end
109
638
  end
110
639
 
111
640
  # Extend the Kernel module with a simple interface for the Attempt class.
112
641
  module Kernel
113
642
  # :call-seq:
114
- # attempt(tries: 3, interval: 60, timeout: 10){ # some op }
643
+ # attempt(tries: 3, interval: 60, timeout: 10, **options){ # some op }
115
644
  #
116
645
  # Attempt to perform the operation in the provided block up to +tries+
117
646
  # times, sleeping +interval+ between each try. By default the number
@@ -126,12 +655,19 @@ module Kernel
126
655
  #
127
656
  # This is really just a convenient wrapper for Attempt.new + Attempt#attempt.
128
657
  #
658
+ # All options supported by Attempt.new are also supported here.
659
+ #
129
660
  # Example:
130
661
  #
131
662
  # # Make 3 attempts to connect to the database, 60 seconds apart.
132
663
  # attempt{ DBI.connect(dsn, user, passwd) }
133
664
  #
665
+ # # Make 5 attempts with exponential backoff
666
+ # attempt(tries: 5, interval: 1, increment: 2) { risky_operation }
667
+ #
134
668
  def attempt(**kwargs, &block)
669
+ raise ArgumentError, 'No block given' unless block_given?
670
+
135
671
  object = Attempt.new(**kwargs)
136
672
  object.attempt(&block)
137
673
  end
data/spec/attempt_spec.rb CHANGED
@@ -33,7 +33,7 @@ RSpec.describe Attempt do
33
33
  end
34
34
 
35
35
  example 'version constant is set to expected value' do
36
- expect(Attempt::VERSION).to eq('0.6.3')
36
+ expect(Attempt::VERSION).to eq('0.8.0')
37
37
  expect(Attempt::VERSION).to be_frozen
38
38
  end
39
39
 
data.tar.gz.sig CHANGED
Binary file