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 =

rubocop:disable Metrics/ClassLength

'.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)

162
163
164
165
166
167
168
169
# File 'lib/faulty/circuit.rb', line 162

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]


351
352
353
# File 'lib/faulty/circuit.rb', line 351

def history
  storage.history(self)
end

#lock_closed!self

Force the circuit to stay closed until unlocked

Returns:

  • (self)

308
309
310
311
# File 'lib/faulty/circuit.rb', line 308

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

#lock_open!self

Force the circuit to stay open until unlocked

Returns:

  • (self)

300
301
302
303
# File 'lib/faulty/circuit.rb', line 300

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


213
214
215
216
217
218
219
# File 'lib/faulty/circuit.rb', line 213

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)

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

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


285
286
287
288
289
290
291
292
293
294
295
# File 'lib/faulty/circuit.rb', line 285

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:


340
341
342
# File 'lib/faulty/circuit.rb', line 340

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.


250
251
252
253
254
# File 'lib/faulty/circuit.rb', line 250

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)

316
317
318
319
# File 'lib/faulty/circuit.rb', line 316

def unlock!
  storage.unlock(self)
  self
end