Class: GenevaDrive::ExceptionPolicy

Inherits:
Object
  • Object
show all
Defined in:
lib/geneva_drive/exception_policy.rb

Overview

Bundles exception handling configuration into a reusable object. Can be used at both class level (via on_exception) and step level (via the on_exception: keyword argument).

Supports two mutually exclusive modes:

Declarative mode — specify an action symbol and options: ExceptionPolicy.new(:reattempt!, wait: 15.seconds, max_reattempts: 5)

Imperative mode — provide a block that receives the exception and calls flow control methods in the workflow context: ExceptionPolicy.new { |error| reattempt!(wait: error.retry_after) }

Policies can optionally target specific exception classes via the +matching:+ keyword. A policy without +matching:+ is a "blanket" policy that matches any exception. A policy with +matching:+ is a "specific" policy that only fires for errors matching the given class(es).

Multiple policies can be composed into an array and passed to a step's +on_exception:+ option. GenevaDrive wraps the array in a CombinedExceptionPolicy that walks specific policies first, then falls back to the first blanket policy. If no policy in the array matches, resolution continues at the class level.

Examples:

Declarative policy with exception matching

ExceptionPolicy.new(:reattempt!, matching: Net::OpenTimeout, max_reattempts: 5)

Composing multiple policies for a step

step :sync, on_exception: [
  ExceptionPolicy.new(:reattempt!, matching: Timeout::Error, max_reattempts: 10),
  ExceptionPolicy.new(:cancel!,    matching: OAuth2::Error),
  ExceptionPolicy.new(:skip!)  # blanket fallback
] do
  ExternalApi.sync(hero)
end

See Also:

Defined Under Namespace

Classes: LazyExceptionMatcher

Constant Summary collapse

VALID_ACTIONS =

Valid action values (same as StepDefinition::EXCEPTION_HANDLERS)

%i[pause! cancel! reattempt! skip!].freeze
VALID_TERMINAL_ACTIONS =

Creates a new exception policy.

Valid terminal_action values

%i[pause! cancel! skip!].freeze
VALID_REPORT_OPTIONS =

Valid report values

%i[always never terminal_only].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(action, matching: nil, wait: nil, max_reattempts: nil, terminal_action: :pause!, report: :always) ⇒ ExceptionPolicy #initialize(matching: nil, report: :always) {|error| ... } ⇒ ExceptionPolicy

Returns a new instance of ExceptionPolicy.

Examples:

Blanket reattempt policy

ExceptionPolicy.new(:reattempt!, wait: 30.seconds, max_reattempts: 5)

Specific policy matching a single exception class

ExceptionPolicy.new(:reattempt!, matching: Net::OpenTimeout, max_reattempts: 10)

Specific policy matching multiple exception classes

ExceptionPolicy.new(:cancel!, matching: [OAuth2::Error, "Faraday::ConnectionFailed"])

Imperative policy

ExceptionPolicy.new { |error| reattempt!(wait: error.retry_after) }

Imperative policy with exception matching

ExceptionPolicy.new(matching: Timeout::Error) { |error| reattempt!(wait: error.retry_after) }

Suppress error reporting for expected exceptions

ExceptionPolicy.new(:reattempt!, matching: RateLimitError, report: :never)

Only report when reattempts are exhausted

ExceptionPolicy.new(:reattempt!, matching: Net::OpenTimeout, max_reattempts: 5, report: :terminal_only)

Overloads:

  • #initialize(action, matching: nil, wait: nil, max_reattempts: nil, terminal_action: :pause!, report: :always) ⇒ ExceptionPolicy

    Declarative mode — specify action and options.

    Parameters:

    • action (Symbol)

      the flow control action (:pause!, :cancel!, :reattempt!, :skip!)

    • matching (Class, String, #===, Array<Class, String, #===>, nil) (defaults to: nil)

      exception classes this policy applies to. When +nil+ (the default), the policy matches any exception (blanket policy). When set, only errors matching the given class(es) trigger this policy (specific policy). Strings are resolved lazily via +safe_constantize+, so the exception class does not need to be loaded at definition time.

    • wait (ActiveSupport::Duration, nil) (defaults to: nil)

      wait time before reattempt

    • max_reattempts (Integer, nil) (defaults to: nil)

      max consecutive reattempts (nil = unlimited)

    • terminal_action (Symbol) (defaults to: :pause!)

      what to do when max_reattempts is exceeded (:pause!, :cancel!, or :skip!)

    • report (Symbol) (defaults to: :always)

      when to report the exception to +Rails.error.report+.

      • +:always+ (default) — report every exception regardless of what action is taken
      • +:never+ — never report; the exception is expected and handled by the policy
      • +:terminal_only+ — suppress reports while the step is being reattempted, but report when reattempts are exhausted and the +terminal_action+ fires
  • #initialize(matching: nil, report: :always) {|error| ... } ⇒ ExceptionPolicy

    Imperative mode — block receives exception, runs in workflow context. Must call a flow control method (reattempt!, cancel!, pause!, skip!). Can be combined with +matching:+ and +report:+ to target specific exception classes and control error reporting.

    Parameters:

    • matching (Class, String, #===, Array<Class, String, #===>, nil) (defaults to: nil)

      exception classes

    • report (Symbol) (defaults to: :always)

      when to report the exception (+:always+, +:never+, or +:terminal_only+)

    Yields:

    • (error)

      the exception that was raised



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/geneva_drive/exception_policy.rb', line 148

def initialize(action = nil, wait: nil, max_reattempts: nil, terminal_action: :pause!, matching: nil, report: :always, &block)
  @report = report
  unless VALID_REPORT_OPTIONS.include?(@report)
    raise ArgumentError,
      "report: must be one of #{VALID_REPORT_OPTIONS.join(", ")}, got #{@report.inspect}"
  end
  if block
    if action || wait || max_reattempts || terminal_action != :pause!
      raise ArgumentError,
        "Cannot pass action, wait, max_reattempts, or terminal_action when a block is given"
    end
    @handler = block
    @action = nil
    @wait = nil
    @max_reattempts = nil
    @terminal_action = :pause!
  else
    raise ArgumentError, "Either an action or a block is required" unless action
    @handler = nil
    @action = action
    @wait = wait
    @max_reattempts = max_reattempts
    @terminal_action = terminal_action
    validate!
  end

  @exception_matchers = build_matchers(matching)
end

Instance Attribute Details

#actionSymbol? (readonly)

Returns the action (:pause!, :cancel!, :reattempt!, :skip!) — nil in imperative mode.

Returns:

  • (Symbol, nil)

    the action (:pause!, :cancel!, :reattempt!, :skip!) — nil in imperative mode



73
74
75
# File 'lib/geneva_drive/exception_policy.rb', line 73

def action
  @action
end

#exception_matchersArray<#===> (readonly)

Returns exception matchers this policy checks (empty = match all). Each entry can be an Exception subclass or any object responding to #===.

Returns:

  • (Array<#===>)

    exception matchers this policy checks (empty = match all). Each entry can be an Exception subclass or any object responding to #===.



86
87
88
# File 'lib/geneva_drive/exception_policy.rb', line 86

def exception_matchers
  @exception_matchers
end

#handlerProc? (readonly)

Returns the handler block (imperative mode).

Returns:

  • (Proc, nil)

    the handler block (imperative mode)



92
93
94
# File 'lib/geneva_drive/exception_policy.rb', line 92

def handler
  @handler
end

#max_reattemptsInteger? (readonly)

Returns maximum consecutive reattempts before pausing (nil = unlimited).

Returns:

  • (Integer, nil)

    maximum consecutive reattempts before pausing (nil = unlimited)



79
80
81
# File 'lib/geneva_drive/exception_policy.rb', line 79

def max_reattempts
  @max_reattempts
end

#reportSymbol (readonly)

Returns when to report the exception via Rails.error.report (:always, :never, or :terminal_only).

Returns:

  • (Symbol)

    when to report the exception via Rails.error.report (:always, :never, or :terminal_only)



89
90
91
# File 'lib/geneva_drive/exception_policy.rb', line 89

def report
  @report
end

#terminal_actionSymbol (readonly)

Returns what to do when max_reattempts is exceeded (:pause!, :cancel!, or :skip!).

Returns:

  • (Symbol)

    what to do when max_reattempts is exceeded (:pause!, :cancel!, or :skip!)



82
83
84
# File 'lib/geneva_drive/exception_policy.rb', line 82

def terminal_action
  @terminal_action
end

#waitActiveSupport::Duration? (readonly)

Returns wait time before reattempt.

Returns:

  • (ActiveSupport::Duration, nil)

    wait time before reattempt



76
77
78
# File 'lib/geneva_drive/exception_policy.rb', line 76

def wait
  @wait
end

Instance Method Details

#apply(error, reattempt_count:, workflow:) ⇒ Hash

Applies this policy to the given error and returns a result Hash describing the action to take.

For declarative policies, checks the reattempt limit and returns the appropriate action. For imperative policies, executes the handler block in the workflow context and translates the resulting flow control signal into a result.

The returned Hash always contains +:action+ (Symbol without +!+), +:error+ (the original exception), and +:report+ (the reporting mode). Reattempt results also include +:wait+. When the terminal action fires (reattempt limit exceeded), +:terminal+ is set to +true+.

Examples:

Declarative policy result

policy = ExceptionPolicy.new(:reattempt!, wait: 5.seconds, max_reattempts: 3)
policy.apply(error, reattempt_count: 0, workflow: wf)
# => { action: :reattempt, wait: 5.seconds, error: error }

Parameters:

  • error (Exception)

    the exception that was raised

  • reattempt_count (Integer)

    consecutive reattempts so far for this step

  • workflow (GenevaDrive::Workflow)

    the workflow instance (needed for imperative handlers)

Returns:

  • (Hash)

    result with +:action+, +:error+, and optionally +:wait+ keys

See Also:



234
235
236
237
238
239
240
# File 'lib/geneva_drive/exception_policy.rb', line 234

def apply(error, reattempt_count:, workflow:)
  if handler
    apply_imperative(error, workflow)
  else
    apply_declarative(error, reattempt_count)
  end
end

#blanket?Boolean

Returns true if this is a blanket policy (no exception matchers).

Returns:

  • (Boolean)


206
207
208
# File 'lib/geneva_drive/exception_policy.rb', line 206

def blanket?
  exception_matchers.empty?
end

#captures?(error) ⇒ Boolean Also known as: matches?

Returns true if this policy captures the given error. A policy with no exception matchers captures all errors (blanket policy). A policy with matchers only captures errors matching the given class(es).

Parameters:

  • error (Exception)

    the exception to check

Returns:

  • (Boolean)


190
191
192
# File 'lib/geneva_drive/exception_policy.rb', line 190

def captures?(error)
  exception_matchers.empty? || exception_matchers.any? { |matcher| matcher === error }
end

#declarative?Boolean

Returns true if this is a declarative policy (action symbol, no block).

Returns:

  • (Boolean)


180
181
182
# File 'lib/geneva_drive/exception_policy.rb', line 180

def declarative?
  handler.nil?
end

#policiesArray<GenevaDrive::ExceptionPolicy>

Returns this policy wrapped in an Array for uniform iteration.

CombinedExceptionPolicy stores its children in an Array via #policies. This method lets callers use +exception_policy.policies+ on either type without branching, producing a flat list of leaf policies.



250
251
252
# File 'lib/geneva_drive/exception_policy.rb', line 250

def policies
  [self]
end

#specific?Boolean

Returns true if this policy has exception matchers.

Returns:

  • (Boolean)


199
200
201
# File 'lib/geneva_drive/exception_policy.rb', line 199

def specific?
  exception_matchers.any?
end