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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGES.md +15 -0
- data/README.md +1 -1
- data/attempt.gemspec +3 -3
- data/doc/ENHANCED_DETECTION_SUMMARY.md +86 -0
- data/doc/FIBER_TIMEOUT_ADDITION.md +65 -0
- data/doc/IMPROVEMENTS.md +64 -0
- data/doc/TIMEOUT_STRATEGIES.md +178 -0
- data/examples/test_enhanced_detection.rb +111 -0
- data/examples/test_fiber_timeout.rb +92 -0
- data/examples/test_improvements.rb +65 -0
- data/examples/test_timeout_strategies.rb +57 -0
- data/lib/attempt.rb +573 -37
- data/spec/attempt_spec.rb +1 -1
- data.tar.gz.sig +0 -0
- metadata +12 -17
- metadata.gz.sig +0 -0
data/lib/attempt.rb
CHANGED
@@ -1,18 +1,230 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
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.
|
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
|
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 -
|
60
|
-
#
|
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:
|
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
|
69
|
-
@interval = options[:interval] || 60
|
70
|
-
@log = options[:log]
|
71
|
-
@increment = options[:increment] || 0
|
72
|
-
@timeout = options[:timeout]
|
73
|
-
@
|
74
|
-
@
|
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
|
-
|
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
|
-
|
87
|
-
|
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
|
-
|
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
|
-
|
103
|
-
|
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
data.tar.gz.sig
CHANGED
Binary file
|