Stoplight is a 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.
Add it to your Gemfile:
gem 'stoplight'Or install it manually:
$ gem install stoplightStoplight 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: Errors reach threshold
Red --> Yellow: After cool_off_time
Yellow --> Green: Successful recovery
Yellow --> Red: Failed recovery
Green --> Green: Success
classDef greenState fill:#28a745,stroke:#1e7e34,stroke-width:2px,color:#fff
classDef redState fill:#dc3545,stroke:#c82333,stroke-width:2px,color:#fff
classDef yellowState fill:#ffc107,stroke:#e0a800,stroke-width:2px,color:#000
class Green greenState
class Red redState
class Yellow yellowState
- 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 two main parameters:
- Window Size (default:
nil): Time window in which errors are counted toward the threshold. By default, all errors are counted. - Threshold (default:
3): Number of errors required to transition from green to red.
Additionally, two other parameters control how Stoplight behaves after it turns red:
- Cool Off Time (default:
60seconds): Time to wait in the red state before transitioning to yellow. - Recovery Threshold (default:
1): Number of successful attempts required to transition from yellow back to green.
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")
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 0After 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"The Stoplight::Error::RedLight provides metadata about the error:
def run_request
light = Stoplight("Example", cool_off_time: 10)
light.run { 1 / 0 } #=> raises Stoplight::Error::RedLight
rescue Stoplight::Error::RedLight => error
puts error.light_name #=> "Example"
puts error.cool_off_time #=> 10
puts error.retry_after #=> Absolute Time after which a recovery attempt can occur (e.g., "2025-10-21 15:39:50.672414 +0600")
endAfter one minute, the light transitions to yellow, allowing a test execution:
# Wait for the cool-off time
sleep 60
light.run { 1 / 1 } #=> 1If 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 errors:
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 comes with a built-in Admin Panel that can track all active Lights and manually lock them in the desired state (Green or Red). Locking lights in certain states might be helpful in scenarios like E2E testing.
To add Admin Panel protected by basic authentication to your Rails project, add this configuration to your config/routes.rb file.
Rails.application.routes.draw do
# ...
Stoplight::Admin.use(Rack::Auth::Basic) do |username, password|
username == ENV["STOPLIGHT_ADMIN_USERNAME"] && password == ENV["STOPLIGHT_ADMIN_PASSWORD"]
end
mount Stoplight::Admin => '/stoplights'
# ...
endThen set up STOPLIGHT_ADMIN_USERNAME and STOPLIGHT_ADMIN_PASSWORD env variables to access your Admin panel.
IMPORTANT: Stoplight Admin Panel requires you to have sinatra and sinatra-contrib gems installed. You can either add them to your Gemfile:
gem "sinatra", require: false
gem "sinatra-contrib", require: falseOr install it manually:
gem install sinatra
gem install sinatra-contribIt is possible to run the Admin Panel separately from your application using the stoplight-admin:<release-version> docker image.
docker run --net=host bolshakov/stoplight-adminIMPORTANT: Standalone Admin Panel should use the same Redis your application uses. To achieve this, set the REDIS_URL ENV variable via -e REDIS_URL=<url-to-your-redis-servier>. E.g.:
docker run -e REDIS_URL=redis://localhost:6378 --net=host bolshakov/stoplight-adminStoplight allows you to set default values for all lights in your application:
Stoplight.configure do |config|
# Set default behavior for all stoplights
config.traffic_control = :error_rate
config.window_size = 300
config.threshold = 0.5
config.cool_off_time = 30
config.recovery_threshold = 5
# 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]
endThe simplest way to create a stoplight is with a name:
light = Stoplight("Payment Service")You can also provide settings during creation:
data_store = Stoplight::DataStore::Redis.new(Redis.new)
light = Stoplight("Payment Service",
window_size: 300, # Only count errors in the last five minutes
threshold: 5, # 5 errors before turning red
cool_off_time: 60, # Wait 60 seconds before attempting recovery
recovery_threshold: 1, # 1 successful attempt to turn green again
data_store: data_store, # 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("Service API")
# Create specialized version for the users endpoint
users_api = base_api.with(
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.
By default, Stoplight tracks all StandardError exceptions.
Note: System-level exceptions (e.g., NoMemoryError, SignalException) are not tracked, as they are not subclasses of StandardError.
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.
You've seen how Stoplight transitions from green to red when errors reach the threshold. But how exactly does it decide when that threshold is reached? That's where traffic control strategies come in.
Stoplight offers two built-in strategies for counting errors:
Stops traffic when a specified number of consecutive errors occur. Works with or without time sliding windows.
light = Stoplight(
"Payment API",
traffic_control: :consecutive_errors,
threshold: 5,
)Counts consecutive errors regardless of when they occurred. Once 5 consecutive errors happen, the stoplight turns red and stops traffic.
light = Stoplight(
"Payment API",
traffic_control: :consecutive_errors,
threshold: 5,
window_size: 300,
)Counts consecutive errors within a 5-minute sliding window. Both conditions must be met: 5 consecutive errors AND at least 5 total errors within the window.
This is Stoplight's default strategy when no traffic_control is specified. You can omit traffic_control parameter
in the above examples:
light = Stoplight(
"Payment API",
threshold: 5,
)Stops traffic when the error rate exceeds a percentage within a sliding time window. Requires window_size to be
configured:
light = Stoplight(
"Payment API",
traffic_control: :error_rate,
window_size: 300,
threshold: 0.5,
)Monitors error rate over a 5-minute sliding window. The stoplight turns red when error rate exceeds 50%.
light = Stoplight(
"Payment API",
traffic_control: {
error_rate: { min_requests: 20 },
},
window_size: 300,
threshold: 0.5,
)Only evaluates error rate after at least 20 requests within the window. Default min_requests is 10.
- Consecutive Errors: Low-medium traffic, simple behavior, occasional spikes expected
- Error Rate: High traffic, percentage-based SLAs, variable traffic patterns
In the yellow state, Stoplight behaves differently from normal (green) operation. Instead of blocking all traffic, it allows a limited number of real requests to pass through to the underlying service to determine if it has recovered. These aren't synthetic probes - they're actual user requests that will execute normally if the service is healthy.
After collecting the necessary data from these requests, Stoplight decides whether to return to green or red state.
Traffic Recovery strategies control how Stoplight evaluates these requests during the recovery phase.
Returns to green after a specified number of consecutive successful recovery attempts. This is the default behavior.
light = Stoplight(
"Payment API",
traffic_recovery: :consecutive_successes,
recovery_threshold: 3,
)This configuration requires 3 consecutive successful recovery probes before resuming normal traffic. If any probe fails during recovery, the stoplight immediately returns to red and waits for another cool-off period before trying again.
Default behavior: If no recovery_threshold is specified, Stoplight uses a conservative default of 1, meaning a
single successful recovery probe will resume traffic flow.
Stoplight officially supports three data stores:
- In-memory data store
- Redis
- Valkey
By default, Stoplight uses an in-memory data store:
require "stoplight"
Stoplight::Default::DATA_STORE
# => #<Stoplight::DataStore::Memory:...>For production environments, you'll likely want to use a persistent data store. One of the supported options is Redis.
# 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
endStoplight also supports Valkey, a drop-in replacement for Redis.
Just point your Redis client to a Valkey instance and configure Stoplight as usual:
# ...
# We assume that Valkey is available on 127.0.0.1:6379 address
valkey = Redis.new(url: "redis://127.0.0.1:6379")
data_store = Stoplight::DataStore::Redis.new(valkey)
Stoplight.configure do |config|
config.data_store = data_store
# ...
endAlthough Stoplight does not officially support DragonflyDB, it can be used with it. For details, you may refer to the official DragonflyDB documentation.
NOTE: Compatibility with DragonflyDB is not guaranteed, and results may vary. However, you are welcome to contribute to the project if you find any issues.
For high-traffic applications or when you want to control the number of open connections to the Data Store:
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
endStoplight notifies when the 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]
endIn 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) }
endSometimes 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.unlockWrap 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
endConfigure 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)]
endYou can generate initializer with Redis, Admin Panel route and add needed gems to your Gemfile:
rails generate stoplight:install --with-admin-panelTips 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.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}")We focus on supporting current, secure versions rather than maintaining extensive backwards compatibility. We follow semantic versioning and give reasonable notice before dropping support.
We only actively support the latest major version of Stoplight.
- ✅ Bug fixes and new features go to the current major version only (e.g., if you're on 4.x, upgrade to 5.x for fixes)
- ✅ Upgrade guidance is provided in release notes for major version changes
- ✅ We won't break compatibility in patch/minor releases
- ✅ We may accept community-contributed security patches for the previous major version
- ❌ We don't backport fixes to old Stoplight major versions
- ❌ We don't add new features to old Stoplight major versions
Ruby: Major versions that receive security updates (see Ruby Maintenance Branches):
- Currently: Ruby 3.2.x, 3.3.x and 3.4.x
- We test against these versions in CI
Data Stores: Current supported versions from upstream (versions that receive security updates):
- Redis: 8.0.x, 7.4.x, 7.2.x, 6.2.x (following Redis's support policy)
- Valkey: 8.0.x, 7.2.x (following Valkey's support policy)
- We test against the latest version of each major release
For dependencies:
- ✅ We test all supported dependency combinations in CI
- ✅ We investigate bug reports on supported dependency versions
- ❌ We don't test unsupported dependency versions (e.g., Ruby 3.1, Redis 5.x)
- ❌ We don't fix bugs specific to unsupported dependencies
- Ruby: When Ruby core team ends security support, we drop it in our next major release
- Data Stores: When Redis/Valkey ends maintenance, we drop it in our next major release
Example: "Ruby 3.2 reaches end-of-life in March 2026, so Stoplight 6.0 will require Ruby 3.3+"
After checking out the repo, run bundle install to install dependencies. Run tests with bundle exec rspec and check
code style with bundle exec standardrb. We follow a git flow branching strategy - see our Git Flow wiki page for
details on branch naming, releases, and contribution workflow.
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.
