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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +1 -1
- data/lib/standard_circuit/mailer/retry.rb +85 -8
- data/lib/standard_circuit/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27549729eb52395cef12a0e01e39d48f7218b2cd564f823f2d12d515ffe2fb82
|
|
4
|
+
data.tar.gz: 6ed8b33e22ccd8e655bb75dcb815098bb4a9dd16ed1daef7bd7c9c586eb1a18d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
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
|
-
|
|
49
|
-
|
|
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.
|
|
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
|