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, Postmark) and circuit presets (
:postmark,:s3) - 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) with opt-inmailer_retryfordeliver_laterjobs - 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:installPass --with-health-endpoint to also print the route line for the health
endpoint, 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
# with a fallback, returned instead of raising while the circuit is open
StandardCircuit.run(:analytics, fallback: -> { nil }) do
Analytics.track(event)
endA fallback may take the error (->(error) { ... }: the error the block raised,
or nil once the circuit is open, since the block then never runs) or take
nothing (-> { ... }, or a zero-arity method(...)); all are called correctly.
Error taxonomies
tracked_errors decide what counts toward tripping a circuit; skipped_errors are re-raised without counting (and win over tracked_errors when a class matches both). StandardCircuit::ErrorTaxonomies::<Adapter>.tracked combines NetworkErrors.defaults with the adapter's server-side errors for Stripe, Smtp, Aws, Faraday, and Postmark; StandardCircuit::AdapterErrors::<Adapter>.caller_errors lists the adapter's caller-side (4xx-style) errors.
When skipped_errors: is omitted it defaults to [] — except when the tracked list covers the AWS or Postmark caller errors. (Postmark's ApiInputError and InvalidApiKeyError subclass Postmark::HttpServerError, so a circuit tracking ErrorTaxonomies::Postmark.tracked without skipped_errors: defaults to skipping them.) AWS 5xx responses are dynamically generated Aws::Errors::ServiceError subclasses, so ErrorTaxonomies::Aws.tracked has to track ServiceError itself, which is also the superclass of Aws::S3::Errors::AccessDenied and NoSuchKey. So for such circuits skipped_errors defaults to AdapterErrors::Aws.caller_errors (via ErrorTaxonomies.default_skipped_for), and a burst of missing-key lookups or permission errors no longer trips the S3 breaker:
StandardCircuit.configure do |c|
# skipped_errors defaults to [Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::AccessDenied]
c.register_prefix(:s3, tracked_errors: StandardCircuit::ErrorTaxonomies::Aws.tracked)
# An explicit skipped_errors — even [] — always wins over the default.
# c.register(:s3_strict, tracked_errors: StandardCircuit::ErrorTaxonomies::Aws.tracked, skipped_errors: [])
endPresets
Some integrations were registered identically, line for line, in several apps. c.register_preset registers them with the shared settings; any register option you pass alongside wins, and name: renames the circuit.
| Preset | Registers | Settings |
|---|---|---|
:postmark |
register(:postmark, ...) |
threshold 3, cool-off 60s, :standard, ErrorTaxonomies::Postmark.tracked, skips AdapterErrors::Postmark.caller_errors
|
:s3 |
register_prefix(:s3, ...) — matches the s3_<bucket> circuits StandardCircuitS3 opens |
threshold 3, cool-off 30s, :standard, ErrorTaxonomies::Aws.tracked, skips NoSuchKey / AccessDenied
|
Each preset requires its SDK (postmark, aws-sdk-s3) before building the error lists and raises ArgumentError if the gem is missing. That matters for gem "aws-sdk-s3", require: false: ErrorTaxonomies::Aws.tracked returns only network errors when the SDK hasn't been loaded yet at configure time, which quietly leaves S3 5xx responses untracked.
Replace your host code with:
# Before — config/initializers/standard_circuit.rb
c.register(:postmark,
threshold: 3,
cool_off_time: 60,
criticality: :standard,
tracked_errors: StandardCircuit::NetworkErrors.defaults +
[ Postmark::HttpServerError, Postmark::TimeoutError ],
skipped_errors: [ Postmark::ApiInputError, Postmark::InvalidApiKeyError ])
c.register_prefix(:s3,
threshold: 3,
cool_off_time: 30,
criticality: :standard,
tracked_errors: StandardCircuit::ErrorTaxonomies::Aws.tracked)
# After
c.register_preset(:postmark)
c.register_preset(:s3)There is deliberately no :stripe preset — the apps that wrap Stripe disagree on threshold, criticality, and skips, so ErrorTaxonomies::Stripe.tracked stays the shared piece.
Circuit state storage (data_store)
Circuit state (failure counts, colors, locks) lives in a Stoplight data store. StandardCircuit defaults to Stoplight::DataStore::Memory.new, which is per-process: each Puma worker, Sidekiq/SolidQueue worker, and console gets its own independent view of every circuit.
That is the deliberate default for a Redis-free deployment, and it is usually the right one — a circuit exists to stop this process from hammering a dead upstream, and per-process thresholds mean one unlucky worker can't trip the breaker for everyone. But be explicit about what it implies:
- Thresholds are counted per process, so an app with 4 web workers tolerates roughly 4× the configured
thresholdin aggregate before every worker has tripped. -
/healthreports the circuit colors of the process that served the request, so two consecutive probes can legitimately disagree while a circuit is tripping. -
force_open/force_closedandreset!affect only the calling process — they are test and console tools, not an operational kill switch.
Point data_store at a shared store if you want cross-process state instead:
StandardCircuit.configure do |c|
# Default — per-process, no external dependency.
c.data_store = Stoplight::DataStore::Memory.new
# Shared across processes and hosts (requires the redis gem + a Redis server).
# c.data_store = Stoplight::DataStore::Redis.new(Redis.new(url: ENV["REDIS_URL"]))
endSentry reporting
The built-in Sentry subscriber is on by default (c.sentry_enabled = true) and reports every circuit-open transition at a flat :warning, with the circuit name, colors, and error in extra.
Set sentry_criticality_levels to derive the level from the circuit's registered criticality instead. That also adds circuit / circuit_criticality tags and a stable ["circuit-open", <circuit>] fingerprint, so Sentry alert rules can route on criticality (e.g. page on circuit_criticality:critical) and group per circuit:
StandardCircuit.configure do |c|
# { critical: :error, standard: :warning, optional: :info }
c.sentry_criticality_levels = true
# Or override part of that map — unlisted criticalities keep the default.
# c.sentry_criticality_levels = { optional: :debug }
endThis is opt-in, not the default. Both the level and the fingerprint feed Sentry's alerting and issue grouping, so turning it on for existing apps at gem-upgrade time would silently change what pages and re-group open issues. Leaving sentry_criticality_levels unset keeps the flat :warning shape byte-for-byte.
If you want something else entirely, set c.sentry_enabled = false and subscribe to standard_circuit.circuit.opened yourself — the payload carries criticality.
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 everyRunner#executeinvocation and onforce_openruns; not emitted forforce_closedruns (which intentionally bypass the runner). All payload keys are always present —error_classanderror_messagearenilon:success. Payload duration usesduration_ms(numeric), notevent.duration, so subscribers work identically on theRails.eventandActiveSupport::Notificationsbackends.
Note on
standard_circuit.circuit.registered: subscribers are wired up after theStandardCircuit.configureblock yields, so anyc.registercalls inside that block fire before any subscriber can hear them. This event is reliable only for post-boot, dynamicregister/register_prefixcalls — 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) })
endMail delivery
The :standard_circuit delivery method wraps any registered ActionMailer delivery method in a circuit and raises StandardCircuit::Mailer::CircuitOpenError (instead of attempting the send) while that circuit is open:
# config/environments/production.rb
config.action_mailer.delivery_method = :standard_circuit
config.action_mailer.standard_circuit_settings = {
underlying: :postmark,
underlying_settings: { api_token: ENV.fetch("POSTMARK_API_TOKEN") },
circuit: :postmark
}Retrying deliver_later while the circuit is open (mailer_retry)
Without a retry, a deliver_later job that runs during an outage fails once and the email is lost. Opt in and the gem installs retry_on CircuitOpenError on ActionMailer::MailDeliveryJob:
StandardCircuit.configure do |c|
c.mailer_retry = true # wait: 90, attempts: 5, jitter: 0.15
# c.mailer_retry = { wait: 120, attempts: 8 } # partial overrides; wait: takes anything retry_on does
end- Off by default. Nothing is installed unless you set it.
-
Keep
waitlonger than the mail circuit'scool_off_time(the:postmarkpreset uses 60s), or retries land on a circuit that is still open. -
Reload-safe and idempotent. Calling
configurefromto_prepareon every reload installs the handler once. If aretry_on CircuitOpenErroris already on the job (for example your old initializer, during migration), the gem leaves it alone. The first install wins, so changing the options later needs a restart. -
Subclasses inherit it. A custom
self.delivery_job = MyJob < ActionMailer::MailDeliveryJobgets the handler, and aretry_on CircuitOpenErrordeclared on the subclass takes precedence. -
On exhaustion it writes one
errorlog line, sends a Sentry:errorevent (whensentry_enabledand Sentry is initialized, fingerprinted by mailer and action), and emitsstandard_circuit.mailer.retries_exhausted. Every payload holds onlymailer_class,mail_action,recipient_domains,job_idandexecutions: recipient domains, never addresses or subjects. It then re-raises theCircuitOpenError, so the job fails and lands in your queue backend's failed jobs (Solid Queue's failed executions) where it can be inspected and retried, instead of completing with the email dropped (since 0.4.2). If your Sentry setup also captures job failures (sentry-rails' ActiveJob integration does), that failure arrives as a second event, grouped by the exception rather than by the fingerprint above. - Only
CircuitOpenErroris retried. Errors from the provider while the circuit is closed, such as a SendGrid 429 or a Postmark 422, keep their existing behaviour. Add your ownretry_on/discard_onfor those.
Replace your host code with:
# Before — config/initializers/mail_delivery_retry.rb (~40–60 lines)
Rails.application.config.to_prepare do
next if ActionMailer::MailDeliveryJob.rescue_handlers.any? { |h| h.first == StandardCircuit::Mailer::CircuitOpenError.name }
ActionMailer::MailDeliveryJob.retry_on(StandardCircuit::Mailer::CircuitOpenError,
wait: 90.seconds, attempts: 5, jitter: 0.15) do |job, error|
# ...recipient-domain extraction, Rails.logger.error, Sentry.capture_message...
end
end
# After — inside your existing StandardCircuit.configure block
c.mailer_retry = true
# or, keeping ENV tunables:
c.mailer_retry = {
wait: ENV.fetch("SENDGRID_CIRCUIT_RETRY_WAIT", 90).to_i,
attempts: ENV.fetch("SENDGRID_CIRCUIT_RETRY_ATTEMPTS", 5).to_i
}The Sentry fingerprint changes to ["standard_circuit-mailer-retries-exhausted", mailer, action], so the first exhaustion after switching opens a new Sentry issue rather than regrouping into your old one.
Calling configure more than once
configure is safe to call repeatedly. Apps register circuits from to_prepare because it's the first point where autoloaded error classes are available, and to_prepare re-runs on every code reload. Each call re-applies the config and rebuilds the subscribers. add_notifier is idempotent: adding "the same" notifier again replaces the earlier entry in place and does not stack a duplicate. Two notifiers are the same when they share an explicit key:, or else the same class name (a reloaded class is a new object with the same name), or for lambdas and methods the same source location. The newest instance wins. To register two instances of one class, give them distinct keys:
c.add_notifier(WebhookNotifier.new(ops_url), key: :ops)
c.add_notifier(WebhookNotifier.new(audit_url), key: :audit)Replace your host code with:
# Before — hand-rolled guard against stacking a notifier on every reload
unless c.extra_notifiers.any? { |n| n.class.name == "CircuitAuditNotifier" }
c.add_notifier(CircuitAuditNotifier.new)
end
# After
c.add_notifier(CircuitAuditNotifier.new)Circuit registrations (register / register_prefix / register_preset) were already idempotent: the same name overwrites.
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
endSame 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 a 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.
The controller lives in the engine's app/controllers, so it is autoloaded. The route is the only opt-in, and apps that don't draw it never load the controller.
# config/routes.rb
Rails.application.routes.draw do
get "/health", to: "standard_circuit/health#show"
endReplace your host code with:
# Before — top of config/routes.rb (or config/initializers/standard_circuit_health.rb)
require "standard_circuit/health_controller"
# After — delete the line (and the initializer, if that's all it contained).The old require was deprecated in 0.4 and removed in 0.5: it now raises LoadError, so delete it.
The controller inherits from ActionController::API to sidestep app-level filters (authentication, bootstrap redirects, etc.) so probes can call it anonymously.
If your app also mounts StandardHealth::Engine at /health, draw the aggregate route first:
get "/health", to: "standard_circuit/health#show" # aggregate — FIRST
mount StandardHealth::Engine => "/health", as: :standard_healthStandardHealth::Engine registers sub-paths only (/alive, /ready, /diagnostics/env) — it never serves the aggregate tier itself. An app that mounts the engine and assumes /health is covered silently has no aggregate tier at all, with no boot error and no failing route spec to reveal it. The ordering is load-bearing; draw the aggregate route explicitly, first.
Test API
These are supported public API for host test suites, not internals:
| Method | What it does |
|---|---|
StandardCircuit.force_open(name) { ... } |
Treat name as open for the block (or until reset_force! without a block): run raises Stoplight::Error::RedLight, or returns the fallback. Emits the same run.completed / fallback_invoked events as a real open circuit. |
StandardCircuit.force_closed(name) { ... } |
Bypass the circuit for the block — run just yields. No events. |
StandardCircuit.reset_force! |
Clear every forced state. |
StandardCircuit.reset! |
Clear the light cache and forced states, and swap in a fresh Memory data store (left alone when the store is Redis). |
require "standard_circuit/rspec" |
Adds a before(:each) that runs reset! and tears down subscribers. Circuit registrations are kept. |
All of these are process-local. They are test and console tools, not an operational kill switch (see data_store above).
Removed in 0.5
0.4 deprecated these and 0.5 removes them. None of the five consumer apps used any of them at removal time.
| Removed | Use instead |
|---|---|
require "standard_circuit/health_controller" (now LoadError) |
nothing — the controller is autoloaded; keep the route |
StandardCircuit.health_snapshot |
StandardCircuit.health_report[:circuits] |
StandardCircuit.health_overall (and StandardCircuit.runner.health_overall) |
StandardCircuit.health_report[:status] |
StandardCircuit::AdapterErrors::Faraday.caller_errors |
drop it — Faraday::ClientError is never tracked, so skipping it was a no-op |
StandardCircuit::ActiveStorage::S3Service |
ActiveStorage::Service::StandardCircuitS3Service / service: StandardCircuitS3
|
Future deprecations warn through Rails.application.deprecators[:standard_circuit], so your app's config.active_support.deprecation setting applies.
License
MIT