Class: Faulty::Circuit

Inherits:
Object
  • Object
show all
Defined in:
lib/faulty/circuit.rb

Overview

Runs code protected by a circuit breaker

https://www.martinfowler.com/bliki/CircuitBreaker.html

A circuit is intended to protect against repeated calls to a failing external dependency. For example, a vendor API may be failing continuously. In that case, we trip the circuit breaker and stop calling that API for a specified cool-down period.

Once the cool-down passes, we try the API again, and if it succeeds, we reset the circuit.

Why isn't there a timeout option?

Timeout is inherently unsafe, and should not be used blindly. See Why Ruby's timeout is Dangerous.

You should prefer a network timeout like open_timeout and read_timeout, or write your own code to periodically check how long it has been running. If you're sure you want ruby's generic Timeout, you can apply it yourself inside the circuit run block.

Defined Under Namespace

Classes: Options

Constant Summary collapse

CACHE_REFRESH_SUFFIX =
'.faulty_refresh'

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name, **options) {|Options| ... } ⇒ Circuit

Returns a new instance of Circuit.

Parameters:

  • name (String)

    The name of the circuit

  • options (Hash)

    Attributes for Options

Yields:

  • (Options)

    For setting options in a block

Raises:

  • (ArgumentError)


181
182
183
184
185
186
187
188
# File 'lib/faulty/circuit.rb', line 181

def initialize(name, **options, &block)
  raise ArgumentError, 'name must be a String' unless name.is_a?(String)

  @name = name
  @given_options = Options.new(options, &block)
  @pulled_options = nil
  @options_pushed = false
end

Instance Attribute Details

#nameObject (readonly)

Returns the value of attribute name.



29
30
31
# File 'lib/faulty/circuit.rb', line 29

def name
  @name
end

Instance Method Details

#historyArray<Array>

Get the history of runs of this circuit

The history is an array of tuples where the first value is the run time, and the second value is a boolean which is true if the run was successful.

Returns:

  • (Array<Array>)

    ] An array of tuples of [run_time, is_success]



370
371
372
# File 'lib/faulty/circuit.rb', line 370

def history
  storage.history(self)
end

#inspectString

Returns Text representation of the circuit.

Returns:

  • (String)

    Text representation of the circuit



164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/faulty/circuit.rb', line 164

def inspect
  interested_opts = %i[
    cache_expires_in
    cache_refreshes_after
    cache_refresh_jitter
    cool_down evaluation_window
    rate_threshold
    sample_threshold
    errors exclude
  ]
  options_text = options.each_pair.map { |k, v| "#{k}: #{v}" if interested_opts.include?(k) }.compact.join(', ')
  %(#<#{self.class.name} name: #{name}, state: #{status.state}, options: { #{options_text} }>)
end

#lock_closed!self

Force the circuit to stay closed until unlocked

Returns:

  • (self)


327
328
329
330
# File 'lib/faulty/circuit.rb', line 327

def lock_closed!
  storage.lock(self, :closed)
  self
end

#lock_open!self

Force the circuit to stay open until unlocked

Returns:

  • (self)


319
320
321
322
# File 'lib/faulty/circuit.rb', line 319

def lock_open!
  storage.lock(self, :open)
  self
end

#optionsOptions

Get the options for this circuit

If this circuit has been run, these will the options exactly as given to new. However, if this circuit has not yet been run, these options will be supplemented by the last-known options from the circuit storage.

Once a circuit is run, the given options are pushed to circuit storage to be persisted.

This is to allow circuit objects to behave as expected in contexts where the exact options for a circuit are not known such as an admin dashboard or in a debug console.

Note that this distinction isn't usually important unless using distributed circuit storage like the Redis storage backend.

Examples:

Faulty.circuit('api', cool_down: 5).run { api.users }
# This status will be calculated using the cool_down of 5 because
# the circuit was already run
Faulty.circuit('api').status
# This status will be calculated using the cool_down in circuit storage
# if it is available instead of using the default value.
Faulty.circuit('api').status
# For typical usage, this behaves as expected, but note that it's
# possible to run into some unexpected behavior when creating circuits
# in unusual ways.

# For example, this status will be calculated using the cool_down in
# circuit storage if it is available despite the given value of 5.
Faulty.circuit('api', cool_down: 5).status
Faulty.circuit('api').run { api.users }
# However now, after the circuit is run, status will be calculated
# using the given cool_down of 5 and the value of 5 will be pushed
# permanently to circuit storage
Faulty.circuit('api').status

Returns:

  • (Options)

    The resolved options



232
233
234
235
236
237
238
# File 'lib/faulty/circuit.rb', line 232

def options
  return @given_options if @options_pushed
  return @pulled_options if @pulled_options

  stored = @given_options.storage.get_options(self)
  @pulled_options = stored ? @given_options.dup_with(stored) : @given_options
end

#reset!self

Reset this circuit to its initial state

This removes the current state, all history, and locks

Returns:

  • (self)


345
346
347
348
349
350
# File 'lib/faulty/circuit.rb', line 345

def reset!
  @options_pushed = false
  @pulled_options = nil
  storage.reset(self)
  self
end

#run(cache: nil) { ... } ⇒ Object

Run a block protected by this circuit

If the circuit is closed, the block will run. Any exceptions raised inside the block will be checked against the error and exclude options to determine whether that error should be captured. If the error is captured, this run will be recorded as a failure.

If the circuit exceeds the failure conditions, this circuit will be tripped and marked as open. Any future calls to run will not execute the block, but instead wait for the cool down period. Once the cool down period passes, the circuit transitions to half-open, and the block will be allowed to run.

If the circuit fails again while half-open, the circuit will be closed for a second cool down period. However, if the circuit completes successfully, the circuit will be closed and reset to its initial state.

When this is run, the given options are persisted to the storage backend.

Parameters:

  • cache (String, nil) (defaults to: nil)

    A cache key, or nil if caching is not desired

Yields:

  • The block to protect with this circuit

Returns:

  • The return value of the block

Raises:

  • If the block raises an error not in the error list, or if the error is excluded.

  • (OpenCircuitError)

    if the circuit is open

  • (CircuitTrippedError)

    if this run causes the circuit to trip. It's possible for concurrent runs to simultaneously trip the circuit if the storage engine is not concurrency-safe.

  • (CircuitFailureError)

    if this run fails, but doesn't cause the circuit to trip



304
305
306
307
308
309
310
311
312
313
314
# File 'lib/faulty/circuit.rb', line 304

def run(cache: nil, &block)
  push_options
  cached_value = cache_read(cache)
  # return cached unless cached.nil?
  return cached_value if !cached_value.nil? && !cache_should_refresh?(cache)

  current_status = status
  return run_skipped(cached_value) unless current_status.can_run?

  run_exec(current_status, cached_value, cache, &block)
end

#statusStatus

Get the current status of the circuit

This method is not safe for concurrent operations, so it's unsafe to check this method and make runtime decisions based on that. However, it's useful for getting a non-synchronized snapshot of a circuit.

Returns:



359
360
361
# File 'lib/faulty/circuit.rb', line 359

def status
  storage.status(self)
end

#try_run(**options) { ... } ⇒ Result<Object, Error>

Run the circuit as with #run, but return a Result

This is syntax sugar for running a circuit and rescuing an error

Examples:

result = Faulty.circuit(:api).try_run do
  api.get
end

response = if result.ok?
  result.get
else
  { error: result.error.message }
end
# The Result object has a fetch method that can return a default value
# if an error occurs
result = Faulty.circuit(:api).try_run do
  api.get
end.fetch({})

Parameters:

  • cache (String, nil)

    A cache key, or nil if caching is not desired

Yields:

  • The block to protect with this circuit

Returns:

  • (Result<Object, Error>)

    A result where the ok value is the return value of the block, or the error value is an error captured by the circuit.

Raises:

  • If the block raises an error not in the error list, or if the error is excluded.



269
270
271
272
273
# File 'lib/faulty/circuit.rb', line 269

def try_run(**options, &block)
  Result.new(ok: run(**options, &block))
rescue FaultyError => e
  Result.new(error: e)
end

#unlock!self

Remove any open or closed locks

Returns:

  • (self)


335
336
337
338
# File 'lib/faulty/circuit.rb', line 335

def unlock!
  storage.unlock(self)
  self
end