chrono_forge 0.9.1 → 0.11.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +390 -46
  4. data/Rakefile +4 -0
  5. data/cliff.toml +62 -0
  6. data/docs/design/per-child-commit-overhead.md +213 -0
  7. data/docs/fanout-scale-test.md +247 -0
  8. data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md +1748 -0
  9. data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md.tasks.json +17 -0
  10. data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md +930 -0
  11. data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md.tasks.json +54 -0
  12. data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md +241 -0
  13. data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md.tasks.json +12 -0
  14. data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md +1378 -0
  15. data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md.tasks.json +67 -0
  16. data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md +709 -0
  17. data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md.tasks.json +19 -0
  18. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
  19. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
  20. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
  21. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
  22. data/docs/superpowers/specs/2026-06-03-unified-retry-policy-design.md +226 -0
  23. data/docs/superpowers/specs/2026-06-25-chrono_forge-dashboard-design.md +190 -0
  24. data/docs/superpowers/specs/2026-06-25-composite-retry-policies-design.md +228 -0
  25. data/docs/superpowers/specs/2026-06-25-reserved-kwarg-guard-design.md +169 -0
  26. data/docs/superpowers/specs/2026-06-25-spawn-merge-branches-design.md +468 -0
  27. data/docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md +142 -0
  28. data/docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md +265 -0
  29. data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
  30. data/lib/chrono_forge/branch_merge_job.rb +275 -0
  31. data/lib/chrono_forge/branch_probe.rb +70 -0
  32. data/lib/chrono_forge/cleanup.rb +6 -0
  33. data/lib/chrono_forge/configuration.rb +25 -0
  34. data/lib/chrono_forge/definition.rb +37 -0
  35. data/lib/chrono_forge/definition_analyzer.rb +501 -0
  36. data/lib/chrono_forge/execution_log.rb +6 -0
  37. data/lib/chrono_forge/executor/composite_retry_policy.rb +47 -0
  38. data/lib/chrono_forge/executor/context.rb +23 -0
  39. data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
  40. data/lib/chrono_forge/executor/methods/branch.rb +185 -0
  41. data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
  42. data/lib/chrono_forge/executor/methods/durably_execute.rb +36 -26
  43. data/lib/chrono_forge/executor/methods/durably_repeat.rb +148 -39
  44. data/lib/chrono_forge/executor/methods/merge_branches.rb +84 -0
  45. data/lib/chrono_forge/executor/methods/wait.rb +2 -4
  46. data/lib/chrono_forge/executor/methods/wait_until.rb +25 -25
  47. data/lib/chrono_forge/executor/methods/workflow_states.rb +50 -46
  48. data/lib/chrono_forge/executor/methods.rb +2 -0
  49. data/lib/chrono_forge/executor/retry_policy.rb +111 -0
  50. data/lib/chrono_forge/executor.rb +241 -28
  51. data/lib/chrono_forge/version.rb +1 -1
  52. data/lib/chrono_forge/workflow.rb +10 -1
  53. data/lib/chrono_forge.rb +8 -0
  54. data/lib/generators/chrono_forge/migration_actions.rb +1 -0
  55. data/lib/generators/chrono_forge/templates/add_chrono_forge_parent_execution_log.rb +38 -0
  56. data/lib/tasks/release.rake +212 -0
  57. metadata +67 -4
  58. data/lib/chrono_forge/executor/retry_strategy.rb +0 -29
@@ -0,0 +1,84 @@
1
+ module ChronoForge
2
+ module Executor
3
+ module Methods
4
+ module MergeBranches
5
+ # Join one or more named branches. Separate from dispatch so branches run
6
+ # concurrently. Does one immediate check; if not done, hands off to the
7
+ # lightweight BranchMergeJob and halts (the heavy parent is not replayed
8
+ # per poll). Poll cadence is driven by estimated time-to-drain, clamped
9
+ # between min/max (see BranchMergeJob#reschedule_delay).
10
+ def merge_branches(*names, min_interval: 5.seconds, max_interval: 5.minutes)
11
+ names.each do |nm|
12
+ validate_step_name_segment!(nm) # rejects "$"
13
+ if nm.to_s.include?(",")
14
+ raise InvalidStepName,
15
+ "branch name may not contain ',' (reserved merge separator): #{nm.inspect}"
16
+ end
17
+ end
18
+
19
+ # Validate cadence here, in the parent, so a misconfiguration fails at the
20
+ # call site instead of deep inside the poller — where the clamp to
21
+ # [min_interval, max_interval] would raise ArgumentError, a non-transient
22
+ # error that dead-letters BranchMergeJob and orphans the parent.
23
+ if min_interval > max_interval
24
+ raise ArgumentError,
25
+ "min_interval (#{min_interval}) must be <= max_interval (#{max_interval})"
26
+ end
27
+
28
+ names = names.map(&:to_s).uniq
29
+ step_name = "merge$#{names.sort.join(",")}"
30
+ log = find_or_create_execution_log!(step_name) { |l| l.started_at = Time.current }
31
+
32
+ if log.completed?
33
+ # Already done — remove from registry so the completion gate does not
34
+ # see these as unmerged, then skip.
35
+ names.each { |nm| @open_branches&.delete(nm.to_s) }
36
+ return
37
+ end
38
+
39
+ branch_log_ids = names.map { |nm| open_branch!(nm)[:log_id] }
40
+
41
+ if branches_done?(branch_log_ids)
42
+ names.each { |nm| @open_branches&.delete(nm.to_s) }
43
+ log.update!(state: :completed, completed_at: Time.current)
44
+ return
45
+ end
46
+
47
+ enqueue_branch_merge_job(branch_log_ids, min_interval, max_interval)
48
+ halt_execution!
49
+ end
50
+ alias_method :merge_branch, :merge_branches
51
+
52
+ private
53
+
54
+ def open_branch!(name)
55
+ (@open_branches || {}).fetch(name.to_s) do
56
+ raise UnknownBranchError, "no open branch named #{name.inspect} (open it with `branch #{name.inspect} do … end` first)"
57
+ end
58
+ end
59
+
60
+ def branches_done?(branch_log_ids)
61
+ branch_log_ids.all? { |id| BranchProbe.done?(id) }
62
+ end
63
+
64
+ def enqueue_branch_merge_job(branch_log_ids, min_interval, max_interval)
65
+ # Mint a fresh fencing token and stamp it on each branch log under a row
66
+ # lock — the read-modify-write must not clobber a concurrent poll-state
67
+ # write from an in-flight poller. Rotating the token orphans any prior
68
+ # poller chain (its token no longer matches), so only the chain we enqueue
69
+ # below drives the merge. See BranchMergeJob#superseded?.
70
+ token = SecureRandom.uuid
71
+ ExecutionLog.where(id: branch_log_ids).find_each do |log|
72
+ log.with_lock do
73
+ log.update!(metadata: (log.metadata || {}).merge("poll_token" => token))
74
+ end
75
+ end
76
+ BranchMergeJob.perform_later(
77
+ @workflow.key, self.class.to_s, branch_log_ids,
78
+ min_interval.to_i, max_interval.to_i, token
79
+ )
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -102,10 +102,8 @@ module ChronoForge
102
102
  last_executed_at: Time.current
103
103
  )
104
104
 
105
- # Reschedule the job
106
- self.class
107
- .set(wait: duration)
108
- .perform_later(@workflow.key)
105
+ # Record the reschedule; the executor publishes it after lock release.
106
+ enqueue_continuation(wait: duration)
109
107
 
110
108
  # Halt current execution
111
109
  halt_execution!
@@ -14,7 +14,11 @@ module ChronoForge
14
14
  # The method should return a truthy value when the condition is met.
15
15
  # @param timeout [ActiveSupport::Duration] Maximum time to wait for condition (default: 1.hour)
16
16
  # @param check_interval [ActiveSupport::Duration] Time between condition checks (default: 15.minutes)
17
- # @param retry_on [Array<Class>] Exception classes that should trigger retries instead of failures
17
+ # @param retry_policy [RetryPolicy, nil] Policy governing errors raised *while
18
+ # evaluating the condition* (not the poll cadence). When nil, uses
19
+ # RetryPolicy.wait_default, which retries nothing — a raised condition fails
20
+ # fast. Pass a policy with `retry_on:` to opt specific errors into retrying.
21
+ # Note: unlike steps, wait_until does NOT inherit the class-level default.
18
22
  #
19
23
  # @return [true] When the condition is met
20
24
  #
@@ -31,7 +35,7 @@ module ChronoForge
31
35
  # wait_until :database_migration_complete?,
32
36
  # timeout: 2.hours,
33
37
  # check_interval: 30.seconds,
34
- # retry_on: [ActiveRecord::ConnectionNotEstablished, Net::TimeoutError]
38
+ # retry_policy: RetryPolicy.new(retry_on: [ActiveRecord::ConnectionNotEstablished, Net::TimeoutError])
35
39
  #
36
40
  # @example Waiting for external system
37
41
  # def third_party_service_ready?
@@ -42,7 +46,7 @@ module ChronoForge
42
46
  # wait_until :third_party_service_ready?,
43
47
  # timeout: 1.hour,
44
48
  # check_interval: 2.minutes,
45
- # retry_on: [Net::TimeoutError, Net::HTTPClientException]
49
+ # retry_policy: RetryPolicy.new(retry_on: [Net::TimeoutError, Net::HTTPClientException])
46
50
  #
47
51
  # @example Waiting for file processing
48
52
  # def file_processing_complete?
@@ -60,7 +64,7 @@ module ChronoForge
60
64
  # The condition method is called on each check interval:
61
65
  # - Should return truthy value when condition is met
62
66
  # - Should return falsy value when condition is not yet met
63
- # - Can raise exceptions that will be handled based on retry_on parameter
67
+ # - Can raise exceptions that will be handled based on the retry_policy
64
68
  #
65
69
  # === Timeout Handling
66
70
  # - Timeout is calculated from the first execution start time
@@ -69,9 +73,10 @@ module ChronoForge
69
73
  #
70
74
  # === Error Handling
71
75
  # - Exceptions during condition evaluation are caught and logged
72
- # - If exception class is in retry_on array, it triggers retry with exponential backoff
73
- # - Other exceptions cause immediate failure with ExecutionFailedError
74
- # - Retry backoff: 2^attempt seconds (capped at 2^5 = 32 seconds)
76
+ # - If the retry_policy deems the error retryable, it triggers a retry with the
77
+ # policy's backoff
78
+ # - Otherwise the error causes immediate failure with ExecutionFailedError
79
+ # - Backoff is governed by the resolved RetryPolicy
75
80
  #
76
81
  # === Persistence and Resumability
77
82
  # - Wait state is persisted in execution logs with metadata
@@ -85,7 +90,8 @@ module ChronoForge
85
90
  # - Tracks attempt count and execution times
86
91
  # - Records final result (true for success, :timed_out for timeout)
87
92
  #
88
- def wait_until(condition, timeout: 1.hour, check_interval: 15.minutes, retry_on: [])
93
+ def wait_until(condition, timeout: 1.hour, check_interval: 15.minutes, retry_policy: nil)
94
+ policy = wait_retry_policy(retry_policy)
89
95
  validate_step_name_segment!(condition)
90
96
  step_name = "wait_until$#{condition}"
91
97
  # Find or create execution log
@@ -117,16 +123,15 @@ module ChronoForge
117
123
  Rails.logger.error { "Error evaluating condition #{condition}: #{e.message}" }
118
124
  self.class::ExecutionTracker.track_error(workflow, e, execution_log: execution_log)
119
125
 
120
- # Optional retry logic
121
- if retry_on.include?(e.class)
122
- # Reschedule with exponential backoff
123
- backoff = (2**[execution_log.attempts, 5].min).seconds
124
-
125
- self.class
126
- .set(wait: backoff)
127
- .perform_later(
128
- @workflow.key
129
- )
126
+ # Optional retry logic for errors raised while evaluating the
127
+ # condition. The poll cadence (check_interval/timeout) below is
128
+ # separate and unaffected by the retry policy.
129
+ backoff = policy.retry_backoff(e, attempts: execution_log.attempts) do |policy_key|
130
+ bump_retry_count!(execution_log, policy_key)
131
+ end
132
+ if backoff
133
+ # Reschedule with the policy's backoff (published after lock release).
134
+ enqueue_continuation(wait: backoff)
130
135
 
131
136
  # Halt current execution
132
137
  halt_execution!
@@ -167,13 +172,8 @@ module ChronoForge
167
172
  raise error
168
173
  end
169
174
 
170
- # Reschedule with delay
171
- self.class
172
- .set(wait: check_interval)
173
- .perform_later(
174
- @workflow.key,
175
- wait_condition: condition
176
- )
175
+ # Reschedule the poll (published after lock release).
176
+ enqueue_continuation(wait: check_interval, wait_condition: condition)
177
177
 
178
178
  # Halt current execution
179
179
  halt_execution!
@@ -48,38 +48,48 @@ module ChronoForge
48
48
  # - Safe to call multiple times without side effects
49
49
  #
50
50
  def complete_workflow!
51
- # Create an execution log for workflow completion
52
- execution_log = find_or_create_execution_log!("$workflow_completion$") do |log|
53
- log.started_at = Time.current
54
- end
51
+ enforce_branch_joins!
55
52
 
53
+ # Completion is two writes with no external side effect between them: the
54
+ # workflow → :completed transition and the (born-completed) marker. Batch
55
+ # them in one transaction so a trivial child pays a single commit here,
56
+ # and write the marker in its terminal state in a single INSERT.
57
+ execution_log = nil
56
58
  begin
57
- workflow.completed_at = Time.current
58
- workflow.completed!
59
-
60
- # Mark execution log as completed. Attempt tracking and the terminal
61
- # state are written together: completion is not retried on an
62
- # attempt-count basis, so there is no need for a separate pre-write.
63
- execution_log.update!(
64
- attempts: execution_log.attempts + 1,
65
- last_executed_at: Time.current,
66
- state: :completed,
67
- completed_at: Time.current
68
- )
59
+ ActiveRecord::Base.transaction do
60
+ workflow.completed_at = Time.current
61
+ workflow.completed!
62
+ execution_log = create_completed_execution_log!("$workflow_completion$")
63
+ end
69
64
 
70
65
  # Return the execution log for tracking
71
66
  execution_log
72
67
  rescue => e
73
- # Log any errors
74
- execution_log.update!(
75
- state: :failed,
76
- error_message: e.message,
77
- error_class: e.class.name
78
- )
68
+ # The transaction rolled back (so the marker may be gone too). Re-find
69
+ # or recreate it and record the failure for observability, then re-raise.
70
+ # The workflow stays not-completed, so a resume retries completion.
71
+ log = find_or_create_execution_log!("$workflow_completion$") do |l|
72
+ l.started_at = Time.current
73
+ end
74
+ log.update!(state: :failed, error_message: e.message, error_class: e.class.name)
79
75
  raise
80
76
  end
81
77
  end
82
78
 
79
+ # Every branch must be joined: automerge branches join inline at their
80
+ # block's close (removing themselves from @open_branches); explicitly
81
+ # awaited branches are removed by merge_branches. Anything still in
82
+ # @open_branches here was opened but never joined — fail fast.
83
+ def enforce_branch_joins!
84
+ leftover = (@open_branches || {}).keys
85
+ return if leftover.empty?
86
+
87
+ raise UnmergedBranchError,
88
+ "branch(es) #{leftover.join(", ")} were opened but never merged. " \
89
+ "Add `merge_branches #{leftover.map { |n| ":#{n}" }.join(", ")}` " \
90
+ "or open with `branch(..., automerge: true)`."
91
+ end
92
+
83
93
  # Marks a workflow as failed due to an unrecoverable error.
84
94
  #
85
95
  # This method provides durable workflow failure tracking with proper state
@@ -137,36 +147,30 @@ module ChronoForge
137
147
  # - Safe to call multiple times with same error_log
138
148
  #
139
149
  def fail_workflow!(error_log)
140
- # Create an execution log for workflow failure
141
- execution_log = find_or_create_execution_log!("$workflow_failure$#{error_log.id}") do |log|
142
- log.started_at = Time.current
143
- log.metadata = {
144
- error_log_id: error_log.id
145
- }
146
- end
150
+ step_name = "$workflow_failure$#{error_log.id}"
147
151
 
152
+ # Mirror complete_workflow!: the workflow → :failed transition and the
153
+ # (born-completed) failure marker are batched in one transaction, and the
154
+ # marker is written in its terminal state in a single INSERT.
155
+ execution_log = nil
148
156
  begin
149
- workflow.failed!
150
-
151
- # Mark execution log as completed. Attempt tracking and the terminal
152
- # state are written together (failure handling is not retried on an
153
- # attempt-count basis).
154
- execution_log.update!(
155
- attempts: execution_log.attempts + 1,
156
- last_executed_at: Time.current,
157
- state: :completed,
158
- completed_at: Time.current
159
- )
157
+ ActiveRecord::Base.transaction do
158
+ workflow.failed!
159
+ execution_log = create_completed_execution_log!(step_name) do |log|
160
+ log.metadata = {error_log_id: error_log.id}
161
+ end
162
+ end
160
163
 
161
164
  # Return the execution log for tracking
162
165
  execution_log
163
166
  rescue => e
164
- # Log any errors
165
- execution_log.update!(
166
- state: :failed,
167
- error_message: e.message,
168
- error_class: e.class.name
169
- )
167
+ # The transaction rolled back; re-find/recreate the marker and record
168
+ # the failure for observability, then re-raise.
169
+ log = find_or_create_execution_log!(step_name) do |l|
170
+ l.started_at = Time.current
171
+ l.metadata = {error_log_id: error_log.id}
172
+ end
173
+ log.update!(state: :failed, error_message: e.message, error_class: e.class.name)
170
174
  raise
171
175
  end
172
176
  end
@@ -6,6 +6,8 @@ module ChronoForge
6
6
  include Methods::ContinueIf
7
7
  include Methods::DurablyExecute
8
8
  include Methods::DurablyRepeat
9
+ include Methods::Branch
10
+ include Methods::MergeBranches
9
11
  include Methods::WorkflowStates
10
12
  end
11
13
  end
@@ -0,0 +1,111 @@
1
+ module ChronoForge
2
+ module Executor
3
+ # A single, unified description of retry behavior shared by every retry site
4
+ # (workflow-level uncaught errors, durably_execute, durably_repeat, and
5
+ # wait_until's condition errors).
6
+ #
7
+ # It answers the only two questions a retry site ever asks:
8
+ # - retryable?(error, attempts) — should this failure be retried?
9
+ # - backoff_for(attempts) — how long until the next attempt?
10
+ #
11
+ # `attempts` is always the 1-based count of attempts made so far, *including*
12
+ # the one that just failed (matching ExecutionLog#attempts). So on the first
13
+ # failure `attempts == 1`.
14
+ class RetryPolicy
15
+ attr_reader :max_attempts, :base, :cap, :jitter, :retry_on
16
+
17
+ # @param max_attempts [Integer, nil] cap on total attempts; nil = no count
18
+ # cap (bounded elsewhere, e.g. wait_until's timeout)
19
+ # @param base [Numeric, ActiveSupport::Duration] delay of the first retry
20
+ # @param cap [Numeric, ActiveSupport::Duration] ceiling for a single delay
21
+ # @param jitter [Boolean] apply equal jitter to spread retries
22
+ # @param retry_on [Array<Class>, nil] nil = retry any StandardError;
23
+ # an array = retry only those classes (and subclasses); [] = retry nothing
24
+ def initialize(max_attempts: 3, base: 1, cap: 30, jitter: true, retry_on: nil)
25
+ @max_attempts = max_attempts
26
+ @base = base
27
+ @cap = cap
28
+ @jitter = jitter
29
+ @retry_on = retry_on
30
+ end
31
+
32
+ def retryable?(error, attempts)
33
+ within_attempt_cap?(attempts) && retryable_error?(error)
34
+ end
35
+
36
+ # Equal jitter: half the computed delay plus a random portion of the other
37
+ # half. Computed once at re-enqueue time and never persisted, so the
38
+ # randomness does not affect replay determinism.
39
+ def backoff_for(attempts)
40
+ exponent = [attempts - 1, 0].max
41
+ delay = [cap.to_f, base.to_f * (2**exponent)].min
42
+ delay = (delay / 2) + rand(0.0..(delay / 2)) if jitter
43
+ delay.seconds
44
+ end
45
+
46
+ # Public routing predicate: would this policy handle this error at all?
47
+ # (independent of the attempt cap). nil retry_on = any StandardError;
48
+ # [] = nothing; a list = those classes and their subclasses.
49
+ def matches?(error)
50
+ retryable_error?(error)
51
+ end
52
+
53
+ # Single-call decision used by every retry site: the backoff Duration to
54
+ # retry, or nil to stop. A plain policy uses `attempts` and ignores any
55
+ # block (the block exists only so a CompositeRetryPolicy can supply a
56
+ # per-error count — see CompositeRetryPolicy#retry_backoff).
57
+ def retry_backoff(error, attempts:)
58
+ retryable?(error, attempts) ? backoff_for(attempts) : nil
59
+ end
60
+
61
+ # Stable per-policy identifier derived from the errors this policy
62
+ # *declares* (its retry_on), not the error thrown. Inside a composite this
63
+ # keys the policy's attempt budget, so the budget is shared across every
64
+ # class the policy lists (and their subclasses) and is independent of the
65
+ # policy's position — reordering the composite does not reset counts. A
66
+ # catch-all (retry_on: nil) keys "*".
67
+ def budget_key
68
+ retry_on.nil? ? "*" : retry_on.map(&:name).sort.join(",")
69
+ end
70
+
71
+ def self.step_default
72
+ new(max_attempts: 3, base: 1, cap: 30, jitter: true, retry_on: nil)
73
+ end
74
+
75
+ # Workflow-level (uncaught) errors retry the whole workflow from the top
76
+ # (replaying completed steps). They cover two populations the default can't
77
+ # distinguish: transient infra blips — worth riding out — and deterministic
78
+ # bugs, where every replay is waste. 10 attempts gives a tolerant window of
79
+ # up to ~8.5 min (≈4 min typical, since equal jitter puts each wait in
80
+ # [d/2, d]) — enough for a DB failover or deploy restart — without dragging
81
+ # out the bug case; cap (600s / 10 min) bounds any single backoff and only
82
+ # binds if a caller configures more attempts.
83
+ def self.workflow_default
84
+ new(max_attempts: 10, base: 1, cap: 600, jitter: true, retry_on: nil)
85
+ end
86
+
87
+ def self.wait_default
88
+ new(max_attempts: nil, base: 1, cap: 30, jitter: true, retry_on: [])
89
+ end
90
+
91
+ # Build a composite policy from an ordered list of RetryPolicy objects.
92
+ def self.compose(*policies)
93
+ CompositeRetryPolicy.new(policies)
94
+ end
95
+
96
+ private
97
+
98
+ def within_attempt_cap?(attempts)
99
+ max_attempts.nil? || attempts < max_attempts
100
+ end
101
+
102
+ def retryable_error?(error)
103
+ if retry_on.nil?
104
+ error.is_a?(StandardError)
105
+ else
106
+ retry_on.any? { |klass| error.is_a?(klass) }
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end