No release in over 3 years
StandardCircuit wraps the stoplight gem with opinionated error taxonomy, Sentry notifiers, ActiveStorage S3 and ActionMailer adapters, and test helpers shared across Rails apps.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

Runtime

 Project Readme

StandardCircuit

Circuit breaker primitives for Rails apps, built on stoplight.

Wraps the upstream stoplight gem with:

  • Opinionated default error taxonomy (network errors track; caller/config errors do not)
  • SDK-specific adapter error bundles (Stripe, AWS, Faraday, SMTP)
  • Rails event emission (standard_circuit.circuit.{opened,closed,degraded,fallback_invoked,registered}) with built-in Logger, Sentry, and Sentry::Metrics subscribers
  • ActiveStorage S3 adapter with per-bucket circuit keying
  • Generic ActionMailer delivery-method wrapper (supports both instance and symbol underlying: forms)
  • Controller concern for standardized 503 responses on Stoplight::Error::RedLight
  • Test helpers (force_open, force_closed, reset_force!) with RSpec auto-cleanup

Installation

# Gemfile
gem "standard_circuit", git: "https://github.com/rarebit-one/standard_circuit", ref: "<sha>"

Then run the install generator to drop a commented-out initializer into config/initializers/standard_circuit.rb:

bundle add standard_circuit
rails g standard_circuit:install

Pass --with-health-endpoint to also generate config/initializers/standard_circuit_health.rb (which requires the opt-in health controller); the generator prints the matching route line for you to add to config/routes.rb.

The generator is idempotent — re-running skips an existing initializer unless you pass --force.

Quick start

# config/initializers/standard_circuit.rb
StandardCircuit.configure do |c|
  c.sentry_enabled = true
  c.metric_prefix = "external"

  c.register(:stripe,
    threshold: 5,
    cool_off_time: 30,
    tracked_errors: StandardCircuit::ErrorTaxonomies::Stripe.tracked,
    skipped_errors: StandardCircuit::AdapterErrors::Stripe.caller_errors)
end
# anywhere in app code
StandardCircuit.run(:stripe) do
  Stripe::PaymentIntent.create(amount:, currency:)
end

Events

Every circuit lifecycle moment is emitted as a Rails event. On Rails 8.1+ the canonical bus is Rails.event; on older Rails versions the gem transparently falls back to ActiveSupport::Notifications. Detection happens per-emit, so subscribers do not need to care which backend is live.

Event When it fires Payload
standard_circuit.circuit.opened RED transition (circuit tripped) circuit:, from_color:, to_color:, criticality:, error_class:, error_message:
standard_circuit.circuit.closed GREEN transition (recovered) circuit:, from_color:, to_color:, criticality:
standard_circuit.circuit.degraded YELLOW transition (half-open probe) circuit:, from_color:, to_color:, criticality:
standard_circuit.circuit.fallback_invoked Runner returned a fallback instead of raising RedLight circuit:, reason: (:circuit_open|:forced_open), criticality:
standard_circuit.circuit.registered Config#register / register_prefix was called (see note below) circuit:, criticality:, scope: (:name|:prefix)
standard_circuit.run.completed Every wrapped StandardCircuit.run call (success, failure, or circuit_open) circuit:, status: (:success|:failure|:circuit_open), duration_ms:, criticality:, error_class:, error_message:

Note on standard_circuit.run.completed: the per-call event for cost / latency / success-rate dashboards. Fires on every Runner#execute invocation and on force_open runs; not emitted for force_closed runs (which intentionally bypass the runner). All payload keys are always present — error_class and error_message are nil on :success. Payload duration uses duration_ms (numeric), not event.duration, so subscribers work identically on the Rails.event and ActiveSupport::Notifications backends.

Note on standard_circuit.circuit.registered: subscribers are wired up after the StandardCircuit.configure block yields, so any c.register calls inside that block fire before any subscriber can hear them. This event is reliable only for post-boot, dynamic register / register_prefix calls — do not rely on it for a boot-time circuit inventory.

Built-in subscribers (Logger / Sentry / Metrics) are registered automatically by the gem's Railtie. Host apps can subscribe to the namespace however they like:

# Rails 8.1+
class MyAuditSubscriber
  def emit(event)
    return unless event[:name].start_with?("standard_circuit.")
    Rails.logger.info("circuit event: #{event[:name]} #{event[:payload].inspect}")
  end
end
Rails.event.subscribe(MyAuditSubscriber.new)

# Older Rails
ActiveSupport::Notifications.subscribe(/\Astandard_circuit\./) do |name, _start, _finish, _id, payload|
  Rails.logger.info("circuit event: #{name} #{payload.inspect}")
end

# Quick host-supplied callable (auto-wired at boot via the Railtie)
StandardCircuit.configure do |c|
  c.add_notifier(->(name, payload) { MyAlerting.notify(name, payload) })
end

Streaming and non-controller contexts

ControllerSupport.circuit_open_fallback only works for non-streaming responses — once a Live controller has flushed any output, Rails can't render an error template over the wire. For a streaming controller, catch Stoplight::Error::RedLight inside the streaming proc and write a degraded payload before the stream closes:

class Api::MessagesController < ApplicationController
  include ActionController::Live

  def stream
    response.headers["Content-Type"] = "application/x-ndjson"

    StandardCircuit.run(:openai) do
      llm.stream do |chunk|
        response.stream.write({ delta: chunk }.to_json + "\n")
      end
    end
  rescue Stoplight::Error::RedLight
    # Only reachable when the circuit was already open at call time —
    # Stoplight raises RedLight before executing the block, not mid-stream.
    # Errors raised mid-stream propagate as their original class through the
    # `ensure` below; add a broader rescue if you also need to write a
    # terminal NDJSON line for those.
    response.stream.write({ error: "service_unavailable" }.to_json + "\n")
  ensure
    response.stream.close
  end
end

Same pattern applies in background jobs (where circuit_open_fallback doesn't help): wrap the work in StandardCircuit.run and rescue Stoplight::Error::RedLight to either discard_on (avoid thundering retries) or retry_on with backoff (defer until cool-off), depending on whether eventual delivery is required.

Health endpoint

StandardCircuit ships an opt-in controller that renders StandardCircuit.health_report as JSON. It returns 503 when the rolled-up status is :critical (so orchestrators pull the instance out of rotation) and 200 otherwise.

It's opt-in — not auto-required — so apps that don't want a health route don't pay for it.

# config/routes.rb
require "standard_circuit/health_controller"

Rails.application.routes.draw do
  get "/health", to: "standard_circuit/health#show"
end

The controller inherits from ActionController::API to sidestep app-level filters (authentication, bootstrap redirects, etc.) so probes can call it anonymously.

See standard_circuit-design.md for the full design.

License

MIT