Skip to content

bolshakov/stoplight

Repository files navigation

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.

Stoplight helps your application gracefully handle failures in external dependencies (like flaky databases, unreliable APIs, or spotty web services). By wrapping these unreliable calls, Stoplight prevents cascading failures from affecting your entire application.

The best part? Stoplight works with zero configuration out of the box, while offering deep customization when you need it.

Check out stoplight-admin for a web UI to control 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.

Core Concepts

Stoplight operates like a traffic light with three states:

stateDiagram
    Green --> Red: Failures reach threshold
    Red --> Yellow: After cool_off_time
    Yellow --> Green: Successful attempt
    Yellow --> Red: Failed attempt
    Green --> Green: Success
Loading
  • Green: Normal operation. Code runs as expected. (Circuit closed)
  • Red: Failure state. Fast-fails without running the code. (Circuit open)
  • Yellow: Recovery state. Allows a test execution to see if the problem is resolved. (Circuit half-open)

Stoplight's behavior is controlled by three primary parameters:

  1. Threshold (default: 3): Number of failures required to transition from green to red.
  2. Cool Off Time (default: 60 seconds): Time to wait in the red state before transitioning to yellow.
  3. Window Size (default: infinity): Time window in which failures are counted toward the threshold.

Basic Usage

Stoplight works right out of the box with sensible defaults:

# Create a stoplight with default settings
light = Stoplight('payment-service')

# Use it to wrap code that might fail
result = light.run { payment_gateway.process(order) }

When everything works, the light stays green and your code runs normally. If the code fails repeatedly, the light turns red and raises a Stoplight::Error::RedLight exception to prevent further calls.

light = Stoplight('example-zero')
light.run { 1 / 0 } #=> raises ZeroDivisionError: divided by 0
light.run { 1 / 0 } #=> raises ZeroDivisionError: divided by 0
light.run { 1 / 0 } #=> raises ZeroDivisionError: divided by 0

After the last failure, the light turns red. The next call will raise a Stoplight::Error::RedLight exception without executing the block:

light.run { 1 / 0 } #=> raises Stoplight::Error::RedLight: example-zero
light.color # => "red"

After one minute, the light transitions to yellow, allowing a test execution:

# Wait for the cool off time
sleep 60
light.run { 1 / 1 } #=> 1

If the test probe succeeds, the light turns green again. If it fails, the light turns red again.

light.color #=> "green"

Using Fallbacks

Provide fallbacks to gracefully handle failures:

fallback = ->(error) { error ? "Failed: #{error.message}" : "Service unavailable" }

light = Stoplight('example-fallback')
result = light.run(fallback) { external_service.call }

If the light is green but the call fails, the fallback receives the error. If the light is red, the fallback receives nil. In both cases, the return value of the fallback becomes the return value of the run method.

Configuration

Global Configuration

Stoplight allows you to set default values for all lights in your application:

Stoplight.configure do |config|
  # Set default behavior for all stoplights
  config.threshold = 5
  config.cool_off_time = 30
  config.window_size = 60
  
  # Set up default data store and notifiers
  config.data_store = Stoplight::DataStore::Redis.new(redis)
  config.notifiers = [Stoplight::Notifier::Logger.new(Rails.logger)]
  
  # Configure error handling defaults
  config.tracked_errors = [StandardError, CustomError]
  config.skipped_errors = [ActiveRecord::RecordNotFound]
end

Creating Stoplights

The simplest way to create a stoplight is with a name:

light = Stoplight('payment-service')

You can also provide settings during creation:

light = Stoplight('payment-service', 
  threshold: 5,                           # 5 failures before turning red
  cool_off_time: 30,                      # Wait 30 seconds before attempting recovery
  window_size: 60,                        # Only count failures in the last minute
  data_store: Redis.new,                  # Use Redis for persistence
  tracked_errors: [TimeoutError],         # Only count TimeoutError
  skipped_errors: [ValidationError]       # Ignore ValidationError
)

Modifying Stoplights

You can create specialized versions of existing stoplights:

# Base configuration for API calls
base_api = Stoplight('api-service')

# Create specialized version for the users endpoint
users_api = base_api.with(
  cool_off_time: 10,                      # Faster recovery for user API
  tracked_errors: [TimeoutError]          # Only track timeouts
)

The #with method creates a new stoplight instance without modifying the original, making it ideal for creating specialized stoplights from a common configuration.

Builder Style

For a more expressive configuration style, you can use method chaining:

light = Stoplight('payment-service')
  .with_threshold(5)
  .with_cool_off_time(30)
  .with_window_size(60)

Error Handling

By default, Stoplight tracks all StandardError exceptions, except for:

NoMemoryError, ScriptError, SecurityError, SignalException, SystemExit, SystemStackError

Custom Error Configuration

Control which errors affect your stoplight state. Skip specific errors (will not count toward failure threshold)

light = Stoplight('example-api', skipped_errors: [ActiveRecord::RecordNotFound, ValidationError])

Only track specific errors (only these count toward failure threshold)

light = Stoplight('example-api', tracked_errors: [NetworkError, Timeout::Error])

When both methods are used, skipped_errors takes precedence over tracked_errors.

Advanced Configuration

Data Store

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

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

For production environments, you'll likely want to use a persistent data store. Currently, Redis is the supported option:

# Configure Redis as the data store
require 'redis'
redis = Redis.new
data_store = Stoplight::DataStore::Redis.new(redis)

Stoplight.configure do |config|
  config.data_store = data_store
end

Connection Pooling with Redis

For high-traffic applications or when you want to control a number of opened connections to redis:

require 'connection_pool'
pool = ConnectionPool.new(size: 5, timeout: 3) { Redis.new }
data_store = Stoplight::DataStore::Redis.new(pool)

Stoplight.configure do |config|
  config.data_store = data_store
end

Notifiers

Stoplight notifies when lights change state. Configure how these notifications are delivered:

# Log to a specific logger
logger = Logger.new('stoplight.log')
notifier = Stoplight::Notifier::Logger.new(logger)

# Configure globally
Stoplight.configure do |config|
  config.notifiers = [notifier]
end

In this example, when Stoplight fails three times in a row, it will log the error to stoplight.log:

W, [2025-04-16T09:18:46.778447 #44233]  WARN -- : Switching test-light from green to red because RuntimeError bang!

By default, Stoplight logs state transitions to STDERR.

Community-supported Notifiers

Pull requests to update this section are welcome. If you want to implement your own notifier, refer to the notifier interface documentation for detailed instructions. Pull requests to update this section are welcome.

Error Notifiers

Stoplight is built for resilience. If the Redis data store fails, Stoplight automatically falls back to the in-memory data store. To get notified about such errors, you can configure an error notifier:

Stoplight.configure do |config|
  config.error_notifier = ->(error) { Bugsnag.notify(error) }
end

Locking

Sometimes you need to override Stoplight's automatic behavior. Locking allows you to manually control the state of a stoplight, which is useful for:

  • Maintenance periods: Lock to red when a service is known to be unavailable
  • Emergency overrides: Lock to green to force traffic through during critical operations
  • Testing scenarios: Control circuit state without waiting for failures
  • Gradual rollouts: Manually control which stoplights are active during deployments
# Force a stoplight to red state (fail all requests)
# Useful during planned maintenance or when you know a service is down
light.lock(Stoplight::Color::RED)

# Force a stoplight to green state (allow all requests)
# Useful for critical operations that must attempt to proceed
light.lock(Stoplight::Color::GREEN)

# Return to normal operation (automatic state transitions)
light.unlock

Rails Integration

Wrap controller actions with minimal effort:

class ApplicationController < ActionController::Base
  around_action :stoplight

  private

  def stoplight(&block)
    Stoplight("#{params[:controller]}##{params[:action]}")
      .run(-> { render(nothing: true, status: :service_unavailable) }, &block)
  end
end

Configure Stoplight in an initializer:

# config/initializers/stoplight.rb
require 'stoplight'
Stoplight.configure do |config|
  config.data_store = Stoplight::DataStore::Redis.new(Redis.new)
  config.notifiers += [Stoplight::Notifier::Logger.new(Rails.logger)]
end

Testing

Tips for working with Stoplight in test environments:

  1. Silence notifications in tests
Stoplight.configure do |config|
  config.error_notifier = -> _ {}
  config.notifiers = []
end
  1. Reset data store between tests
before(:each) do
  Stoplight.reset_config!
  Stoplight.configure do |config|
    config.data_store = Stoplight::DataStore::Memory.new
  end
end
  1. Or use unique names for test Stoplights to avoid persistence between tests:
stoplight = Stoplight("test-#{rand}")

Maintenance Policy

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

Credits

Stoplight was originally created by camdez and tfausak. It is currently maintained by bolshakov and Lokideos. You can find a complete list of contributors on GitHub. The project was inspired by Martin Fowler’s CircuitBreaker article.