rspec-rewind 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -1
- data/README.md +42 -124
- data/lib/rspec/rewind/api.rb +37 -0
- data/lib/rspec/rewind/attempt_runner.rb +7 -2
- data/lib/rspec/rewind/backoff.rb +26 -2
- data/lib/rspec/rewind/configuration.rb +176 -30
- data/lib/rspec/rewind/configuration_validation.rb +75 -0
- data/lib/rspec/rewind/core.rb +150 -0
- data/lib/rspec/rewind/event.rb +67 -16
- data/lib/rspec/rewind/example_state_resetter.rb +14 -42
- data/lib/rspec/rewind/failure_fingerprint.rb +19 -0
- data/lib/rspec/rewind/flaky_reporter.rb +18 -16
- data/lib/rspec/rewind/flaky_transition.rb +20 -3
- data/lib/rspec/rewind/matcher_validation.rb +17 -4
- data/lib/rspec/rewind/retry_budget.rb +126 -3
- data/lib/rspec/rewind/retry_count_resolver.rb +26 -3
- data/lib/rspec/rewind/retry_decision.rb +128 -20
- data/lib/rspec/rewind/retry_delay_resolver.rb +57 -6
- data/lib/rspec/rewind/retry_event_builder.rb +73 -6
- data/lib/rspec/rewind/retry_gate.rb +100 -6
- data/lib/rspec/rewind/retry_loop.rb +53 -6
- data/lib/rspec/rewind/retry_notifier.rb +55 -8
- data/lib/rspec/rewind/retry_policy.rb +137 -9
- data/lib/rspec/rewind/retry_summary.rb +61 -0
- data/lib/rspec/rewind/retry_transition.rb +189 -8
- data/lib/rspec/rewind/rspec_adapter.rb +47 -0
- data/lib/rspec/rewind/runner.rb +5 -4
- data/lib/rspec/rewind/runner_component_factory.rb +16 -0
- data/lib/rspec/rewind/runner_components.rb +11 -5
- data/lib/rspec/rewind/version.rb +1 -1
- data/lib/rspec/rewind.rb +2 -65
- data/sig/rspec/rewind.rbs +290 -24
- metadata +8 -1
|
@@ -18,36 +18,38 @@ module RSpec
|
|
|
18
18
|
|
|
19
19
|
class NullReporter
|
|
20
20
|
def record(_event); end
|
|
21
|
+
|
|
22
|
+
def flush; end
|
|
23
|
+
|
|
24
|
+
def close; end
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
class JsonlReporter
|
|
28
|
+
attr_reader :path
|
|
29
|
+
|
|
24
30
|
def initialize(path)
|
|
25
31
|
@path = path
|
|
26
32
|
@mutex = Mutex.new
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
def record(event)
|
|
30
|
-
payload = {
|
|
31
|
-
schema_version: event.schema_version,
|
|
32
|
-
status: event.status,
|
|
33
|
-
retry_reason: event.retry_reason,
|
|
34
|
-
example_id: event.example_id,
|
|
35
|
-
description: event.description,
|
|
36
|
-
location: event.location,
|
|
37
|
-
attempt: event.attempt,
|
|
38
|
-
retries: event.retries,
|
|
39
|
-
exception_class: event.exception_class,
|
|
40
|
-
exception_message: event.exception_message,
|
|
41
|
-
duration: event.duration,
|
|
42
|
-
sleep_seconds: event.sleep_seconds,
|
|
43
|
-
timestamp: event.timestamp
|
|
44
|
-
}
|
|
36
|
+
payload = event.respond_to?(:to_h) ? event.to_h : {}
|
|
45
37
|
|
|
46
38
|
@mutex.synchronize do
|
|
47
39
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
48
|
-
File.open(@path, 'a')
|
|
40
|
+
File.open(@path, 'a') do |file|
|
|
41
|
+
file.flock(File::LOCK_EX)
|
|
42
|
+
file.puts(JSON.generate(payload))
|
|
43
|
+
file.flush
|
|
44
|
+
ensure
|
|
45
|
+
file&.flock(File::LOCK_UN)
|
|
46
|
+
end
|
|
49
47
|
end
|
|
50
48
|
end
|
|
49
|
+
|
|
50
|
+
def flush; end
|
|
51
|
+
|
|
52
|
+
def close; end
|
|
51
53
|
end
|
|
52
54
|
end
|
|
53
55
|
end
|
|
@@ -8,15 +8,32 @@ module RSpec
|
|
|
8
8
|
@notifier = notifier
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
def perform(
|
|
11
|
+
def perform(
|
|
12
|
+
attempt:,
|
|
13
|
+
retries:,
|
|
14
|
+
duration:,
|
|
15
|
+
exception:,
|
|
16
|
+
total_duration: nil,
|
|
17
|
+
attempt_durations: nil,
|
|
18
|
+
first_failure_duration: nil,
|
|
19
|
+
sleep_total: 0.0,
|
|
20
|
+
budget_decision: nil
|
|
21
|
+
)
|
|
12
22
|
event = @event_builder.build(
|
|
13
23
|
status: :flaky,
|
|
14
|
-
retry_reason:
|
|
24
|
+
retry_reason: :exception,
|
|
15
25
|
attempt: attempt,
|
|
16
26
|
retries: retries,
|
|
17
27
|
duration: duration,
|
|
28
|
+
total_duration: total_duration,
|
|
29
|
+
attempt_durations: attempt_durations,
|
|
30
|
+
first_failure_duration: first_failure_duration,
|
|
18
31
|
sleep_seconds: 0.0,
|
|
19
|
-
|
|
32
|
+
scheduled_sleep_seconds: 0.0,
|
|
33
|
+
actual_sleep_seconds: 0.0,
|
|
34
|
+
sleep_total: sleep_total,
|
|
35
|
+
budget_decision: budget_decision,
|
|
36
|
+
exception: exception
|
|
20
37
|
)
|
|
21
38
|
|
|
22
39
|
@notifier.publish_flaky(event)
|
|
@@ -5,14 +5,27 @@ module RSpec
|
|
|
5
5
|
module MatcherValidation
|
|
6
6
|
private
|
|
7
7
|
|
|
8
|
-
def normalize_matchers(values, field:)
|
|
8
|
+
def normalize_matchers(values, field:, strict_exception_matchers: false)
|
|
9
9
|
matchers = Array(values).flatten.compact
|
|
10
|
-
matchers.each
|
|
10
|
+
matchers.each do |matcher|
|
|
11
|
+
validate_matcher!(
|
|
12
|
+
matcher,
|
|
13
|
+
field: field,
|
|
14
|
+
strict_exception_matchers: strict_exception_matchers
|
|
15
|
+
)
|
|
16
|
+
end
|
|
11
17
|
matchers
|
|
12
18
|
end
|
|
13
19
|
|
|
14
|
-
def validate_matcher!(matcher, field:)
|
|
15
|
-
|
|
20
|
+
def validate_matcher!(matcher, field:, strict_exception_matchers:)
|
|
21
|
+
if matcher.is_a?(Module)
|
|
22
|
+
return unless strict_exception_matchers
|
|
23
|
+
return if matcher.is_a?(Class) && matcher <= Exception
|
|
24
|
+
|
|
25
|
+
raise ArgumentError,
|
|
26
|
+
"#{field} Module entries must be Exception classes when strict matcher validation is enabled"
|
|
27
|
+
end
|
|
28
|
+
return if matcher.is_a?(Regexp) || matcher.respond_to?(:call)
|
|
16
29
|
|
|
17
30
|
raise ArgumentError, "#{field} entries must be Module, Regexp, or callable"
|
|
18
31
|
end
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
3
5
|
module RSpec
|
|
4
6
|
module Rewind
|
|
7
|
+
BudgetDecision = Struct.new(
|
|
8
|
+
:allowed,
|
|
9
|
+
:limit,
|
|
10
|
+
:used,
|
|
11
|
+
:remaining,
|
|
12
|
+
keyword_init: true
|
|
13
|
+
) do
|
|
14
|
+
def allowed?
|
|
15
|
+
allowed
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
5
19
|
class RetryBudget
|
|
6
20
|
attr_reader :limit, :used
|
|
7
21
|
|
|
@@ -12,13 +26,17 @@ module RSpec
|
|
|
12
26
|
end
|
|
13
27
|
|
|
14
28
|
def consume!
|
|
15
|
-
|
|
29
|
+
consume.allowed?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def consume
|
|
33
|
+
return decision(true) if unlimited?
|
|
16
34
|
|
|
17
35
|
@mutex.synchronize do
|
|
18
|
-
return false if @used >= @limit
|
|
36
|
+
return decision(false) if @used >= @limit
|
|
19
37
|
|
|
20
38
|
@used += 1
|
|
21
|
-
true
|
|
39
|
+
decision(true)
|
|
22
40
|
end
|
|
23
41
|
end
|
|
24
42
|
|
|
@@ -32,8 +50,21 @@ module RSpec
|
|
|
32
50
|
@limit.nil?
|
|
33
51
|
end
|
|
34
52
|
|
|
53
|
+
def reset!
|
|
54
|
+
@mutex.synchronize { @used = 0 }
|
|
55
|
+
end
|
|
56
|
+
|
|
35
57
|
private
|
|
36
58
|
|
|
59
|
+
def decision(allowed)
|
|
60
|
+
BudgetDecision.new(
|
|
61
|
+
allowed: allowed,
|
|
62
|
+
limit: @limit,
|
|
63
|
+
used: @used,
|
|
64
|
+
remaining: remaining
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
37
68
|
def normalize_limit(limit)
|
|
38
69
|
return nil if limit.nil?
|
|
39
70
|
|
|
@@ -48,5 +79,97 @@ module RSpec
|
|
|
48
79
|
parsed
|
|
49
80
|
end
|
|
50
81
|
end
|
|
82
|
+
|
|
83
|
+
class FileRetryBudget
|
|
84
|
+
attr_reader :limit, :path
|
|
85
|
+
|
|
86
|
+
def initialize(limit:, path:)
|
|
87
|
+
@limit = normalize_limit(limit)
|
|
88
|
+
@path = path
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def consume!
|
|
92
|
+
consume.allowed?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def consume
|
|
96
|
+
with_locked_counter do |used, file|
|
|
97
|
+
return decision(false, used) if used >= @limit
|
|
98
|
+
|
|
99
|
+
used += 1
|
|
100
|
+
write_used(file, used)
|
|
101
|
+
decision(true, used)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def used
|
|
106
|
+
with_locked_counter { |current| current }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def remaining
|
|
110
|
+
[@limit - used, 0].max
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def unlimited?
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def reset!
|
|
118
|
+
with_lock { |file| write_used(file, 0) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def with_locked_counter
|
|
124
|
+
with_lock do |file|
|
|
125
|
+
current = read_used(file)
|
|
126
|
+
yield current, file
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def with_lock
|
|
131
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
132
|
+
File.open(@path, File::RDWR | File::CREAT, 0o644) do |file|
|
|
133
|
+
file.flock(File::LOCK_EX)
|
|
134
|
+
yield file
|
|
135
|
+
ensure
|
|
136
|
+
file&.flock(File::LOCK_UN)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def read_used(file)
|
|
141
|
+
file.rewind
|
|
142
|
+
text = file.read
|
|
143
|
+
Integer(text.empty? ? '0' : text)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def write_used(file, value)
|
|
147
|
+
file.rewind
|
|
148
|
+
file.truncate(0)
|
|
149
|
+
file.write(value.to_s)
|
|
150
|
+
file.flush
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def decision(allowed, used)
|
|
154
|
+
BudgetDecision.new(
|
|
155
|
+
allowed: allowed,
|
|
156
|
+
limit: @limit,
|
|
157
|
+
used: used,
|
|
158
|
+
remaining: [@limit - used, 0].max
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def normalize_limit(limit)
|
|
163
|
+
parsed = begin
|
|
164
|
+
Integer(limit)
|
|
165
|
+
rescue TypeError, ArgumentError
|
|
166
|
+
raise ArgumentError, 'file retry budget must be a non-negative integer'
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
raise ArgumentError, 'file retry budget must be >= 0' if parsed.negative?
|
|
170
|
+
|
|
171
|
+
parsed
|
|
172
|
+
end
|
|
173
|
+
end
|
|
51
174
|
end
|
|
52
175
|
end
|
|
@@ -4,6 +4,7 @@ module RSpec
|
|
|
4
4
|
module Rewind
|
|
5
5
|
class RetryCountResolver
|
|
6
6
|
ENV_RETRIES_KEY = 'RSPEC_REWIND_RETRIES'
|
|
7
|
+
ENV_DISABLE_KEY = 'RSPEC_REWIND_DISABLE'
|
|
7
8
|
|
|
8
9
|
def initialize(configuration:, metadata:)
|
|
9
10
|
@configuration = configuration
|
|
@@ -11,8 +12,11 @@ module RSpec
|
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def resolve(explicit_retries:)
|
|
14
|
-
|
|
15
|
-
return
|
|
15
|
+
return 0 if normalize_retry_override(explicit_retries) == 0 # rubocop:disable Style/NumericPredicate
|
|
16
|
+
return 0 if env_disabled?
|
|
17
|
+
|
|
18
|
+
env_retries = env_retries_value
|
|
19
|
+
return capped(parse_non_negative_integer(env_retries, source: ENV_RETRIES_KEY)) if env_retries
|
|
16
20
|
|
|
17
21
|
configured = first_non_nil(
|
|
18
22
|
normalize_retry_override(explicit_retries),
|
|
@@ -20,7 +24,7 @@ module RSpec
|
|
|
20
24
|
@configuration.default_retries
|
|
21
25
|
)
|
|
22
26
|
|
|
23
|
-
parse_non_negative_integer(configured, source: 'retries')
|
|
27
|
+
capped(parse_non_negative_integer(configured, source: 'retries'))
|
|
24
28
|
end
|
|
25
29
|
|
|
26
30
|
private
|
|
@@ -46,6 +50,25 @@ module RSpec
|
|
|
46
50
|
parsed
|
|
47
51
|
end
|
|
48
52
|
|
|
53
|
+
def env_retries_value
|
|
54
|
+
value = ENV.fetch(ENV_RETRIES_KEY, nil)
|
|
55
|
+
return nil if value.nil? || value.to_s.empty?
|
|
56
|
+
|
|
57
|
+
value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def env_disabled?
|
|
61
|
+
value = ENV.fetch(ENV_DISABLE_KEY, nil)
|
|
62
|
+
%w[1 true yes on].include?(value.to_s.downcase)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def capped(value)
|
|
66
|
+
max_retries = @configuration.max_retries
|
|
67
|
+
return value if max_retries.nil? || value <= max_retries
|
|
68
|
+
|
|
69
|
+
raise ArgumentError, "retries must be <= #{max_retries}"
|
|
70
|
+
end
|
|
71
|
+
|
|
49
72
|
def first_non_nil(*values)
|
|
50
73
|
values.find { |value| !value.nil? }
|
|
51
74
|
end
|
|
@@ -2,43 +2,132 @@
|
|
|
2
2
|
|
|
3
3
|
module RSpec
|
|
4
4
|
module Rewind
|
|
5
|
+
RetryDecisionResult = Struct.new(
|
|
6
|
+
:allowed,
|
|
7
|
+
:reason,
|
|
8
|
+
:matched_retry_on,
|
|
9
|
+
:matched_skip_retry_on,
|
|
10
|
+
:matcher_error,
|
|
11
|
+
keyword_init: true
|
|
12
|
+
) do
|
|
13
|
+
def allowed?
|
|
14
|
+
allowed
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
RetryContext = Struct.new(
|
|
19
|
+
:attempt,
|
|
20
|
+
:retries,
|
|
21
|
+
:metadata,
|
|
22
|
+
:budget_remaining,
|
|
23
|
+
:failure_fingerprint,
|
|
24
|
+
:elapsed_time,
|
|
25
|
+
:sleep_total,
|
|
26
|
+
keyword_init: true
|
|
27
|
+
)
|
|
28
|
+
|
|
5
29
|
class RetryDecision
|
|
6
|
-
def initialize(
|
|
30
|
+
def initialize(
|
|
31
|
+
exception:,
|
|
32
|
+
example:,
|
|
33
|
+
retry_on:,
|
|
34
|
+
skip_retry_on:,
|
|
35
|
+
retry_if:,
|
|
36
|
+
retry_on_default: :all,
|
|
37
|
+
context: nil,
|
|
38
|
+
strict_callable_arity: false
|
|
39
|
+
)
|
|
7
40
|
@exception = exception
|
|
8
41
|
@example = example
|
|
9
42
|
@retry_on = normalize_matchers(retry_on)
|
|
10
43
|
@skip_retry_on = normalize_matchers(skip_retry_on)
|
|
11
44
|
@retry_if = retry_if
|
|
45
|
+
@retry_on_default = retry_on_default
|
|
46
|
+
@context = context
|
|
47
|
+
@strict_callable_arity = strict_callable_arity
|
|
12
48
|
end
|
|
13
49
|
|
|
14
50
|
def retry?
|
|
15
|
-
|
|
16
|
-
|
|
51
|
+
decision.allowed?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def decision
|
|
55
|
+
return rejected(:no_exception) unless @exception
|
|
56
|
+
|
|
57
|
+
skip_match = find_match(@skip_retry_on)
|
|
58
|
+
if skip_match.matched?
|
|
59
|
+
return rejected(
|
|
60
|
+
:skip_retry_on_matched,
|
|
61
|
+
matched_skip_retry_on: skip_match.description,
|
|
62
|
+
matcher_error: skip_match.error
|
|
63
|
+
)
|
|
64
|
+
end
|
|
17
65
|
|
|
18
|
-
|
|
66
|
+
retry_match = nil
|
|
67
|
+
if @retry_on.any?
|
|
68
|
+
retry_match = find_match(@retry_on)
|
|
69
|
+
unless retry_match.matched?
|
|
70
|
+
return rejected(
|
|
71
|
+
:retry_on_not_matched,
|
|
72
|
+
matcher_error: retry_match.error
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
elsif !retry_on_default_allowed?
|
|
76
|
+
return rejected(:retry_on_default_rejected)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if @retry_if && !call_with_context(@retry_if)
|
|
80
|
+
return rejected(
|
|
81
|
+
:predicate_rejected,
|
|
82
|
+
matched_retry_on: retry_match&.description
|
|
83
|
+
)
|
|
84
|
+
end
|
|
19
85
|
|
|
20
|
-
|
|
86
|
+
allowed(matched_retry_on: retry_match&.description)
|
|
87
|
+
end
|
|
21
88
|
|
|
22
|
-
|
|
89
|
+
MatchResult = Struct.new(:matched, :description, :error, keyword_init: true) do
|
|
90
|
+
def matched?
|
|
91
|
+
matched
|
|
92
|
+
end
|
|
23
93
|
end
|
|
24
94
|
|
|
25
95
|
private
|
|
26
96
|
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
-
end
|
|
97
|
+
def find_match(matchers)
|
|
98
|
+
last_error = nil
|
|
30
99
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
matcher.match?(@exception.message.to_s)
|
|
37
|
-
else
|
|
38
|
-
matcher.respond_to?(:call) && !!call_with_context(matcher)
|
|
100
|
+
matchers.each do |matcher|
|
|
101
|
+
result = match(matcher)
|
|
102
|
+
return result if result.matched?
|
|
103
|
+
|
|
104
|
+
last_error ||= result.error
|
|
39
105
|
end
|
|
40
|
-
|
|
41
|
-
false
|
|
106
|
+
|
|
107
|
+
MatchResult.new(matched: false, error: last_error)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def match(matcher)
|
|
111
|
+
matched =
|
|
112
|
+
case matcher
|
|
113
|
+
when Module
|
|
114
|
+
@exception.is_a?(matcher)
|
|
115
|
+
when Regexp
|
|
116
|
+
matcher.match?(@exception.message.to_s)
|
|
117
|
+
else
|
|
118
|
+
matcher.respond_to?(:call) && !call_with_context(matcher).nil?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
MatchResult.new(matched: matched, description: matcher_description(matcher))
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
MatchResult.new(matched: false, description: matcher_description(matcher), error: "#{e.class}: #{e.message}")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def retry_on_default_allowed?
|
|
127
|
+
return @exception.is_a?(StandardError) if @retry_on_default == :standard_errors
|
|
128
|
+
return false if @retry_on_default == :none
|
|
129
|
+
|
|
130
|
+
true
|
|
42
131
|
end
|
|
43
132
|
|
|
44
133
|
def call_with_context(callable)
|
|
@@ -46,7 +135,11 @@ module RSpec
|
|
|
46
135
|
return callable.call if arity.zero?
|
|
47
136
|
|
|
48
137
|
required = arity.negative? ? (-arity - 1) : arity
|
|
49
|
-
args = [@exception, @example]
|
|
138
|
+
args = [@exception, @example, @context]
|
|
139
|
+
if @strict_callable_arity && arity.positive? && required > args.length
|
|
140
|
+
raise ArgumentError, "callable accepts #{required} required arguments; maximum supported is #{args.length}"
|
|
141
|
+
end
|
|
142
|
+
|
|
50
143
|
args << nil while args.length < required
|
|
51
144
|
|
|
52
145
|
if arity.positive?
|
|
@@ -65,6 +158,21 @@ module RSpec
|
|
|
65
158
|
def normalize_matchers(values)
|
|
66
159
|
Array(values).flatten.compact
|
|
67
160
|
end
|
|
161
|
+
|
|
162
|
+
def allowed(**attributes)
|
|
163
|
+
RetryDecisionResult.new(allowed: true, reason: :allowed, **attributes)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def rejected(reason, **attributes)
|
|
167
|
+
RetryDecisionResult.new(allowed: false, reason: reason, **attributes)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def matcher_description(matcher)
|
|
171
|
+
return matcher.description if matcher.respond_to?(:description)
|
|
172
|
+
return matcher.name if matcher.respond_to?(:name) && matcher.name
|
|
173
|
+
|
|
174
|
+
matcher.inspect
|
|
175
|
+
end
|
|
68
176
|
end
|
|
69
177
|
end
|
|
70
178
|
end
|
|
@@ -3,26 +3,60 @@
|
|
|
3
3
|
module RSpec
|
|
4
4
|
module Rewind
|
|
5
5
|
class RetryDelayResolver
|
|
6
|
-
def initialize(configuration:, metadata:, example:)
|
|
6
|
+
def initialize(configuration:, metadata:, example:, warn: ->(_message) {})
|
|
7
7
|
@configuration = configuration
|
|
8
8
|
@metadata = metadata || {}
|
|
9
9
|
@example = example
|
|
10
|
+
@warn = warn
|
|
11
|
+
@delay_conflict_warned = false
|
|
10
12
|
end
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
DelayContext = Struct.new(
|
|
15
|
+
:retry_number,
|
|
16
|
+
:resolved_retries,
|
|
17
|
+
:metadata,
|
|
18
|
+
:previous_sleep_seconds,
|
|
19
|
+
:failure_fingerprint,
|
|
20
|
+
keyword_init: true
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def resolve(
|
|
24
|
+
retry_number:,
|
|
25
|
+
backoff:,
|
|
26
|
+
wait:,
|
|
27
|
+
exception:,
|
|
28
|
+
resolved_retries: nil,
|
|
29
|
+
previous_sleep_seconds: 0.0,
|
|
30
|
+
failure_fingerprint: nil
|
|
31
|
+
)
|
|
13
32
|
explicit_wait = first_non_nil(wait, @metadata[:rewind_wait])
|
|
14
|
-
|
|
33
|
+
warn_delay_conflict(wait: wait, backoff: backoff)
|
|
34
|
+
return normalize_delay(explicit_wait) unless explicit_wait.nil?
|
|
15
35
|
|
|
16
36
|
strategy = first_non_nil(backoff, @metadata[:rewind_backoff], @configuration.backoff)
|
|
17
37
|
return normalize_delay(strategy) if strategy.is_a?(Numeric)
|
|
18
38
|
|
|
19
|
-
|
|
39
|
+
unless strategy.respond_to?(:call)
|
|
40
|
+
raise ArgumentError,
|
|
41
|
+
'backoff must be a non-negative numeric value or callable'
|
|
42
|
+
end
|
|
20
43
|
|
|
21
|
-
|
|
44
|
+
args = {
|
|
22
45
|
retry_number: retry_number,
|
|
23
46
|
example: @example,
|
|
24
47
|
exception: exception
|
|
25
|
-
|
|
48
|
+
}
|
|
49
|
+
if accepts_keyword?(strategy, :context)
|
|
50
|
+
args[:context] = DelayContext.new(
|
|
51
|
+
retry_number: retry_number,
|
|
52
|
+
resolved_retries: resolved_retries,
|
|
53
|
+
metadata: @metadata,
|
|
54
|
+
previous_sleep_seconds: previous_sleep_seconds,
|
|
55
|
+
failure_fingerprint: failure_fingerprint
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
raw = strategy.call(**args)
|
|
26
60
|
|
|
27
61
|
normalize_delay(raw)
|
|
28
62
|
end
|
|
@@ -44,6 +78,23 @@ module RSpec
|
|
|
44
78
|
def first_non_nil(*values)
|
|
45
79
|
values.find { |value| !value.nil? }
|
|
46
80
|
end
|
|
81
|
+
|
|
82
|
+
def warn_delay_conflict(wait:, backoff:)
|
|
83
|
+
return unless @configuration.warn_on_delay_conflict
|
|
84
|
+
return if @delay_conflict_warned
|
|
85
|
+
return unless !first_non_nil(wait, @metadata[:rewind_wait]).nil? &&
|
|
86
|
+
!first_non_nil(backoff, @metadata[:rewind_backoff]).nil?
|
|
87
|
+
|
|
88
|
+
@delay_conflict_warned = true
|
|
89
|
+
@warn.call('[rspec-rewind] wait and backoff are both configured; wait takes precedence')
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def accepts_keyword?(callable, keyword)
|
|
93
|
+
parameters = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
|
|
94
|
+
parameters.any? do |type, name|
|
|
95
|
+
type == :keyrest || (%i[key keyreq].include?(type) && name == keyword)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
47
98
|
end
|
|
48
99
|
end
|
|
49
100
|
end
|