:traffic_light: Traffic control for code.
[![Version badge][]][version] [![Build badge][]][build] [![Coverage badge][]][coverage]
Stoplight is a traffic control for code. It's an implementation of the circuit breaker pattern in Ruby.
:warning:️ You're currently browsing the documentation for Stoplight 6. If you're looking for the documentation of the previous version 5.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.
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:
![Stoplight state diagram][]
Stoplight's behavior is controlled by two main parameters:
nil): Time window in which errors are counted toward the threshold. By default, all errors are counted.3): Number of errors required to transition from green to red.Additionally, two other parameters control how Stoplight behaves after it turns red:
60 seconds): Time to wait in the red state before transitioning to yellow.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 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"
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")
end
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 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 for observing and controlling all lights across your application. It displays each light's current state, recent failures, and provides controls to lock/unlock lights manually.
Add the Admin Panel to your Rails application with authentication:
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'
end
Then set environment variables:
export STOPLIGHT_ADMIN_USERNAME=admin
export STOPLIGHT_ADMIN_PASSWORD=secret
IMPORTANT: Stoplight Admin Panel requires sinatra and sinatra-contrib gems:
gem "sinatra", require: false
gem "sinatra-contrib", require: false
Run the Admin Panel as a separate service:
docker run \
-e REDIS_URL=redis://localhost:6379 \
-e STOPLIGHT_ADMIN_USERNAME=admin \
-e STOPLIGHT_ADMIN_PASSWORD=secret \
-p 4567:4567 \
bolshakov/stoplight-admin
For complete setup and multi-system configuration details, see the Admin Panel guide.
Stoplight 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]
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",
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
tracked_errors: [TimeoutError], # Only count TimeoutError
skipped_errors: [ValidationError] # Ignore ValidationError
)
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.
Either list can be replaced for a single call without changing the light's configuration:
light.run(tracked_errors: [Timeout::Error]) { fetch_data }
light.run(skipped_errors: [ValidationError]) { process_data }
Any list omitted from run keeps its configured value. The provided list is replaced only for that call, and
skipped_errors still takes precedence over tracked_errors.
Calling Stoplight("name", ...) at every call site works well for a handful of lights. As an app
grows, repeating the same settings everywhere makes them easy to drift out of sync, and there's no
single place listing what lights exist.
Register a light once and look it up by name wherever you need it, instead of repeating the same settings at every call site.
# config/initializers/stoplight.rb
Stoplight.register("Payment Service", threshold: 5, cool_off_time: 60)
# anywhere else in your app
Stoplight.light("Payment Service").run { payment_gateway.process(order) }
Stoplight("name", ...) still works as shown above -- registration is an addition, not a replacement.
Stoplight.light is also approximately 10 times faster, since it's a plain lookup rather than re-validating
the configuration on every call.
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%.
Error rate evaluation starts only after 100 requests within the window — enough samples
for a statistically reliable estimate. If your service handles fewer than 100 requests
per window, the breaker will never trip on error rate; use traffic_control: :consecutive_errors instead.
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-
No open issues yet, or sync has not completed.