Giter Site home page Giter Site logo

stoplight's Introduction

Version badge Build badge Coverage badge Climate badge

Stoplight is traffic control for code. It's an implementation of the circuit breaker pattern in Ruby.


⚠️️ You're currently browsing the documentation for Stoplight 4.x. If you're looking for the documentation of the previous version 3.x, you can find it here.

Does your code use unreliable systems, like a flaky database or a spotty web service? Wrap calls to those up in stoplights to prevent them from affecting the rest of your application.

Check out stoplight-admin for controlling your stoplights.

Installation

Add it to your Gemfile:

gem 'stoplight'

Or install it manually:

$ gem install stoplight

Stoplight uses Semantic Versioning. Check out the change log for a detailed list of changes.

Basic Usage

To get started, create a stoplight:

light = Stoplight('example-pi')

Then you can run it with a block of code and it will return the result of calling the block. This is the green state. (The green state corresponds to the closed state for circuit breakers.)

light.run { 22.0 / 7 }
# => 3.142857142857143
light.color
# => "green"

If everything goes well, you shouldn't even be able to tell that you're using a stoplight. That's not very interesting though, so let's make stoplight fail.

When you run it, the error will be recorded and passed through. After running it a few times, the stoplight will stop trying and fail fast. This is the red state. (The red state corresponds to the open state for circuit breakers.)

light = Stoplight('example-zero')
# => #<Stoplight::CircuitBreaker:...>
light.run { 1 / 0 }
# ZeroDivisionError: divided by 0
light.run { 1 / 0 }
# ZeroDivisionError: divided by 0
light.run { 1 / 0 }
# Switching example-zero from green to red because ZeroDivisionError divided by 0
# ZeroDivisionError: divided by 0
light.run { 1 / 0 }
# Stoplight::Error::RedLight: example-zero
light.color
# => "red"

When the Stoplight changes from green to red, it will notify every configured notifier. See the notifiers section to learn more about notifiers.

The stoplight will move into the yellow state after being in the red state for a while. (The yellow state corresponds to the half open state for circuit breakers.) To configure how long it takes to switch into the yellow state, check out the cool off time section When stoplights are yellow, they will try to run their code. If it fails, they'll switch back to red. If it succeeds, they'll switch to green.

Custom Errors

Some errors shouldn't cause your stoplight to move into the red state. Usually these are handled elsewhere in your stack and don't represent real failures. A good example is ActiveRecord::RecordNotFound.

To prevent some errors from changing the state of your stoplight, you can provide a custom block that will be called with the error and a handler Proc. It can do one of three things:

  1. Re-raise the error. This causes Stoplight to ignore the error. Do this for errors like ActiveRecord::RecordNotFound that don't represent real failures.

  2. Call the handler with the error. This is the default behavior. Stoplight will only ignore the error if it shouldn't have been caught in the first place. See Stoplight::Error::AVOID_RESCUING for a list of errors that will be ignored.

  3. Do nothing. This is not recommended. Doing nothing causes Stoplight to never ignore the error. That means a NoMemoryError could change the color of your stoplights.

light = Stoplight('example-not-found')
  .with_error_handler do |error, handle|
    if error.is_a?(ActiveRecord::RecordNotFound)
      raise error
    else      
      handle.call(error)
    end
  end
# => #<Stoplight::CircuitBreaker:...>
light.run { User.find(123) }
# ActiveRecord::RecordNotFound: Couldn't find User with ID=123
light.run { User.find(123) }
# ActiveRecord::RecordNotFound: Couldn't find User with ID=123
light.run { User.find(123) }
# ActiveRecord::RecordNotFound: Couldn't find User with ID=123
light.color
# => "green"

Custom Fallback

By default, stoplights will re-raise errors when they're green. When they're red, they'll raise a Stoplight::Error::RedLight error. You can provide a fallback that will be called in both of these cases. It will be passed the error if the light was green.

light = Stoplight('example-fallback')
  .with_fallback { |e| p e; 'default' }
# => #<Stoplight::CircuitBreaker:..>
light.run { 1 / 0 }
# #<ZeroDivisionError: divided by 0>
# => "default"
light.run { 1 / 0 }
# #<ZeroDivisionError: divided by 0>
# => "default"
light.run { 1 / 0 }
# Switching example-fallback from green to red because ZeroDivisionError divided by 0
# #<ZeroDivisionError: divided by 0>
# => "default"
light.run { 1 / 0 }
# nil
# => "default"

Custom Threshold

Some bits of code might be allowed to fail more or less frequently than others. You can configure this by setting a custom threshold.

light = Stoplight('example-threshold')
  .with_threshold(1)
# => #<Stoplight::CircuitBreaker:...>
light.run { fail }
# Switching example-threshold from green to red because RuntimeError
# RuntimeError:
light.run { fail }
# Stoplight::Error::RedLight: example-threshold

The default threshold is 3.

Custom Window Size

By default, all recorded failures, regardless of the time these happen, will count to reach the threshold (hence turning the light to red). If needed, a window size can be set, meaning you can control how many errors per period of time will count to reach the red state.

By default, every recorded failure contributes to reaching the threshold, regardless of when it occurs, causing the stoplight to turn red. By configuring a custom window size, you control how errors are counted within a specified time frame. Here's how it works:

Let's say you set the window size to 2 seconds:

window_size_in_seconds = 2

light = Stoplight('example-threshold')
 .with_window_size(window_size_in_seconds)
 .with_threshold(1) #=> #<Stoplight::CircuitBreaker:...>

light.run { 1 / 0 } #=> #<ZeroDivisionError: divided by 0>
sleep(3)
light.run { 1 / 0 }

Without the window size configuration, the second light.run { 1 / 0 } call will result in a Stoplight::Error::RedLight exception being raised, as the stoplight transitions to the red state after the first call. With a sliding window of 2 seconds, only the errors that occur within the latest 2 seconds are considered. The first error causes the stoplight to turn red, but after 3 seconds (when the second error occurs), the window has shifted, and the stoplight switches to green state causing the error to raise again. This provides a way to focus on the most recent errors.

The default window size is infinity, so all failures counts.

Custom Cool Off Time

Stoplights will automatically attempt to recover after a certain amount of time. A light in the red state for longer than the cool off period will transition to the yellow state. This cool off time is customizable.

light = Stoplight('example-cool-off')
  .with_cool_off_time(1)
# => #<Stoplight::CircuitBreaker:...>
light.run { fail }
# RuntimeError:
light.run { fail }
# RuntimeError:
light.run { fail }
# Switching example-cool-off from green to red because RuntimeError
# RuntimeError:
sleep(1)
# => 1
light.color
# => "yellow"
light.run { fail }
# RuntimeError:

The default cool off time is 60 seconds. To disable automatic recovery, set the cool off to Float::INFINITY. To make automatic recovery instantaneous, set the cool off to 0 seconds. Note that this is not recommended, as it effectively replaces the red state with yellow.

Rails

Stoplight was designed to wrap Rails actions with minimal effort. Here's an example configuration:

class ApplicationController < ActionController::Base
  around_action :stoplight

  private

  def stoplight(&block)
    Stoplight("#{params[:controller]}##{params[:action]}")
      .with_fallback do |error|
        Rails.logger.error(error)
        render(nothing: true, status: :service_unavailable)
      end
      .run(&block)
  end
end

Setup

Data store

Stoplight uses an in-memory data store out of the box.

require 'stoplight'
# => true
Stoplight.default_data_store
# => #<Stoplight::DataStore::Memory:...>

If you want to use a persistent data store, you'll have to set it up. Currently the only supported persistent data store is Redis.

Redis

Make sure you have the Redis gem installed before configuring Stoplight.

require 'redis'
# => true
redis = Redis.new
# => #<Redis client ...>
data_store = Stoplight::DataStore::Redis.new(redis)
# => #<Stoplight::DataStore::Redis:...>
Stoplight.default_data_store = data_store
# => #<Stoplight::DataStore::Redis:...>

Notifiers

Stoplight sends notifications to standard error by default.

Stoplight.default_notifiers
# => [#<Stoplight::Notifier::IO:...>]

If you want to send notifications elsewhere, you'll have to set them up.

IO

Stoplight can notify not only into STDOUT, but into any IO object. You can configure the Stoplight::Notifier::IO notifier for that.

require 'stringio'

io = StringIO.new
# => #<StringIO:...>
notifier = Stoplight::Notifier::IO.new(io)
# => #<Stoplight::Notifier::IO:...>
Stoplight.default_notifiers += [notifier]
# => [#<Stoplight::Notifier::IO:...>, #<Stoplight::Notifier::IO:...>]

Logger

Stoplight can be configured to use the Logger class from the standard library.

require 'logger'
# => true
logger = Logger.new(STDERR)
# => #<Logger:...>
notifier = Stoplight::Notifier::Logger.new(logger)
# => #<Stoplight::Notifier::Logger:...>
Stoplight.default_notifiers += [notifier]
# => [#<Stoplight::Notifier::IO:...>, #<Stoplight::Notifier::Logger:...>]

Community-supported Notifiers

You you want to implement your own notifier, the following section contains all the required information.

Pull requests to update this section are welcome.

How to implement your own notifier?

A notifier has to implement the Stoplight::Notifier::Base interface:

def notify(light, from_color, to_color, error)
  raise NotImplementedError
end

For convenience, you can use the Stoplight::Notifier::Generic module. It takes care of the message formatting, and you have to implement only the put method, which takes message sting as an argument:

class IO < Stoplight::Notifier::Base
  include Generic
   
  private
    
  def put(message)
    @object.puts(message)
  end
end

Rails

Stoplight is designed to work seamlessly with Rails. If you want to use the in-memory data store, you don't need to do anything special. If you want to use a persistent data store, you'll need to configure it. Create an initializer for Stoplight:

# config/initializers/stoplight.rb
require 'stoplight'
Stoplight.default_data_store = Stoplight::DataStore::Redis.new(...)
Stoplight.default_notifiers += [Stoplight::Notifier::Logger.new(Rails.logger)]

Advanced usage

Locking

Although stoplights can operate on their own, occasionally you may want to override the default behavior. You can lock a light using #lock(color) method. Color should be either Stoplight::Color::GREEN or Stoplight::Color::RED.

light = Stoplight('example-locked')
# => #<Stoplight::CircuitBreaker:..>
light.run { true }
# => true
light.lock(Stoplight::Color::RED)
# => #<Stoplight::CircuitBreaker:..>
light.run { true } 
# Stoplight::Error::RedLight: example-locked

Code in locked red lights may still run under certain conditions! If you have configured a custom data store and that data store fails, Stoplight will switch over to using a blank in-memory data store. That means you will lose the locked state of any stoplights.

You can go back to using the default behavior by unlocking the stoplight using #unlock.

light.unlock
# => #<Stoplight::CircuitBreaker:..>

Testing

Stoplights typically work as expected without modification in test suites. However there are a few things you can do to make them behave better. If your stoplights are spewing messages into your test output, you can silence them with a couple configuration changes.

Stoplight.default_error_notifier = -> _ {}
Stoplight.default_notifiers = []

If your tests mysteriously fail because stoplights are the wrong color, you can try resetting the data store before each test case. For example, this would give each test case a fresh data store with RSpec.

before(:each) do
  Stoplight.default_data_store = Stoplight::DataStore::Memory.new
end

Sometimes you may want to test stoplights directly. You can avoid resetting the data store by giving each stoplight a unique name.

stoplight = Stoplight("test-#{rand}")

Maintenance Policy

Stoplight supports the latest three minor versions of Ruby, which currently are: 3.0.x, 3.1.x, and 3.2.x. Changing the minimum supported Ruby version is not considered a breaking change. We support the current stable Redis version (7.2) and the latest release of the previous major version (6.2.9)

Credits

Stoplight is brought to you by @camdez and @tfausak from @OrgSync. @bolshakov is the current maintainer of the gem. A complete list of contributors is available on GitHub. We were inspired by Martin Fowler's CircuitBreaker article.

Stoplight is licensed under the MIT License.

stoplight's People

Contributors

aaronlasseigne avatar ahaymond avatar andreaswurm avatar artygus avatar billthompson avatar bobbymcwho avatar bolshakov avatar bschaeffer avatar camdez avatar casperisfine avatar ct-clearhaus avatar dependabot[bot] avatar f-mer avatar gottfrois avatar jesse-spevack avatar jwg2s avatar knapo avatar lokideos avatar mkrogemann avatar mrdziuban avatar olleolleolle avatar tfausak avatar thedrow avatar zeisler avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

stoplight's Issues

Long running code changes red stoplight back to green too quickly

When a long-running block of code is called and the stoplight changes from green to red while that code is still running, it is possible for the failures to be cleared and the stoplight to be changed back to green as soon as the long-running code completes and run_green continues executing. Consider the following scenario, which describes a stoplight turning from green to red due to a database concurrency limit:

Event Start time Stoplight color Stoplight method Resulting action
Query 1 starts 11:00:00 Green run_green Query begins
Query 2 starts 11:00:05 Green run_green Query is rejected because of the concurrency limit; stoplight switches from green to red
Query 1 completes 11:00:10 Red run_green (continued from when query 1 started) Failures are cleared; stoplight switches from red to green

In this case, the stoplight was only red for 5 seconds, rather than the default timeout of 60 seconds. Additionally, no notifications indicate that the stoplight returned from red to green, because run_green does not supply an on_success callback.

A possible solution here is to store the color of the stoplight before running the code and only call clear_failures after the code finishes executing if the stoplight color has not changed.

Notifications should be generated with a lambda

Currently notifications are generated with String#%.

format % message

Even if the changes suggested by #41 are implemented, they will still be generated with #%.

format % [light.name, from_color, to_color]

It would be much more robust to call a lambda with the same parameters to generate the message.

format_lambda.call(light, from_color, to_color)

Threshold in percent

According to the docs, seems like the threshold is based in failed API calls in sequence (e.g. a 3 threshold will trips the circuit after the third call fails). At least is what I understood.
Is it possible to open the circuit after a certain % of calls failed?

Error percent

Hey guys,

Would it be possible to trigger red lights after N% errors rather than a hard threshold? Don't know what it would take to allow that.

Thanks!

Restoring failures from JSON fails with custom initializers

This is similar to #42.

>> class CustomError < StandardError
..   def initialize(message, something_else)
..     super(message)
..     end
..   end
=> nil
>> json = Stoplight::Failure.new(CustomError.new('custom message', 'whatever')).to_json
=> "{\"error\":\"#<CustomError: custom message>\",\"time\":\"2014-09-12 11:46:56 -0500\"}"
>> Stoplight::Failure.from_json(json)
ArgumentError: wrong number of arguments (1 for 2)

Stoplights can change state without notifying

$fail = true
light = Stoplight::Light.
  new('example') { fail if $fail }.
  with_threshold(1).
  with_timeout(-1)
light.run

$fail = false
light.run
# Switching example from red to green

Clarify using timeout to disable automatic recovery

In the readme, it says: "Set the timeout to -1 to disable automatic recovery." Based on this, I would have expected that after enough failures it would remain red indefinitely (i.e. not automatically recover), but this is not the case. It actually goes immediately back to yellow (because it's been longer since the failure than the negative timeout).

Is this a bug or am I just reading it incorrectly?

Should stoplight use exponential backoff?

As I can see, stoplight always wait the same amount of time before switching from red to yellow.

After a few unsuccessful attempts it could wait longer and longer, and so on...

I would be glad to jump into this if it's interesting.

Remove with_allowed_errors

#89 introduced with_whitelisted_errors and effectively deprecated with_allowed_errors. The latter should be removed as part of the v2.0.0 release.

Green to red transition should be on error % rather than arbitrary number

# request key could be the first 8 chars of the req UUID to start
# later, we could transition to using failures by portal or user id or whatever
def record_success(request_key)
  conn.zadd("#@key-success", Time.now.to_i, request_key)
  cleanup! if rand > 0.98
end

def record_error(request_key)
  conn.multi do
    conn.zadd("#@key-error", Time.now.to_i, request_key)
    @error_cnt = conn.zcount("#@key-error", 2.hours.ago.to_i, Time.now.to_i)
    @success_cnt = conn.zcount("#@key-success", 2.hours.ago.to_i, Time.now.to_i)
    cleanup! if rand > 0.98
  end
  errors, successes = [@error_cnt, @success_cnt].map(&:value)
  transiton_to_red! if errors.to_f / (errors  + successes).to_f > 0.15
end

def cleanup!
  conn.zremrangebyscore(...)
end

This could also be done with hashes + counters in one hour groupings where you'd keep the last 3 hours (or some other time period that's sensible) but cleanup would be more difficult.

Adjustable cool_off_time proc

My basic idea is accept cool_off_time as a proc

-> (times) { return times * 30 }

where times is the number of cycles of the circuit has been in RED. E.g. first circuit open, times = 1, after cool_off_time, test and remain RED, get cool_off_time with times = 2, etc.

Any suggestions, and places to start if I want to add this? Thanks.

Thread Safety

Hey,

Stoplight with MemoryStore is not threadsafe.
It would be awsome if stoplight would be usable in threaded ruby code.
The only shared state between Stoplights seems to be the class variables in Stoplight::Light and the DataStore.

Pass light and states to notifier

Instead of passing a predefined message to the notifier, the light itself and the states should be passed.

notifier.notify('Switching some light from green to red.')
notifier.notify(light, COLOR_GREEN, COLOR_RED)
# "Switching #{light.name} from #{from_color} to #{to_color}."

This would be much more flexible. Since we already support customizing the format (#32), the message itself could be customized without too much hassle. Plus this would allow you to use notifiers for things other than actually notifying, like sending metrics.

class MetricsNotifier < Stoplight::Notifier::Base
  def notify(light, from_color, to_color)
    # send information about `light` changing from `from_color` to `to_color` to something.
  end
end
Stoplight.notifiers << MetricsNotifier.new

Pass Redis client to data store

Instead of creating a Redis client inside the data store, one could simply be passed into it. This would remove the need to track if Redis was loaded or not.

# before
data_store = Stoplight::DataStore::Redis.new(url: 'redis://...')

# after
redis = Redis.new(url: 'redis://...')
data_store = Stoplight::DataStore::Redis.new(redis)

That would free up the data store to accept actual options, like which key prefix to use.

Stoplight::DataStore::Redis.new(redis, key_prefix: 'stoplight-no-conflict')

Rename timeout?

I think the term "timeout" can be confusing. In Ruby, "timeout" most often means Timeout.timeout. In Stoplight, timeouts don't have anything to do with limiting execution time. Instead they have to do with attempting to recover from a red light.

I don't have any better names for the concept though.

Notifier never being called after color change

I have a circuit like this

  def stoplight(name, exception_message = DEFAULT_MESSSAGE, &block)
    raise if name.nil?
    raise unless block_given?
    stoplight = Stoplight(name, &block)
                .with_error_handler do |error, handle|
                  raise error if WHITELIST_EXCEPTIONS.include?(error.class)
                  handle.call(error)
                end
                .with_cool_off_time(15)
                .with_fallback { raise Errors::ExternalServiceError, exception_message }
    stoplight.run

that I use for different purpose (e.g. call facebook api, google api). I also want to expose the status to our health monitor. So that I decide to create a new notifier that will save the color to redis everytime a status is changed

class LightObserver < Stoplight::Notifier::Base
  def notify(light, _from_color, to_color, _error)
    REDIS.set("#{light.name}_color", to_color)
  end
end

...

Stoplight::Light.default_notifiers += [LightObserver.new]

when the status changed to red, LightObserver is invoked, but when change to yellow, nothing happen.
I also didn't noticed the status going back to green ever.

Remove mixin?

I think Stoplight::Mixin is of questionable utility.

include Stoplight::Mixin
stoplight('pi') { 22.0 / 7.0 }
Stoplight::Light.new('pi') { 22.0 / 7.0 }.run

I think we should get rid of it. What do you think, @justinsteffy?

Have a class macro for traffic control

Stoplight is good at wrapping whole Rails controllers actions, though sometimes you just want a small piece of the action to be controlled - like integrating with a remote service from which you can recover with good user experience when the service fails. A good example - lets say you want to pull a user's data from a few services. If one of them doesn't respond it's still OK to show the partial data and say "Sorry, this one is out".

For such a use case I'd love to have a class macro that has sensible defaults I can override easily. An example:

class RemoteService
  include Stoplight::TrafficControl

  def remote_call
    # whatever 
  end

  controlls_traffic_on :remote_call, 
    allow_errors: [SomeError], # optional
    threshold: 10, # optional
    timeout: 120, # optional
    name: 'ServiceName', # optional
    on_red: :do_something_different #optional

  def do_something_different
    # whatever
  end
end

I'd love to open a PR for this. Just wanted to hear what you think.

Restoring failures from JSON doesn't always work

It's possible that the error class in the JSON isn't in scope when converting from JSON.

>> Stoplight::Failure.from_json('{ "error": "#<CustomError: message>", "time": "2001-02-03T04:05:06Z" }')
NameError: uninitialized constant CustomError

Create simple writers for class variables

Instead of combined getters and setters for the data store (and notifiers in #20), we should just use regular getters and setters.

module Stoplight
  @data_store = Stoplight::DataStore::Memory.new
  attr_accessor :data_store
end

Stoplight.data_store
# => #<Stoplight::DataStore::Memeory...>

Stoplight.data_store = Stoplight::DataStore::Redis.new(...)

Notifications should be sent asynchronously

Sending notifications can take a while. Best case, HipChat takes about a quarter of a second. The first message, though, can take several seconds because it has to initialize a connection to HipChat.

Allow disabling threshold

We should allow disabling the threshold entirely. The only way to do so currently is to set it to something obscenely high, which could potentially store a lot of data in your data store. Like the custom timeout, I think -1 is a good sentinel value.

yellow state?

The README says:

Stoplights will automatically attempt to recover after a certain amount of time. A light in the red state for longer than the timeout will transition to the yellow state. This timeout is customizable.

But that's the only mention of a "yellow" state, rather than "red" and "green". What is the yellow state?

Benchmark performance

Since stoplights are meant to be used around every action in a Rails app, it needs to be performant.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.