Skip to content

Latest commit

 

History

1,130 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Version badge Build badge Coverage badge

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


⚠️️ 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.

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:

Stoplight state diagram

  • 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:

  1. Window Size (default: nil): Time window in which errors are counted toward the threshold. By default, all errors are counted.
  2. 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:

  1. Cool Off Time (default: 60 seconds): Time to wait in the red state before transitioning to yellow.
  2. Recovery Threshold (default: 1): Number of successful attempts required to transition from yellow back to green.

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")
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"

Using Fallbacks

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.

Admin Panel

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.

Admin Panel Screenshot

Basic Setup

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

Standalone Docker Setup

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.

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.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

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",
  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
)

Error Handling

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.

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.

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.

Advanced Configuration

Registering Lights

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.

Traffic Control Strategies

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:

Consecutive Errors (Default)

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,
)

Error Rate

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.

When to use:

  • Consecutive Errors: Low-medium traffic, simple behavior, occasional spikes expected
  • Error Rate: High traffic, percentage-based SLAs, variable traffic patterns

Traffic Recovery Strategies

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.

Consecutive Successes (Default)

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.

Data Store

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:...>

Redis for Production

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
end

Valkey Support

Stoplight 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
  # ...
end

DragonflyDB Support

Although 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.

Connection Pooling

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
end

Notifiers

Stoplight 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]
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.

For a lower-level way to observe circuit breaker events (state transitions, recovery, trips, and more - e.g. to build a metrics integration), see the Telemetry guide.

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

Multiple Independent Systems

By default, all lights share the same global configuration and data store. For larger applications with multiple services or tenants, you can create named systems -- completely isolated instances with their own configuration, notifiers, and data store:

# Create independent systems with separate data stores
Payments = Stoplight.register_system("Payments", threshold: 3, cool_off_time: 30)
Analytics = Stoplight.register_system("Analytics", threshold: 5, cool_off_time: 60)

# Register lights in each system
Payments.register("stripe", cool_off_time: 30)
Analytics.register("amplitude")

# Use them — one system's state does not affect another
Payments.light("stripe").run { charge_card }
Analytics.light("amplitude").run { track_event }

Use cases for multiple systems:

  • Multi-tenancy: Each tenant gets its own isolated system and data store
  • Service boundaries: Separate failure domains with independent SLOs (e.g., payments vs. analytics)
  • Independent data stores: One service uses Redis for persistence, another uses in-memory

For complete details on system configuration, boot-time registration patterns, and isolation guarantees, see the Systems guide.

Admin

Admin Panel can work in an read-only which could be useful for observability. To enabled read-only mode:

Stoplight::Admin.configure do |config|
  config.read_only = true
end

Read-only mode could be turned on for a pre-built docker image by passing STOPLIGHT_ADMIN_READ_ONLY environment variable:

docker run -e REDIS_URL=redis://localhost:6378  -e STOPLIGHT_ADMIN_READ_ONLY=true --net=host bolshakov/stoplight-admin

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

You can generate initializer with Redis, Admin Panel route and add needed gems to your Gemfile:

rails generate stoplight:install --with-admin-panel

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.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

We focus on supporting current, secure versions rather than maintaining extensive backwards compatibility. We follow semantic versioning and give reasonable notice before dropping support.

Stoplight Version 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

What We Support

Ruby: Major versions that receive security updates (see Ruby Maintenance Branches):

  • Currently: Ruby 3.3.x, 3.4.x and 4.0.x
  • We test against these versions in CI

Data Stores: Current supported versions from upstream (versions that receive security updates):

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

When We Drop Support

  • 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.3 reaches end-of-life in March 2027, so Stoplight 7.0 will require Ruby 3.4+"

Development

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. Also check our CONTRIBUTING.md guide for contributors.

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.

Releases

Sponsor this project

Used by

Contributors

Languages