standard_circuit 0.1.1 → 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 +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +90 -2
- data/lib/generators/standard_circuit/install/install_generator.rb +77 -0
- data/lib/generators/standard_circuit/install/templates/health_initializer.rb.tt +13 -0
- data/lib/generators/standard_circuit/install/templates/initializer.rb.tt +83 -0
- data/lib/standard_circuit/config.rb +22 -9
- data/lib/standard_circuit/controller_support.rb +21 -0
- data/lib/standard_circuit/engine.rb +16 -0
- data/lib/standard_circuit/error_taxonomies.rb +43 -0
- data/lib/standard_circuit/event_emitter.rb +35 -0
- data/lib/standard_circuit/notifier_bridge.rb +49 -0
- data/lib/standard_circuit/notifiers/logger.rb +26 -6
- data/lib/standard_circuit/notifiers/metrics.rb +11 -7
- data/lib/standard_circuit/notifiers/sentry.rb +11 -8
- data/lib/standard_circuit/rspec.rb +13 -0
- data/lib/standard_circuit/runner.rb +35 -9
- data/lib/standard_circuit/subscribers.rb +102 -0
- data/lib/standard_circuit/version.rb +1 -1
- data/lib/standard_circuit.rb +10 -0
- metadata +52 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8a58ef94e0f296c0e3b107fda1decfc29037324942c5326ec2fcfd1fdf19f2f
|
|
4
|
+
data.tar.gz: b3a78ae40f5a60c0e9754011eca3174ec4d489f61e51d41f81a6eb5a7efeb946
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c9e4a015cf36e76ff7bece785930be035b323e4306897538750d5641237b0c34a931ba69f72aeb568456202b1472657d9e44c628967bbcd0bf2d240727c34a9b
|
|
7
|
+
data.tar.gz: 37a2b60ddb1488905ab5a01652ca41fd5f5452e55f689168041edb3dd46235503c31c23e92d51b154d86f1faaae3755aa506b05c15aef6f9e77a7463fff02ce3
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,41 @@ 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
|
+
|
|
34
|
+
## [0.1.2] - 2026-04-27
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- `StandardCircuit::ErrorTaxonomies::{Stripe,Smtp,Aws,Faraday}.tracked` — pre-combined `NetworkErrors.defaults + AdapterErrors::X.server_errors` arrays. Saves consumers from typing the same line for every circuit they register and gives a single place to evolve what counts as a "server-side outage" per integration. `caller_errors` (validation/auth/etc.) stay on `AdapterErrors::*` because the right `skipped_errors` set is usually app-specific.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- `ControllerSupport.circuit_open_fallback` now appends a fresh `rescue_from Stoplight::Error::RedLight` handler each time it's called (deduplicating any prior RedLight handler on the class first, so repeated calls don't accumulate). Rails matches `rescue_handlers` last-declared-first, so a `rescue_from StandardError` catch-all declared *after* `include StandardCircuit::ControllerSupport` previously shadowed the gem's RedLight handler. As long as `circuit_open_fallback` is called after any catch-all rescues in your controller, RedLight now keeps routing to `handle_circuit_open` reliably.
|
|
41
|
+
|
|
7
42
|
## [0.1.1] - 2026-04-27
|
|
8
43
|
|
|
9
44
|
### Fixed
|
|
@@ -29,6 +64,7 @@ All notable changes to this project will be documented in this file.
|
|
|
29
64
|
- `StandardCircuit.force_open`, `force_closed`, `reset_force!` + `require "standard_circuit/rspec"` for auto-cleanup.
|
|
30
65
|
|
|
31
66
|
### Changed
|
|
67
|
+
- Minimum Ruby version is now `>= 4.0` (was `>= 3.4`). CI tests all four published 4.0.x patches.
|
|
32
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.
|
|
33
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.
|
|
34
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
|
-
-
|
|
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::
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -14,6 +14,14 @@ module StandardCircuit
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
class_methods do
|
|
17
|
+
# Rails iterates `rescue_handlers` from last-declared to first to find a
|
|
18
|
+
# match, so a `rescue_from StandardError` declared *after* `include
|
|
19
|
+
# StandardCircuit::ControllerSupport` shadows the gem's `RedLight` handler
|
|
20
|
+
# registered in `included`. Re-appending the handler here makes the DSL
|
|
21
|
+
# call effectively bump it to the end of the list — so as long as
|
|
22
|
+
# `circuit_open_fallback` is the last thing in your controller (or at
|
|
23
|
+
# least after any `rescue_from StandardError`-style catch-alls),
|
|
24
|
+
# `Stoplight::Error::RedLight` keeps routing to `handle_circuit_open`.
|
|
17
25
|
def circuit_open_fallback(status: :service_unavailable, html: nil, json: nil, stream: nil)
|
|
18
26
|
self._circuit_open_fallback = {
|
|
19
27
|
status: status,
|
|
@@ -21,6 +29,19 @@ module StandardCircuit
|
|
|
21
29
|
json: json,
|
|
22
30
|
stream: stream
|
|
23
31
|
}
|
|
32
|
+
|
|
33
|
+
# Drop existing RedLight handlers on this class before re-appending so
|
|
34
|
+
# repeated DSL calls (or sub-controllers calling the DSL after a base
|
|
35
|
+
# controller did) don't accumulate duplicate entries in
|
|
36
|
+
# `rescue_handlers`. Use the writer (not `reject!`) to avoid mutating
|
|
37
|
+
# the array a parent class might still share via `class_attribute`.
|
|
38
|
+
# Each entry's first element is a class-name string
|
|
39
|
+
# ("Stoplight::Error::RedLight"), not the constant itself.
|
|
40
|
+
self.rescue_handlers = rescue_handlers.reject { |handler| handler.first == "Stoplight::Error::RedLight" }
|
|
41
|
+
|
|
42
|
+
rescue_from Stoplight::Error::RedLight do |error|
|
|
43
|
+
handle_circuit_open(error)
|
|
44
|
+
end
|
|
24
45
|
end
|
|
25
46
|
end
|
|
26
47
|
|
|
@@ -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,43 @@
|
|
|
1
|
+
module StandardCircuit
|
|
2
|
+
# Pre-combined `tracked_errors` sets per adapter — saves consumers from
|
|
3
|
+
# typing the same `NetworkErrors.defaults + AdapterErrors::X.server_errors`
|
|
4
|
+
# line for every circuit they register, and gives a single place to evolve
|
|
5
|
+
# what counts as a "server-side outage" for each integration.
|
|
6
|
+
#
|
|
7
|
+
# Each adapter's `tracked` returns a fresh array, so callers can safely
|
|
8
|
+
# `+` additional app-specific error classes without mutating shared state.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# c.register(:stripe,
|
|
12
|
+
# tracked_errors: StandardCircuit::ErrorTaxonomies::Stripe.tracked,
|
|
13
|
+
# skipped_errors: StandardCircuit::AdapterErrors::Stripe.caller_errors)
|
|
14
|
+
#
|
|
15
|
+
# Adapter-specific `caller_errors` (validation/auth/etc.) stay on
|
|
16
|
+
# `AdapterErrors::*` because the right `skipped_errors` set is usually
|
|
17
|
+
# app-specific and a shared taxonomy would over-skip.
|
|
18
|
+
module ErrorTaxonomies
|
|
19
|
+
module Stripe
|
|
20
|
+
def self.tracked
|
|
21
|
+
NetworkErrors.defaults + AdapterErrors::Stripe.server_errors
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module Smtp
|
|
26
|
+
def self.tracked
|
|
27
|
+
NetworkErrors.defaults + AdapterErrors::Smtp.server_errors
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module Aws
|
|
32
|
+
def self.tracked
|
|
33
|
+
NetworkErrors.defaults + AdapterErrors::Aws.server_errors
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
module Faraday
|
|
38
|
+
def self.tracked
|
|
39
|
+
NetworkErrors.defaults + AdapterErrors::Faraday.server_errors
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
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
|
|
11
|
-
|
|
12
|
-
|
|
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(
|
|
20
|
-
words = [
|
|
21
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
15
|
-
state =
|
|
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:
|
|
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
|
|
5
|
-
return unless
|
|
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: #{
|
|
11
|
+
message = "Circuit breaker opened: #{payload[:circuit]}"
|
|
9
12
|
::Sentry.capture_message(
|
|
10
13
|
message,
|
|
11
14
|
level: :warning,
|
|
12
15
|
extra: {
|
|
13
|
-
circuit:
|
|
14
|
-
from_color: from_color,
|
|
15
|
-
to_color: to_color,
|
|
16
|
-
error_class:
|
|
17
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
data/lib/standard_circuit.rb
CHANGED
|
@@ -8,15 +8,20 @@ require "standard_circuit/adapter_errors/stripe"
|
|
|
8
8
|
require "standard_circuit/adapter_errors/aws"
|
|
9
9
|
require "standard_circuit/adapter_errors/faraday"
|
|
10
10
|
require "standard_circuit/adapter_errors/smtp"
|
|
11
|
+
require "standard_circuit/error_taxonomies"
|
|
12
|
+
require "standard_circuit/event_emitter"
|
|
13
|
+
require "standard_circuit/notifier_bridge"
|
|
11
14
|
require "standard_circuit/notifiers/logger"
|
|
12
15
|
require "standard_circuit/notifiers/sentry"
|
|
13
16
|
require "standard_circuit/notifiers/metrics"
|
|
17
|
+
require "standard_circuit/subscribers"
|
|
14
18
|
require "standard_circuit/config"
|
|
15
19
|
require "standard_circuit/health"
|
|
16
20
|
require "standard_circuit/runner"
|
|
17
21
|
require "standard_circuit/mailer/circuit_open_error"
|
|
18
22
|
require "standard_circuit/mailer/delivery_method"
|
|
19
23
|
require "standard_circuit/controller_support"
|
|
24
|
+
require "standard_circuit/engine" if defined?(::Rails::Engine)
|
|
20
25
|
|
|
21
26
|
module StandardCircuit
|
|
22
27
|
class Error < StandardError; end
|
|
@@ -26,6 +31,7 @@ module StandardCircuit
|
|
|
26
31
|
def configure
|
|
27
32
|
yield config
|
|
28
33
|
runner.apply_config!(config)
|
|
34
|
+
subscribers.setup!
|
|
29
35
|
config
|
|
30
36
|
end
|
|
31
37
|
|
|
@@ -37,6 +43,10 @@ module StandardCircuit
|
|
|
37
43
|
@runner ||= Runner.new
|
|
38
44
|
end
|
|
39
45
|
|
|
46
|
+
def subscribers
|
|
47
|
+
@subscribers ||= Subscribers.new
|
|
48
|
+
end
|
|
49
|
+
|
|
40
50
|
def run(name, fallback: nil, &block)
|
|
41
51
|
runner.run(name, fallback: fallback, &block)
|
|
42
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.
|
|
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,16 +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
|
|
136
|
+
- lib/standard_circuit/error_taxonomies.rb
|
|
137
|
+
- lib/standard_circuit/event_emitter.rb
|
|
90
138
|
- lib/standard_circuit/health.rb
|
|
91
139
|
- lib/standard_circuit/health_controller.rb
|
|
92
140
|
- lib/standard_circuit/mailer/circuit_open_error.rb
|
|
93
141
|
- lib/standard_circuit/mailer/delivery_method.rb
|
|
94
142
|
- lib/standard_circuit/network_errors.rb
|
|
143
|
+
- lib/standard_circuit/notifier_bridge.rb
|
|
95
144
|
- lib/standard_circuit/notifiers/logger.rb
|
|
96
145
|
- lib/standard_circuit/notifiers/metrics.rb
|
|
97
146
|
- lib/standard_circuit/notifiers/sentry.rb
|
|
98
147
|
- lib/standard_circuit/rspec.rb
|
|
99
148
|
- lib/standard_circuit/runner.rb
|
|
149
|
+
- lib/standard_circuit/subscribers.rb
|
|
100
150
|
- lib/standard_circuit/version.rb
|
|
101
151
|
homepage: https://github.com/rarebit-one/standard_circuit
|
|
102
152
|
licenses:
|
|
@@ -113,7 +163,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
113
163
|
requirements:
|
|
114
164
|
- - ">="
|
|
115
165
|
- !ruby/object:Gem::Version
|
|
116
|
-
version: '
|
|
166
|
+
version: '4.0'
|
|
117
167
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
118
168
|
requirements:
|
|
119
169
|
- - ">="
|