standard_circuit 0.2.0 → 0.3.1

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: f8a58ef94e0f296c0e3b107fda1decfc29037324942c5326ec2fcfd1fdf19f2f
4
- data.tar.gz: b3a78ae40f5a60c0e9754011eca3174ec4d489f61e51d41f81a6eb5a7efeb946
3
+ metadata.gz: 335a8efd1db9120747a00cd5b6fc331cb8f31e81b6b391638a6af5877f5c1728
4
+ data.tar.gz: f23e64bf48c6e5ad494674a68e456b5874aef0800c832841c1251e4f20a74e95
5
5
  SHA512:
6
- metadata.gz: c9e4a015cf36e76ff7bece785930be035b323e4306897538750d5641237b0c34a931ba69f72aeb568456202b1472657d9e44c628967bbcd0bf2d240727c34a9b
7
- data.tar.gz: 37a2b60ddb1488905ab5a01652ca41fd5f5452e55f689168041edb3dd46235503c31c23e92d51b154d86f1faaae3755aa506b05c15aef6f9e77a7463fff02ce3
6
+ metadata.gz: ec3442f7cf7d4d8efe3ef105fe8f404036691904f95826427fb5838441ff16f4d711e5041e0006f097b3c1c43a8b1426128faae75c4433b483d1a1849103a87f
7
+ data.tar.gz: d3fd9b73de2eea2a8b68f7863aff6328072ec6963e7f5d93a4654d27a82c856f86d99a6da96dd076e65757d4677fc9131d27246d9a39baf0028a62be74ac0530
data/CHANGELOG.md CHANGED
@@ -2,8 +2,42 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
5
8
  ## [Unreleased]
6
9
 
10
+ ## [0.3.1] - 2026-09-24
11
+
12
+ Two behaviour changes — both are bug fixes, but both change numbers or breaker behaviour you may be alerting on.
13
+
14
+ ### Fixed
15
+ - **Circuit-open requests were counted twice in `<metric_prefix>.request`.** `Runner` already emits `<metric_prefix>.request{status: circuit_open}` when a call is rejected by a tripped or forced-open circuit; `ControllerSupport#handle_circuit_open` then emitted the same metric again when the `Stoplight::Error::RedLight` reached a controller. The controller-side emission (and the private `emit_circuit_open_metric` helper) is removed; fallback dispatch is unchanged. **Behaviour change:** `circuit_open` request counts for rejections that reach a controller drop by half, back to the true value — dashboards and alert thresholds tuned against the doubled number should be revisited.
16
+ - **S3 caller errors tripped the S3 breaker.** `ErrorTaxonomies::Aws.tracked` includes `Aws::Errors::ServiceError`, which is the superclass of `Aws::S3::Errors::AccessDenied` and `NoSuchKey` as well as of the dynamically generated 5xx errors, so a burst of missing-key lookups or permission errors opened the circuit. New `ErrorTaxonomies.default_skipped_for(tracked)` returns the AWS caller errors whenever the tracked list covers them, and `register` / `register_prefix` now use it as the default `skipped_errors`. **Behaviour change:** AWS circuits registered without `skipped_errors:` no longer count `AccessDenied` / `NoSuchKey` toward the threshold; 5xx (`ServiceUnavailable` etc.) and `Seahorse::Client::NetworkingError` still trip. An explicit `skipped_errors:` — including `[]` — always wins, and circuits for other adapters keep defaulting to `[]`.
17
+
18
+ ### Added
19
+ - Specs for `NetworkErrors` and the Stripe / AWS / Faraday / SMTP `AdapterErrors` modules.
20
+ - README section on error taxonomies and the AWS `skipped_errors` default; the initializer template notes it too.
21
+
22
+ ### Documentation
23
+ - **Consumer list corrected in `CLAUDE.md`: this gem has five consumers, not four.** `sidekick-web` was missing. The same entry also claimed the `workspace-os` → `jumpdrive-web` directory rename "was deferred" and pointed at `~/Workspace/rarebit-one/workspace-os`; that rename completed 2026-07-14 and the old husk is gone, so an agent following the note was looking in a directory that no longer exists. Verified against the canonical matrix in the workspace's `rollout-gem/SKILL.md`, which the new advisory `check-gem-family-drift.sh` now diffs this list against on every sweep.
24
+
25
+ ## [0.3.0] - 2026-07-30
26
+
27
+ ### Added
28
+ - `config.sentry_criticality_levels` — opt in to criticality-aware Sentry reporting for the built-in Sentry subscriber. Accepts `true` (the recommended map `{ critical: :error, standard: :warning, optional: :info }`), a partial Hash merged over that map, or `nil` / `false` for the previous flat behaviour. In criticality-aware mode a circuit-open report also gains `circuit` / `circuit_criticality` tags and a stable `["circuit-open", <circuit>]` fingerprint, so Sentry alert rules can page on `circuit_criticality:critical` and group one issue per breaker. Invalid criticalities and non-symbolizable levels raise `ArgumentError` at configure time rather than failing silently at alert time.
29
+
30
+ **This is opt-in, and deliberately not the new default.** Both the level and the fingerprint feed Sentry's alerting and issue grouping, so flipping the map on at gem-upgrade time would silently re-page and re-group live issues in apps that never asked for it. Apps that leave `sentry_criticality_levels` unset get the 0.2.x report byte-for-byte — same `:warning` level, same message, no tags, no fingerprint. Host apps that hand-rolled this by setting `sentry_enabled = false` and registering their own alerter can now delete that class and set `sentry_enabled = true` + `sentry_criticality_levels = true` instead.
31
+ - `isolate_namespace StandardCircuit` on the engine, bringing it in line with every other engine gem in the family. Verified non-breaking for the aggregate health route every consumer draws (`get "/health", to: "standard_circuit/health#show"`): that path is resolved by the *application's* route set through constant lookup, which isolation does not touch, and the new `spec/integration/health_route_boot_spec.rb` boots a real Rails app and requests the route end to end so a regression fails here rather than in a consumer after release. Safe specifically because this engine is library-only — no `config/routes.rb` for the isolated `default_scope` to scope, and no ActiveRecord models for the `standard_circuit_` `table_name_prefix` to apply to.
32
+
33
+ One consequence to know about: `StandardCircuit::HealthController` now picks up the engine's (empty) url helpers instead of the application's, so app path helpers used inside it — or inside a host subclass of it — need a `main_app.` prefix. The gem's controller only renders JSON, so nothing in-gem is affected.
34
+ - README section on `data_store`, previously undocumented. Spells out that the `Stoplight::DataStore::Memory` default is **per-process** — thresholds count per worker, `/health` reports the serving process's view, and `force_open` / `reset!` are process-local — plus the shared-store alternative for apps that want cross-process state.
35
+ - README section on Sentry reporting covering `sentry_enabled`, the new `sentry_criticality_levels` opt-in, and the "subscribe yourself" escape hatch.
36
+
37
+ ### Changed
38
+ - The install generator now warns that the aggregate `get "/health", to: "standard_circuit/health#show"` route must be drawn **before** `mount StandardHealth::Engine => "/health"`. `StandardHealth::Engine` registers sub-paths only (`/alive`, `/ready`, `/diagnostics/env`) and never serves the aggregate tier itself, so an app that mounts it and assumes `/health` is covered silently has no aggregate tier — with no boot error and no failing route spec to reveal it. The warning appears in the initializer template, in the `--with-health-endpoint` health initializer next to the route line, and in the hint the generator prints. Same note added to the README's health-endpoint section.
39
+ - `.github/workflows/ci.yml` follows the shared reusable workflow at `@v2` again, matching the rest of the gem family. The previous SHA pin carried a stale rationale: it claimed to restore pre-`rarebit-one/.github#14` job names for branch protection, but `main`'s protection now requires the *post*-#14 names (`ci / lint`, `ci / test-matrix (4.0.x)`) and the pinned revision's `reusable-gem-ci.yml` is byte-identical to `@v2` — so it was neither restoring old names nor changing any check name. It did honour `extra-lint-commands`, so the brakeman / bundler-audit gate was live throughout.
40
+
7
41
  ## [0.2.0] - 2026-04-28
8
42
 
9
43
  ### Added
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Rarebit One
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md CHANGED
@@ -58,6 +58,64 @@ StandardCircuit.run(:stripe) do
58
58
  end
59
59
  ```
60
60
 
61
+ ## Error taxonomies
62
+
63
+ `tracked_errors` decide what counts toward tripping a circuit; `skipped_errors` are re-raised without counting (and win over `tracked_errors` when a class matches both). `StandardCircuit::ErrorTaxonomies::<Adapter>.tracked` combines `NetworkErrors.defaults` with the adapter's server-side errors for `Stripe`, `Smtp`, `Aws`, and `Faraday`; `StandardCircuit::AdapterErrors::<Adapter>.caller_errors` lists the adapter's caller-side (4xx-style) errors.
64
+
65
+ When `skipped_errors:` is omitted it defaults to `[]` — except when the tracked list covers the AWS caller errors. AWS 5xx responses are dynamically generated `Aws::Errors::ServiceError` subclasses, so `ErrorTaxonomies::Aws.tracked` has to track `ServiceError` itself, which is also the superclass of `Aws::S3::Errors::AccessDenied` and `NoSuchKey`. So for such circuits `skipped_errors` defaults to `AdapterErrors::Aws.caller_errors` (via `ErrorTaxonomies.default_skipped_for`), and a burst of missing-key lookups or permission errors no longer trips the S3 breaker:
66
+
67
+ ```ruby
68
+ StandardCircuit.configure do |c|
69
+ # skipped_errors defaults to [Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::AccessDenied]
70
+ c.register_prefix(:s3, tracked_errors: StandardCircuit::ErrorTaxonomies::Aws.tracked)
71
+
72
+ # An explicit skipped_errors — even [] — always wins over the default.
73
+ # c.register(:s3_strict, tracked_errors: StandardCircuit::ErrorTaxonomies::Aws.tracked, skipped_errors: [])
74
+ end
75
+ ```
76
+
77
+ ## Circuit state storage (`data_store`)
78
+
79
+ Circuit state (failure counts, colors, locks) lives in a Stoplight data store. StandardCircuit defaults to `Stoplight::DataStore::Memory.new`, which is **per-process**: each Puma worker, Sidekiq/SolidQueue worker, and console gets its own independent view of every circuit.
80
+
81
+ That is the deliberate default for a Redis-free deployment, and it is usually the right one — a circuit exists to stop *this* process from hammering a dead upstream, and per-process thresholds mean one unlucky worker can't trip the breaker for everyone. But be explicit about what it implies:
82
+
83
+ - Thresholds are counted per process, so an app with 4 web workers tolerates roughly 4× the configured `threshold` in aggregate before every worker has tripped.
84
+ - `/health` reports the circuit colors of **the process that served the request**, so two consecutive probes can legitimately disagree while a circuit is tripping.
85
+ - `force_open` / `force_closed` and `reset!` affect only the calling process — they are test and console tools, not an operational kill switch.
86
+
87
+ Point `data_store` at a shared store if you want cross-process state instead:
88
+
89
+ ```ruby
90
+ StandardCircuit.configure do |c|
91
+ # Default — per-process, no external dependency.
92
+ c.data_store = Stoplight::DataStore::Memory.new
93
+
94
+ # Shared across processes and hosts (requires the redis gem + a Redis server).
95
+ # c.data_store = Stoplight::DataStore::Redis.new(Redis.new(url: ENV["REDIS_URL"]))
96
+ end
97
+ ```
98
+
99
+ ## Sentry reporting
100
+
101
+ The built-in Sentry subscriber is on by default (`c.sentry_enabled = true`) and reports every circuit-open transition at a flat `:warning`, with the circuit name, colors, and error in `extra`.
102
+
103
+ Set `sentry_criticality_levels` to derive the level from the circuit's registered `criticality` instead. That also adds `circuit` / `circuit_criticality` tags and a stable `["circuit-open", <circuit>]` fingerprint, so Sentry alert rules can route on criticality (e.g. page on `circuit_criticality:critical`) and group per circuit:
104
+
105
+ ```ruby
106
+ StandardCircuit.configure do |c|
107
+ # { critical: :error, standard: :warning, optional: :info }
108
+ c.sentry_criticality_levels = true
109
+
110
+ # Or override part of that map — unlisted criticalities keep the default.
111
+ # c.sentry_criticality_levels = { optional: :debug }
112
+ end
113
+ ```
114
+
115
+ This is **opt-in, not the default**. Both the level and the fingerprint feed Sentry's alerting and issue grouping, so turning it on for existing apps at gem-upgrade time would silently change what pages and re-group open issues. Leaving `sentry_criticality_levels` unset keeps the flat `:warning` shape byte-for-byte.
116
+
117
+ If you want something else entirely, set `c.sentry_enabled = false` and subscribe to `standard_circuit.circuit.opened` yourself — the payload carries `criticality`.
118
+
61
119
  ## Events
62
120
 
63
121
  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.
@@ -147,7 +205,14 @@ end
147
205
 
148
206
  The controller inherits from `ActionController::API` to sidestep app-level filters (authentication, bootstrap redirects, etc.) so probes can call it anonymously.
149
207
 
150
- See [`standard_circuit-design.md`](../standard_circuit-design.md) for the full design.
208
+ **If your app also mounts `StandardHealth::Engine` at `/health`, draw the aggregate route first:**
209
+
210
+ ```ruby
211
+ get "/health", to: "standard_circuit/health#show" # aggregate — FIRST
212
+ mount StandardHealth::Engine => "/health", as: :standard_health
213
+ ```
214
+
215
+ `StandardHealth::Engine` registers sub-paths only (`/alive`, `/ready`, `/diagnostics/env`) — it never serves the aggregate tier itself. An app that mounts the engine and assumes `/health` is covered silently has no aggregate tier at all, with no boot error and no failing route spec to reveal it. The ordering is load-bearing; draw the aggregate route explicitly, first.
151
216
 
152
217
  ## License
153
218
 
@@ -67,6 +67,12 @@ module StandardCircuit
67
67
  say ""
68
68
  say ' get "/health", to: "standard_circuit/health#show"'
69
69
  say ""
70
+ say "If you also mount StandardHealth::Engine at \"/health\", draw the"
71
+ say "line above BEFORE the mount. That engine serves sub-paths only"
72
+ say "(/alive, /ready, /diagnostics/env), never the aggregate tier — so"
73
+ say "an app that mounts it and assumes \"/health\" is covered silently has"
74
+ say "no aggregate tier, with no boot error to warn you."
75
+ say ""
70
76
  say "The controller returns 503 when the rolled-up circuit health is"
71
77
  say ":critical and 200 otherwise — wire it up to your load balancer."
72
78
  say "=" * 79
@@ -6,6 +6,18 @@
6
6
  #
7
7
  # get "/health", to: "standard_circuit/health#show"
8
8
  #
9
+ # ORDERING IS LOAD-BEARING: if your app also mounts StandardHealth::Engine at
10
+ # "/health", draw this aggregate route BEFORE the mount —
11
+ #
12
+ # get "/health", to: "standard_circuit/health#show" # aggregate — FIRST
13
+ # mount StandardHealth::Engine => "/health", as: :standard_health
14
+ #
15
+ # StandardHealth::Engine registers sub-paths only ("/alive", "/ready",
16
+ # "/diagnostics/env") — it never serves the aggregate tier itself. So an app
17
+ # that mounts the engine and assumes "/health" is covered has no aggregate tier
18
+ # at all, and nothing says so: no boot error, and no failing route spec unless
19
+ # one already exists. Draw this route explicitly, first.
20
+ #
9
21
  # The endpoint renders StandardCircuit.health_report as JSON and returns
10
22
  # 503 when the rolled-up status is :critical (so orchestrators pull the
11
23
  # instance out of rotation) and 200 otherwise.
@@ -17,6 +17,17 @@ StandardCircuit.configure do |config|
17
17
  # Default: true
18
18
  # config.sentry_enabled = true
19
19
 
20
+ # Report circuit-open events at a Sentry level chosen from the circuit's
21
+ # criticality, and add `circuit` / `circuit_criticality` tags plus a stable
22
+ # ["circuit-open", <circuit>] fingerprint so Sentry alert rules can route and
23
+ # group by circuit. Opt-in: leaving this unset keeps the flat :warning
24
+ # reporting (no tags, no fingerprint) that earlier versions used.
25
+ # true -> { critical: :error, standard: :warning, optional: :info }
26
+ # Hash -> the above, merged with your overrides
27
+ # Default: nil (flat :warning)
28
+ # config.sentry_criticality_levels = true
29
+ # config.sentry_criticality_levels = { optional: :debug }
30
+
20
31
  # Prefix used for emitted metrics (e.g. "<prefix>.circuit_breaker",
21
32
  # "<prefix>.request"). Default: "external"
22
33
  # config.metric_prefix = "external"
@@ -43,7 +54,10 @@ StandardCircuit.configure do |config|
43
54
  # tracked_errors — error classes that count toward the threshold
44
55
  # (default: StandardCircuit::NetworkErrors.defaults)
45
56
  # skipped_errors — error classes that bypass the circuit (re-raised
46
- # without counting — typically caller/validation errors)
57
+ # without counting — typically caller/validation errors;
58
+ # default: [], or the AWS caller errors (NoSuchKey,
59
+ # AccessDenied) when tracked_errors covers them — see
60
+ # StandardCircuit::ErrorTaxonomies.default_skipped_for)
47
61
  # criticality — :critical | :standard | :optional
48
62
  # (affects health rollup; default: :standard)
49
63
 
@@ -64,6 +78,8 @@ StandardCircuit.configure do |config|
64
78
  # Use `register_prefix` to dynamically name circuits matching a pattern.
65
79
  # The first matching prefix wins; the circuit name is "<prefix>_<suffix>".
66
80
 
81
+ # AWS caller errors (NoSuchKey, AccessDenied) are skipped by default here,
82
+ # so missing keys and permission errors don't trip the S3 breaker.
67
83
  # config.register_prefix(:s3,
68
84
  # threshold: 10,
69
85
  # tracked_errors: StandardCircuit::ErrorTaxonomies::Aws.tracked,
@@ -80,4 +96,14 @@ StandardCircuit.configure do |config|
80
96
  # contract as the built-in subscribers.
81
97
 
82
98
  # config.add_notifier(->(name, payload) { MyTracer.record(name, payload) })
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Health endpoint (see --with-health-endpoint)
102
+ # ---------------------------------------------------------------------------
103
+ # The aggregate route is `get "/health", to: "standard_circuit/health#show"`.
104
+ # If your app also mounts StandardHealth::Engine at "/health", draw the
105
+ # aggregate route BEFORE the mount. That engine registers sub-paths only
106
+ # ("/alive", "/ready", "/diagnostics/env") and never serves the aggregate tier
107
+ # itself, so an app that mounts it and assumes "/health" is covered silently
108
+ # has no aggregate tier — no boot error, no failing route spec.
83
109
  end
@@ -22,22 +22,28 @@ module StandardCircuit
22
22
  "invalid criticality #{criticality.inspect}; must be one of #{CRITICALITIES.inspect}"
23
23
  end
24
24
 
25
+ tracked_errors = opts.fetch(:tracked_errors, NetworkErrors.defaults)
26
+
25
27
  new(
26
28
  threshold: opts.fetch(:threshold, DEFAULT_THRESHOLD),
27
29
  cool_off_time: opts.fetch(:cool_off_time, DEFAULT_COOL_OFF),
28
30
  window_size: opts.fetch(:window_size, DEFAULT_WINDOW),
29
- tracked_errors: opts.fetch(:tracked_errors, NetworkErrors.defaults),
30
- skipped_errors: opts.fetch(:skipped_errors, []),
31
+ tracked_errors: tracked_errors,
32
+ # An explicit `skipped_errors:` (including `[]`) always wins; the
33
+ # default only kicks in when the key is absent. See
34
+ # ErrorTaxonomies.default_skipped_for for why AWS needs one.
35
+ skipped_errors: opts.fetch(:skipped_errors) { ErrorTaxonomies.default_skipped_for(tracked_errors) },
31
36
  criticality: criticality
32
37
  )
33
38
  end
34
39
  end
35
40
 
36
41
  attr_accessor :sentry_enabled, :metric_prefix, :data_store, :logger
37
- attr_reader :circuits, :prefixes, :extra_notifiers
42
+ attr_reader :circuits, :prefixes, :extra_notifiers, :sentry_criticality_levels
38
43
 
39
44
  def initialize
40
45
  @sentry_enabled = true
46
+ @sentry_criticality_levels = nil
41
47
  @metric_prefix = "external"
42
48
  @data_store = Stoplight::DataStore::Memory.new
43
49
  @logger = nil
@@ -46,6 +52,22 @@ module StandardCircuit
46
52
  @extra_notifiers = []
47
53
  end
48
54
 
55
+ # Opt in to criticality-aware Sentry reporting for the built-in Sentry
56
+ # subscriber. Accepts:
57
+ #
58
+ # nil / false — (default) flat :warning for every circuit, no tags and
59
+ # no fingerprint. The 0.2.x behaviour.
60
+ # true — Notifiers::Sentry::DEFAULT_LEVELS
61
+ # ({ critical: :error, standard: :warning, optional: :info })
62
+ # Hash — DEFAULT_LEVELS merged with the given criticality => level
63
+ # pairs, so a partial map is enough.
64
+ #
65
+ # Stored normalized: the reader returns either nil or a frozen, complete
66
+ # criticality => level Hash.
67
+ def sentry_criticality_levels=(value)
68
+ @sentry_criticality_levels = normalize_sentry_criticality_levels(value)
69
+ end
70
+
49
71
  def reset_registry!
50
72
  @circuits.clear
51
73
  @prefixes.clear
@@ -90,6 +112,39 @@ module StandardCircuit
90
112
 
91
113
  private
92
114
 
115
+ def normalize_sentry_criticality_levels(value)
116
+ case value
117
+ when nil, false then nil
118
+ when true then Notifiers::Sentry::DEFAULT_LEVELS
119
+ when Hash then merge_sentry_criticality_levels(value)
120
+ else
121
+ raise ArgumentError,
122
+ "sentry_criticality_levels must be nil, true, false, or a Hash of " \
123
+ "criticality => Sentry level; got #{value.class}"
124
+ end
125
+ end
126
+
127
+ def merge_sentry_criticality_levels(map)
128
+ normalized = map.to_h do |criticality, level|
129
+ # Check symbolizability before coercing: a key like 42 would otherwise
130
+ # raise NoMethodError instead of the ArgumentError this setter promises.
131
+ criticality = criticality.to_sym if criticality.respond_to?(:to_sym)
132
+ unless CRITICALITIES.include?(criticality)
133
+ raise ArgumentError,
134
+ "invalid criticality #{criticality.inspect} in sentry_criticality_levels; " \
135
+ "must be one of #{CRITICALITIES.inspect}"
136
+ end
137
+ unless level.respond_to?(:to_sym)
138
+ raise ArgumentError,
139
+ "invalid Sentry level #{level.inspect} for criticality #{criticality.inspect}; " \
140
+ "must be a Symbol or String"
141
+ end
142
+ [ criticality, level.to_sym ]
143
+ end
144
+
145
+ Notifiers::Sentry::DEFAULT_LEVELS.merge(normalized).freeze
146
+ end
147
+
93
148
  def spec_for_prefix(name)
94
149
  key = name.to_s
95
150
  _matched_prefix, spec = @prefixes.find { |prefix, _| key.start_with?("#{prefix}_") }
@@ -47,9 +47,10 @@ module StandardCircuit
47
47
 
48
48
  private
49
49
 
50
- def handle_circuit_open(error)
50
+ def handle_circuit_open(_error)
51
+ # No metric here: Runner already counted this rejection as
52
+ # `<metric_prefix>.request{status: circuit_open}` before re-raising.
51
53
  fallback = self.class._circuit_open_fallback || {}
52
- emit_circuit_open_metric(error)
53
54
 
54
55
  return instance_exec(&fallback[:json]) if request.format.json? && fallback[:json]
55
56
  return instance_exec(response, &fallback[:stream]) if streaming_controller? && fallback[:stream]
@@ -61,14 +62,5 @@ module StandardCircuit
61
62
  def streaming_controller?
62
63
  defined?(::ActionController::Live) && self.class.include?(::ActionController::Live)
63
64
  end
64
-
65
- def emit_circuit_open_metric(error)
66
- prefix = StandardCircuit.config.metric_prefix
67
- ::Sentry::Metrics.count(
68
- "#{prefix}.request",
69
- value: 1,
70
- attributes: { service: error.light_name, status: "circuit_open" }
71
- )
72
- end
73
65
  end
74
66
  end
@@ -9,6 +9,26 @@ module StandardCircuit
9
9
  # final state when the subscriber set is built. This is independent of
10
10
  # ActiveRecord — apps that don't load AR still need observability.
11
11
  class Engine < ::Rails::Engine
12
+ # Brings StandardCircuit in line with the other engine gems in the family.
13
+ # Safe here specifically because this engine is library-only:
14
+ #
15
+ # * No `config/routes.rb`, so the `default_scope` isolate_namespace applies
16
+ # to the engine's own route set has nothing to scope.
17
+ # * No ActiveRecord models, so the `standard_circuit_` table_name_prefix it
18
+ # defines on the StandardCircuit module applies to nothing.
19
+ # * The host-drawn route the convention prescribes —
20
+ # `get "/health", to: "standard_circuit/health#show"` — resolves through
21
+ # the *application's* route set by constant lookup, which isolation does
22
+ # not touch. Verified by booting a real Rails app both ways (see
23
+ # spec/integration/health_route_boot_spec.rb, which fails if this ever
24
+ # regresses).
25
+ #
26
+ # One real consequence: `StandardCircuit::HealthController` now picks up the
27
+ # engine's (empty) url_helpers instead of the application's, so app path
28
+ # helpers inside it — or inside a host subclass of it — need a `main_app.`
29
+ # prefix. The controller only renders JSON, so nothing in-gem is affected.
30
+ isolate_namespace StandardCircuit
31
+
12
32
  initializer "standard_circuit.subscribers", after: :load_config_initializers do
13
33
  StandardCircuit.subscribers.setup!
14
34
  end
@@ -16,6 +16,24 @@ module StandardCircuit
16
16
  # `AdapterErrors::*` because the right `skipped_errors` set is usually
17
17
  # app-specific and a shared taxonomy would over-skip.
18
18
  module ErrorTaxonomies
19
+ # Default `skipped_errors` for a circuit registered without an explicit
20
+ # `skipped_errors:`. Most adapters default to `[]`, but AWS can't: its 5xx
21
+ # errors are dynamically generated `Aws::Errors::ServiceError` subclasses
22
+ # (e.g. `Aws::S3::Errors::ServiceUnavailable`), so `Aws.tracked` has to
23
+ # track `ServiceError` itself — which is also the superclass of caller
24
+ # errors like `AccessDenied` and `NoSuchKey`. Without a skip list, a burst
25
+ # of missing-key lookups or permission errors would trip the S3 breaker.
26
+ #
27
+ # Returns the AWS caller errors that some entry of +tracked+ covers (is the
28
+ # same class or an ancestor of), or `[]` when none are covered. Returns a
29
+ # fresh array each call.
30
+ def self.default_skipped_for(tracked)
31
+ trackable = Array(tracked).grep(Module)
32
+ AdapterErrors::Aws.caller_errors.select do |caller_error|
33
+ trackable.any? { |klass| caller_error <= klass }
34
+ end
35
+ end
36
+
19
37
  module Stripe
20
38
  def self.tracked
21
39
  NetworkErrors.defaults + AdapterErrors::Stripe.server_errors
@@ -1,27 +1,88 @@
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
+ # Subscribes to standard_circuit.circuit.opened and forwards a message to
4
+ # Sentry. Other transitions are ignored — only RED matters for alerting.
5
+ #
6
+ # Two reporting shapes:
7
+ #
8
+ # * **Flat** (default, and the only 0.2.x behaviour): every circuit reports
9
+ # at `:warning` with the circuit's colors and error in `extra`. No tags,
10
+ # no fingerprint.
11
+ # * **Criticality-aware** (opt in via `config.sentry_criticality_levels`):
12
+ # the level is chosen from the circuit's registered criticality, and the
13
+ # report gains `circuit` / `circuit_criticality` tags plus a stable
14
+ # `["circuit-open", circuit]` fingerprint so Sentry alert rules can route
15
+ # and group by circuit.
16
+ #
17
+ # Flat stays the default deliberately. Both the level and the fingerprint
18
+ # feed Sentry's alerting and grouping, so a gem bump must not silently
19
+ # re-page or re-group a host app's existing issues — see CHANGELOG.
6
20
  class Sentry
21
+ OPENED_EVENT = "standard_circuit.circuit.opened".freeze
22
+
23
+ # Level used in flat mode, and the fallback for an unrecognised
24
+ # criticality in criticality-aware mode.
25
+ DEFAULT_LEVEL = :warning
26
+
27
+ # The recommended criticality -> Sentry level mapping, used when
28
+ # `config.sentry_criticality_levels = true`. :critical is loud enough to
29
+ # page; :optional stays informational so a flapping nice-to-have
30
+ # upstream doesn't cry wolf.
31
+ DEFAULT_LEVELS = {
32
+ critical: :error,
33
+ standard: :warning,
34
+ optional: :info
35
+ }.freeze
36
+
37
+ # @param levels [Hash{Symbol=>Symbol}, nil] criticality -> level map.
38
+ # nil (the default) selects flat :warning reporting.
39
+ def initialize(levels: nil)
40
+ @levels = levels
41
+ end
42
+
7
43
  def call(event_name, payload)
8
- return unless event_name == "standard_circuit.circuit.opened"
44
+ return unless event_name == OPENED_EVENT
9
45
  return unless defined?(::Sentry) && ::Sentry.respond_to?(:capture_message)
10
46
 
47
+ @levels ? capture_criticality_aware(payload) : capture_flat(payload)
48
+ end
49
+
50
+ private
51
+
52
+ def capture_flat(payload)
11
53
  message = "Circuit breaker opened: #{payload[:circuit]}"
12
54
  ::Sentry.capture_message(
13
55
  message,
14
- level: :warning,
15
- extra: {
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]
21
- }.compact
56
+ level: DEFAULT_LEVEL,
57
+ extra: base_extra(payload)
22
58
  )
23
59
  message
24
60
  end
61
+
62
+ def capture_criticality_aware(payload)
63
+ circuit = payload[:circuit].to_s
64
+ criticality = (payload[:criticality] || Config::DEFAULT_CRITICALITY).to_sym
65
+ message = "Circuit breaker opened: #{circuit} (#{criticality})"
66
+
67
+ ::Sentry.capture_message(
68
+ message,
69
+ level: @levels.fetch(criticality, DEFAULT_LEVEL),
70
+ tags: { circuit: circuit, circuit_criticality: criticality.to_s },
71
+ fingerprint: [ "circuit-open", circuit ],
72
+ extra: base_extra(payload).merge(circuit: circuit, criticality: criticality)
73
+ )
74
+ message
75
+ end
76
+
77
+ def base_extra(payload)
78
+ {
79
+ circuit: payload[:circuit],
80
+ from_color: payload[:from_color],
81
+ to_color: payload[:to_color],
82
+ error_class: payload[:error_class],
83
+ error_message: payload[:error_message]
84
+ }.compact
85
+ end
25
86
  end
26
87
  end
27
88
  end
@@ -72,7 +72,7 @@ module StandardCircuit
72
72
  def internal_subscribers
73
73
  config = StandardCircuit.config
74
74
  list = [ Notifiers::Logger.new(config.logger) ]
75
- list << Notifiers::Sentry.new if config.sentry_enabled
75
+ list << Notifiers::Sentry.new(levels: config.sentry_criticality_levels) if config.sentry_enabled
76
76
  list << Notifiers::Metrics.new(metric_prefix: config.metric_prefix)
77
77
  list
78
78
  end
@@ -1,3 +1,3 @@
1
1
  module StandardCircuit
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.1"
3
3
  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.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -117,7 +117,7 @@ extensions: []
117
117
  extra_rdoc_files: []
118
118
  files:
119
119
  - CHANGELOG.md
120
- - MIT-LICENSE
120
+ - LICENSE
121
121
  - README.md
122
122
  - Rakefile
123
123
  - lib/active_storage/service/standard_circuit_s3_service.rb
data/MIT-LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Jaryl Sim
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- THE SOFTWARE.