standard_circuit 0.4.0 → 0.4.2

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: 60bd3d9dbd17cad5bdf8062be096c8f7b1d8baa00f167e25f1ea96581a078cca
4
- data.tar.gz: 5abdcf7eb5bf00b65bc0b724d3ced093f71f6ee1cb59a8d594c6a8d3944998e1
3
+ metadata.gz: 27549729eb52395cef12a0e01e39d48f7218b2cd564f823f2d12d515ffe2fb82
4
+ data.tar.gz: 6ed8b33e22ccd8e655bb75dcb815098bb4a9dd16ed1daef7bd7c9c586eb1a18d
5
5
  SHA512:
6
- metadata.gz: f88dc067ff12e4651a0f6f5317d55f22f5a9b78507d0bc31bf7fc737fa2dc3a7efa09853b64bb89f3fb608660eb4da79b126921d90a87e7ad9396bcb81cb317b
7
- data.tar.gz: 1ed24fba0bdd0e373dfbe4cc4a18cc9e22675db527c109b151395ba2681d403ea64dbf0a12672b53250edfd8886aa66be1cedd42a36099833c23562a828b89c0
6
+ metadata.gz: e8ebb089c834961f250167f316c9e0648fa51f3600a6a1d6774731acdad3842be7c7fde2afb24806fb4be6af4fb341f0a412090a4100151226c9b3dca58aab46
7
+ data.tar.gz: 3a5f2af8401788aba1f13321a16dd23d7a94f9becf14834e6bc95672a4d21beeaa0e2b87e6cf440ad3417d90ded2c47b30067d4d3cb72634a63b1aca474a0348
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.2] - 2026-09-24
11
+
12
+ ### Fixed
13
+ - **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.
14
+
15
+ ### Upgrade notes
16
+ - **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.
17
+ - 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.
18
+
19
+ ## [0.4.1] - 2026-09-24
20
+
21
+ ### Fixed
22
+ - **`mailer_retry` no longer crashes a lazily-loaded process with `NameError: uninitialized constant ActionMailer::MailDeliveryJob`.** 0.4.0 installed the retry from `ActiveSupport.on_load(:active_job)` and referenced `ActionMailer::MailDeliveryJob` inside the hook. When `class MailDeliveryJob < ActiveJob::Base` was itself what loaded `ActiveJob::Base`, the hook ran while that class was still being autoloaded. In development this broke the first `deliver_later` of a fresh process, and `bin/tapioca dsl` hit it too. Eager-loaded production processes and RSpec runs were not affected. The retry is now installed at whichever of these happens first, and never while `MailDeliveryJob` is still mid-autoload:
23
+ - `ActiveJob::Base` loads when `MailDeliveryJob` can already be referenced safely.
24
+ - `ActionMailer::Base` loads. It references `MailDeliveryJob`, so the class is complete by then. Every delivery goes through a mailer, so this also covers a worker that deserializes the job before any mailer loads.
25
+ - Something subclasses `MailDeliveryJob`. This covers a custom `delivery_job` with its own `rescue_from` that loads before any mailer. The subclass's own handlers keep precedence.
26
+
27
+ A new integration spec boots a Rails app in a subprocess for each load order (mailer first, job first, subclass first, `ActiveJob::Base` first, eager load).
28
+
29
+ **Hosts can delete their workaround.** Any initializer that preloads `ActiveJob::Base` to dodge this bug, such as jumpdrive-web's `config.after_initialize { ActiveJob::Base }`, can be removed.
30
+
10
31
  ## [0.4.0] - 2026-09-24
11
32
 
12
33
  A developer-experience release that moves code copy-pasted across the five consumer apps into the gem. Everything is additive: behaviour is unchanged unless you opt in, and deprecated APIs keep working until 0.5.
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:
@@ -29,25 +29,81 @@ 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
36
38
  FINGERPRINT = "standard_circuit-mailer-retries-exhausted".freeze
37
39
 
40
+ # Prepended onto ActiveJob::Base's singleton class only when the
41
+ # :active_job hook fired mid-way through MailDeliveryJob's own
42
+ # definition. MailDeliveryJob is complete by the time anything
43
+ # subclasses it, and installing on it before the subclass body runs keeps
44
+ # the handler's precedence the same as for any other load order.
45
+ module InstallBeforeSubclassing
46
+ def inherited(subclass)
47
+ StandardCircuit::Mailer::Retry.install_configured if name == "ActionMailer::MailDeliveryJob"
48
+ super
49
+ end
50
+ end
51
+
38
52
  class << self
39
- # Defers installation until ActiveJob::Base has loaded (immediately if
40
- # it already has), so enabling this from an early initializer doesn't
41
- # force ActiveJob to load before its own configuration is applied. The
42
- # hook reads the live config when it runs; it is registered once.
53
+ # Defers installation until MailDeliveryJob can be referenced safely,
54
+ # so enabling this from an early initializer doesn't force ActiveJob
55
+ # or ActionMailer to load before their own configuration is applied.
56
+ # The hooks read the live config when they run; they are registered
57
+ # once.
58
+ #
59
+ # `ActionMailer::MailDeliveryJob` can't simply be referenced from an
60
+ # `on_load(:active_job)` hook: when `class MailDeliveryJob <
61
+ # ActiveJob::Base` is itself what loads ActiveJob::Base (a mailer or
62
+ # `deliver_later` touched first in a lazily-loaded process), the hook
63
+ # fires while that class is still being autoloaded and the reference
64
+ # raises NameError. So each trigger checks first, and together they
65
+ # cover every load order:
66
+ #
67
+ # * :active_job — installs straight away when ActiveJob::Base loads
68
+ # ahead of MailDeliveryJob (eager load, or any ApplicationJob first).
69
+ # * :action_mailer — ActionMailer::Base references MailDeliveryJob, so
70
+ # by the time this runs the class is complete. Every delivery goes
71
+ # through a mailer class, so this runs before the first delivery can
72
+ # raise, including in a worker that deserialized the job first
73
+ # (rescue handlers are read when an error is rescued, not at enqueue).
74
+ # * the first subclass of MailDeliveryJob — covers a custom
75
+ # `delivery_job` loaded ahead of any mailer, which would otherwise
76
+ # take a copy of the parent's rescue handlers without this one.
43
77
  def install_on_load
44
78
  return if @on_load_registered
45
79
 
46
80
  @on_load_registered = true
47
81
  ::ActiveSupport.on_load(:active_job) do
48
- options = StandardCircuit.config.mailer_retry
49
- StandardCircuit::Mailer::Retry.install(::ActionMailer::MailDeliveryJob, options) if options
82
+ if StandardCircuit::Mailer::Retry.mail_delivery_job_referenceable?
83
+ StandardCircuit::Mailer::Retry.install_configured
84
+ else
85
+ singleton_class.prepend(StandardCircuit::Mailer::Retry::InstallBeforeSubclassing)
86
+ end
50
87
  end
88
+ ::ActiveSupport.on_load(:action_mailer) { StandardCircuit::Mailer::Retry.install_configured }
89
+ end
90
+
91
+ # Installs on ActionMailer::MailDeliveryJob with the configured options
92
+ # when mailer_retry is on and the class is safe to reference. Returns
93
+ # true when the handler is in place afterwards.
94
+ def install_configured
95
+ options = StandardCircuit.config.mailer_retry
96
+ return false unless options && mail_delivery_job_referenceable?
97
+
98
+ install(::ActionMailer::MailDeliveryJob, options)
99
+ true
100
+ end
101
+
102
+ # False only while MailDeliveryJob is mid-autoload on this thread (see
103
+ # install_on_load): Ruby reports an in-progress autoload as undefined.
104
+ # When the autoload is merely pending, referencing it loads it normally.
105
+ def mail_delivery_job_referenceable?
106
+ defined?(::ActionMailer) && ::ActionMailer.const_defined?(:MailDeliveryJob, false)
51
107
  end
52
108
 
53
109
  # Adds the retry_on to +job_class+ unless a CircuitOpenError handler is
@@ -61,11 +117,32 @@ module StandardCircuit
61
117
  attempts: options.fetch(:attempts),
62
118
  jitter: options.fetch(:jitter)
63
119
  ) do |job, error|
64
- StandardCircuit::Mailer::Retry.report_exhausted(job, error)
120
+ StandardCircuit::Mailer::Retry.exhausted!(job, error)
65
121
  end
66
122
  true
67
123
  end
68
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
+
69
146
  def installed?(job_class)
70
147
  job_class.rescue_handlers.any? { |(error_name, _handler)| error_name == CircuitOpenError.name }
71
148
  end
@@ -1,3 +1,3 @@
1
1
  module StandardCircuit
2
- VERSION = "0.4.0"
2
+ VERSION = "0.4.2"
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.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim