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
|
@@ -3,27 +3,94 @@
|
|
|
3
3
|
module RSpec
|
|
4
4
|
module Rewind
|
|
5
5
|
class RetryEventBuilder
|
|
6
|
-
def initialize(example_source:)
|
|
6
|
+
def initialize(example_source:, metadata_keys: [])
|
|
7
7
|
@example_source = example_source
|
|
8
|
+
@metadata_keys = Array(metadata_keys).map(&:to_sym)
|
|
8
9
|
end
|
|
9
10
|
|
|
10
|
-
def build(
|
|
11
|
+
def build(
|
|
12
|
+
status:,
|
|
13
|
+
retry_reason:,
|
|
14
|
+
attempt:,
|
|
15
|
+
retries:,
|
|
16
|
+
duration:,
|
|
17
|
+
sleep_seconds:,
|
|
18
|
+
exception:,
|
|
19
|
+
decision_reason: nil,
|
|
20
|
+
total_duration: nil,
|
|
21
|
+
attempt_durations: nil,
|
|
22
|
+
first_failure_duration: nil,
|
|
23
|
+
scheduled_sleep_seconds: sleep_seconds,
|
|
24
|
+
actual_sleep_seconds: nil,
|
|
25
|
+
sleep_total: nil,
|
|
26
|
+
budget_decision: nil,
|
|
27
|
+
matched_retry_on: nil,
|
|
28
|
+
matched_skip_retry_on: nil,
|
|
29
|
+
matcher_error: nil
|
|
30
|
+
)
|
|
11
31
|
Event.new(
|
|
12
32
|
schema_version: EVENT_SCHEMA_VERSION,
|
|
13
33
|
status: status,
|
|
14
34
|
retry_reason: retry_reason,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
35
|
+
decision_reason: decision_reason,
|
|
36
|
+
example_id: safe_read(:id, 'unknown'),
|
|
37
|
+
description: safe_read(:full_description, 'unknown'),
|
|
38
|
+
location: safe_read(:location, 'unknown'),
|
|
18
39
|
attempt: attempt,
|
|
19
40
|
retries: retries,
|
|
41
|
+
max_attempts: retries.to_i + 1,
|
|
20
42
|
exception_class: exception&.class&.name,
|
|
21
43
|
exception_message: exception&.message,
|
|
44
|
+
exception_backtrace_top: exception&.backtrace&.first,
|
|
45
|
+
failure_fingerprint: FailureFingerprint.build(exception),
|
|
22
46
|
duration: duration,
|
|
47
|
+
total_duration: total_duration,
|
|
48
|
+
attempt_durations: attempt_durations,
|
|
49
|
+
first_failure_duration: first_failure_duration,
|
|
23
50
|
sleep_seconds: sleep_seconds,
|
|
24
|
-
|
|
51
|
+
scheduled_sleep_seconds: scheduled_sleep_seconds,
|
|
52
|
+
actual_sleep_seconds: actual_sleep_seconds,
|
|
53
|
+
sleep_total: sleep_total,
|
|
54
|
+
timestamp: Time.now.utc.iso8601,
|
|
55
|
+
budget_limit: read_decision_value(budget_decision, :limit),
|
|
56
|
+
budget_used: read_decision_value(budget_decision, :used),
|
|
57
|
+
budget_remaining: read_decision_value(budget_decision, :remaining),
|
|
58
|
+
matched_retry_on: matched_retry_on,
|
|
59
|
+
matched_skip_retry_on: matched_skip_retry_on,
|
|
60
|
+
matcher_error: matcher_error,
|
|
61
|
+
metadata: filtered_metadata
|
|
25
62
|
)
|
|
26
63
|
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def safe_read(method_name, fallback)
|
|
68
|
+
return fallback unless @example_source.respond_to?(method_name)
|
|
69
|
+
|
|
70
|
+
value = @example_source.public_send(method_name)
|
|
71
|
+
value.nil? ? fallback : value
|
|
72
|
+
rescue StandardError
|
|
73
|
+
fallback
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def filtered_metadata
|
|
77
|
+
return nil if @metadata_keys.empty?
|
|
78
|
+
return {} unless @example_source.respond_to?(:metadata)
|
|
79
|
+
|
|
80
|
+
metadata = @example_source.metadata || {}
|
|
81
|
+
@metadata_keys.each_with_object({}) do |key, selected|
|
|
82
|
+
selected[key] = metadata[key] if metadata.key?(key)
|
|
83
|
+
end
|
|
84
|
+
rescue StandardError
|
|
85
|
+
{}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def read_decision_value(decision, field)
|
|
89
|
+
return nil unless decision
|
|
90
|
+
return decision.public_send(field) if decision.respond_to?(field)
|
|
91
|
+
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
27
94
|
end
|
|
28
95
|
end
|
|
29
96
|
end
|
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module RSpec
|
|
4
4
|
module Rewind
|
|
5
|
+
RetryGateDecision = Struct.new(
|
|
6
|
+
:allowed,
|
|
7
|
+
:reason,
|
|
8
|
+
:policy_decision,
|
|
9
|
+
:budget_decision,
|
|
10
|
+
keyword_init: true
|
|
11
|
+
) do
|
|
12
|
+
def allowed?
|
|
13
|
+
allowed
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
5
17
|
class RetryGate
|
|
6
18
|
def initialize(configuration:, retry_policy:, debug:)
|
|
7
19
|
@configuration = configuration
|
|
@@ -16,25 +28,107 @@ module RSpec
|
|
|
16
28
|
retry_on:,
|
|
17
29
|
skip_retry_on:,
|
|
18
30
|
retry_if:,
|
|
19
|
-
example_id
|
|
31
|
+
example_id:,
|
|
32
|
+
elapsed_time: nil,
|
|
33
|
+
sleep_total: nil
|
|
20
34
|
)
|
|
21
|
-
|
|
35
|
+
decision(
|
|
36
|
+
exception: exception,
|
|
37
|
+
retry_number: retry_number,
|
|
38
|
+
resolved_retries: resolved_retries,
|
|
39
|
+
retry_on: retry_on,
|
|
40
|
+
skip_retry_on: skip_retry_on,
|
|
41
|
+
retry_if: retry_if,
|
|
42
|
+
example_id: example_id,
|
|
43
|
+
elapsed_time: elapsed_time,
|
|
44
|
+
sleep_total: sleep_total
|
|
45
|
+
).allowed?
|
|
46
|
+
end
|
|
22
47
|
|
|
23
|
-
|
|
48
|
+
def decision(
|
|
49
|
+
exception:,
|
|
50
|
+
retry_number:,
|
|
51
|
+
resolved_retries:,
|
|
52
|
+
retry_on:,
|
|
53
|
+
skip_retry_on:,
|
|
54
|
+
retry_if:,
|
|
55
|
+
example_id:,
|
|
56
|
+
elapsed_time: nil,
|
|
57
|
+
sleep_total: nil
|
|
58
|
+
)
|
|
59
|
+
return rejected(:retries_exhausted) unless retry_number <= resolved_retries
|
|
60
|
+
return rejected(:max_elapsed_time_exceeded) if exceeded?(@configuration.max_elapsed_time, elapsed_time)
|
|
61
|
+
return rejected(:max_total_sleep_exceeded) if exceeded?(@configuration.max_total_sleep, sleep_total)
|
|
62
|
+
|
|
63
|
+
policy_decision = @retry_policy.decision(
|
|
24
64
|
exception: exception,
|
|
25
65
|
retry_on: retry_on,
|
|
26
66
|
skip_retry_on: skip_retry_on,
|
|
27
|
-
retry_if: retry_if
|
|
67
|
+
retry_if: retry_if,
|
|
68
|
+
retry_number: retry_number,
|
|
69
|
+
resolved_retries: resolved_retries,
|
|
70
|
+
budget_remaining: budget_remaining,
|
|
71
|
+
elapsed_time: elapsed_time,
|
|
72
|
+
sleep_total: sleep_total
|
|
28
73
|
)
|
|
74
|
+
return rejected(policy_decision.reason, policy_decision: policy_decision) unless policy_decision.allowed?
|
|
29
75
|
|
|
30
|
-
return
|
|
76
|
+
return rejected(:dry_run, policy_decision: policy_decision) if @configuration.dry_run
|
|
77
|
+
|
|
78
|
+
budget_decision = consume_budget
|
|
79
|
+
return allowed(policy_decision: policy_decision, budget_decision: budget_decision) if budget_decision.allowed?
|
|
31
80
|
|
|
32
81
|
debug("retry budget exhausted for #{example_id}")
|
|
33
|
-
|
|
82
|
+
rejected(:budget_exhausted, policy_decision: policy_decision, budget_decision: budget_decision)
|
|
34
83
|
end
|
|
35
84
|
|
|
36
85
|
private
|
|
37
86
|
|
|
87
|
+
def allowed(policy_decision: nil, budget_decision: nil)
|
|
88
|
+
RetryGateDecision.new(
|
|
89
|
+
allowed: true,
|
|
90
|
+
reason: :allowed,
|
|
91
|
+
policy_decision: policy_decision,
|
|
92
|
+
budget_decision: budget_decision
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def rejected(reason, policy_decision: nil, budget_decision: nil)
|
|
97
|
+
RetryGateDecision.new(
|
|
98
|
+
allowed: false,
|
|
99
|
+
reason: reason,
|
|
100
|
+
policy_decision: policy_decision,
|
|
101
|
+
budget_decision: budget_decision
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def consume_budget
|
|
106
|
+
budget = @configuration.retry_budget
|
|
107
|
+
return budget.consume if budget.respond_to?(:consume)
|
|
108
|
+
|
|
109
|
+
allowed = budget.consume!
|
|
110
|
+
BudgetDecision.new(
|
|
111
|
+
allowed: allowed,
|
|
112
|
+
limit: read_budget_value(budget, :limit),
|
|
113
|
+
used: read_budget_value(budget, :used),
|
|
114
|
+
remaining: read_budget_value(budget, :remaining)
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def budget_remaining
|
|
119
|
+
read_budget_value(@configuration.retry_budget, :remaining)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def read_budget_value(budget, field)
|
|
123
|
+
return budget.public_send(field) if budget.respond_to?(field)
|
|
124
|
+
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def exceeded?(limit, value)
|
|
129
|
+
!limit.nil? && !value.nil? && value >= limit
|
|
130
|
+
end
|
|
131
|
+
|
|
38
132
|
def debug(message)
|
|
39
133
|
@debug.call(message)
|
|
40
134
|
end
|
|
@@ -10,7 +10,8 @@ module RSpec
|
|
|
10
10
|
attempt_runner:,
|
|
11
11
|
retry_gate:,
|
|
12
12
|
retry_transition:,
|
|
13
|
-
flaky_transition
|
|
13
|
+
flaky_transition:,
|
|
14
|
+
clock: nil
|
|
14
15
|
)
|
|
15
16
|
@example = example
|
|
16
17
|
@context = context
|
|
@@ -19,6 +20,7 @@ module RSpec
|
|
|
19
20
|
@retry_gate = retry_gate
|
|
20
21
|
@retry_transition = retry_transition
|
|
21
22
|
@flaky_transition = flaky_transition
|
|
23
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
def run(retries:, backoff:, wait:, retry_on:, skip_retry_on:, retry_if:)
|
|
@@ -27,46 +29,91 @@ module RSpec
|
|
|
27
29
|
|
|
28
30
|
total_attempts = resolved_retries + 1
|
|
29
31
|
attempt = 1
|
|
32
|
+
first_exception = nil
|
|
33
|
+
first_failure_duration = nil
|
|
34
|
+
attempt_durations = []
|
|
35
|
+
sleep_total = 0.0
|
|
36
|
+
started_at = monotonic_time
|
|
30
37
|
|
|
31
38
|
while attempt <= total_attempts
|
|
32
39
|
exception, duration, raised = @attempt_runner.run(
|
|
33
40
|
run_target: @example,
|
|
34
41
|
exception_source: @context.source
|
|
35
42
|
)
|
|
43
|
+
attempt_durations << duration
|
|
36
44
|
|
|
37
45
|
if exception.nil?
|
|
38
|
-
|
|
46
|
+
if attempt > 1
|
|
47
|
+
@flaky_transition.perform(
|
|
48
|
+
attempt: attempt,
|
|
49
|
+
retries: resolved_retries,
|
|
50
|
+
duration: duration,
|
|
51
|
+
exception: first_exception,
|
|
52
|
+
total_duration: monotonic_time - started_at,
|
|
53
|
+
attempt_durations: attempt_durations.dup,
|
|
54
|
+
first_failure_duration: first_failure_duration,
|
|
55
|
+
sleep_total: sleep_total
|
|
56
|
+
)
|
|
57
|
+
end
|
|
39
58
|
return
|
|
40
59
|
end
|
|
41
60
|
|
|
61
|
+
first_exception ||= exception
|
|
62
|
+
first_failure_duration ||= duration
|
|
42
63
|
retry_number = attempt
|
|
43
|
-
|
|
64
|
+
decision = @retry_gate.decision(
|
|
44
65
|
exception: exception,
|
|
45
66
|
retry_number: retry_number,
|
|
46
67
|
resolved_retries: resolved_retries,
|
|
47
68
|
retry_on: retry_on,
|
|
48
69
|
skip_retry_on: skip_retry_on,
|
|
49
70
|
retry_if: retry_if,
|
|
50
|
-
example_id: @context.id
|
|
71
|
+
example_id: @context.id,
|
|
72
|
+
elapsed_time: monotonic_time - started_at,
|
|
73
|
+
sleep_total: sleep_total
|
|
51
74
|
)
|
|
75
|
+
unless decision.allowed?
|
|
76
|
+
@retry_transition.publish_not_retried(
|
|
77
|
+
retry_number: retry_number,
|
|
78
|
+
resolved_retries: resolved_retries,
|
|
79
|
+
duration: duration,
|
|
80
|
+
exception: exception,
|
|
81
|
+
decision: decision,
|
|
82
|
+
total_duration: monotonic_time - started_at,
|
|
83
|
+
attempt_durations: attempt_durations.dup,
|
|
84
|
+
sleep_total: sleep_total
|
|
85
|
+
)
|
|
52
86
|
raise exception if raised
|
|
53
87
|
|
|
54
88
|
return
|
|
55
89
|
end
|
|
56
90
|
|
|
57
|
-
@retry_transition.perform(
|
|
91
|
+
sleep_measurement = @retry_transition.perform(
|
|
58
92
|
retry_number: retry_number,
|
|
59
93
|
resolved_retries: resolved_retries,
|
|
60
94
|
duration: duration,
|
|
61
95
|
exception: exception,
|
|
62
96
|
backoff: backoff,
|
|
63
97
|
wait: wait,
|
|
64
|
-
example_source: @context.source
|
|
98
|
+
example_source: @context.source,
|
|
99
|
+
total_duration: monotonic_time - started_at,
|
|
100
|
+
attempt_durations: attempt_durations.dup,
|
|
101
|
+
sleep_total: sleep_total,
|
|
102
|
+
failure_fingerprint: FailureFingerprint.build(exception),
|
|
103
|
+
budget_decision: decision.budget_decision,
|
|
104
|
+
policy_decision: decision.policy_decision
|
|
65
105
|
)
|
|
106
|
+
sleep_total += sleep_measurement.actual
|
|
66
107
|
|
|
67
108
|
attempt += 1
|
|
68
109
|
end
|
|
69
110
|
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def monotonic_time
|
|
115
|
+
@clock.call
|
|
116
|
+
end
|
|
70
117
|
end
|
|
71
118
|
end
|
|
72
119
|
end
|
|
@@ -11,32 +11,79 @@ module RSpec
|
|
|
11
11
|
|
|
12
12
|
def notify_retry(event)
|
|
13
13
|
debug("retry #{event.attempt}/#{event.retries} for #{event.example_id} in #{event.sleep_seconds.round(3)}s")
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
record_summary(event)
|
|
15
|
+
publish_retry_report(event) if @configuration.report_retry_events
|
|
16
|
+
invoke_callback(@configuration.retry_callback, event, 'retry callback')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def notify_before_retry(event)
|
|
20
|
+
invoke_callback(@configuration.before_retry, event, 'before_retry hook')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def notify_after_retry(event)
|
|
24
|
+
invoke_callback(@configuration.after_retry, event, 'after_retry hook')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def publish_not_retried(event)
|
|
28
|
+
debug("not retrying #{event.example_id}: #{event.decision_reason}")
|
|
29
|
+
record_summary(event)
|
|
30
|
+
publish_retry_report(event) if @configuration.report_retry_events
|
|
31
|
+
invoke_callback(@configuration.not_retried_callback, event, 'not_retried callback')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def publish_reset_failed(event)
|
|
35
|
+
debug("state reset failed for #{event.example_id}: #{event.exception_class}: #{event.exception_message}")
|
|
36
|
+
record_summary(event)
|
|
37
|
+
publish_retry_report(event) if @configuration.report_retry_events
|
|
17
38
|
end
|
|
18
39
|
|
|
19
40
|
def publish_flaky(event)
|
|
41
|
+
record_summary(event)
|
|
20
42
|
publish_flaky_report(event)
|
|
21
|
-
|
|
43
|
+
invoke_callback(@configuration.flaky_callback, event, 'flaky callback')
|
|
22
44
|
end
|
|
23
45
|
|
|
24
46
|
def show_failure_message(exception)
|
|
25
|
-
|
|
47
|
+
message = "[rspec-rewind] #{exception.class}: #{exception.message}"
|
|
48
|
+
if @configuration.display_retry_backtrace_top && exception.backtrace&.first
|
|
49
|
+
message = "#{message} (#{exception.backtrace.first})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@reporter_message.call(message)
|
|
26
53
|
end
|
|
27
54
|
|
|
28
55
|
private
|
|
29
56
|
|
|
57
|
+
def publish_retry_report(event)
|
|
58
|
+
@configuration.flaky_reporter.record(event)
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
raise if @configuration.strict_callbacks
|
|
61
|
+
|
|
62
|
+
debug("failed to record retry event: #{e.class}: #{e.message}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def record_summary(event)
|
|
66
|
+
@configuration.retry_summary.record(event)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
raise if @configuration.strict_callbacks
|
|
69
|
+
|
|
70
|
+
debug("failed to record retry summary: #{e.class}: #{e.message}")
|
|
71
|
+
end
|
|
72
|
+
|
|
30
73
|
def publish_flaky_report(event)
|
|
31
74
|
@configuration.flaky_reporter.record(event)
|
|
32
75
|
rescue StandardError => e
|
|
76
|
+
raise if @configuration.strict_callbacks
|
|
77
|
+
|
|
33
78
|
debug("failed to record flaky event: #{e.class}: #{e.message}")
|
|
34
79
|
end
|
|
35
80
|
|
|
36
|
-
def
|
|
37
|
-
|
|
81
|
+
def invoke_callback(callback, event, label)
|
|
82
|
+
callback&.call(event)
|
|
38
83
|
rescue StandardError => e
|
|
39
|
-
|
|
84
|
+
raise if @configuration.strict_callbacks
|
|
85
|
+
|
|
86
|
+
debug("#{label} failed: #{e.class}: #{e.message}")
|
|
40
87
|
end
|
|
41
88
|
|
|
42
89
|
def debug(message)
|
|
@@ -12,40 +12,168 @@ module RSpec
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def retry_allowed?(exception:, retry_on:, skip_retry_on:, retry_if:)
|
|
15
|
+
decision(
|
|
16
|
+
exception: exception,
|
|
17
|
+
retry_on: retry_on,
|
|
18
|
+
skip_retry_on: skip_retry_on,
|
|
19
|
+
retry_if: retry_if
|
|
20
|
+
).allowed?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def decision(
|
|
24
|
+
exception:,
|
|
25
|
+
retry_on:,
|
|
26
|
+
skip_retry_on:,
|
|
27
|
+
retry_if:,
|
|
28
|
+
retry_number: nil,
|
|
29
|
+
resolved_retries: nil,
|
|
30
|
+
budget_remaining: nil,
|
|
31
|
+
elapsed_time: nil,
|
|
32
|
+
sleep_total: nil
|
|
33
|
+
)
|
|
15
34
|
RetryDecision.new(
|
|
16
35
|
exception: exception,
|
|
17
36
|
example: @example,
|
|
18
37
|
retry_on: effective_retry_on(retry_on),
|
|
19
38
|
skip_retry_on: effective_skip_retry_on(skip_retry_on),
|
|
20
|
-
retry_if: effective_retry_if(retry_if)
|
|
21
|
-
|
|
39
|
+
retry_if: effective_retry_if(retry_if),
|
|
40
|
+
retry_on_default: @configuration.retry_on_default,
|
|
41
|
+
strict_callable_arity: @configuration.strict_callable_arity,
|
|
42
|
+
context: retry_context(
|
|
43
|
+
retry_number: retry_number,
|
|
44
|
+
resolved_retries: resolved_retries,
|
|
45
|
+
budget_remaining: budget_remaining,
|
|
46
|
+
elapsed_time: elapsed_time,
|
|
47
|
+
sleep_total: sleep_total,
|
|
48
|
+
exception: exception
|
|
49
|
+
)
|
|
50
|
+
).decision
|
|
22
51
|
end
|
|
23
52
|
|
|
24
53
|
private
|
|
25
54
|
|
|
26
55
|
def effective_retry_on(explicit_retry_on)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
56
|
+
if retry_on_mode == :override
|
|
57
|
+
return normalize_policy_matchers(@metadata[:rewind_retry_on], field: 'rewind_retry_on') +
|
|
58
|
+
normalize_policy_matchers(explicit_retry_on, field: 'retry_on')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
normalize_policy_matchers(@configuration.retry_on, field: 'retry_on') +
|
|
62
|
+
normalize_policy_matchers(@metadata[:rewind_retry_on], field: 'rewind_retry_on') +
|
|
63
|
+
normalize_policy_matchers(explicit_retry_on, field: 'retry_on')
|
|
30
64
|
end
|
|
31
65
|
|
|
32
66
|
def effective_skip_retry_on(explicit_skip_retry_on)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
67
|
+
if skip_retry_on_mode == :override
|
|
68
|
+
return normalize_policy_matchers(metadata_skip_retry_on, field: 'rewind_skip_retry_on') +
|
|
69
|
+
normalize_policy_matchers(explicit_skip_retry_on, field: 'skip_retry_on')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
normalize_policy_matchers(@configuration.skip_retry_on, field: 'skip_retry_on') +
|
|
73
|
+
normalize_policy_matchers(metadata_skip_retry_on, field: 'rewind_skip_retry_on') +
|
|
74
|
+
normalize_policy_matchers(explicit_skip_retry_on, field: 'skip_retry_on')
|
|
36
75
|
end
|
|
37
76
|
|
|
38
77
|
def effective_retry_if(explicit_retry_if)
|
|
39
|
-
|
|
78
|
+
predicates = [@configuration.retry_if, @metadata[:rewind_if], explicit_retry_if].compact
|
|
79
|
+
predicates.each { |predicate| validate_callable!(predicate, field: 'retry_if') }
|
|
80
|
+
|
|
81
|
+
case retry_if_mode
|
|
82
|
+
when :and
|
|
83
|
+
return nil if predicates.empty?
|
|
84
|
+
|
|
85
|
+
->(*args) { predicates.all? { |predicate| call_predicate(predicate, args) } }
|
|
86
|
+
when :or
|
|
87
|
+
return nil if predicates.empty?
|
|
88
|
+
|
|
89
|
+
->(*args) { predicates.any? { |predicate| call_predicate(predicate, args) } }
|
|
90
|
+
else
|
|
91
|
+
first_non_nil(explicit_retry_if, @metadata[:rewind_if], @configuration.retry_if)
|
|
92
|
+
end
|
|
40
93
|
end
|
|
41
94
|
|
|
42
95
|
def metadata_skip_retry_on
|
|
43
96
|
@metadata[:rewind_skip_retry_on]
|
|
44
97
|
end
|
|
45
98
|
|
|
99
|
+
def normalize_policy_matchers(values, field:)
|
|
100
|
+
normalize_matchers(
|
|
101
|
+
values,
|
|
102
|
+
field: field,
|
|
103
|
+
strict_exception_matchers: @configuration.strict_matcher_validation
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def retry_if_mode
|
|
108
|
+
normalize_mode(
|
|
109
|
+
@metadata[:rewind_if_mode],
|
|
110
|
+
allowed: %i[override and or],
|
|
111
|
+
fallback: @configuration.retry_if_mode,
|
|
112
|
+
field: 'rewind_if_mode'
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def retry_on_mode
|
|
117
|
+
normalize_mode(
|
|
118
|
+
@metadata[:rewind_retry_on_mode],
|
|
119
|
+
allowed: %i[append override],
|
|
120
|
+
fallback: :append,
|
|
121
|
+
field: 'rewind_retry_on_mode'
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def skip_retry_on_mode
|
|
126
|
+
normalize_mode(
|
|
127
|
+
@metadata[:rewind_skip_retry_on_mode],
|
|
128
|
+
allowed: %i[append override],
|
|
129
|
+
fallback: :append,
|
|
130
|
+
field: 'rewind_skip_retry_on_mode'
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def retry_context(retry_number:, resolved_retries:, budget_remaining:, elapsed_time:, sleep_total:, exception:)
|
|
135
|
+
RetryContext.new(
|
|
136
|
+
attempt: retry_number,
|
|
137
|
+
retries: resolved_retries,
|
|
138
|
+
metadata: @metadata,
|
|
139
|
+
budget_remaining: budget_remaining,
|
|
140
|
+
failure_fingerprint: FailureFingerprint.build(exception),
|
|
141
|
+
elapsed_time: elapsed_time,
|
|
142
|
+
sleep_total: sleep_total
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_callable!(callable, field:)
|
|
147
|
+
return if callable.respond_to?(:call)
|
|
148
|
+
|
|
149
|
+
raise ArgumentError, "#{field} must be callable"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def call_predicate(predicate, args)
|
|
153
|
+
arity = predicate.respond_to?(:arity) ? predicate.arity : predicate.method(:call).arity
|
|
154
|
+
return predicate.call if arity.zero?
|
|
155
|
+
|
|
156
|
+
if arity.positive?
|
|
157
|
+
predicate.call(*args.take(arity))
|
|
158
|
+
else
|
|
159
|
+
predicate.call(*args)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
46
163
|
def first_non_nil(*values)
|
|
47
164
|
values.find { |value| !value.nil? }
|
|
48
165
|
end
|
|
166
|
+
|
|
167
|
+
def normalize_mode(value, allowed:, fallback:, field:)
|
|
168
|
+
return fallback.to_sym if value.nil?
|
|
169
|
+
|
|
170
|
+
mode = value.to_sym
|
|
171
|
+
return mode if allowed.include?(mode)
|
|
172
|
+
|
|
173
|
+
raise ArgumentError, "#{field} must be one of: #{allowed.join(', ')}"
|
|
174
|
+
rescue NoMethodError
|
|
175
|
+
raise ArgumentError, "#{field} must be one of: #{allowed.join(', ')}"
|
|
176
|
+
end
|
|
49
177
|
end
|
|
50
178
|
end
|
|
51
179
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Rewind
|
|
5
|
+
class FlakyThresholdExceeded < StandardError; end
|
|
6
|
+
|
|
7
|
+
class RetrySummary
|
|
8
|
+
attr_reader :retry_events, :flaky_examples, :not_retried_events, :reset_failed_events, :sleep_seconds
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
reset!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def record(event)
|
|
15
|
+
@mutex.synchronize do
|
|
16
|
+
case event.status
|
|
17
|
+
when :retrying
|
|
18
|
+
@retry_events += 1
|
|
19
|
+
@sleep_seconds += event.actual_sleep_seconds.to_f
|
|
20
|
+
when :flaky
|
|
21
|
+
@flaky_examples += 1
|
|
22
|
+
when :not_retried
|
|
23
|
+
@not_retried_events += 1
|
|
24
|
+
when :reset_failed
|
|
25
|
+
@reset_failed_events += 1
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reset!
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
@retry_events = 0
|
|
33
|
+
@flaky_examples = 0
|
|
34
|
+
@not_retried_events = 0
|
|
35
|
+
@reset_failed_events = 0
|
|
36
|
+
@sleep_seconds = 0.0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_message(budget:)
|
|
40
|
+
parts = [
|
|
41
|
+
"#{@flaky_examples} flaky examples",
|
|
42
|
+
"#{@retry_events} retry attempts",
|
|
43
|
+
"#{format('%.3f', @sleep_seconds)}s spent sleeping",
|
|
44
|
+
"#{@not_retried_events} not retried",
|
|
45
|
+
"#{@reset_failed_events} reset failures"
|
|
46
|
+
]
|
|
47
|
+
parts << budget_message(budget)
|
|
48
|
+
"[rspec-rewind] #{parts.compact.join(', ')}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def budget_message(budget)
|
|
54
|
+
return nil unless budget.respond_to?(:used)
|
|
55
|
+
|
|
56
|
+
limit = budget.respond_to?(:limit) ? budget.limit : nil
|
|
57
|
+
limit.nil? ? "budget #{budget.used}/unlimited used" : "budget #{budget.used}/#{limit} used"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|