standard_circuit 0.4.1 → 0.5.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: 5772ca0d1cec01fde070aab6ed65f232e52b00f7b2f4fa966348f3866a49dea7
4
- data.tar.gz: a8ce2582796937fe0816f454c9a2fd56e6a29d85e35850136a0b5f7a0bc7885f
3
+ metadata.gz: 1c26b42239426b6e97f01c017a4dead949a827d1f4d3e0cd991493c5292d127a
4
+ data.tar.gz: 2689f5e66f85ea012dd3d84f2a17fdf9700fa7938c862ba35a2f5d4f651f4951
5
5
  SHA512:
6
- metadata.gz: 1c880e1e97c57f07a0a576a3b2e699583cf7719b1414a4dcc363f55b35109c30367954cea575b163abb8419439a1e9ed264a724110eec85264871e9894b7b930
7
- data.tar.gz: 0b14981a97b3588b149b41d24c7ac7a554254a449ce40a1773f5921f9d6ca1417e7f5f21116ac239aaefba1993aade11d54a9985026d79adc88aeaaa78555580
6
+ metadata.gz: fa4ac99fa63f03cd2790084476189a8d0ecc6d3d993b4ceaf5c1aa7d5a06a2fe2b5dcb64f8a5165b230c7a700ae30e2a7385c68301e7e0c7937f64f5216da6a0
7
+ data.tar.gz: 0addb3ca191eeb7cf62fe15a297c8997b62e716ca5bf48f9eea1ffbd5493ad429edf994df0b91bf62c89a035e15062ec3b0445460a4304c7a4569a40194ea31c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-24
11
+
12
+ **Breaking.** Removes everything 0.4 deprecated. Behaviour of everything that remains is unchanged, including 0.4.2's `mailer_retry` (exhaustion reports, then re-raises so the job dead-letters).
13
+
14
+ ### Removed
15
+ - `require "standard_circuit/health_controller"`. The shim file is gone, so the require now raises `LoadError`. `StandardCircuit::HealthController` is autoloaded by the engine (since 0.4); keep the `get "/health", to: "standard_circuit/health#show"` route and delete the require.
16
+ - `StandardCircuit.health_snapshot` and `StandardCircuit.health_overall`. Use `StandardCircuit.health_report[:circuits]` / `[:status]`, which read both atomically. `StandardCircuit::Runner#health_overall`, which only backed the removed reader, is removed too; `Runner#health_snapshot` stays because `health_report` uses it.
17
+ - `StandardCircuit::AdapterErrors::Faraday.caller_errors`. `Faraday::ClientError` is never in `ErrorTaxonomies::Faraday.tracked`, so skipping it was a no-op. `Aws`, `Stripe`, `Smtp` and `Postmark` `caller_errors` are unchanged.
18
+ - `StandardCircuit::ActiveStorage::S3Service`, the unused alias. Use `ActiveStorage::Service::StandardCircuitS3Service`, or `service: StandardCircuitS3` in storage.yml (unchanged).
19
+ - The boot probe's two legacy-require modes; a spec now pins the `LoadError`.
20
+
21
+ ### Changed
22
+ - `StandardCircuit.deprecator`'s horizon is now `0.6`.
23
+
24
+ ### Upgrade notes
25
+ - **No host code change is required in any of the five consumer apps.** Grepped `origin/main` of sidekick-web, jumpdrive-web (control-plane), fundbright-web, luminality-web and nutripod-web on 2026-09-24: none calls `health_snapshot` / `health_overall` / `Faraday.caller_errors`, references `StandardCircuit::ActiveStorage::S3Service`, or requires `standard_circuit/health_controller` (sidekick-web's `health_snapshots` is an unrelated model).
26
+ - Regenerate Sorbet RBIs after bumping (`bin/tapioca gem standard_circuit`): jumpdrive-web's `control-plane/sorbet/rbi/gems/standard_circuit@0.4.1.rbi` still declares the removed readers. `require "standard_circuit/controller_support"` / `"standard_circuit/mailer/delivery_method"` in fundbright-web's and luminality-web's `sorbet/tapioca/require.rb` are still valid paths and can stay.
27
+ - If anything outside these apps still has the legacy require, it now fails at boot with `LoadError` rather than warning. Delete the line.
28
+
29
+ ## [0.4.2] - 2026-09-24
30
+
31
+ ### Fixed
32
+ - **Exhausted mail jobs now fail (dead-letter) instead of being discarded.** `mailer_retry` installs `retry_on CircuitOpenError` with a block, and ActiveJob swallows the error when that block returns normally. So on exhaustion 0.4.0 and 0.4.1 logged, sent the Sentry event, emitted `standard_circuit.mailer.retries_exhausted`, and then let the job complete. The email was silently dropped, and nothing reached Solid Queue's failed executions, where it could have been retried. The handler now reports exactly as before and then re-raises the `CircuitOpenError`, so the job fails. Earlier attempts still re-enqueue with the configured wait. If reporting itself raises, that is logged and the original error is still re-raised. No config change.
33
+
34
+ ### Upgrade notes
35
+ - **Expect one more Sentry event per exhausted email** if your host captures failed jobs (sentry-rails' ActiveJob integration does). It is the re-raised `CircuitOpenError`, grouped by exception class rather than by the `standard_circuit-mailer-retries-exhausted` fingerprint. The fingerprinted event is unchanged and is still the one to alert on. To keep only the fingerprinted event, drop the job-failure event in `before_send`. The gem does not set the fingerprint on the ambient Sentry scope, because outside a job's own scope (inline `perform_now`) it would leak onto unrelated events.
36
+ - Failed mail jobs now accumulate in your queue backend's failed jobs (Solid Queue: `solid_queue_failed_executions`). Retry them once the provider is healthy, or discard them deliberately.
37
+
10
38
  ## [0.4.1] - 2026-09-24
11
39
 
12
40
  ### Fixed
data/README.md CHANGED
@@ -219,7 +219,7 @@ end
219
219
  - **Keep `wait` longer than the mail circuit's `cool_off_time`** (the `:postmark` preset uses 60s), or retries land on a circuit that is still open.
220
220
  - **Reload-safe and idempotent.** Calling `configure` from `to_prepare` on every reload installs the handler once. If a `retry_on CircuitOpenError` is already on the job (for example your old initializer, during migration), the gem leaves it alone. The first install wins, so changing the options later needs a restart.
221
221
  - **Subclasses inherit it.** A custom `self.delivery_job = MyJob < ActionMailer::MailDeliveryJob` gets the handler, and a `retry_on CircuitOpenError` declared on the subclass takes precedence.
222
- - **On exhaustion** it writes one `error` log line, sends a Sentry `:error` event (when `sentry_enabled` and Sentry is initialized, fingerprinted by mailer and action), and emits `standard_circuit.mailer.retries_exhausted`. Every payload holds only `mailer_class`, `mail_action`, `recipient_domains`, `job_id` and `executions`: recipient **domains**, never addresses or subjects.
222
+ - **On exhaustion** it writes one `error` log line, sends a Sentry `:error` event (when `sentry_enabled` and Sentry is initialized, fingerprinted by mailer and action), and emits `standard_circuit.mailer.retries_exhausted`. Every payload holds only `mailer_class`, `mail_action`, `recipient_domains`, `job_id` and `executions`: recipient **domains**, never addresses or subjects. It then **re-raises** the `CircuitOpenError`, so the job fails and lands in your queue backend's failed jobs (Solid Queue's failed executions) where it can be inspected and retried, instead of completing with the email dropped (since 0.4.2). If your Sentry setup also captures job failures (sentry-rails' ActiveJob integration does), that failure arrives as a second event, grouped by the exception rather than by the fingerprint above.
223
223
  - Only `CircuitOpenError` is retried. Errors from the provider while the circuit is closed, such as a SendGrid 429 or a Postmark 422, keep their existing behaviour. Add your own `retry_on` / `discard_on` for those.
224
224
 
225
225
  Replace your host code with:
@@ -323,7 +323,7 @@ require "standard_circuit/health_controller"
323
323
  # After — delete the line (and the initializer, if that's all it contained).
324
324
  ```
325
325
 
326
- The old `require` still works in 0.4 but emits a deprecation through `Rails.application.deprecators[:standard_circuit]`. It will be removed in 0.5.
326
+ The old `require` was deprecated in 0.4 and **removed in 0.5**: it now raises `LoadError`, so delete it.
327
327
 
328
328
  The controller inherits from `ActionController::API` to sidestep app-level filters (authentication, bootstrap redirects, etc.) so probes can call it anonymously.
329
329
 
@@ -350,18 +350,20 @@ These are supported public API for host test suites, not internals:
350
350
 
351
351
  All of these are **process-local**. They are test and console tools, not an operational kill switch (see `data_store` above).
352
352
 
353
- ## Deprecations
353
+ ## Removed in 0.5
354
354
 
355
- 0.4 deprecates the following. Each still works and warns through `Rails.application.deprecators[:standard_circuit]`, so your app's `config.active_support.deprecation` setting applies. They will be removed in 0.5.
355
+ 0.4 deprecated these and 0.5 removes them. None of the five consumer apps used any of them at removal time.
356
356
 
357
- | Deprecated | Use instead |
358
- |------------|-------------|
359
- | `require "standard_circuit/health_controller"` | nothing — the controller is autoloaded |
357
+ | Removed | Use instead |
358
+ |---------|-------------|
359
+ | `require "standard_circuit/health_controller"` (now `LoadError`) | nothing — the controller is autoloaded; keep the route |
360
360
  | `StandardCircuit.health_snapshot` | `StandardCircuit.health_report[:circuits]` |
361
- | `StandardCircuit.health_overall` | `StandardCircuit.health_report[:status]` |
362
- | `StandardCircuit::AdapterErrors::Faraday.caller_errors` | drop it — `Faraday::ClientError` is never tracked, so skipping it is a no-op |
361
+ | `StandardCircuit.health_overall` (and `StandardCircuit.runner.health_overall`) | `StandardCircuit.health_report[:status]` |
362
+ | `StandardCircuit::AdapterErrors::Faraday.caller_errors` | drop it — `Faraday::ClientError` is never tracked, so skipping it was a no-op |
363
363
  | `StandardCircuit::ActiveStorage::S3Service` | `ActiveStorage::Service::StandardCircuitS3Service` / `service: StandardCircuitS3` |
364
364
 
365
+ Future deprecations warn through `Rails.application.deprecators[:standard_circuit]`, so your app's `config.active_support.deprecation` setting applies.
366
+
365
367
  ## License
366
368
 
367
369
  MIT
@@ -12,8 +12,8 @@ module StandardCircuit
12
12
  # get "/health", to: "standard_circuit/health#show"
13
13
  # end
14
14
  #
15
- # The pre-0.4 `require "standard_circuit/health_controller"` still works but
16
- # is deprecated (see lib/standard_circuit/health_controller.rb).
15
+ # The pre-0.4 `require "standard_circuit/health_controller"` was removed in
16
+ # 0.5 (it raises LoadError); delete it and keep the route.
17
17
  #
18
18
  # Inherits from +ActionController::API+ to sidestep any ApplicationController
19
19
  # filters (authentication, bootstrap redirects, etc.) — health probes must be
@@ -33,17 +33,3 @@ module ActiveStorage
33
33
  end
34
34
  end
35
35
  end
36
-
37
- module StandardCircuit
38
- # @deprecated The StandardCircuit::ActiveStorage::S3Service alias is unused —
39
- # storage.yml resolves `service: StandardCircuitS3` to
40
- # ActiveStorage::Service::StandardCircuitS3Service — and is removed in 0.5.
41
- module ActiveStorage
42
- include ::ActiveSupport::Deprecation::DeprecatedConstantAccessor
43
-
44
- deprecate_constant "S3Service", "::ActiveStorage::Service::StandardCircuitS3Service",
45
- deprecator: StandardCircuit.deprecator,
46
- message: "StandardCircuit::ActiveStorage::S3Service is deprecated and will be removed in 0.5; " \
47
- "use ActiveStorage::Service::StandardCircuitS3Service (or `service: StandardCircuitS3` in storage.yml)."
48
- end
49
- end
@@ -9,20 +9,6 @@ module StandardCircuit
9
9
  errors << ::Faraday::ServerError if defined?(::Faraday::ServerError)
10
10
  errors.select { |klass| klass.is_a?(Class) }
11
11
  end
12
-
13
- # @deprecated Removed in 0.5. Faraday::ClientError (4xx) is not in
14
- # +server_errors+, so it never counts toward a Faraday circuit —
15
- # listing it in +skipped_errors+ is a no-op. Unused by every consumer.
16
- def caller_errors
17
- StandardCircuit.deprecator.warn(
18
- "StandardCircuit::AdapterErrors::Faraday.caller_errors is deprecated and will be removed in 0.5: " \
19
- "Faraday::ClientError is never tracked by ErrorTaxonomies::Faraday.tracked, so skipping it is a no-op. " \
20
- "Drop it from skipped_errors."
21
- )
22
- return [] unless defined?(::Faraday::ClientError)
23
-
24
- [ ::Faraday::ClientError ]
25
- end
26
12
  end
27
13
  end
28
14
  end
@@ -7,6 +7,6 @@ module StandardCircuit
7
7
  # host's `config.active_support.deprecation` behaviour (log / raise /
8
8
  # silence / report) applies to StandardCircuit deprecations too.
9
9
  def self.deprecator
10
- @deprecator ||= ActiveSupport::Deprecation.new("0.5", "StandardCircuit")
10
+ @deprecator ||= ActiveSupport::Deprecation.new("0.6", "StandardCircuit")
11
11
  end
12
12
  end
@@ -5,8 +5,7 @@ module StandardCircuit
5
5
  #
6
6
  # Intended for mounting in a Rails HealthController. Use +health_report+ —
7
7
  # it takes a single atomic snapshot, so the rendered status and circuits
8
- # always describe the same moment (the separate module-level
9
- # +health_snapshot+ / +health_overall+ readers are deprecated):
8
+ # always describe the same moment:
10
9
  #
11
10
  # report = StandardCircuit.health_report
12
11
  # render json: report, status: (report[:status] == :critical ? 503 : 200)
@@ -29,7 +29,9 @@ module StandardCircuit
29
29
  # On exhaustion: one error log line, a Sentry `:error` event (when
30
30
  # `sentry_enabled` and Sentry is initialized), and a
31
31
  # `standard_circuit.mailer.retries_exhausted` event. PII: recipient
32
- # DOMAINS only — never addresses or subjects.
32
+ # DOMAINS only — never addresses or subjects. Then the CircuitOpenError is
33
+ # re-raised, so the job FAILS (dead letter) rather than completing with
34
+ # the email dropped (0.4.2; 0.4.0/0.4.1 swallowed it).
33
35
  module Retry
34
36
  EXHAUSTED_EVENT = "standard_circuit.mailer.retries_exhausted".freeze
35
37
  EXHAUSTED_MESSAGE = "Email delivery failed: mail circuit breaker retries exhausted".freeze
@@ -115,11 +117,32 @@ module StandardCircuit
115
117
  attempts: options.fetch(:attempts),
116
118
  jitter: options.fetch(:jitter)
117
119
  ) do |job, error|
118
- StandardCircuit::Mailer::Retry.report_exhausted(job, error)
120
+ StandardCircuit::Mailer::Retry.exhausted!(job, error)
119
121
  end
120
122
  true
121
123
  end
122
124
 
125
+ # Runs when the last attempt still hits an open circuit. Reports first
126
+ # (log, fingerprinted Sentry event, retries_exhausted event), then
127
+ # RE-RAISES so the job fails. ActiveJob's retry_on swallows the error
128
+ # when given a block unless the block raises, and a swallowed error
129
+ # means the job "succeeds" and the email is silently dropped. Raising
130
+ # lands it in the queue backend's failed executions (Solid Queue's
131
+ # dead letter), where it can be inspected and retried.
132
+ #
133
+ # A report that raises must not turn into a dropped email, so it is
134
+ # rescued and logged; the original error is raised regardless.
135
+ #
136
+ # @api private
137
+ def exhausted!(job, error)
138
+ begin
139
+ report_exhausted(job, error)
140
+ rescue StandardError => report_error
141
+ logger&.error("[standard_circuit] failed to report mail retry exhaustion: #{report_error.class}: #{report_error.message}")
142
+ end
143
+ raise error
144
+ end
145
+
123
146
  def installed?(job_class)
124
147
  job_class.rescue_handlers.any? { |(error_name, _handler)| error_name == CircuitOpenError.name }
125
148
  end
@@ -63,14 +63,6 @@ module StandardCircuit
63
63
  Health.snapshot(self, @config)
64
64
  end
65
65
 
66
- # Accepts an optional pre-computed snapshot so callers that also need the
67
- # raw circuits list don't read the data store twice (which could yield
68
- # an inconsistent status/circuits pair). Prefer +health_report+ when you
69
- # need both.
70
- def health_overall(snapshot = nil)
71
- Health.overall(snapshot || health_snapshot)
72
- end
73
-
74
66
  # Atomic health report — takes a single snapshot and returns both the
75
67
  # rolled-up status and the per-circuit list. Preferred for rendering a
76
68
  # health-check endpoint so status and circuits describe the same moment.
@@ -1,3 +1,3 @@
1
1
  module StandardCircuit
2
- VERSION = "0.4.1"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -72,24 +72,6 @@ module StandardCircuit
72
72
  runner.reset!
73
73
  end
74
74
 
75
- # @deprecated Use +health_report[:circuits]+. Removed in 0.5.
76
- def health_snapshot
77
- deprecator.warn(
78
- "StandardCircuit.health_snapshot is deprecated and will be removed in 0.5; " \
79
- "use StandardCircuit.health_report[:circuits] (one atomic read of status + circuits)."
80
- )
81
- runner.health_snapshot
82
- end
83
-
84
- # @deprecated Use +health_report[:status]+. Removed in 0.5.
85
- def health_overall(snapshot = nil)
86
- deprecator.warn(
87
- "StandardCircuit.health_overall is deprecated and will be removed in 0.5; " \
88
- "use StandardCircuit.health_report[:status] (one atomic read of status + circuits)."
89
- )
90
- runner.health_overall(snapshot)
91
- end
92
-
93
75
  def health_report
94
76
  runner.health_report
95
77
  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.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -166,7 +166,6 @@ files:
166
166
  - lib/standard_circuit/error_taxonomies.rb
167
167
  - lib/standard_circuit/event_emitter.rb
168
168
  - lib/standard_circuit/health.rb
169
- - lib/standard_circuit/health_controller.rb
170
169
  - lib/standard_circuit/mailer/circuit_open_error.rb
171
170
  - lib/standard_circuit/mailer/delivery_method.rb
172
171
  - lib/standard_circuit/mailer/retry.rb
@@ -1,23 +0,0 @@
1
- # Deprecated load path, kept so `require "standard_circuit/health_controller"`
2
- # at the top of a host's config/routes.rb (the pre-0.4 opt-in) keeps working.
3
- #
4
- # The controller now lives at app/controllers/standard_circuit/health_controller.rb
5
- # and the engine autoloads it — delete the require and keep the route.
6
- require "action_controller"
7
- require "standard_circuit"
8
-
9
- StandardCircuit.deprecator.warn(
10
- 'require "standard_circuit/health_controller" is no longer needed: the engine ' \
11
- "autoloads StandardCircuit::HealthController. Remove the require and keep the " \
12
- '`get "/health", to: "standard_circuit/health#show"` route.'
13
- )
14
-
15
- # Booted Rails app (e.g. required from config/routes.rb): the engine's
16
- # autoloader already has an autoload set for the constant, so there is nothing
17
- # to do — loading the file here would race Zeitwerk for the same path.
18
- # Otherwise (required from an initializer before autoloaders are set up, or
19
- # outside Rails entirely) load it directly; Zeitwerk later sees the constant
20
- # is defined and leaves it alone.
21
- unless StandardCircuit.autoload?(:HealthController) || StandardCircuit.const_defined?(:HealthController, false)
22
- require_relative "../../app/controllers/standard_circuit/health_controller"
23
- end