rails_webhook_outbox 0.1.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.
Files changed (24) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +203 -5
  3. data/app/jobs/rails_webhook_outbox/delivery_job.rb +55 -2
  4. data/app/models/rails_webhook_outbox/delivery.rb +9 -0
  5. data/app/models/rails_webhook_outbox/subscription.rb +44 -1
  6. data/db/migrate/20260629000002_add_idempotency_key_to_webhook_outbox_deliveries.rb +6 -0
  7. data/db/migrate/20260701000001_add_secret_rotation_to_webhook_outbox_subscriptions.rb +6 -0
  8. data/db/migrate/20260701000002_add_consecutive_failures_to_webhook_outbox_subscriptions.rb +5 -0
  9. data/lib/generators/rails_webhook_outbox/install/install_generator.rb +2 -2
  10. data/lib/generators/rails_webhook_outbox/install/templates/{create_webhook_outbox_deliveries.rb → create_webhook_outbox_deliveries.rb.tt} +2 -1
  11. data/lib/generators/rails_webhook_outbox/install/templates/{create_webhook_outbox_subscriptions.rb → create_webhook_outbox_subscriptions.rb.tt} +3 -0
  12. data/lib/generators/rails_webhook_outbox/install/templates/initializer.rb +8 -0
  13. data/lib/rails_webhook_outbox/configuration.rb +14 -1
  14. data/lib/rails_webhook_outbox/dispatchable.rb +8 -0
  15. data/lib/rails_webhook_outbox/payload_size_error.rb +7 -0
  16. data/lib/rails_webhook_outbox/rspec_matchers.rb +32 -0
  17. data/lib/rails_webhook_outbox/secret_rotation_error.rb +8 -0
  18. data/lib/rails_webhook_outbox/sender.rb +3 -2
  19. data/lib/rails_webhook_outbox/signature.rb +2 -2
  20. data/lib/rails_webhook_outbox/testing.rb +13 -0
  21. data/lib/rails_webhook_outbox/version.rb +1 -1
  22. data/lib/rails_webhook_outbox.rb +27 -0
  23. data/lib/tasks/rails_webhook_outbox_tasks.rake +51 -4
  24. metadata +10 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 743d24ea18d5fad259efd9369fc228866eeb883e6f5911d96d081e304a62aca4
4
- data.tar.gz: 9bd087f6a44e6eedb95b1ccd97fd9e52423c547f67f3d4d384eb5b407fdfee1e
3
+ metadata.gz: 72234fe9faabd82b982170783792dd9d50188b8a727b72f46ab30e5f8d36929a
4
+ data.tar.gz: bf7c3b453aaf3c66d1ce4a06fb2a72d169dfcae330cc3ba8819dbe75de020e67
5
5
  SHA512:
6
- metadata.gz: 02b57c5bfb462a475dde3036a6ee5f18fe033faaaa808d03d1bd45417f687162210ad6987aee0135775a5d2298fa989d65ac24c06762201cffcad05bb66938f8
7
- data.tar.gz: 5fdaac46aa8ad0591e5c0837df73d9d6bd10a9af15f02f96415ac45ced02e85608198bc8beed2baaa42449e93986ac3a816b9da0e66ae8ff5ff636c2c6807a1a
6
+ metadata.gz: 6363ae17aa5037b3c58e8169d97c7bb6c997f5e8e515a963aa5964f9aba9ffe29c4e09805f1bcd69e69f569e7887b2e1f7c69119c93c25f8004258809ab6581e
7
+ data.tar.gz: 7009433848a306ff1465d73d526a9378230ca7730fa7a9bcc4aa3159091bdaab663f442e2124ff3b6507a9fb63adb65bda814607a3d56fb297ffc04b5018cc25
data/README.md CHANGED
@@ -17,10 +17,16 @@ 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)
29
+ - [Testing](#testing)
24
30
  - [Development](#development)
25
31
  - [Dummy App](#dummy-app)
26
32
  - [Contributing](#contributing)
@@ -64,9 +70,16 @@ RailsWebhookOutbox.configure do |config|
64
70
  config.retry_backoff = :exponential
65
71
  config.request_timeout = 5
66
72
  config.delivery_job_queue = :webhooks
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
67
76
  end
68
77
  ```
69
78
 
79
+ When `config.events` is set, both `dispatch` and `Dispatchable` callbacks will raise `ArgumentError` if the event name is not in the list. Leave `config.events` empty to skip validation entirely.
80
+
81
+ If the JSON-serialised payload exceeds `config.max_payload_size` bytes, `RailsWebhookOutbox::PayloadSizeError` is raised before any `Delivery` record is created or job enqueued. The default limit is 64 KB (`65_536` bytes).
82
+
70
83
  [Back to top](#table-of-contents)
71
84
 
72
85
  ## Subscriptions
@@ -146,7 +159,7 @@ The job uses polynomial backoff (`:polynomially_longer`) between retries — wai
146
159
  | n (`max_retries`) | non-2xx response | `failed` — no further retry |
147
160
  | any | 2xx response | `delivered` |
148
161
 
149
- Every attempt (success or failure) increments `delivery.attempts` and stores the `response_code` and `response_body`. Successful deliveries also set `delivered_at`.
162
+ Every attempt (success or failure) increments `delivery.attempts` and stores the `response_code` and `response_body`. Successful deliveries also set `delivered_at`. Retryable failures set `next_retry_at` to the estimated time of the next attempt (based on the polynomial formula `executions⁴ + 2` seconds); it is cleared to `nil` once all retries are exhausted.
150
163
 
151
164
  Enqueue a delivery manually:
152
165
 
@@ -156,6 +169,69 @@ RailsWebhookOutbox::DeliveryJob.perform_later(delivery)
156
169
 
157
170
  [Back to top](#table-of-contents)
158
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
+
159
235
  ## HTTP Request Format
160
236
 
161
237
  Each webhook delivery is an HTTP POST to the subscription URL with the following headers and body:
@@ -175,6 +251,8 @@ X-Webhook-Timestamp: 1719100800
175
251
  }
176
252
  ```
177
253
 
254
+ `X-Webhook-Delivery` is the delivery's `idempotency_key` — a UUID generated once when the `Delivery` record is created and reused on every retry attempt. Subscribers can use this value to deduplicate incoming webhooks.
255
+
178
256
  Non-2xx responses raise `RailsWebhookOutbox::DeliveryError`, which carries `response_code` and `response_body` for logging and retry decisions.
179
257
 
180
258
  [Back to top](#table-of-contents)
@@ -187,11 +265,14 @@ Every outgoing request includes an `X-Webhook-Signature` header (configurable) c
187
265
  X-Webhook-Signature: sha256=a1b2c3d4...
188
266
  ```
189
267
 
190
- 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:
191
271
 
192
272
  ```ruby
193
- expected = RailsWebhookOutbox::Signature.header_value(raw_body, subscription.secret)
194
- 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}") }
195
276
  ```
196
277
 
197
278
  You can also call the primitives directly:
@@ -206,6 +287,83 @@ RailsWebhookOutbox::Signature.header_value(payload, secret)
206
287
  # => "sha256=a1b2c3d4..."
207
288
  ```
208
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
+
209
367
  [Back to top](#table-of-contents)
210
368
 
211
369
  ## Usage
@@ -257,12 +415,52 @@ RailsWebhookOutbox.dispatch("payment.completed", {
257
415
  })
258
416
  ```
259
417
 
260
- `dispatch` finds every active `Subscription` that includes the given event, creates a `Delivery` record for each one, and enqueues a `DeliveryJob`. Subscriptions that are inactive or do not subscribe to the event are skipped silently.
418
+ `dispatch` validates the event name against `config.events` (if configured), then finds every active `Subscription` that includes the given event, creates a `Delivery` record for each one, and enqueues a `DeliveryJob`. Subscriptions that are inactive or do not subscribe to the event are skipped silently.
419
+
420
+ You can also validate an event name directly without dispatching:
421
+
422
+ ```ruby
423
+ RailsWebhookOutbox.validate_event!("payment.completed")
424
+ # raises ArgumentError if the event is not in config.events
425
+ ```
261
426
 
262
427
  This is the same delivery pipeline used by `Dispatchable` callbacks, so retries, HMAC signing, and delivery logging all apply.
263
428
 
264
429
  [Back to top](#table-of-contents)
265
430
 
431
+ ## Testing
432
+
433
+ Enable test mode in your `rails_helper.rb` to suppress HTTP calls and DB writes during specs:
434
+
435
+ ```ruby
436
+ require "rails_webhook_outbox/rspec_matchers"
437
+
438
+ RailsWebhookOutbox.configure { |c| c.test_mode = true }
439
+
440
+ RSpec.configure do |config|
441
+ config.before { RailsWebhookOutbox::Testing.clear_deliveries! }
442
+ end
443
+ ```
444
+
445
+ When `test_mode` is `true`, dispatched events are captured in memory instead of creating `Delivery` records or enqueuing jobs. Use the `dispatch_webhook` matcher to assert on them:
446
+
447
+ ```ruby
448
+ expect { order.save! }.to dispatch_webhook("order.created")
449
+ expect { order.save! }.to dispatch_webhook("order.created").with_payload({ id: order.id })
450
+ expect { order.save! }.not_to dispatch_webhook("order.updated")
451
+ ```
452
+
453
+ Inspect captured events directly if needed:
454
+
455
+ ```ruby
456
+ RailsWebhookOutbox::Testing.deliveries
457
+ # => [{ event: "order.created", payload: { "id" => 1, ... } }]
458
+ ```
459
+
460
+ `DeliveryJob` also respects `test_mode` — if a job is enqueued and performed directly in a test, it marks the delivery as `delivered` without making an HTTP call.
461
+
462
+ [Back to top](#table-of-contents)
463
+
266
464
  ## Development
267
465
 
268
466
  ```bash
@@ -4,7 +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
+
9
+ if RailsWebhookOutbox.config.test_mode
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)
13
+ return
14
+ end
15
+
16
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
7
17
  response = Sender.call(delivery)
18
+ duration_ms = elapsed_ms(start)
8
19
  delivery.update!(
9
20
  status: :delivered,
10
21
  response_code: response.code.to_i,
@@ -12,15 +23,57 @@ module RailsWebhookOutbox
12
23
  delivered_at: Time.current,
13
24
  attempts: delivery.attempts + 1
14
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)
15
29
  rescue DeliveryError => e
30
+ duration_ms = elapsed_ms(start)
16
31
  max_retries = RailsWebhookOutbox.config.max_retries
32
+ final = executions >= max_retries
17
33
  delivery.update!(
18
34
  response_code: e.response_code,
19
35
  response_body: e.response_body&.truncate(1000),
20
36
  attempts: delivery.attempts + 1,
21
- status: executions >= max_retries ? :failed : :pending
37
+ status: final ? :failed : :pending,
38
+ next_retry_at: final ? nil : Time.current + ((executions**4) + 2).seconds
22
39
  )
23
- raise if executions < max_retries
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)
24
77
  end
25
78
  end
26
79
  end
@@ -6,9 +6,18 @@ module RailsWebhookOutbox
6
6
 
7
7
  enum :status, { pending: 0, delivered: 1, failed: 2 }
8
8
 
9
+ before_validation :generate_idempotency_key, on: :create
10
+
9
11
  validates :event, presence: true
10
12
  validates :payload, presence: true
13
+ validates :idempotency_key, presence: true
11
14
 
12
15
  scope :retryable, -> { pending }
16
+
17
+ private
18
+
19
+ def generate_idempotency_key
20
+ self.idempotency_key ||= SecureRandom.uuid
21
+ end
13
22
  end
14
23
  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 AddIdempotencyKeyToWebhookOutboxDeliveries < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :webhook_outbox_deliveries, :idempotency_key, :string
4
+ add_index :webhook_outbox_deliveries, :idempotency_key, unique: true
5
+ end
6
+ 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
@@ -16,11 +16,11 @@ module RailsWebhookOutbox
16
16
 
17
17
  def copy_migrations
18
18
  migration_template(
19
- "create_webhook_outbox_subscriptions.rb",
19
+ "create_webhook_outbox_subscriptions.rb.tt",
20
20
  "db/migrate/create_webhook_outbox_subscriptions.rb"
21
21
  )
22
22
  migration_template(
23
- "create_webhook_outbox_deliveries.rb",
23
+ "create_webhook_outbox_deliveries.rb.tt",
24
24
  "db/migrate/create_webhook_outbox_deliveries.rb"
25
25
  )
26
26
  end
@@ -8,6 +8,7 @@ class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[<%= ActiveRecord::
8
8
  t.integer :response_code
9
9
  t.text :response_body
10
10
  t.integer :attempts, null: false, default: 0
11
+ t.string :idempotency_key
11
12
  t.datetime :delivered_at
12
13
  t.datetime :next_retry_at
13
14
  t.timestamps
@@ -17,4 +18,4 @@ class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[<%= ActiveRecord::
17
18
  add_index :webhook_outbox_deliveries, :event
18
19
  add_index :webhook_outbox_deliveries, [:subscription_id, :created_at]
19
20
  end
20
- end
21
+ 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
@@ -19,4 +19,12 @@ RailsWebhookOutbox.configure do |config|
19
19
 
20
20
  # ActiveJob queue for delivery jobs.
21
21
  config.delivery_job_queue = :webhooks
22
+
23
+ # Maximum payload size in bytes. Raises PayloadSizeError before enqueuing if exceeded.
24
+ # Set to nil or 0 to disable.
25
+ config.max_payload_size = 65_536
26
+
27
+ # Set to true in test environments to suppress HTTP calls and capture dispatched events
28
+ # in RailsWebhookOutbox::Testing.deliveries instead of creating DB records or jobs.
29
+ # config.test_mode = false
22
30
  end
@@ -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
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 = []
@@ -15,6 +16,10 @@ module RailsWebhookOutbox
15
16
  @retry_backoff = :exponential
16
17
  @request_timeout = 5
17
18
  @delivery_job_queue = :webhooks
19
+ @max_payload_size = 65_536
20
+ @test_mode = false
21
+ @secret_rotation_grace_period = 24.hours
22
+ @circuit_breaker_threshold = 10
18
23
  end
19
24
 
20
25
  def signing_algorithm=(value)
@@ -34,5 +39,13 @@ module RailsWebhookOutbox
34
39
 
35
40
  @retry_backoff = value
36
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
37
50
  end
38
51
  end
@@ -24,7 +24,15 @@ module RailsWebhookOutbox
24
24
  def _dispatch_webhook(event, condition)
25
25
  return if condition && !instance_exec(&condition)
26
26
 
27
+ RailsWebhookOutbox.validate_event!(event)
28
+
27
29
  payload = webhook_payload
30
+ RailsWebhookOutbox.validate_payload_size!(payload)
31
+
32
+ if RailsWebhookOutbox.config.test_mode
33
+ RailsWebhookOutbox::Testing.deliveries << { event: event.to_s, payload: payload }
34
+ return
35
+ end
28
36
 
29
37
  Subscription.active.each do |subscription|
30
38
  next unless subscription.subscribes_to?(event)
@@ -0,0 +1,7 @@
1
+ module RailsWebhookOutbox
2
+ class PayloadSizeError < StandardError
3
+ def initialize(size, max)
4
+ super("Payload too large: #{size} bytes exceeds the #{max}-byte limit")
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,32 @@
1
+ require "rails_webhook_outbox/testing"
2
+
3
+ RSpec::Matchers.define :dispatch_webhook do |expected_event|
4
+ chain :with_payload do |payload|
5
+ @expected_payload = payload
6
+ end
7
+
8
+ match do |block|
9
+ before = RailsWebhookOutbox::Testing.deliveries.dup
10
+ block.call
11
+ @dispatched = RailsWebhookOutbox::Testing.deliveries.drop(before.size)
12
+ @dispatched.any? do |d|
13
+ d[:event] == expected_event.to_s &&
14
+ (@expected_payload.nil? || d[:payload] == @expected_payload)
15
+ end
16
+ end
17
+
18
+ supports_block_expectations
19
+
20
+ failure_message do
21
+ if @dispatched.empty?
22
+ "expected #{expected_event.inspect} webhook to be dispatched, but no webhooks were dispatched"
23
+ else
24
+ events = @dispatched.map { |d| d[:event].inspect }.join(", ")
25
+ "expected #{expected_event.inspect} webhook to be dispatched, but dispatched: #{events}"
26
+ end
27
+ end
28
+
29
+ failure_message_when_negated do
30
+ "expected #{expected_event.inspect} webhook not to be dispatched, but it was"
31
+ end
32
+ 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,9 +36,9 @@ 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
- req["X-Webhook-Delivery"] = SecureRandom.uuid
41
+ req["X-Webhook-Delivery"] = @delivery.idempotency_key
41
42
  req["X-Webhook-Timestamp"] = Time.now.utc.to_i.to_s
42
43
  req.body = body
43
44
  req
@@ -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
@@ -0,0 +1,13 @@
1
+ module RailsWebhookOutbox
2
+ module Testing
3
+ class << self
4
+ def deliveries
5
+ @deliveries ||= []
6
+ end
7
+
8
+ def clear_deliveries!
9
+ @deliveries = []
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsWebhookOutbox
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -2,6 +2,9 @@ require "rails_webhook_outbox/version"
2
2
  require "rails_webhook_outbox/configuration"
3
3
  require "rails_webhook_outbox/signature"
4
4
  require "rails_webhook_outbox/delivery_error"
5
+ require "rails_webhook_outbox/payload_size_error"
6
+ require "rails_webhook_outbox/secret_rotation_error"
7
+ require "rails_webhook_outbox/testing"
5
8
  require "rails_webhook_outbox/sender"
6
9
  require "rails_webhook_outbox/dispatchable"
7
10
  require "rails_webhook_outbox/engine"
@@ -22,7 +25,31 @@ module RailsWebhookOutbox
22
25
  @configuration = Configuration.new
23
26
  end
24
27
 
28
+ def validate_event!(event)
29
+ registered = config.events
30
+ return if registered.empty?
31
+ return if registered.include?(event.to_s)
32
+
33
+ raise ArgumentError, "Unknown event #{event.inspect}. Registered events: #{registered.join(", ")}"
34
+ end
35
+
36
+ def validate_payload_size!(payload)
37
+ max = config.max_payload_size
38
+ return unless max && max > 0
39
+
40
+ size = JSON.generate(payload).bytesize
41
+ raise PayloadSizeError.new(size, max) if size > max
42
+ end
43
+
25
44
  def dispatch(event, payload)
45
+ validate_event!(event)
46
+ validate_payload_size!(payload)
47
+
48
+ if config.test_mode
49
+ Testing.deliveries << { event: event.to_s, payload: payload }
50
+ return
51
+ end
52
+
26
53
  Subscription.active.each do |subscription|
27
54
  next unless subscription.subscribes_to?(event)
28
55
 
@@ -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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -44,17 +44,24 @@ files:
44
44
  - config/routes.rb
45
45
  - db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb
46
46
  - db/migrate/20260624000002_create_webhook_outbox_deliveries.rb
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
47
50
  - lib/generators/rails_webhook_outbox/install/install_generator.rb
48
- - lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb
49
- - lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb
51
+ - lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb.tt
52
+ - lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb.tt
50
53
  - lib/generators/rails_webhook_outbox/install/templates/initializer.rb
51
54
  - lib/rails_webhook_outbox.rb
52
55
  - lib/rails_webhook_outbox/configuration.rb
53
56
  - lib/rails_webhook_outbox/delivery_error.rb
54
57
  - lib/rails_webhook_outbox/dispatchable.rb
55
58
  - lib/rails_webhook_outbox/engine.rb
59
+ - lib/rails_webhook_outbox/payload_size_error.rb
60
+ - lib/rails_webhook_outbox/rspec_matchers.rb
61
+ - lib/rails_webhook_outbox/secret_rotation_error.rb
56
62
  - lib/rails_webhook_outbox/sender.rb
57
63
  - lib/rails_webhook_outbox/signature.rb
64
+ - lib/rails_webhook_outbox/testing.rb
58
65
  - lib/rails_webhook_outbox/version.rb
59
66
  - lib/tasks/rails_webhook_outbox_tasks.rake
60
67
  homepage: https://github.com/eclectic-coding/rails_webhook_outbox