Stoplight is traffic control for code. It's an implementation of the circuit breaker pattern in Ruby.
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.
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.
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
- 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:
- Threshold (default:
3
): Number of failures required to transition from green to red. - Cool Off Time (default:
60
seconds): Time to wait in the red state before transitioning to yellow. - Window Size (default:
infinity
): Time window in which failures are counted toward the threshold.
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"
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.
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
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
)
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.
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)
By default, Stoplight tracks all StandardError
exceptions, except for:
NoMemoryError, ScriptError, SecurityError, SignalException, SystemExit, SystemStackError
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
.
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
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
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.
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.
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
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
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
Tips for working with Stoplight in test environments:
- Silence notifications in tests
Stoplight.configure do |config|
config.error_notifier = -> _ {}
config.notifiers = []
end
- Reset data store between tests
before(:each) do
Stoplight.reset_config!
Stoplight.configure do |config|
config.data_store = Stoplight::DataStore::Memory.new
end
end
- Or use unique names for test Stoplights to avoid persistence between tests:
stoplight = Stoplight("test-#{rand}")
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
)
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.