class Attempt
The Attempt
class encapsulates methods related to multiple attempts at running the same method before actually failing.
Constants
- VERSION
-
The version of the attempt library.
Attributes
If set, this increments the interval with each failed attempt by that number of seconds.
Number of seconds to wait between attempts. The default is 60.
Determines which exception level to check when looking for errors to retry. The default is ‘Exception’ (i.e. all errors).
If you provide an IO handle to this option then errors that would have been raised are sent to that handle.
If set, the code block is further wrapped in a timeout block.
Strategy to use for timeout implementation Options: :auto, :custom, :thread, :process, :fiber, :ruby_timeout
Number of attempts to make before failing. The default is 3.
A boolean value that determines whether errors that would have been raised should be sent to STDERR as warnings. The default is true.
Public Class Methods
Source
# File lib/attempt.rb, line 179 def initialize(**options) @tries = validate_tries(options[:tries] || 3) @interval = validate_interval(options[:interval] || 60) @log = validate_log(options[:log]) @increment = validate_increment(options[:increment] || 0) @timeout = validate_timeout(options[:timeout]) @timeout_strategy = options[:timeout_strategy] || :auto @level = options[:level] || StandardError # More appropriate default than Exception @warnings = options.fetch(:warnings, true) # More explicit than || freeze_configuration if options[:freeze_config] end
Creates and returns a new Attempt
object. The supported keyword options are as follows:
-
tries - The number of attempts to make before giving up. Must be positive. The default is 3.
-
interval - The delay in seconds between each attempt. Must be non-negative. The default is 60.
-
log - An IO handle or Logger instance where warnings/errors are logged to. The default is nil.
-
increment - The amount to increment the interval between tries. Must be non-negative. The default is 0.
-
level - The level of exception to be caught. The default is StandardError (recommended over Exception).
-
warnings - Boolean value that indicates whether or not errors are treated as warnings
until the maximum number of attempts has been made. The default is true.
-
timeout - Timeout in seconds to automatically wrap your proc in a Timeout block.
Must be positive if provided. The default is nil (no timeout).
-
timeout_strategy
- Strategy for timeout implementation. Options: :auto (default), :custom, :thread, :process, :fiber, :ruby_timeout
Example:
a = Attempt.new(tries: 5, increment: 10, timeout: 30, timeout_strategy: :process) a.attempt{ http.get("http://something.foo.com") }
Raises ArgumentError if any parameters are invalid.
Public Instance Methods
Source
# File lib/attempt.rb, line 201 def attempt(&block) raise ArgumentError, 'No block given' unless block_given? attempts_made = 0 current_interval = @interval max_tries = @tries begin attempts_made += 1 result = if timeout_enabled? execute_with_timeout(&block) else yield end return result rescue @level => err remaining_tries = max_tries - attempts_made if remaining_tries > 0 log_retry_attempt(attempts_made, err) sleep current_interval if current_interval > 0 current_interval += @increment if @increment && @increment > 0 retry else log_final_failure(attempts_made, err) raise end end end
Attempt
to perform the operation in the provided block up to tries
times, sleeping interval
between each try.
You will not typically use this method directly, but the Kernel#attempt
method instead.
Returns the result of the block if successful. Raises the last caught exception if all attempts fail.
Source
# File lib/attempt.rb, line 246 def configuration { tries: @tries, interval: @interval, increment: @increment, timeout: @timeout, timeout_strategy: @timeout_strategy, level: @level, warnings: @warnings, log: @log&.class&.name } end
Returns a summary of the current configuration
Source
# File lib/attempt.rb, line 240 def effective_timeout return nil unless timeout_enabled? @timeout.is_a?(Numeric) ? @timeout : 10 # Default timeout if true was passed end
Returns the effective timeout value (handles both boolean and numeric values)
Source
# File lib/attempt.rb, line 235 def timeout_enabled? !@timeout.nil? && @timeout != false end
Returns true if this attempt instance has been configured to use timeouts
Private Instance Methods
Source
# File lib/attempt.rb, line 284 def execute_with_auto_timeout(timeout_value, &block) # Try custom timeout first (most reliable) begin return execute_with_custom_timeout(timeout_value, &block) rescue NameError, NoMethodError # Fall back to other strategies execute_with_fallback_timeout(timeout_value, &block) end end
Automatic timeout strategy selection
Source
# File lib/attempt.rb, line 295 def execute_with_custom_timeout(timeout_value, &block) begin return AttemptTimeout.timeout(timeout_value, &block) rescue AttemptTimeout::Error => e raise Timeout::Error, e.message # Convert to expected exception type end end
Custom timeout using our AttemptTimeout
class
Source
# File lib/attempt.rb, line 304 def execute_with_fallback_timeout(timeout_value, &block) # Strategy 2: Process-based timeout (most reliable for blocking operations) if respond_to?(:system) && (!defined?(RUBY_ENGINE) || RUBY_ENGINE != 'jruby') return execute_with_process_timeout(timeout_value, &block) end # Strategy 3: Fiber-based timeout (lightweight alternative) begin return execute_with_fiber_timeout(timeout_value, &block) rescue NameError, NoMethodError # Fiber support may not be available in all Ruby versions end # Strategy 4: Thread-based timeout with better error handling return execute_with_thread_timeout(timeout_value, &block) rescue # Strategy 5: Last resort - use Ruby's Timeout (least reliable) Timeout.timeout(timeout_value, &block) end
Fallback timeout implementation using multiple strategies
Source
# File lib/attempt.rb, line 397 def execute_with_fiber_timeout(timeout_value, &block) begin return AttemptTimeout.fiber_timeout(timeout_value, &block) rescue AttemptTimeout::Error => e raise Timeout::Error, e.message # Convert to expected exception type end end
Fiber-based timeout - lightweight alternative
Source
# File lib/attempt.rb, line 325 def execute_with_process_timeout(timeout_value, &block) reader, writer = IO.pipe pid = fork do reader.close begin result = yield Marshal.dump(result, writer) rescue => e Marshal.dump({error: e}, writer) ensure writer.close end end writer.close if Process.waitpid(pid, Process::WNOHANG) # Process completed immediately result = Marshal.load(reader) else # Wait for timeout if IO.select([reader], nil, nil, timeout_value) Process.waitpid(pid) result = Marshal.load(reader) else Process.kill('TERM', pid) Process.waitpid(pid) raise Timeout::Error, "execution expired after #{timeout_value} seconds" end end reader.close if result.is_a?(Hash) && result[:error] raise result[:error] end result rescue Errno::ECHILD, NotImplementedError # Fork not available, fall back to thread-based execute_with_thread_timeout(timeout_value, &block) end
Process-based timeout - most reliable for I/O operations
Source
# File lib/attempt.rb, line 370 def execute_with_thread_timeout(timeout_value, &block) result = nil exception = nil completed = false thread = Thread.new do begin result = yield rescue => e exception = e ensure completed = true end end # Wait for completion or timeout unless thread.join(timeout_value) thread.kill thread.join(0.1) # Give thread time to clean up raise Timeout::Error, "execution expired after #{timeout_value} seconds" end raise exception if exception result end
Improved thread-based timeout
Source
# File lib/attempt.rb, line 263 def execute_with_timeout(&block) timeout_value = effective_timeout return yield unless timeout_value case @timeout_strategy when :custom execute_with_custom_timeout(timeout_value, &block) when :thread execute_with_thread_timeout(timeout_value, &block) when :process execute_with_process_timeout(timeout_value, &block) when :fiber execute_with_fiber_timeout(timeout_value, &block) when :ruby_timeout Timeout.timeout(timeout_value, &block) else # :auto execute_with_auto_timeout(timeout_value, &block) end end
Execute the block with appropriate timeout mechanism Uses multiple strategies for better reliability
Source
# File lib/attempt.rb, line 473 def freeze_configuration instance_variables.each { |var| instance_variable_get(var).freeze } freeze end
Source
# File lib/attempt.rb, line 414 def log_final_failure(total_attempts, error) msg = "All #{total_attempts} attempts failed. Final error: #{error.class}: #{error.message}" log_message(msg) end
Log final failure information
Source
# File lib/attempt.rb, line 420 def log_message(message) return unless @log if @log.respond_to?(:warn) @log.warn(message) elsif @log.respond_to?(:puts) @log.puts(message) elsif @log.respond_to?(:write) @log.write("#{message}\n") end end
Helper method to handle logging to various output types
Source
# File lib/attempt.rb, line 406 def log_retry_attempt(attempt_number, error) msg = "Attempt #{attempt_number} failed: #{error.class}: #{error.message}; retrying" warn Warning, msg if @warnings log_message(msg) end
Log retry attempt information
Source
# File lib/attempt.rb, line 447 def validate_increment(increment) unless increment.is_a?(Numeric) && increment >= 0 raise ArgumentError, "increment must be a non-negative number, got: #{increment.inspect}" end increment end
Source
# File lib/attempt.rb, line 440 def validate_interval(interval) unless interval.is_a?(Numeric) && interval >= 0 raise ArgumentError, "interval must be a non-negative number, got: #{interval.inspect}" end interval end
Source
# File lib/attempt.rb, line 464 def validate_log(log) return nil if log.nil? unless log.respond_to?(:puts) || log.respond_to?(:warn) || log.respond_to?(:write) raise ArgumentError, "log must respond to :puts, :warn, or :write methods" end log end
Source
# File lib/attempt.rb, line 454 def validate_timeout(timeout) return nil if timeout.nil? return false if timeout == false unless timeout.is_a?(Numeric) && timeout > 0 raise ArgumentError, "timeout must be a positive number or nil, got: #{timeout.inspect}" end timeout end
Source
# File lib/attempt.rb, line 433 def validate_tries(tries) unless tries.is_a?(Integer) && tries > 0 raise ArgumentError, "tries must be a positive integer, got: #{tries.inspect}" end tries end
Validation methods for better error handling