standard_circuit 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e92eb79ddb1cfc0f2e9221aa62f45d7c304df0dfea22c114eb31a78c071b6c3c
4
- data.tar.gz: 0aac1e3c093805ea2825cfb434840d83842999cb757cdcca896466c86a6abbff
3
+ metadata.gz: f8a58ef94e0f296c0e3b107fda1decfc29037324942c5326ec2fcfd1fdf19f2f
4
+ data.tar.gz: b3a78ae40f5a60c0e9754011eca3174ec4d489f61e51d41f81a6eb5a7efeb946
5
5
  SHA512:
6
- metadata.gz: 9e5ac3bb1d7352c6300fa70530602910dd3a45539e1cfad5d45b8ac702a223a22961cdaef377daf7c968ad7bbb5db54814c70a40b1a054a41df88c15fc5908c2
7
- data.tar.gz: e8f4b265dd59f67442b52fafdb4db15923236df241b4296adc80bb6576654a888cdc564a5c2aa36e9ad291f5720f0c7fae7a274edcce046bb6995b6fbcb04dc7
6
+ metadata.gz: c9e4a015cf36e76ff7bece785930be035b323e4306897538750d5641237b0c34a931ba69f72aeb568456202b1472657d9e44c628967bbcd0bf2d240727c34a9b
7
+ data.tar.gz: 37a2b60ddb1488905ab5a01652ca41fd5f5452e55f689168041edb3dd46235503c31c23e92d51b154d86f1faaae3755aa506b05c15aef6f9e77a7463fff02ce3
data/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.0] - 2026-04-28
8
+
9
+ ### Added
10
+ - Rails event emission for every circuit-breaker lifecycle moment. The `StandardCircuit::Runner` (via a small internal `NotifierBridge` registered with Stoplight) now emits five events as host apps' breakers change state:
11
+ - `standard_circuit.circuit.opened` — RED transition (the "alert me" event)
12
+ - `standard_circuit.circuit.closed` — GREEN transition (recovery)
13
+ - `standard_circuit.circuit.degraded` — YELLOW transition (half-open probe)
14
+ - `standard_circuit.circuit.fallback_invoked` — Runner returned a fallback rather than raising RedLight
15
+ - `standard_circuit.circuit.registered` — `Config#register` / `register_prefix` was called
16
+
17
+ Payloads carry `circuit:`, `from_color:`, `to_color:`, `criticality:`, and (when applicable) `error_class:` / `error_message:` / `reason:`.
18
+ - `standard_circuit.run.completed` event. Per-call complement to the lifecycle events above — fires once per wrapped `StandardCircuit.run` invocation with `circuit:`, `status:` (`:success` / `:failure` / `:circuit_open`), `duration_ms:`, `criticality:`, `error_class:`, and `error_message:`. The right hook for cost-tracking, p95 latency, and per-circuit success-rate dashboards. (`force_closed` runs are intentionally not emitted — that path bypasses the runner.) Routed through the same `EventEmitter` as the lifecycle events, so subscribers get it on `Rails.event` (8.1+) or `ActiveSupport::Notifications` automatically; `duration_ms` is in the payload (not `event.duration`) so backend choice doesn't matter.
19
+ - Dual-backend dispatch in `StandardCircuit::EventEmitter`: emits through `Rails.event.notify` on Rails 8.1+ and falls back to `ActiveSupport::Notifications.instrument` on older Rails. Detection happens at call time, so the gem still loads cleanly before Rails has booted and before `railties` is even required.
20
+ - `StandardCircuit::Engine` Railtie that registers the internal subscribers (Logger / Sentry / Metrics) plus any `extra_notifiers` at boot via the `standard_circuit.subscribers` initializer.
21
+ - `StandardCircuit.subscribers` accessor + `Subscribers#setup!` / `#teardown!` for tests and host apps that need to re-register listeners after mutating config.
22
+ - `rails g standard_circuit:install` — Rails install generator. Writes `config/initializers/standard_circuit.rb` with commented-out examples covering the public Config DSL (`register`, `register_prefix`, notifiers, data store, criticality). Idempotent: re-running on an existing initializer skips with a clear message; pass `--force` to overwrite. Pass `--with-health-endpoint` to also write `config/initializers/standard_circuit_health.rb` (which `require`s the opt-in `HealthController`) and print the route line to add to `config/routes.rb`. The generator does not auto-edit `routes.rb` — too invasive — so consumers paste the printed line themselves.
23
+ - README section on streaming responses and non-controller contexts: shows the recipe for catching `Stoplight::Error::RedLight` inside a `Live` controller's streaming proc (where `circuit_open_fallback` can't render over an open response), and notes the equivalent pattern for background jobs.
24
+
25
+ ### Changed
26
+ - **BREAKING.** `StandardCircuit::Notifiers::{Logger,Sentry,Metrics}` are no longer Stoplight-shaped notifiers. Each now exposes `call(event_name, payload)` and is registered as an event subscriber by the gem's Railtie. They are still considered an internal implementation detail — host apps that want their own behaviour should subscribe to the `standard_circuit.*` namespace directly rather than instantiating these classes.
27
+ - **BREAKING.** `Config#add_notifier` now requires the supplied object to respond to `call(event_name, payload)`. Stoplight-shaped 4-arg notifiers from 0.1.x are rejected with `ArgumentError`. Callers should subscribe via `Rails.event.subscribe` / `ActiveSupport::Notifications.subscribe("standard_circuit.*")` for full control, or pass a lambda to `add_notifier` for the simple case.
28
+ - **BREAKING.** Stoplight only sees a single internal `StandardCircuit::NotifierBridge` notifier now; host apps that previously read `StandardCircuit.config.notifiers` to build their own Stoplight light will need to register against the new event namespace instead.
29
+ - README "Quick start" and the `rails g standard_circuit:install` initializer template now use `ErrorTaxonomies::*.tracked` consistently (the S3 example in the template still showed the pre-0.1.2 `AdapterErrors::Aws.server_errors` form).
30
+ - The install template's "Extra notifiers" example now uses `add_notifier` with a 2-arg `call(name, payload)` callable, matching the contract `Config#add_notifier` enforces. The previous example wrote directly to `extra_notifiers <<` with a 1-arg lambda — both bypassing validation and using the wrong arity, so any consumer who uncommented it would get `ArgumentError: wrong number of arguments` on the first emitted event.
31
+ - `lib/standard_circuit/rspec.rb` now also tears down event subscribers between examples so a spec that subscribes manually doesn't leak listeners into the next.
32
+ - CI and release workflows migrated to the shared `rarebit-one/.github` reusable workflows (`reusable-gem-ci.yml@v1`, `reusable-gem-release.yml@v1`); `.github/workflows/ci.yml` and `release.yml` are now thin shims.
33
+
7
34
  ## [0.1.2] - 2026-04-27
8
35
 
9
36
  ### Added
@@ -37,6 +64,7 @@ All notable changes to this project will be documented in this file.
37
64
  - `StandardCircuit.force_open`, `force_closed`, `reset_force!` + `require "standard_circuit/rspec"` for auto-cleanup.
38
65
 
39
66
  ### Changed
67
+ - Minimum Ruby version is now `>= 4.0` (was `>= 3.4`). CI tests all four published 4.0.x patches.
40
68
  - Removed redundant `defined?(::Sentry::Metrics)` guards in `Runner`, `ControllerSupport`, and `Notifiers::Metrics`. `sentry-ruby` is a hard runtime dependency; the guards were dead code.
41
69
  - Tightened `sentry-ruby` lower bound from `>= 5.0` to `>= 5.17`. `Sentry::Metrics` was introduced in 5.17; the previous floor let Bundler resolve a version where the metrics API does not exist.
42
70
 
data/README.md CHANGED
@@ -6,7 +6,7 @@ Wraps the upstream `stoplight` gem with:
6
6
 
7
7
  - Opinionated default error taxonomy (network errors track; caller/config errors do not)
8
8
  - SDK-specific adapter error bundles (Stripe, AWS, Faraday, SMTP)
9
- - Sentry notifier (warning on GREEN→RED) and metrics notifier (`external.circuit_breaker`, `external.request`)
9
+ - Rails event emission (`standard_circuit.circuit.{opened,closed,degraded,fallback_invoked,registered}`) with built-in Logger, Sentry, and Sentry::Metrics subscribers
10
10
  - ActiveStorage S3 adapter with per-bucket circuit keying
11
11
  - Generic ActionMailer delivery-method wrapper (supports both instance and symbol `underlying:` forms)
12
12
  - Controller concern for standardized 503 responses on `Stoplight::Error::RedLight`
@@ -19,6 +19,22 @@ Wraps the upstream `stoplight` gem with:
19
19
  gem "standard_circuit", git: "https://github.com/rarebit-one/standard_circuit", ref: "<sha>"
20
20
  ```
21
21
 
22
+ Then run the install generator to drop a commented-out initializer into
23
+ `config/initializers/standard_circuit.rb`:
24
+
25
+ ```bash
26
+ bundle add standard_circuit
27
+ rails g standard_circuit:install
28
+ ```
29
+
30
+ Pass `--with-health-endpoint` to also generate
31
+ `config/initializers/standard_circuit_health.rb` (which requires the opt-in
32
+ health controller); the generator prints the matching route line for you to
33
+ add to `config/routes.rb`.
34
+
35
+ The generator is idempotent — re-running skips an existing initializer
36
+ unless you pass `--force`.
37
+
22
38
  ## Quick start
23
39
 
24
40
  ```ruby
@@ -30,7 +46,7 @@ StandardCircuit.configure do |c|
30
46
  c.register(:stripe,
31
47
  threshold: 5,
32
48
  cool_off_time: 30,
33
- tracked_errors: StandardCircuit::NetworkErrors.defaults + StandardCircuit::AdapterErrors::Stripe.server_errors,
49
+ tracked_errors: StandardCircuit::ErrorTaxonomies::Stripe.tracked,
34
50
  skipped_errors: StandardCircuit::AdapterErrors::Stripe.caller_errors)
35
51
  end
36
52
  ```
@@ -42,6 +58,78 @@ StandardCircuit.run(:stripe) do
42
58
  end
43
59
  ```
44
60
 
61
+ ## Events
62
+
63
+ 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.
64
+
65
+ | Event | When it fires | Payload |
66
+ |-------|---------------|---------|
67
+ | `standard_circuit.circuit.opened` | RED transition (circuit tripped) | `circuit:, from_color:, to_color:, criticality:, error_class:, error_message:` |
68
+ | `standard_circuit.circuit.closed` | GREEN transition (recovered) | `circuit:, from_color:, to_color:, criticality:` |
69
+ | `standard_circuit.circuit.degraded` | YELLOW transition (half-open probe) | `circuit:, from_color:, to_color:, criticality:` |
70
+ | `standard_circuit.circuit.fallback_invoked` | Runner returned a fallback instead of raising RedLight | `circuit:, reason: (:circuit_open\|:forced_open), criticality:` |
71
+ | `standard_circuit.circuit.registered` | `Config#register` / `register_prefix` was called (see note below) | `circuit:, criticality:, scope: (:name\|:prefix)` |
72
+ | `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:` |
73
+
74
+ > **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.
75
+
76
+ > **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.
77
+
78
+ Built-in subscribers (Logger / Sentry / Metrics) are registered automatically by the gem's Railtie. Host apps can subscribe to the namespace however they like:
79
+
80
+ ```ruby
81
+ # Rails 8.1+
82
+ class MyAuditSubscriber
83
+ def emit(event)
84
+ return unless event[:name].start_with?("standard_circuit.")
85
+ Rails.logger.info("circuit event: #{event[:name]} #{event[:payload].inspect}")
86
+ end
87
+ end
88
+ Rails.event.subscribe(MyAuditSubscriber.new)
89
+
90
+ # Older Rails
91
+ ActiveSupport::Notifications.subscribe(/\Astandard_circuit\./) do |name, _start, _finish, _id, payload|
92
+ Rails.logger.info("circuit event: #{name} #{payload.inspect}")
93
+ end
94
+
95
+ # Quick host-supplied callable (auto-wired at boot via the Railtie)
96
+ StandardCircuit.configure do |c|
97
+ c.add_notifier(->(name, payload) { MyAlerting.notify(name, payload) })
98
+ end
99
+ ```
100
+
101
+ ## Streaming and non-controller contexts
102
+
103
+ `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:
104
+
105
+ ```ruby
106
+ class Api::MessagesController < ApplicationController
107
+ include ActionController::Live
108
+
109
+ def stream
110
+ response.headers["Content-Type"] = "application/x-ndjson"
111
+
112
+ StandardCircuit.run(:openai) do
113
+ llm.stream do |chunk|
114
+ response.stream.write({ delta: chunk }.to_json + "\n")
115
+ end
116
+ end
117
+ rescue Stoplight::Error::RedLight
118
+ # Only reachable when the circuit was already open at call time —
119
+ # Stoplight raises RedLight before executing the block, not mid-stream.
120
+ # Errors raised mid-stream propagate as their original class through the
121
+ # `ensure` below; add a broader rescue if you also need to write a
122
+ # terminal NDJSON line for those.
123
+ response.stream.write({ error: "service_unavailable" }.to_json + "\n")
124
+ ensure
125
+ response.stream.close
126
+ end
127
+ end
128
+ ```
129
+
130
+ 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.
131
+
132
+
45
133
  ## Health endpoint
46
134
 
47
135
  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.
@@ -0,0 +1,77 @@
1
+ require "rails/generators"
2
+
3
+ module StandardCircuit
4
+ module Generators
5
+ # Installs StandardCircuit in a host Rails application.
6
+ #
7
+ # By default, writes config/initializers/standard_circuit.rb with
8
+ # commented-out examples covering the public Config DSL.
9
+ #
10
+ # When +--with-health-endpoint+ is passed, also writes
11
+ # config/initializers/standard_circuit_health.rb (which requires the
12
+ # opt-in HealthController) and prints the route line the host should
13
+ # add to config/routes.rb to expose the endpoint.
14
+ #
15
+ # Idempotent: re-running on an existing initializer logs and skips. Pass
16
+ # +--force+ to overwrite.
17
+ class InstallGenerator < Rails::Generators::Base
18
+ source_root File.expand_path("templates", __dir__)
19
+
20
+ desc <<~DESC
21
+ Installs StandardCircuit. By default this writes
22
+ config/initializers/standard_circuit.rb with commented-out examples
23
+ covering circuit registration, prefix registration, and notifier
24
+ wiring.
25
+
26
+ Pass --with-health-endpoint to also write
27
+ config/initializers/standard_circuit_health.rb (which requires the
28
+ opt-in HealthController) and print the route line to add to
29
+ config/routes.rb.
30
+
31
+ The generator is idempotent — already-installed initializers are
32
+ skipped with a clear message. Pass --force to overwrite.
33
+ DESC
34
+
35
+ class_option :with_health_endpoint, type: :boolean, default: false,
36
+ desc: "Also create config/initializers/standard_circuit_health.rb and print the route hint"
37
+
38
+ def create_initializer_file
39
+ path = "config/initializers/standard_circuit.rb"
40
+ if File.exist?(File.join(destination_root, path)) && !options[:force]
41
+ say_status("skip", "#{path} already present, skipping (use --force to overwrite)", :yellow)
42
+ return
43
+ end
44
+
45
+ template "initializer.rb.tt", path
46
+ end
47
+
48
+ def create_health_initializer_file
49
+ return unless options[:with_health_endpoint]
50
+
51
+ path = "config/initializers/standard_circuit_health.rb"
52
+ if File.exist?(File.join(destination_root, path)) && !options[:force]
53
+ say_status("skip", "#{path} already present, skipping (use --force to overwrite)", :yellow)
54
+ else
55
+ template "health_initializer.rb.tt", path
56
+ end
57
+ end
58
+
59
+ def print_health_route_hint
60
+ return unless options[:with_health_endpoint]
61
+
62
+ say ""
63
+ say "=" * 79
64
+ say "StandardCircuit health endpoint installed."
65
+ say ""
66
+ say "Add the following to config/routes.rb to expose the endpoint:"
67
+ say ""
68
+ say ' get "/health", to: "standard_circuit/health#show"'
69
+ say ""
70
+ say "The controller returns 503 when the rolled-up circuit health is"
71
+ say ":critical and 200 otherwise — wire it up to your load balancer."
72
+ say "=" * 79
73
+ say ""
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,13 @@
1
+ # StandardCircuit health-check controller
2
+ # Generated by: rails g standard_circuit:install --with-health-endpoint
3
+ #
4
+ # The controller is opt-in: this require pulls it onto the autoload path so
5
+ # the route below resolves. Add the matching route to config/routes.rb:
6
+ #
7
+ # get "/health", to: "standard_circuit/health#show"
8
+ #
9
+ # The endpoint renders StandardCircuit.health_report as JSON and returns
10
+ # 503 when the rolled-up status is :critical (so orchestrators pull the
11
+ # instance out of rotation) and 200 otherwise.
12
+
13
+ require "standard_circuit/health_controller"
@@ -0,0 +1,83 @@
1
+ # StandardCircuit configuration
2
+ # Generated by: rails g standard_circuit:install
3
+ #
4
+ # StandardCircuit wraps the upstream `stoplight` gem with an opinionated
5
+ # error taxonomy, Sentry/metrics notifiers, ActiveStorage and ActionMailer
6
+ # adapters, and a Rails-aware health rollup.
7
+ #
8
+ # All commented-out lines below show the public DSL — uncomment and edit
9
+ # the ones that apply to your app.
10
+
11
+ StandardCircuit.configure do |config|
12
+ # ---------------------------------------------------------------------------
13
+ # Global settings
14
+ # ---------------------------------------------------------------------------
15
+
16
+ # Send Sentry warnings on GREEN -> RED transitions.
17
+ # Default: true
18
+ # config.sentry_enabled = true
19
+
20
+ # Prefix used for emitted metrics (e.g. "<prefix>.circuit_breaker",
21
+ # "<prefix>.request"). Default: "external"
22
+ # config.metric_prefix = "external"
23
+
24
+ # Logger used by the built-in Logger notifier. Falls back to nil (no
25
+ # logging) when unset; assign Rails.logger or a custom logger to enable.
26
+ # Default: nil
27
+ # config.logger = Rails.logger
28
+
29
+ # Stoplight data store. Default: in-memory (per-process). Use a shared
30
+ # store (e.g. Stoplight::DataStore::Redis) when you want circuit state
31
+ # shared across processes/hosts.
32
+ # Default: Stoplight::DataStore::Memory.new
33
+ # config.data_store = Stoplight::DataStore::Redis.new(Redis.new)
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Circuit registration
37
+ # ---------------------------------------------------------------------------
38
+ # Each call to `register` declares a named circuit. Supported options:
39
+ #
40
+ # threshold — failures before tripping (default: 3)
41
+ # cool_off_time — seconds before half-open retry (default: 30)
42
+ # window_size — seconds for the rolling failure window (default: 60)
43
+ # tracked_errors — error classes that count toward the threshold
44
+ # (default: StandardCircuit::NetworkErrors.defaults)
45
+ # skipped_errors — error classes that bypass the circuit (re-raised
46
+ # without counting — typically caller/validation errors)
47
+ # criticality — :critical | :standard | :optional
48
+ # (affects health rollup; default: :standard)
49
+
50
+ # config.register(:stripe,
51
+ # threshold: 5,
52
+ # cool_off_time: 60,
53
+ # tracked_errors: StandardCircuit::ErrorTaxonomies::Stripe.tracked,
54
+ # skipped_errors: StandardCircuit::AdapterErrors::Stripe.caller_errors,
55
+ # criticality: :critical)
56
+
57
+ # config.register(:smtp,
58
+ # tracked_errors: StandardCircuit::ErrorTaxonomies::Smtp.tracked,
59
+ # criticality: :standard)
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Prefix registration
63
+ # ---------------------------------------------------------------------------
64
+ # Use `register_prefix` to dynamically name circuits matching a pattern.
65
+ # The first matching prefix wins; the circuit name is "<prefix>_<suffix>".
66
+
67
+ # config.register_prefix(:s3,
68
+ # threshold: 10,
69
+ # tracked_errors: StandardCircuit::ErrorTaxonomies::Aws.tracked,
70
+ # criticality: :standard)
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Extra notifiers
74
+ # ---------------------------------------------------------------------------
75
+ # Built-in subscribers (Logger / Sentry / Metrics) are wired up automatically.
76
+ # For most cases, prefer subscribing to the `standard_circuit.*` namespace
77
+ # directly via `Rails.event.subscribe` (Rails 8.1+) or
78
+ # `ActiveSupport::Notifications.subscribe`. `add_notifier` is a convenience
79
+ # for registering a callable that follows the same `call(event_name, payload)`
80
+ # contract as the built-in subscribers.
81
+
82
+ # config.add_notifier(->(name, payload) { MyTracer.record(name, payload) })
83
+ end
@@ -46,13 +46,6 @@ module StandardCircuit
46
46
  @extra_notifiers = []
47
47
  end
48
48
 
49
- def notifiers
50
- built = [ Notifiers::Logger.new(@logger) ]
51
- built << Notifiers::Sentry.new if @sentry_enabled
52
- built << Notifiers::Metrics.new(metric_prefix: @metric_prefix)
53
- built + @extra_notifiers
54
- end
55
-
56
49
  def reset_registry!
57
50
  @circuits.clear
58
51
  @prefixes.clear
@@ -60,14 +53,34 @@ module StandardCircuit
60
53
  end
61
54
 
62
55
  def register(name, **opts)
63
- @circuits[name.to_sym] = CircuitSpec.build(**opts)
56
+ spec = CircuitSpec.build(**opts)
57
+ @circuits[name.to_sym] = spec
58
+ EventEmitter.emit("standard_circuit.circuit.registered",
59
+ circuit: name.to_s,
60
+ criticality: spec.criticality,
61
+ scope: :name)
62
+ spec
64
63
  end
65
64
 
66
65
  def register_prefix(prefix, **opts)
67
- @prefixes[prefix.to_s] = CircuitSpec.build(**opts)
66
+ spec = CircuitSpec.build(**opts)
67
+ @prefixes[prefix.to_s] = spec
68
+ EventEmitter.emit("standard_circuit.circuit.registered",
69
+ circuit: prefix.to_s,
70
+ criticality: spec.criticality,
71
+ scope: :prefix)
72
+ spec
68
73
  end
69
74
 
75
+ # Register a host-supplied subscriber. Subscribers must respond to
76
+ # `call(event_name, payload)` — Stoplight-shaped 4-arg notifiers from the
77
+ # 0.1.x API are no longer accepted as extras (Logger / Sentry / Metrics
78
+ # demonstrate the new shape).
70
79
  def add_notifier(notifier)
80
+ unless notifier.respond_to?(:call)
81
+ raise ArgumentError,
82
+ "extra notifiers must respond to `call(event_name, payload)`; got #{notifier.class}"
83
+ end
71
84
  @extra_notifiers << notifier
72
85
  end
73
86
 
@@ -0,0 +1,16 @@
1
+ module StandardCircuit
2
+ # Boot hook: register the internal Logger / Sentry / Metrics subscribers (and
3
+ # any `extra_notifiers` the host configured) against whichever event bus is
4
+ # live in this Rails version.
5
+ #
6
+ # We hook `after: :load_config_initializers` so any host-side
7
+ # `StandardCircuit.configure` block in `config/initializers/*` has finished
8
+ # running and `extra_notifiers` / `metric_prefix` / `logger` are in their
9
+ # final state when the subscriber set is built. This is independent of
10
+ # ActiveRecord — apps that don't load AR still need observability.
11
+ class Engine < ::Rails::Engine
12
+ initializer "standard_circuit.subscribers", after: :load_config_initializers do
13
+ StandardCircuit.subscribers.setup!
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,35 @@
1
+ module StandardCircuit
2
+ # Internal helper that emits StandardCircuit lifecycle events through whichever
3
+ # event reporter is live in the host process.
4
+ #
5
+ # - On Rails 8.1+, `Rails.event.notify(name, **payload)` is the canonical bus.
6
+ # - On older Rails (or any host without the structured reporter), we fall back
7
+ # to `ActiveSupport::Notifications.instrument(name, payload)`.
8
+ #
9
+ # Detection is performed at *call time* — the gem is required before Rails has
10
+ # finished booting, so we cannot cache the decision at load time.
11
+ #
12
+ # @api private
13
+ module EventEmitter
14
+ module_function
15
+
16
+ # Emit a single event. Both backends are best-effort: any exception raised
17
+ # by a subscriber is swallowed so circuit-breaker observability never takes
18
+ # down a circuit-protected request.
19
+ def emit(event_name, payload)
20
+ if rails_event_available?
21
+ ::Rails.event.notify(event_name, **payload)
22
+ else
23
+ ::ActiveSupport::Notifications.instrument(event_name, payload)
24
+ end
25
+ rescue => e
26
+ warn "[StandardCircuit] event emit for #{event_name.inspect} failed: #{e.class}: #{e.message}"
27
+ end
28
+
29
+ def rails_event_available?
30
+ defined?(::Rails) &&
31
+ ::Rails.respond_to?(:event) &&
32
+ ::Rails.event.respond_to?(:notify)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,49 @@
1
+ module StandardCircuit
2
+ # Stoplight-shaped notifier whose only job is to translate the upstream
3
+ # `notifier.notify(light, from_color, to_color, error)` callback into a
4
+ # StandardCircuit Rails event.
5
+ #
6
+ # Stoplight calls notifiers on every color transition (GREEN<->RED, RED->YELLOW
7
+ # for half-open recovery). We map each transition to a stable event name and
8
+ # forward a uniform payload to whichever event bus is live.
9
+ #
10
+ # This is the single Stoplight notifier StandardCircuit registers — Logger,
11
+ # Sentry, and Metrics are now subscribers, not direct notifiers.
12
+ #
13
+ # @api private
14
+ class NotifierBridge
15
+ EVENT_FOR_COLOR = {
16
+ Stoplight::Color::RED => "standard_circuit.circuit.opened",
17
+ Stoplight::Color::GREEN => "standard_circuit.circuit.closed",
18
+ Stoplight::Color::YELLOW => "standard_circuit.circuit.degraded"
19
+ }.freeze
20
+
21
+ def initialize(config)
22
+ @config = config
23
+ end
24
+
25
+ def notify(light, from_color, to_color, error)
26
+ event_name = EVENT_FOR_COLOR[to_color]
27
+ return unless event_name
28
+
29
+ EventEmitter.emit(event_name, payload_for(light, from_color, to_color, error))
30
+ end
31
+
32
+ private
33
+
34
+ def payload_for(light, from_color, to_color, error)
35
+ spec = @config.spec_for(light.name)
36
+ payload = {
37
+ circuit: light.name,
38
+ from_color: from_color,
39
+ to_color: to_color,
40
+ criticality: spec&.criticality
41
+ }
42
+ if error
43
+ payload[:error_class] = error.class.name
44
+ payload[:error_message] = error.message
45
+ end
46
+ payload
47
+ end
48
+ end
49
+ end
@@ -2,23 +2,43 @@ require "logger"
2
2
 
3
3
  module StandardCircuit
4
4
  module Notifiers
5
+ # Subscribes to standard_circuit.circuit.* events and writes a human-readable
6
+ # log line for each transition. Always-on by default; pass a custom logger
7
+ # via `StandardCircuit.config.logger=`.
5
8
  class Logger
9
+ TRANSITION_EVENTS = %w[
10
+ standard_circuit.circuit.opened
11
+ standard_circuit.circuit.closed
12
+ standard_circuit.circuit.degraded
13
+ ].freeze
14
+
6
15
  def initialize(logger = nil)
7
16
  @logger = logger || ::Logger.new($stdout)
8
17
  end
9
18
 
10
- def notify(light, from_color, to_color, error)
11
- message = build_message(light, from_color, to_color, error)
12
- level = to_color == Stoplight::Color::RED ? :warn : :info
19
+ def call(event_name, payload)
20
+ return unless TRANSITION_EVENTS.include?(event_name)
21
+
22
+ message = build_message(event_name, payload)
23
+ level = event_name == "standard_circuit.circuit.opened" ? :warn : :info
13
24
  @logger.public_send(level, message)
14
25
  message
15
26
  end
16
27
 
17
28
  private
18
29
 
19
- def build_message(light, from_color, to_color, error)
20
- words = [ "Stoplight", light.name, "switched from", from_color, "to", to_color ]
21
- words += [ "because", error.class.name, error.message ] if error
30
+ def build_message(event_name, payload)
31
+ words = [
32
+ "Stoplight",
33
+ payload[:circuit],
34
+ "switched from",
35
+ payload[:from_color],
36
+ "to",
37
+ payload[:to_color]
38
+ ]
39
+ if payload[:error_class]
40
+ words += [ "because", payload[:error_class], payload[:error_message] ]
41
+ end
22
42
  words.join(" ")
23
43
  end
24
44
  end
@@ -1,22 +1,26 @@
1
1
  module StandardCircuit
2
2
  module Notifiers
3
+ # Subscribes to all standard_circuit.circuit.{opened,closed,degraded} events
4
+ # and emits a Sentry::Metrics counter with the canonical state name.
3
5
  class Metrics
4
- STATE_FOR_COLOR = {
5
- Stoplight::Color::RED => "opened",
6
- Stoplight::Color::GREEN => "closed",
7
- Stoplight::Color::YELLOW => "half_open"
6
+ STATE_FOR_EVENT = {
7
+ "standard_circuit.circuit.opened" => "opened",
8
+ "standard_circuit.circuit.closed" => "closed",
9
+ "standard_circuit.circuit.degraded" => "half_open"
8
10
  }.freeze
9
11
 
10
12
  def initialize(metric_prefix: "external")
11
13
  @metric_prefix = metric_prefix
12
14
  end
13
15
 
14
- def notify(light, _from_color, to_color, _error)
15
- state = STATE_FOR_COLOR.fetch(to_color, to_color)
16
+ def call(event_name, payload)
17
+ state = STATE_FOR_EVENT[event_name]
18
+ return unless state
19
+
16
20
  ::Sentry::Metrics.count(
17
21
  "#{@metric_prefix}.circuit_breaker",
18
22
  value: 1,
19
- attributes: { service: light.name, state: state }
23
+ attributes: { service: payload[:circuit], state: state }
20
24
  )
21
25
  end
22
26
  end
@@ -1,20 +1,23 @@
1
1
  module StandardCircuit
2
2
  module Notifiers
3
+ # Subscribes to standard_circuit.circuit.opened and forwards a warning-level
4
+ # message to Sentry. Other transitions are ignored — only RED matters for
5
+ # alerting.
3
6
  class Sentry
4
- def notify(light, from_color, to_color, error)
5
- return unless to_color == Stoplight::Color::RED
7
+ def call(event_name, payload)
8
+ return unless event_name == "standard_circuit.circuit.opened"
6
9
  return unless defined?(::Sentry) && ::Sentry.respond_to?(:capture_message)
7
10
 
8
- message = "Circuit breaker opened: #{light.name}"
11
+ message = "Circuit breaker opened: #{payload[:circuit]}"
9
12
  ::Sentry.capture_message(
10
13
  message,
11
14
  level: :warning,
12
15
  extra: {
13
- circuit: light.name,
14
- from_color: from_color,
15
- to_color: to_color,
16
- error_class: error&.class&.name,
17
- error_message: error&.message
16
+ circuit: payload[:circuit],
17
+ from_color: payload[:from_color],
18
+ to_color: payload[:to_color],
19
+ error_class: payload[:error_class],
20
+ error_message: payload[:error_message]
18
21
  }.compact
19
22
  )
20
23
  message
@@ -5,14 +5,27 @@ require "standard_circuit"
5
5
  # - Clears the light cache so rebuilt lights pick up fresh config if a spec
6
6
  # mutates it.
7
7
  # - Clears forced states (force_open / force_closed).
8
+ # - Tears down all event subscribers so a spec that subscribes manually doesn't
9
+ # leak listeners into the next example. The internal Logger/Sentry/Metrics
10
+ # subscribers are re-registered if a spec calls `StandardCircuit.configure`
11
+ # or `StandardCircuit.subscribers.setup!`.
8
12
  # - Swaps a fresh Stoplight::DataStore::Memory into the Config when the
9
13
  # current store is already Memory, so failure counters from one spec don't
10
14
  # leak into the next. Redis stores are left alone.
11
15
  #
16
+ # Circuit registrations are intentionally NOT cleared. Host apps usually
17
+ # define circuits once in a `config/initializers/standard_circuit.rb`
18
+ # initializer; clearing the registry between examples would force every spec
19
+ # to re-`configure`. Specs that register circuits in `before(:each)` are
20
+ # unaffected by leftover registrations from prior examples (the new register
21
+ # call wins). If you do need a clean registry, call
22
+ # `StandardCircuit.config.reset_registry!` explicitly in your own hook.
23
+ #
12
24
  # This is intentionally `before(:each)` rather than `after(:each)` so the
13
25
  # setup happens even when a previous example aborted in an after hook.
14
26
  RSpec.configure do |config|
15
27
  config.before(:each) do
16
28
  StandardCircuit.reset!
29
+ StandardCircuit.subscribers.teardown!
17
30
  end
18
31
  end
@@ -86,21 +86,32 @@ module StandardCircuit
86
86
  light = light_for(name)
87
87
 
88
88
  result = fallback ? light.run(fallback, &block) : light.run(&block)
89
- emit_request_metric(name, :success, duration_ms(started_at))
89
+ duration = duration_ms(started_at)
90
+ emit_request_metric(name, :success, duration)
91
+ emit_run_completed(name, status: :success, duration_ms: duration)
90
92
  result
91
93
  rescue Stoplight::Error::RedLight => e
92
- emit_request_metric(name, :circuit_open, duration_ms(started_at))
94
+ duration = duration_ms(started_at)
95
+ emit_request_metric(name, :circuit_open, duration)
96
+ emit_run_completed(name, status: :circuit_open, duration_ms: duration, error: e)
93
97
  raise e unless fallback
94
98
 
99
+ emit_fallback_invoked(name, reason: :circuit_open)
95
100
  fallback.call(nil)
96
101
  rescue StandardError => e
97
- emit_request_metric(name, :failure, duration_ms(started_at))
102
+ duration = duration_ms(started_at)
103
+ emit_request_metric(name, :failure, duration)
104
+ emit_run_completed(name, status: :failure, duration_ms: duration, error: e)
98
105
  raise e
99
106
  end
100
107
 
101
108
  def run_forced_open(name, fallback)
102
109
  emit_request_metric(name, :circuit_open, 0)
103
- return fallback.call(nil) if fallback
110
+ emit_run_completed(name, status: :circuit_open, duration_ms: 0)
111
+ if fallback
112
+ emit_fallback_invoked(name, reason: :forced_open)
113
+ return fallback.call(nil)
114
+ end
104
115
 
105
116
  spec = @config.spec_for(name)
106
117
  raise Stoplight::Error::RedLight.new(
@@ -138,14 +149,10 @@ module StandardCircuit
138
149
  tracked_errors: spec.tracked_errors,
139
150
  skipped_errors: spec.skipped_errors,
140
151
  data_store: @config.data_store,
141
- notifiers: notifiers
152
+ notifiers: [ NotifierBridge.new(@config) ]
142
153
  )
143
154
  end
144
155
 
145
- def notifiers
146
- @config.notifiers
147
- end
148
-
149
156
  def emit_request_metric(name, status, duration)
150
157
  prefix = @config.metric_prefix
151
158
  attrs = { service: name.to_s, status: status.to_s }
@@ -153,6 +160,25 @@ module StandardCircuit
153
160
  ::Sentry::Metrics.distribution("#{prefix}.request.duration", duration, unit: "millisecond", attributes: attrs)
154
161
  end
155
162
 
163
+ def emit_fallback_invoked(name, reason:)
164
+ spec = @config.spec_for(name)
165
+ EventEmitter.emit("standard_circuit.circuit.fallback_invoked",
166
+ circuit: name.to_s,
167
+ reason: reason,
168
+ criticality: spec&.criticality)
169
+ end
170
+
171
+ def emit_run_completed(name, status:, duration_ms:, error: nil)
172
+ spec = @config.spec_for(name)
173
+ EventEmitter.emit("standard_circuit.run.completed",
174
+ circuit: name.to_s,
175
+ status: status,
176
+ duration_ms: duration_ms,
177
+ criticality: spec&.criticality,
178
+ error_class: error&.class&.name,
179
+ error_message: error&.message)
180
+ end
181
+
156
182
  def monotonic_now
157
183
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
158
184
  end
@@ -0,0 +1,102 @@
1
+ module StandardCircuit
2
+ # Registers internal and user-supplied subscribers against whichever event
3
+ # bus is live (Rails.event on 8.1+, ActiveSupport::Notifications elsewhere).
4
+ #
5
+ # Each subscriber must respond to `call(event_name, payload)`. Internal
6
+ # subscribers (Logger / Sentry / Metrics) are built from the live config so
7
+ # changes to `metric_prefix` / `logger` propagate when `setup!` is re-run.
8
+ #
9
+ # Subscriptions cover the namespace prefix `standard_circuit.`, so a single
10
+ # registration on each backend listens for every lifecycle event the gem
11
+ # emits — bridge color transitions plus runner-side fallback / registration.
12
+ #
13
+ # @api private
14
+ class Subscribers
15
+ EVENT_PATTERN = "standard_circuit."
16
+ EVENT_REGEXP = /\A#{Regexp.escape(EVENT_PATTERN)}/
17
+
18
+ def initialize
19
+ @rails_event_subscribers = []
20
+ @as_subscribers = []
21
+ end
22
+
23
+ def setup!
24
+ teardown!
25
+ register(internal_subscribers + extra_subscribers)
26
+ end
27
+
28
+ # Tear down both backends. Rails.event subscribers are unsubscribed
29
+ # unconditionally — we recorded them at registration time, so we must
30
+ # remove them even if Rails.event has since become unavailable (e.g. test
31
+ # `hide_const("Rails")`). Otherwise the wrappers would remain live in the
32
+ # bus while we believe they are gone.
33
+ def teardown!
34
+ @rails_event_subscribers.each do |subscriber|
35
+ ::Rails.event.unsubscribe(subscriber) if EventEmitter.rails_event_available?
36
+ rescue StandardError
37
+ # If Rails.event is gone (test isolation), we can do nothing more —
38
+ # clearing the array still releases our reference.
39
+ end
40
+ @rails_event_subscribers.clear
41
+
42
+ @as_subscribers.each do |subscriber|
43
+ ::ActiveSupport::Notifications.unsubscribe(subscriber)
44
+ end
45
+ @as_subscribers.clear
46
+ end
47
+
48
+ private
49
+
50
+ def register(subscribers)
51
+ subscribers.each do |subscriber|
52
+ register_one(subscriber)
53
+ end
54
+ end
55
+
56
+ # Register on whichever backend is live. We do not double-subscribe — if
57
+ # `Rails.event` is present, every emit goes through it (see EventEmitter),
58
+ # so the AS::Notifications side would never receive anything.
59
+ def register_one(subscriber)
60
+ if EventEmitter.rails_event_available?
61
+ wrapper = RailsEventAdapter.new(subscriber)
62
+ ::Rails.event.subscribe(wrapper)
63
+ @rails_event_subscribers << wrapper
64
+ else
65
+ handle = ::ActiveSupport::Notifications.subscribe(EVENT_REGEXP) do |name, _start, _finish, _id, payload|
66
+ subscriber.call(name, payload)
67
+ end
68
+ @as_subscribers << handle
69
+ end
70
+ end
71
+
72
+ def internal_subscribers
73
+ config = StandardCircuit.config
74
+ list = [ Notifiers::Logger.new(config.logger) ]
75
+ list << Notifiers::Sentry.new if config.sentry_enabled
76
+ list << Notifiers::Metrics.new(metric_prefix: config.metric_prefix)
77
+ list
78
+ end
79
+
80
+ # Config#add_notifier already enforces that every entry responds to :call,
81
+ # so we can hand the array straight through to register/1.
82
+ def extra_subscribers
83
+ StandardCircuit.config.extra_notifiers
84
+ end
85
+
86
+ # Adapts a `call(name, payload)` subscriber to the Rails.event#subscribe
87
+ # contract, which delivers a Hash event with :name / :payload / :context /
88
+ # :tags / :source_location.
89
+ class RailsEventAdapter
90
+ def initialize(subscriber)
91
+ @subscriber = subscriber
92
+ end
93
+
94
+ def emit(event)
95
+ name = event[:name]
96
+ return unless name&.start_with?(EVENT_PATTERN)
97
+
98
+ @subscriber.call(name, event[:payload] || {})
99
+ end
100
+ end
101
+ end
102
+ end
@@ -1,3 +1,3 @@
1
1
  module StandardCircuit
2
- VERSION = "0.1.2"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -9,15 +9,19 @@ require "standard_circuit/adapter_errors/aws"
9
9
  require "standard_circuit/adapter_errors/faraday"
10
10
  require "standard_circuit/adapter_errors/smtp"
11
11
  require "standard_circuit/error_taxonomies"
12
+ require "standard_circuit/event_emitter"
13
+ require "standard_circuit/notifier_bridge"
12
14
  require "standard_circuit/notifiers/logger"
13
15
  require "standard_circuit/notifiers/sentry"
14
16
  require "standard_circuit/notifiers/metrics"
17
+ require "standard_circuit/subscribers"
15
18
  require "standard_circuit/config"
16
19
  require "standard_circuit/health"
17
20
  require "standard_circuit/runner"
18
21
  require "standard_circuit/mailer/circuit_open_error"
19
22
  require "standard_circuit/mailer/delivery_method"
20
23
  require "standard_circuit/controller_support"
24
+ require "standard_circuit/engine" if defined?(::Rails::Engine)
21
25
 
22
26
  module StandardCircuit
23
27
  class Error < StandardError; end
@@ -27,6 +31,7 @@ module StandardCircuit
27
31
  def configure
28
32
  yield config
29
33
  runner.apply_config!(config)
34
+ subscribers.setup!
30
35
  config
31
36
  end
32
37
 
@@ -38,6 +43,10 @@ module StandardCircuit
38
43
  @runner ||= Runner.new
39
44
  end
40
45
 
46
+ def subscribers
47
+ @subscribers ||= Subscribers.new
48
+ end
49
+
41
50
  def run(name, fallback: nil, &block)
42
51
  runner.run(name, fallback: fallback, &block)
43
52
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_circuit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -65,6 +65,48 @@ dependencies:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
67
  version: '8.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: brakeman
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: bundler-audit
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: simplecov
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
68
110
  description: StandardCircuit wraps the stoplight gem with opinionated error taxonomy,
69
111
  Sentry notifiers, ActiveStorage S3 and ActionMailer adapters, and test helpers shared
70
112
  across Rails apps.
@@ -79,6 +121,9 @@ files:
79
121
  - README.md
80
122
  - Rakefile
81
123
  - lib/active_storage/service/standard_circuit_s3_service.rb
124
+ - lib/generators/standard_circuit/install/install_generator.rb
125
+ - lib/generators/standard_circuit/install/templates/health_initializer.rb.tt
126
+ - lib/generators/standard_circuit/install/templates/initializer.rb.tt
82
127
  - lib/standard_circuit.rb
83
128
  - lib/standard_circuit/active_storage/s3_service.rb
84
129
  - lib/standard_circuit/adapter_errors/aws.rb
@@ -87,17 +132,21 @@ files:
87
132
  - lib/standard_circuit/adapter_errors/stripe.rb
88
133
  - lib/standard_circuit/config.rb
89
134
  - lib/standard_circuit/controller_support.rb
135
+ - lib/standard_circuit/engine.rb
90
136
  - lib/standard_circuit/error_taxonomies.rb
137
+ - lib/standard_circuit/event_emitter.rb
91
138
  - lib/standard_circuit/health.rb
92
139
  - lib/standard_circuit/health_controller.rb
93
140
  - lib/standard_circuit/mailer/circuit_open_error.rb
94
141
  - lib/standard_circuit/mailer/delivery_method.rb
95
142
  - lib/standard_circuit/network_errors.rb
143
+ - lib/standard_circuit/notifier_bridge.rb
96
144
  - lib/standard_circuit/notifiers/logger.rb
97
145
  - lib/standard_circuit/notifiers/metrics.rb
98
146
  - lib/standard_circuit/notifiers/sentry.rb
99
147
  - lib/standard_circuit/rspec.rb
100
148
  - lib/standard_circuit/runner.rb
149
+ - lib/standard_circuit/subscribers.rb
101
150
  - lib/standard_circuit/version.rb
102
151
  homepage: https://github.com/rarebit-one/standard_circuit
103
152
  licenses:
@@ -114,7 +163,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
114
163
  requirements:
115
164
  - - ">="
116
165
  - !ruby/object:Gem::Version
117
- version: '3.4'
166
+ version: '4.0'
118
167
  required_rubygems_version: !ruby/object:Gem::Requirement
119
168
  requirements:
120
169
  - - ">="