geneva_drive 0.4.0 → 0.6.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/MANUAL.md +1037 -355
  4. data/README.md +3 -1
  5. data/Rakefile +13 -2
  6. data/bin/md2html +96 -19
  7. data/lib/generators/geneva_drive/install/install_generator.rb +25 -0
  8. data/lib/generators/geneva_drive/install/templates/add_metadata_to_step_executions.rb +17 -0
  9. data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
  10. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
  11. data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
  12. data/lib/generators/geneva_drive/install/templates/allow_null_hero_on_workflows.rb +66 -0
  13. data/lib/generators/geneva_drive/install/templates/create_step_executions_migration.rb +25 -5
  14. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +14 -3
  15. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +22 -2
  16. data/lib/geneva_drive/combined_exception_policy.rb +106 -0
  17. data/lib/geneva_drive/exception_policy.rb +354 -0
  18. data/lib/geneva_drive/executor.rb +430 -148
  19. data/lib/geneva_drive/flow_control.rb +82 -11
  20. data/lib/geneva_drive/iterable_step.rb +199 -0
  21. data/lib/geneva_drive/job_options.rb +70 -0
  22. data/lib/geneva_drive/jobs/housekeeping_job.rb +174 -52
  23. data/lib/geneva_drive/jobs/perform_step_job.rb +91 -3
  24. data/lib/geneva_drive/migration_helpers.rb +55 -20
  25. data/lib/geneva_drive/resumable_step_definition.rb +97 -0
  26. data/lib/geneva_drive/step_definition.rb +160 -40
  27. data/lib/geneva_drive/step_execution/metadata_accessor.rb +121 -0
  28. data/lib/geneva_drive/step_execution.rb +124 -2
  29. data/lib/geneva_drive/test_helpers.rb +123 -4
  30. data/lib/geneva_drive/version.rb +1 -1
  31. data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
  32. data/lib/geneva_drive/workflow.rb +485 -73
  33. data/lib/geneva_drive.rb +100 -2
  34. data/test/dsl/class_level_on_exception_test.rb +200 -0
  35. data/test/dsl/exception_policy_test.rb +331 -0
  36. data/test/dsl/step_definition_test.rb +126 -2
  37. data/test/dsl/step_level_exception_policy_test.rb +143 -0
  38. data/test/dummy/config/storage.yml +7 -0
  39. data/test/dummy_install/config/storage.yml +7 -0
  40. data/test/jobs/housekeeping_job_test.rb +451 -5
  41. data/test/jobs/perform_step_job_double_deferral_test.rb +268 -0
  42. data/test/jobs/perform_step_job_test.rb +25 -2
  43. data/test/migration_helpers_test.rb +137 -0
  44. data/test/step_execution/metadata_accessor_test.rb +147 -0
  45. data/test/test_helper.rb +37 -0
  46. data/test/test_helper_test.rb +281 -0
  47. data/test/workflow/class_level_on_exception_integration_test.rb +313 -0
  48. data/test/workflow/composable_exception_policy_test.rb +231 -0
  49. data/test/workflow/cursor_size_limit_test.rb +75 -0
  50. data/test/workflow/executor_test.rb +100 -4
  51. data/test/workflow/external_cancel_test.rb +245 -0
  52. data/test/workflow/flow_control_test.rb +4 -3
  53. data/test/workflow/instrumentation_test.rb +28 -0
  54. data/test/workflow/max_reattempts_test.rb +51 -0
  55. data/test/workflow/pause_resume_test.rb +574 -0
  56. data/test/workflow/reattempt_on_exception_integration_test.rb +68 -0
  57. data/test/workflow/resumable_step_integration_test.rb +341 -0
  58. data/test/workflow/resumable_step_test.rb +615 -0
  59. data/test/workflow/resumable_without_migration_test.rb +103 -0
  60. data/test/workflow/resume_and_skip_test.rb +57 -41
  61. data/test/workflow/with_inline_enqueue_test.rb +137 -0
  62. data/test/workflow/workflow_test.rb +117 -2
  63. metadata +47 -61
  64. data/test/dummy/db/migrate/20241217000001_create_geneva_drive_workflows.rb +0 -68
  65. data/test/dummy/db/migrate/20241217000002_create_geneva_drive_step_executions.rb +0 -90
  66. data/test/dummy/db/migrate/20241217000003_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  67. data/test/dummy/db/migrate/20241217000004_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  68. data/test/dummy/db/schema.rb +0 -72
  69. data/test/dummy/log/development.log +0 -112
  70. data/test/dummy/log/test.log +0 -4
  71. data/test/dummy/tmp/local_secret.txt +0 -1
  72. data/test/dummy_install/Rakefile +0 -6
  73. data/test/dummy_install/app/assets/stylesheets/application.css +0 -15
  74. data/test/dummy_install/app/controllers/application_controller.rb +0 -4
  75. data/test/dummy_install/app/helpers/application_helper.rb +0 -2
  76. data/test/dummy_install/app/jobs/application_job.rb +0 -7
  77. data/test/dummy_install/app/models/application_record.rb +0 -3
  78. data/test/dummy_install/app/models/user.rb +0 -9
  79. data/test/dummy_install/app/views/layouts/application.html.erb +0 -27
  80. data/test/dummy_install/app/views/layouts/mailer.html.erb +0 -13
  81. data/test/dummy_install/app/views/layouts/mailer.text.erb +0 -1
  82. data/test/dummy_install/app/views/pwa/manifest.json.erb +0 -22
  83. data/test/dummy_install/app/views/pwa/service-worker.js +0 -26
  84. data/test/dummy_install/bin/dev +0 -2
  85. data/test/dummy_install/bin/rails +0 -4
  86. data/test/dummy_install/bin/rake +0 -4
  87. data/test/dummy_install/bin/setup +0 -34
  88. data/test/dummy_install/config/application.rb +0 -29
  89. data/test/dummy_install/config/boot.rb +0 -5
  90. data/test/dummy_install/config/cable.yml +0 -10
  91. data/test/dummy_install/config/database.yml +0 -13
  92. data/test/dummy_install/config/environment.rb +0 -5
  93. data/test/dummy_install/config/environments/development.rb +0 -51
  94. data/test/dummy_install/config/environments/production.rb +0 -70
  95. data/test/dummy_install/config/environments/test.rb +0 -39
  96. data/test/dummy_install/config/initializers/assets.rb +0 -7
  97. data/test/dummy_install/config/initializers/content_security_policy.rb +0 -25
  98. data/test/dummy_install/config/initializers/filter_parameter_logging.rb +0 -8
  99. data/test/dummy_install/config/initializers/geneva_drive.rb +0 -76
  100. data/test/dummy_install/config/initializers/inflections.rb +0 -16
  101. data/test/dummy_install/config/locales/en.yml +0 -31
  102. data/test/dummy_install/config/puma.rb +0 -38
  103. data/test/dummy_install/config/routes.rb +0 -3
  104. data/test/dummy_install/config.ru +0 -6
  105. data/test/dummy_install/db/migrate/20241217000000_create_users.rb +0 -12
  106. data/test/dummy_install/db/migrate/20260128104738_create_geneva_drive_workflows.rb +0 -74
  107. data/test/dummy_install/db/migrate/20260128104739_create_geneva_drive_step_executions.rb +0 -97
  108. data/test/dummy_install/db/migrate/20260128104740_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  109. data/test/dummy_install/db/migrate/20260128104741_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  110. data/test/dummy_install/db/migrate/20260128104742_add_resumable_step_support_to_geneva_drive_step_executions.rb +0 -25
  111. data/test/dummy_install/db/schema.rb +0 -76
  112. data/test/dummy_install/log/test.log +0 -4031
  113. data/test/dummy_install/public/400.html +0 -114
  114. data/test/dummy_install/public/404.html +0 -114
  115. data/test/dummy_install/public/406-unsupported-browser.html +0 -114
  116. data/test/dummy_install/public/422.html +0 -114
  117. data/test/dummy_install/public/500.html +0 -114
  118. data/test/dummy_install/public/icon.png +0 -0
  119. data/test/dummy_install/public/icon.svg +0 -3
  120. data/test/dummy_install/tmp/local_secret.txt +0 -1
  121. data/test/generators/install_generator_test.rb +0 -94
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Wraps an array of {ExceptionPolicy} objects into a single composable unit.
4
+ #
5
+ # Created automatically when a step's +on_exception:+ receives an Array of
6
+ # {ExceptionPolicy} objects. You never need to instantiate this class directly;
7
+ # it is produced by {StepDefinition} during validation. The executor also builds
8
+ # a CombinedExceptionPolicy at resolution time by composing step-level,
9
+ # class-level, and default policies into one stack.
10
+ #
11
+ # ## Resolution order
12
+ #
13
+ # {#apply} walks the constituent policies in order. The first policy whose
14
+ # {ExceptionPolicy#captures? captures?} returns +true+ for the error wins.
15
+ # Ordering determines precedence: specific policies (those with +matching:+)
16
+ # should be listed before blanket policies, like +rescue+ clauses in Ruby.
17
+ #
18
+ # If no policy captures the error, {#apply} returns +nil+.
19
+ #
20
+ # ## Global reattempt cap
21
+ #
22
+ # {#max_reattempts} returns the *minimum* +max_reattempts+ value across all
23
+ # constituent policies (ignoring +nil+, which means unlimited). When {#apply}
24
+ # delegates to a child that wants to reattempt, it checks this global cap
25
+ # first. If the cap is exceeded, the matched policy's +terminal_action+ is
26
+ # applied instead. This prevents runaway retries when different exception
27
+ # types alternate.
28
+ #
29
+ # @example Policies defined on a step
30
+ # step :sync, on_exception: [
31
+ # ExceptionPolicy.new(:reattempt!, matching: Timeout::Error, max_reattempts: 10),
32
+ # ExceptionPolicy.new(:cancel!, matching: FatalApiError),
33
+ # ExceptionPolicy.new(:skip!) # blanket fallback
34
+ # ] do
35
+ # ExternalApi.sync(hero)
36
+ # end
37
+ # # StepDefinition wraps this array in a CombinedExceptionPolicy automatically.
38
+ # # - Timeout::Error -> reattempt (up to 10 times)
39
+ # # - FatalApiError -> cancel
40
+ # # - anything else -> skip
41
+ # # - global cap -> 10 (the only finite max_reattempts in the array)
42
+ #
43
+ # @see ExceptionPolicy
44
+ # @see StepDefinition#exception_policy
45
+ # @api private
46
+ class GenevaDrive::CombinedExceptionPolicy
47
+ # @return [Array<GenevaDrive::ExceptionPolicy>] the constituent policies
48
+ attr_reader :policies
49
+
50
+ # @param policies [Array<GenevaDrive::ExceptionPolicy>] policies to combine
51
+ def initialize(policies)
52
+ @policies = Array(policies)
53
+ end
54
+
55
+ # Returns true if any constituent policy captures the given error.
56
+ #
57
+ # @param error [Exception]
58
+ # @return [Boolean]
59
+ def captures?(error)
60
+ @policies.any? { |policy| policy.captures?(error) }
61
+ end
62
+
63
+ alias_method :matches?, :captures?
64
+
65
+ # Applies the first matching policy to the given error.
66
+ #
67
+ # Walks policies in order, delegating to the first one that captures the
68
+ # error. Before delegating a reattempt, checks the global cap
69
+ # ({#max_reattempts}) and overrides with the matched policy's
70
+ # +terminal_action+ if exceeded.
71
+ #
72
+ # @param error [Exception] the exception that was raised
73
+ # @param reattempt_count [Integer] consecutive reattempts so far
74
+ # @param workflow [GenevaDrive::Workflow] the workflow instance
75
+ # @return [Hash, nil] result Hash (see {ExceptionPolicy#apply}), or nil if
76
+ # no policy captures the error
77
+ #
78
+ # @see ExceptionPolicy#apply
79
+ def apply(error, reattempt_count:, workflow:)
80
+ child = @policies.find { |p| p.captures?(error) }
81
+ return nil unless child
82
+
83
+ result = child.apply(error, reattempt_count: reattempt_count, workflow: workflow)
84
+
85
+ # Enforce global reattempt cap (min max_reattempts across all children).
86
+ # If the matched policy wants to reattempt but the global cap is exceeded,
87
+ # override with the matched policy's terminal_action.
88
+ cap = max_reattempts
89
+ if cap && result[:action] == :reattempt && reattempt_count >= cap
90
+ terminal = child.terminal_action.to_s.chomp("!").to_sym
91
+ return {action: terminal, error: error, report: result[:report], terminal: true}
92
+ end
93
+
94
+ result
95
+ end
96
+
97
+ # Returns the effective global reattempt cap: the minimum max_reattempts
98
+ # across all constituent policies (ignoring nil = unlimited).
99
+ # Returns nil if all policies have unlimited reattempts.
100
+ #
101
+ # @return [Integer, nil]
102
+ def max_reattempts
103
+ caps = @policies.filter_map(&:max_reattempts)
104
+ caps.empty? ? nil : caps.min
105
+ end
106
+ end
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundles exception handling configuration into a reusable object.
4
+ # Can be used at both class level (via `on_exception`) and step level
5
+ # (via the `on_exception:` keyword argument).
6
+ #
7
+ # Supports two mutually exclusive modes:
8
+ #
9
+ # **Declarative mode** — specify an action symbol and options:
10
+ # ExceptionPolicy.new(:reattempt!, wait: 15.seconds, max_reattempts: 5)
11
+ #
12
+ # **Imperative mode** — provide a block that receives the exception
13
+ # and calls flow control methods in the workflow context:
14
+ # ExceptionPolicy.new { |error| reattempt!(wait: error.retry_after) }
15
+ #
16
+ # Policies can optionally target specific exception classes via the +matching:+
17
+ # keyword. A policy without +matching:+ is a "blanket" policy that matches any
18
+ # exception. A policy with +matching:+ is a "specific" policy that only fires
19
+ # for errors matching the given class(es).
20
+ #
21
+ # Multiple policies can be composed into an array and passed to a step's
22
+ # +on_exception:+ option. GenevaDrive wraps the array in a
23
+ # {CombinedExceptionPolicy} that walks specific policies first, then falls back
24
+ # to the first blanket policy. If no policy in the array matches, resolution
25
+ # continues at the class level.
26
+ #
27
+ # @example Declarative policy with exception matching
28
+ # ExceptionPolicy.new(:reattempt!, matching: Net::OpenTimeout, max_reattempts: 5)
29
+ #
30
+ # @example Composing multiple policies for a step
31
+ # step :sync, on_exception: [
32
+ # ExceptionPolicy.new(:reattempt!, matching: Timeout::Error, max_reattempts: 10),
33
+ # ExceptionPolicy.new(:cancel!, matching: OAuth2::Error),
34
+ # ExceptionPolicy.new(:skip!) # blanket fallback
35
+ # ] do
36
+ # ExternalApi.sync(hero)
37
+ # end
38
+ #
39
+ # @see CombinedExceptionPolicy
40
+ # @api public
41
+ class GenevaDrive::ExceptionPolicy
42
+ # Matches exceptions by class name without requiring the constant to be
43
+ # loaded at definition time. The class lookup happens lazily on each #===
44
+ # call. If the constant cannot be resolved, the matcher returns false
45
+ # (no match) rather than raising.
46
+ #
47
+ # @example
48
+ # on_exception "SomeLib::TransientError", action: :reattempt!
49
+ #
50
+ # @api private
51
+ class LazyExceptionMatcher
52
+ # @param class_name [String] fully-qualified exception class name
53
+ def initialize(class_name)
54
+ @class_name = class_name.to_s.freeze
55
+ end
56
+
57
+ # @param error [Exception]
58
+ # @return [Boolean]
59
+ def ===(error)
60
+ klass = @class_name.safe_constantize
61
+ klass ? error.is_a?(klass) : false
62
+ end
63
+
64
+ def inspect
65
+ "#<LazyExceptionMatcher #{@class_name}>"
66
+ end
67
+ end
68
+
69
+ # Valid action values (same as StepDefinition::EXCEPTION_HANDLERS)
70
+ VALID_ACTIONS = %i[pause! cancel! reattempt! skip!].freeze
71
+
72
+ # @return [Symbol, nil] the action (:pause!, :cancel!, :reattempt!, :skip!) — nil in imperative mode
73
+ attr_reader :action
74
+
75
+ # @return [ActiveSupport::Duration, nil] wait time before reattempt
76
+ attr_reader :wait
77
+
78
+ # @return [Integer, nil] maximum consecutive reattempts before pausing (nil = unlimited)
79
+ attr_reader :max_reattempts
80
+
81
+ # @return [Symbol] what to do when max_reattempts is exceeded (:pause!, :cancel!, or :skip!)
82
+ attr_reader :terminal_action
83
+
84
+ # @return [Array<#===>] exception matchers this policy checks (empty = match all).
85
+ # Each entry can be an Exception subclass or any object responding to #===.
86
+ attr_reader :exception_matchers
87
+
88
+ # @return [Symbol] when to report the exception via Rails.error.report (:always, :never, or :terminal_only)
89
+ attr_reader :report
90
+
91
+ # @return [Proc, nil] the handler block (imperative mode)
92
+ attr_reader :handler
93
+
94
+ # Creates a new exception policy.
95
+ #
96
+ # Valid terminal_action values
97
+ VALID_TERMINAL_ACTIONS = %i[pause! cancel! skip!].freeze
98
+
99
+ # Valid report values
100
+ VALID_REPORT_OPTIONS = %i[always never terminal_only].freeze
101
+
102
+ # @overload initialize(action, matching: nil, wait: nil, max_reattempts: nil, terminal_action: :pause!, report: :always)
103
+ # Declarative mode — specify action and options.
104
+ # @param action [Symbol] the flow control action (:pause!, :cancel!, :reattempt!, :skip!)
105
+ # @param matching [Class, String, #===, Array<Class, String, #===>, nil] exception classes
106
+ # this policy applies to. When +nil+ (the default), the policy matches any exception
107
+ # (blanket policy). When set, only errors matching the given class(es) trigger this policy
108
+ # (specific policy). Strings are resolved lazily via +safe_constantize+, so the exception
109
+ # class does not need to be loaded at definition time.
110
+ # @param wait [ActiveSupport::Duration, nil] wait time before reattempt
111
+ # @param max_reattempts [Integer, nil] max consecutive reattempts (nil = unlimited)
112
+ # @param terminal_action [Symbol] what to do when max_reattempts is exceeded (:pause!, :cancel!, or :skip!)
113
+ # @param report [Symbol] when to report the exception to +Rails.error.report+.
114
+ # - +:always+ (default) — report every exception regardless of what action is taken
115
+ # - +:never+ — never report; the exception is expected and handled by the policy
116
+ # - +:terminal_only+ — suppress reports while the step is being reattempted, but
117
+ # report when reattempts are exhausted and the +terminal_action+ fires
118
+ #
119
+ # @overload initialize(matching: nil, report: :always, &block)
120
+ # Imperative mode — block receives exception, runs in workflow context.
121
+ # Must call a flow control method (reattempt!, cancel!, pause!, skip!).
122
+ # Can be combined with +matching:+ and +report:+ to target specific exception classes
123
+ # and control error reporting.
124
+ # @param matching [Class, String, #===, Array<Class, String, #===>, nil] exception classes
125
+ # @param report [Symbol] when to report the exception (+:always+, +:never+, or +:terminal_only+)
126
+ # @yield [error] the exception that was raised
127
+ #
128
+ # @example Blanket reattempt policy
129
+ # ExceptionPolicy.new(:reattempt!, wait: 30.seconds, max_reattempts: 5)
130
+ #
131
+ # @example Specific policy matching a single exception class
132
+ # ExceptionPolicy.new(:reattempt!, matching: Net::OpenTimeout, max_reattempts: 10)
133
+ #
134
+ # @example Specific policy matching multiple exception classes
135
+ # ExceptionPolicy.new(:cancel!, matching: [OAuth2::Error, "Faraday::ConnectionFailed"])
136
+ #
137
+ # @example Imperative policy
138
+ # ExceptionPolicy.new { |error| reattempt!(wait: error.retry_after) }
139
+ #
140
+ # @example Imperative policy with exception matching
141
+ # ExceptionPolicy.new(matching: Timeout::Error) { |error| reattempt!(wait: error.retry_after) }
142
+ #
143
+ # @example Suppress error reporting for expected exceptions
144
+ # ExceptionPolicy.new(:reattempt!, matching: RateLimitError, report: :never)
145
+ #
146
+ # @example Only report when reattempts are exhausted
147
+ # ExceptionPolicy.new(:reattempt!, matching: Net::OpenTimeout, max_reattempts: 5, report: :terminal_only)
148
+ def initialize(action = nil, wait: nil, max_reattempts: nil, terminal_action: :pause!, matching: nil, report: :always, &block)
149
+ @report = report
150
+ unless VALID_REPORT_OPTIONS.include?(@report)
151
+ raise ArgumentError,
152
+ "report: must be one of #{VALID_REPORT_OPTIONS.join(", ")}, got #{@report.inspect}"
153
+ end
154
+ if block
155
+ if action || wait || max_reattempts || terminal_action != :pause!
156
+ raise ArgumentError,
157
+ "Cannot pass action, wait, max_reattempts, or terminal_action when a block is given"
158
+ end
159
+ @handler = block
160
+ @action = nil
161
+ @wait = nil
162
+ @max_reattempts = nil
163
+ @terminal_action = :pause!
164
+ else
165
+ raise ArgumentError, "Either an action or a block is required" unless action
166
+ @handler = nil
167
+ @action = action
168
+ @wait = wait
169
+ @max_reattempts = max_reattempts
170
+ @terminal_action = terminal_action
171
+ validate!
172
+ end
173
+
174
+ @exception_matchers = build_matchers(matching)
175
+ end
176
+
177
+ # Returns true if this is a declarative policy (action symbol, no block).
178
+ #
179
+ # @return [Boolean]
180
+ def declarative?
181
+ handler.nil?
182
+ end
183
+
184
+ # Returns true if this policy captures the given error.
185
+ # A policy with no exception matchers captures all errors (blanket policy).
186
+ # A policy with matchers only captures errors matching the given class(es).
187
+ #
188
+ # @param error [Exception] the exception to check
189
+ # @return [Boolean]
190
+ def captures?(error)
191
+ exception_matchers.empty? || exception_matchers.any? { |matcher| matcher === error }
192
+ end
193
+
194
+ alias_method :matches?, :captures?
195
+
196
+ # Returns true if this policy has exception matchers.
197
+ #
198
+ # @return [Boolean]
199
+ def specific?
200
+ exception_matchers.any?
201
+ end
202
+
203
+ # Returns true if this is a blanket policy (no exception matchers).
204
+ #
205
+ # @return [Boolean]
206
+ def blanket?
207
+ exception_matchers.empty?
208
+ end
209
+
210
+ # Applies this policy to the given error and returns a result Hash describing
211
+ # the action to take.
212
+ #
213
+ # For declarative policies, checks the reattempt limit and returns the
214
+ # appropriate action. For imperative policies, executes the handler block
215
+ # in the workflow context and translates the resulting flow control signal
216
+ # into a result.
217
+ #
218
+ # The returned Hash always contains +:action+ (Symbol without +!+),
219
+ # +:error+ (the original exception), and +:report+ (the reporting mode).
220
+ # Reattempt results also include +:wait+. When the terminal action fires
221
+ # (reattempt limit exceeded), +:terminal+ is set to +true+.
222
+ #
223
+ # @param error [Exception] the exception that was raised
224
+ # @param reattempt_count [Integer] consecutive reattempts so far for this step
225
+ # @param workflow [GenevaDrive::Workflow] the workflow instance (needed for imperative handlers)
226
+ # @return [Hash] result with +:action+, +:error+, and optionally +:wait+ keys
227
+ #
228
+ # @example Declarative policy result
229
+ # policy = ExceptionPolicy.new(:reattempt!, wait: 5.seconds, max_reattempts: 3)
230
+ # policy.apply(error, reattempt_count: 0, workflow: wf)
231
+ # # => { action: :reattempt, wait: 5.seconds, error: error }
232
+ #
233
+ # @see CombinedExceptionPolicy#apply
234
+ def apply(error, reattempt_count:, workflow:)
235
+ if handler
236
+ apply_imperative(error, workflow)
237
+ else
238
+ apply_declarative(error, reattempt_count)
239
+ end
240
+ end
241
+
242
+ # Returns this policy wrapped in an Array for uniform iteration.
243
+ #
244
+ # {CombinedExceptionPolicy} stores its children in an Array via {#policies}.
245
+ # This method lets callers use +exception_policy.policies+ on either type
246
+ # without branching, producing a flat list of leaf policies.
247
+ #
248
+ # @return [Array<GenevaDrive::ExceptionPolicy>]
249
+ # @see CombinedExceptionPolicy#policies
250
+ def policies
251
+ [self]
252
+ end
253
+
254
+ private
255
+
256
+ # Applies an imperative policy by executing the handler block in the workflow
257
+ # context. The block must call a flow control method (reattempt!, cancel!, etc.).
258
+ # If it doesn't, defaults to :pause.
259
+ #
260
+ # @param error [Exception]
261
+ # @param workflow [GenevaDrive::Workflow]
262
+ # @return [Hash]
263
+ def apply_imperative(error, workflow)
264
+ signal = catch(:flow_control) do
265
+ workflow.instance_exec(error, &handler)
266
+ nil
267
+ end
268
+
269
+ if signal.is_a?(GenevaDrive::FlowControlSignal)
270
+ {action: signal.action, wait: signal.options[:wait], error: error, report: @report}
271
+ else
272
+ {action: :pause, error: error, report: @report}
273
+ end
274
+ end
275
+
276
+ # Applies a declarative policy by checking the reattempt limit and returning
277
+ # the appropriate action.
278
+ #
279
+ # @param error [Exception]
280
+ # @param reattempt_count [Integer]
281
+ # @return [Hash]
282
+ def apply_declarative(error, reattempt_count)
283
+ if action == :reattempt! && max_reattempts && reattempt_count >= max_reattempts
284
+ terminal = terminal_action.to_s.chomp("!").to_sym
285
+ {action: terminal, error: error, report: @report, terminal: true}
286
+ else
287
+ {action: action.to_s.chomp("!").to_sym, wait: wait, error: error, report: @report}
288
+ end
289
+ end
290
+
291
+ # Builds an array of exception matchers from the matching: argument.
292
+ # Strings become LazyExceptionMatcher, classes must be Exception subclasses,
293
+ # and anything else must respond to #===.
294
+ #
295
+ # @param raw [Class, String, #===, Array, nil]
296
+ # @return [Array<#===>]
297
+ def build_matchers(raw)
298
+ return [] if raw.nil?
299
+
300
+ Array(raw).map do |matcher|
301
+ if matcher.is_a?(String)
302
+ LazyExceptionMatcher.new(matcher)
303
+ elsif matcher.is_a?(Class)
304
+ unless matcher <= Exception
305
+ raise ArgumentError,
306
+ "Expected an Exception subclass, got #{matcher.inspect}"
307
+ end
308
+ matcher
309
+ elsif matcher.respond_to?(:===)
310
+ matcher
311
+ else
312
+ raise ArgumentError,
313
+ "Expected an exception matcher (Exception subclass, String, or object responding to #===), got #{matcher.inspect}"
314
+ end
315
+ end
316
+ end
317
+
318
+ # Validates declarative mode configuration.
319
+ #
320
+ # @raise [ArgumentError] if configuration is invalid
321
+ def validate!
322
+ unless VALID_ACTIONS.include?(@action)
323
+ raise ArgumentError,
324
+ "Invalid action #{@action.inspect}: must be one of #{VALID_ACTIONS.join(", ")}"
325
+ end
326
+
327
+ if @wait && @action != :reattempt!
328
+ raise ArgumentError,
329
+ "wait: only makes sense with action: :reattempt!"
330
+ end
331
+
332
+ if @max_reattempts
333
+ unless @action == :reattempt!
334
+ raise ArgumentError,
335
+ "max_reattempts: only makes sense with action: :reattempt!"
336
+ end
337
+
338
+ unless @max_reattempts.is_a?(Integer) && @max_reattempts > 0
339
+ raise ArgumentError,
340
+ "max_reattempts: must be a positive integer or nil"
341
+ end
342
+ end
343
+
344
+ unless VALID_TERMINAL_ACTIONS.include?(@terminal_action)
345
+ raise ArgumentError,
346
+ "terminal_action: must be one of #{VALID_TERMINAL_ACTIONS.join(", ")}, got #{@terminal_action.inspect}"
347
+ end
348
+
349
+ if @terminal_action != :pause! && @action != :reattempt!
350
+ raise ArgumentError,
351
+ "terminal_action: only makes sense with action: :reattempt!"
352
+ end
353
+ end
354
+ end