rails_webhook_outbox 0.2.0 → 0.3.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: 78b2ef77e9c0d4c8e0805566fd1da1e813b5e60163b3032f0584267a925803e3
4
- data.tar.gz: 9a3154d6f2029879ed18aaf83548e9a950f27a27057c9a3c517363eaca21ca3e
3
+ metadata.gz: 72234fe9faabd82b982170783792dd9d50188b8a727b72f46ab30e5f8d36929a
4
+ data.tar.gz: bf7c3b453aaf3c66d1ce4a06fb2a72d169dfcae330cc3ba8819dbe75de020e67
5
5
  SHA512:
6
- metadata.gz: 4e527d2e705089dc96fbba1f913c15d03a5c1ed54b221c4e26dbd1b97d3aae10ad58dc8f910d1db090d6846438302fbef079c78cac0a58b6bca0b50c7c941b2d
7
- data.tar.gz: fd935881ea40dc8e68fd3b4df3a240adf2f23b7c60e5ce938865a1f59b2f74e4e3a62123bffa6553428bb52b709d4e289adb5219bcaf75ec64b16ca38ca7c265
6
+ metadata.gz: 6363ae17aa5037b3c58e8169d97c7bb6c997f5e8e515a963aa5964f9aba9ffe29c4e09805f1bcd69e69f569e7887b2e1f7c69119c93c25f8004258809ab6581e
7
+ data.tar.gz: 7009433848a306ff1465d73d526a9378230ca7730fa7a9bcc4aa3159091bdaab663f442e2124ff3b6507a9fb63adb65bda814607a3d56fb297ffc04b5018cc25
data/README.md CHANGED
@@ -17,8 +17,13 @@ A Rails engine for sending outgoing webhooks with HMAC signing, ActiveJob-based
17
17
  - [Subscriptions](#subscriptions)
18
18
  - [Deliveries](#deliveries)
19
19
  - [Async Delivery](#async-delivery)
20
+ - [Instrumentation](#instrumentation)
21
+ - [Logging](#logging)
20
22
  - [HTTP Request Format](#http-request-format)
21
23
  - [HMAC Signing](#hmac-signing)
24
+ - [Secret Rotation](#secret-rotation)
25
+ - [Circuit Breaker](#circuit-breaker)
26
+ - [Rake Tasks](#rake-tasks)
22
27
  - [Usage](#usage)
23
28
  - [Manual Dispatch](#manual-dispatch)
24
29
  - [Testing](#testing)
@@ -66,6 +71,8 @@ RailsWebhookOutbox.configure do |config|
66
71
  config.request_timeout = 5
67
72
  config.delivery_job_queue = :webhooks
68
73
  config.max_payload_size = 65_536 # bytes; set to nil or 0 to disable
74
+ config.secret_rotation_grace_period = 24.hours
75
+ config.circuit_breaker_threshold = 10 # consecutive permanent failures before auto-disabling; nil or 0 disables
69
76
  end
70
77
  ```
71
78
 
@@ -162,6 +169,69 @@ RailsWebhookOutbox::DeliveryJob.perform_later(delivery)
162
169
 
163
170
  [Back to top](#table-of-contents)
164
171
 
172
+ ## Instrumentation
173
+
174
+ `DeliveryJob` publishes `ActiveSupport::Notifications` events you can subscribe to for logging, metrics, or alerting:
175
+
176
+ | Event | When |
177
+ |-------|------|
178
+ | `webhook.delivered.rails_webhook_outbox` | Delivery succeeded (2xx response) |
179
+ | `webhook.failed.rails_webhook_outbox` | All retries exhausted — permanent failure |
180
+ | `webhook.circuit_breaker_tripped.rails_webhook_outbox` | A permanent failure auto-disabled the subscription — see [Circuit Breaker](#circuit-breaker) |
181
+
182
+ `webhook.delivered` / `webhook.failed` payloads include:
183
+
184
+ | Key | Type | Description |
185
+ |-----|------|-------------|
186
+ | `event` | String | Webhook event name, e.g. `"order.created"` |
187
+ | `subscription_id` | Integer | ID of the `Subscription` record |
188
+ | `delivery_id` | Integer | ID of the `Delivery` record |
189
+ | `duration` | Integer | HTTP round-trip time in milliseconds |
190
+
191
+ `webhook.circuit_breaker_tripped` payloads include `subscription_id` and `consecutive_failures`.
192
+
193
+ Subscribe in an initializer:
194
+
195
+ ```ruby
196
+ ActiveSupport::Notifications.subscribe("webhook.delivered.rails_webhook_outbox") do |*args|
197
+ event = ActiveSupport::Notifications::Event.new(*args)
198
+ Rails.logger.info "[webhook] delivered #{event.payload[:event]} in #{event.payload[:duration]}ms"
199
+ end
200
+
201
+ ActiveSupport::Notifications.subscribe("webhook.failed.rails_webhook_outbox") do |*args|
202
+ event = ActiveSupport::Notifications::Event.new(*args)
203
+ Sentry.capture_message("Webhook permanently failed", extra: event.payload)
204
+ end
205
+ ```
206
+
207
+ [Back to top](#table-of-contents)
208
+
209
+ ## Logging
210
+
211
+ `Sender` and `DeliveryJob` emit structured `Rails.logger` output for every delivery lifecycle event. All lines are prefixed `[RailsWebhookOutbox]` in key=value format, making them easy to grep and compatible with Logfmt-aware log aggregators (Datadog, Papertrail, etc.).
212
+
213
+ | Source | Level | When | Keys logged |
214
+ |--------|-------|------|-------------|
215
+ | `Sender` | `info` | Before each HTTP call | `event`, `key` (idempotency key), `url` |
216
+ | `DeliveryJob` | `info` | Successful delivery | `event`, `delivery_id`, `subscription_id`, `status`, `duration` |
217
+ | `DeliveryJob` | `warn` | Retryable failure | `event`, `delivery_id`, `subscription_id`, `status`, `attempt`, `next_retry_at` |
218
+ | `DeliveryJob` | `error` | Permanent failure (all retries exhausted) | `event`, `delivery_id`, `subscription_id`, `status`, `attempts` |
219
+ | `DeliveryJob` | `warn` | Circuit breaker tripped | `subscription_id`, `consecutive_failures` |
220
+
221
+ Example lines:
222
+
223
+ ```
224
+ [RailsWebhookOutbox] attempt event=order.created key=550e8400-e29b-41d4-a716-446655440000 url=https://example.com/webhooks
225
+ [RailsWebhookOutbox] delivered event=order.created delivery_id=1 subscription_id=1 status=200 duration=45ms
226
+ [RailsWebhookOutbox] retry event=order.created delivery_id=1 subscription_id=1 status=503 attempt=1 next_retry_at=2026-07-01T00:00:13Z
227
+ [RailsWebhookOutbox] failed event=order.created delivery_id=1 subscription_id=1 status=503 attempts=3
228
+ [RailsWebhookOutbox] circuit_breaker_tripped subscription_id=1 consecutive_failures=10
229
+ ```
230
+
231
+ No configuration is required — logging is always on and respects your application's log level.
232
+
233
+ [Back to top](#table-of-contents)
234
+
165
235
  ## HTTP Request Format
166
236
 
167
237
  Each webhook delivery is an HTTP POST to the subscription URL with the following headers and body:
@@ -195,11 +265,14 @@ Every outgoing request includes an `X-Webhook-Signature` header (configurable) c
195
265
  X-Webhook-Signature: sha256=a1b2c3d4...
196
266
  ```
197
267
 
198
- Subscribers can verify the signature:
268
+ Subscribers can verify the signature. The header may contain more than one comma-separated
269
+ `algorithm=digest` pair while a subscription's secret is [rotating](#secret-rotation), so check
270
+ each one and accept the request if any match:
199
271
 
200
272
  ```ruby
201
- expected = RailsWebhookOutbox::Signature.header_value(raw_body, subscription.secret)
202
- Rack::Utils.secure_compare(expected, request.headers["X-Webhook-Signature"])
273
+ expected = RailsWebhookOutbox::Signature.sign(raw_body, subscription.secret, :sha256)
274
+ signatures = request.headers["X-Webhook-Signature"].to_s.split(",")
275
+ valid = signatures.any? { |sig| Rack::Utils.secure_compare(sig, "sha256=#{expected}") }
203
276
  ```
204
277
 
205
278
  You can also call the primitives directly:
@@ -214,6 +287,83 @@ RailsWebhookOutbox::Signature.header_value(payload, secret)
214
287
  # => "sha256=a1b2c3d4..."
215
288
  ```
216
289
 
290
+ `header_value` also accepts an array of secrets, signing the payload with each one and joining the
291
+ results with a comma — this is how dual-secret signing works during [secret rotation](#secret-rotation).
292
+
293
+ [Back to top](#table-of-contents)
294
+
295
+ ## Secret Rotation
296
+
297
+ Rotate a subscription's HMAC secret without dropping any webhook deliveries:
298
+
299
+ ```ruby
300
+ sub.rotate_secret!
301
+ ```
302
+
303
+ This generates a new secret and moves the old one to `previous_secret`, valid until
304
+ `previous_secret_expires_at` (set from `config.secret_rotation_grace_period`, default 24 hours).
305
+ Pass `grace_period:` to override it for a single rotation:
306
+
307
+ ```ruby
308
+ sub.rotate_secret!(grace_period: 3.days)
309
+ ```
310
+
311
+ Calling `rotate_secret!` again while a previous secret is still within its grace period raises
312
+ `RailsWebhookOutbox::SecretRotationError`, since the schema only holds one previous secret — doing
313
+ so would silently invalidate a secret subscribers may not have finished transitioning to yet. Pass
314
+ `force: true` to rotate anyway and discard it immediately.
315
+
316
+ While `previous_secret` is still active, every outgoing request is signed with **both** secrets —
317
+ `X-Webhook-Signature` carries a comma-separated list of `algorithm=digest` pairs:
318
+
319
+ ```
320
+ X-Webhook-Signature: sha256=<new-secret-digest>,sha256=<previous-secret-digest>
321
+ ```
322
+
323
+ See [HMAC Signing](#hmac-signing) for how subscribers should verify a header that may contain more
324
+ than one signature.
325
+
326
+ [Back to top](#table-of-contents)
327
+
328
+ ## Circuit Breaker
329
+
330
+ Each `Subscription` tracks `consecutive_failures` — how many deliveries in a row have permanently
331
+ failed (all retries exhausted). A successful delivery resets it to zero.
332
+
333
+ Once `consecutive_failures` reaches `config.circuit_breaker_threshold` (default 10), the
334
+ subscription is automatically disabled (`active: false`) so a persistently failing endpoint stops
335
+ being hammered — deliveries already in flight are skipped rather than retried further once their
336
+ subscription is disabled — and `webhook.circuit_breaker_tripped.rails_webhook_outbox` is published:
337
+
338
+ ```ruby
339
+ ActiveSupport::Notifications.subscribe("webhook.circuit_breaker_tripped.rails_webhook_outbox") do |*args|
340
+ event = ActiveSupport::Notifications::Event.new(*args)
341
+ Sentry.capture_message("Webhook subscription auto-disabled",
342
+ extra: event.payload.slice(:subscription_id, :consecutive_failures))
343
+ end
344
+ ```
345
+
346
+ Set `config.circuit_breaker_threshold` to `nil` or `0` to disable auto-disabling entirely. Retryable
347
+ (non-final) failures don't count towards the threshold — only a delivery that has exhausted all
348
+ retries counts as one consecutive failure. Re-enable a tripped subscription with
349
+ `sub.update!(active: true)`; `consecutive_failures` resets to zero immediately on reactivation, so a
350
+ single subsequent failure won't instantly re-trip the breaker.
351
+
352
+ [Back to top](#table-of-contents)
353
+
354
+ ## Rake Tasks
355
+
356
+ ```bash
357
+ bin/rails webhook_outbox:retry_failed # re-enqueue all failed deliveries for retry
358
+ bin/rails webhook_outbox:list_subscriptions # list subscriptions with status, events, and failure count
359
+ bin/rails webhook_outbox:cleanup[7] # delete delivered/failed deliveries older than N days
360
+ ```
361
+
362
+ `retry_failed` resets each failed delivery to `pending` and enqueues a `DeliveryJob`; deliveries
363
+ whose subscription has since been disabled are skipped by the job itself, not by the task.
364
+ `cleanup[days]` requires a positive integer argument and only removes deliveries in a terminal
365
+ state (`delivered` or `failed`) — `pending` deliveries are never deleted.
366
+
217
367
  [Back to top](#table-of-contents)
218
368
 
219
369
  ## Usage
@@ -4,12 +4,18 @@ module RailsWebhookOutbox
4
4
  retry_on RailsWebhookOutbox::DeliveryError, wait: :polynomially_longer, attempts: :unlimited
5
5
 
6
6
  def perform(delivery)
7
+ return skip_disabled_subscription(delivery) unless delivery.subscription.active?
8
+
7
9
  if RailsWebhookOutbox.config.test_mode
8
10
  delivery.update!(status: :delivered, attempts: delivery.attempts + 1, delivered_at: Time.current)
11
+ delivery.subscription.record_delivery_success!
12
+ notify("webhook.delivered.rails_webhook_outbox", delivery, 0)
9
13
  return
10
14
  end
11
15
 
16
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
12
17
  response = Sender.call(delivery)
18
+ duration_ms = elapsed_ms(start)
13
19
  delivery.update!(
14
20
  status: :delivered,
15
21
  response_code: response.code.to_i,
@@ -17,7 +23,11 @@ module RailsWebhookOutbox
17
23
  delivered_at: Time.current,
18
24
  attempts: delivery.attempts + 1
19
25
  )
26
+ delivery.subscription.record_delivery_success!
27
+ Rails.logger.info { "[RailsWebhookOutbox] delivered event=#{delivery.event} delivery_id=#{delivery.id} subscription_id=#{delivery.subscription_id} status=#{response.code} duration=#{duration_ms}ms" }
28
+ notify("webhook.delivered.rails_webhook_outbox", delivery, duration_ms)
20
29
  rescue DeliveryError => e
30
+ duration_ms = elapsed_ms(start)
21
31
  max_retries = RailsWebhookOutbox.config.max_retries
22
32
  final = executions >= max_retries
23
33
  delivery.update!(
@@ -27,7 +37,43 @@ module RailsWebhookOutbox
27
37
  status: final ? :failed : :pending,
28
38
  next_retry_at: final ? nil : Time.current + ((executions**4) + 2).seconds
29
39
  )
30
- raise unless final
40
+ if final
41
+ Rails.logger.error { "[RailsWebhookOutbox] failed event=#{delivery.event} delivery_id=#{delivery.id} subscription_id=#{delivery.subscription_id} status=#{e.response_code} attempts=#{delivery.attempts}" }
42
+ notify("webhook.failed.rails_webhook_outbox", delivery, duration_ms)
43
+ record_failure_and_notify_if_tripped(delivery)
44
+ else
45
+ Rails.logger.warn { "[RailsWebhookOutbox] retry event=#{delivery.event} delivery_id=#{delivery.id} subscription_id=#{delivery.subscription_id} status=#{e.response_code} attempt=#{delivery.attempts} next_retry_at=#{delivery.next_retry_at.utc.iso8601}" }
46
+ raise
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def skip_disabled_subscription(delivery)
53
+ delivery.update!(status: :failed, next_retry_at: nil)
54
+ Rails.logger.warn { "[RailsWebhookOutbox] skipped event=#{delivery.event} delivery_id=#{delivery.id} subscription_id=#{delivery.subscription_id} reason=subscription_inactive" }
55
+ end
56
+
57
+ def elapsed_ms(start)
58
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
59
+ end
60
+
61
+ def notify(event_name, delivery, duration_ms)
62
+ ActiveSupport::Notifications.instrument(event_name,
63
+ event: delivery.event,
64
+ subscription_id: delivery.subscription_id,
65
+ delivery_id: delivery.id,
66
+ duration: duration_ms)
67
+ end
68
+
69
+ def record_failure_and_notify_if_tripped(delivery)
70
+ return unless delivery.subscription.record_delivery_failure!
71
+
72
+ subscription = delivery.subscription
73
+ Rails.logger.warn { "[RailsWebhookOutbox] circuit_breaker_tripped subscription_id=#{subscription.id} consecutive_failures=#{subscription.consecutive_failures}" }
74
+ ActiveSupport::Notifications.instrument("webhook.circuit_breaker_tripped.rails_webhook_outbox",
75
+ subscription_id: subscription.id,
76
+ consecutive_failures: subscription.consecutive_failures)
31
77
  end
32
78
  end
33
79
  end
@@ -7,6 +7,7 @@ module RailsWebhookOutbox
7
7
  attribute :active, :boolean, default: true
8
8
 
9
9
  before_validation :generate_secret, on: :create
10
+ before_save :reset_consecutive_failures_on_reactivation
10
11
 
11
12
  validates :url, presence: true, format: { with: URL_FORMAT, message: "must be a valid HTTP or HTTPS URL" }
12
13
  validates :secret, presence: true
@@ -18,10 +19,52 @@ module RailsWebhookOutbox
18
19
  events.include?(event.to_s)
19
20
  end
20
21
 
22
+ def rotate_secret!(grace_period: RailsWebhookOutbox.config.secret_rotation_grace_period, force: false)
23
+ raise SecretRotationError if previous_secret_active? && !force
24
+
25
+ update!(
26
+ previous_secret: secret,
27
+ previous_secret_expires_at: Time.current + grace_period,
28
+ secret: generate_secret_value
29
+ )
30
+ end
31
+
32
+ def previous_secret_active?
33
+ previous_secret.present? && previous_secret_expires_at.present? && previous_secret_expires_at.future?
34
+ end
35
+
36
+ def signing_secrets
37
+ previous_secret_active? ? [secret, previous_secret] : [secret]
38
+ end
39
+
40
+ def record_delivery_success!
41
+ update!(consecutive_failures: 0) if consecutive_failures != 0
42
+ end
43
+
44
+ def record_delivery_failure!(threshold: RailsWebhookOutbox.config.circuit_breaker_threshold)
45
+ with_lock do
46
+ next false unless active?
47
+
48
+ increment!(:consecutive_failures)
49
+ next false unless threshold&.positive? && consecutive_failures >= threshold
50
+
51
+ update!(active: false)
52
+ true
53
+ end
54
+ end
55
+
21
56
  private
22
57
 
23
58
  def generate_secret
24
- self.secret ||= SecureRandom.hex(32)
59
+ self.secret ||= generate_secret_value
60
+ end
61
+
62
+ def generate_secret_value
63
+ SecureRandom.hex(32)
64
+ end
65
+
66
+ def reset_consecutive_failures_on_reactivation
67
+ self.consecutive_failures = 0 if active_changed?(from: false, to: true)
25
68
  end
26
69
  end
27
70
  end
@@ -0,0 +1,6 @@
1
+ class AddSecretRotationToWebhookOutboxSubscriptions < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :webhook_outbox_subscriptions, :previous_secret, :string
4
+ add_column :webhook_outbox_subscriptions, :previous_secret_expires_at, :datetime
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ class AddConsecutiveFailuresToWebhookOutboxSubscriptions < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :webhook_outbox_subscriptions, :consecutive_failures, :integer, null: false, default: 0
4
+ end
5
+ end
@@ -3,8 +3,11 @@ class CreateWebhookOutboxSubscriptions < ActiveRecord::Migration[<%= ActiveRecor
3
3
  create_table :webhook_outbox_subscriptions do |t|
4
4
  t.string :url, null: false
5
5
  t.string :secret, null: false
6
+ t.string :previous_secret
7
+ t.datetime :previous_secret_expires_at
6
8
  t.json :events, null: false, default: []
7
9
  t.boolean :active, null: false, default: true
10
+ t.integer :consecutive_failures, null: false, default: 0
8
11
  t.string :description
9
12
  t.json :metadata, default: {}
10
13
  t.timestamps
@@ -5,7 +5,8 @@ module RailsWebhookOutbox
5
5
 
6
6
  attr_accessor :events, :signing_algorithm, :signing_header,
7
7
  :max_retries, :retry_backoff, :request_timeout,
8
- :delivery_job_queue, :max_payload_size, :test_mode
8
+ :delivery_job_queue, :max_payload_size, :test_mode,
9
+ :secret_rotation_grace_period, :circuit_breaker_threshold
9
10
 
10
11
  def initialize
11
12
  @events = []
@@ -17,6 +18,8 @@ module RailsWebhookOutbox
17
18
  @delivery_job_queue = :webhooks
18
19
  @max_payload_size = 65_536
19
20
  @test_mode = false
21
+ @secret_rotation_grace_period = 24.hours
22
+ @circuit_breaker_threshold = 10
20
23
  end
21
24
 
22
25
  def signing_algorithm=(value)
@@ -36,5 +39,13 @@ module RailsWebhookOutbox
36
39
 
37
40
  @retry_backoff = value
38
41
  end
42
+
43
+ def secret_rotation_grace_period=(value)
44
+ unless value.is_a?(Numeric) && value.positive?
45
+ raise ArgumentError, "secret_rotation_grace_period must be a positive duration"
46
+ end
47
+
48
+ @secret_rotation_grace_period = value
49
+ end
39
50
  end
40
51
  end
@@ -0,0 +1,8 @@
1
+ module RailsWebhookOutbox
2
+ class SecretRotationError < StandardError
3
+ def initialize
4
+ super("Cannot rotate secret: the previous secret is still within its grace period. " \
5
+ "Pass force: true to rotate anyway and immediately invalidate it.")
6
+ end
7
+ end
8
+ end
@@ -17,6 +17,7 @@ module RailsWebhookOutbox
17
17
  uri = URI.parse(@delivery.subscription.url)
18
18
  body = build_body
19
19
  request = build_request(uri, body)
20
+ Rails.logger.info { "[RailsWebhookOutbox] attempt event=#{@delivery.event} key=#{@delivery.idempotency_key} url=#{uri}" }
20
21
  response = execute(uri, request)
21
22
  raise DeliveryError.new(response) unless response.is_a?(Net::HTTPSuccess)
22
23
  response
@@ -35,7 +36,7 @@ module RailsWebhookOutbox
35
36
  def build_request(uri, body)
36
37
  req = Net::HTTP::Post.new(uri)
37
38
  req["Content-Type"] = "application/json"
38
- req["X-Webhook-Signature"] = Signature.header_value(body, @delivery.subscription.secret)
39
+ req["X-Webhook-Signature"] = Signature.header_value(body, @delivery.subscription.signing_secrets)
39
40
  req["X-Webhook-Event"] = @delivery.event
40
41
  req["X-Webhook-Delivery"] = @delivery.idempotency_key
41
42
  req["X-Webhook-Timestamp"] = Time.now.utc.to_i.to_s
@@ -6,9 +6,9 @@ module RailsWebhookOutbox
6
6
  OpenSSL::HMAC.hexdigest(algorithm.to_s.upcase, secret, payload)
7
7
  end
8
8
 
9
- def self.header_value(payload, secret)
9
+ def self.header_value(payload, secrets)
10
10
  algorithm = RailsWebhookOutbox.config.signing_algorithm
11
- "#{algorithm}=#{sign(payload, secret, algorithm)}"
11
+ Array(secrets).map { |secret| "#{algorithm}=#{sign(payload, secret, algorithm)}" }.join(",")
12
12
  end
13
13
  end
14
14
  end
@@ -1,3 +1,3 @@
1
1
  module RailsWebhookOutbox
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -3,6 +3,7 @@ require "rails_webhook_outbox/configuration"
3
3
  require "rails_webhook_outbox/signature"
4
4
  require "rails_webhook_outbox/delivery_error"
5
5
  require "rails_webhook_outbox/payload_size_error"
6
+ require "rails_webhook_outbox/secret_rotation_error"
6
7
  require "rails_webhook_outbox/testing"
7
8
  require "rails_webhook_outbox/sender"
8
9
  require "rails_webhook_outbox/dispatchable"
@@ -1,4 +1,51 @@
1
- # desc "Explaining what the task does"
2
- # task :rails_webhook_outbox do
3
- # # Task goes here
4
- # end
1
+ namespace :webhook_outbox do
2
+ desc "Re-enqueue failed deliveries for retry"
3
+ task retry_failed: :environment do
4
+ deliveries = RailsWebhookOutbox::Delivery.failed
5
+ count = deliveries.count
6
+
7
+ if count.zero?
8
+ puts "No failed deliveries to retry"
9
+ else
10
+ deliveries.find_each do |delivery|
11
+ delivery.update!(status: :pending, next_retry_at: nil)
12
+ RailsWebhookOutbox::DeliveryJob.perform_later(delivery)
13
+ end
14
+ puts "Re-enqueued #{count} failed #{"delivery".pluralize(count)} for retry"
15
+ end
16
+ end
17
+
18
+ desc "List all webhook subscriptions"
19
+ task list_subscriptions: :environment do
20
+ subscriptions = RailsWebhookOutbox::Subscription.order(:id)
21
+
22
+ if subscriptions.none?
23
+ puts "No subscriptions found"
24
+ else
25
+ subscriptions.find_each do |subscription|
26
+ puts format(
27
+ "#%d [%s] %s events=%s failures=%d",
28
+ subscription.id,
29
+ subscription.active? ? "active" : "inactive",
30
+ subscription.url,
31
+ subscription.events.join(","),
32
+ subscription.consecutive_failures
33
+ )
34
+ end
35
+ end
36
+ end
37
+
38
+ desc "Delete delivered and failed deliveries older than the given number of days"
39
+ task :cleanup, [:days] => :environment do |_task, args|
40
+ days = args[:days].to_i
41
+
42
+ if days <= 0
43
+ puts "Usage: rake webhook_outbox:cleanup[days] (days must be a positive integer)"
44
+ else
45
+ scope = RailsWebhookOutbox::Delivery.where(status: [:delivered, :failed]).where(created_at: ...days.days.ago)
46
+ count = scope.count
47
+ scope.delete_all
48
+ puts "Deleted #{count} #{"delivery".pluralize(count)} older than #{days} #{"day".pluralize(days)}"
49
+ end
50
+ end
51
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_webhook_outbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -45,6 +45,8 @@ files:
45
45
  - db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb
46
46
  - db/migrate/20260624000002_create_webhook_outbox_deliveries.rb
47
47
  - db/migrate/20260629000002_add_idempotency_key_to_webhook_outbox_deliveries.rb
48
+ - db/migrate/20260701000001_add_secret_rotation_to_webhook_outbox_subscriptions.rb
49
+ - db/migrate/20260701000002_add_consecutive_failures_to_webhook_outbox_subscriptions.rb
48
50
  - lib/generators/rails_webhook_outbox/install/install_generator.rb
49
51
  - lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb.tt
50
52
  - lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb.tt
@@ -56,6 +58,7 @@ files:
56
58
  - lib/rails_webhook_outbox/engine.rb
57
59
  - lib/rails_webhook_outbox/payload_size_error.rb
58
60
  - lib/rails_webhook_outbox/rspec_matchers.rb
61
+ - lib/rails_webhook_outbox/secret_rotation_error.rb
59
62
  - lib/rails_webhook_outbox/sender.rb
60
63
  - lib/rails_webhook_outbox/signature.rb
61
64
  - lib/rails_webhook_outbox/testing.rb