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 +4 -4
- data/README.md +153 -3
- data/app/jobs/rails_webhook_outbox/delivery_job.rb +47 -1
- data/app/models/rails_webhook_outbox/subscription.rb +44 -1
- data/db/migrate/20260701000001_add_secret_rotation_to_webhook_outbox_subscriptions.rb +6 -0
- data/db/migrate/20260701000002_add_consecutive_failures_to_webhook_outbox_subscriptions.rb +5 -0
- data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb.tt +3 -0
- data/lib/rails_webhook_outbox/configuration.rb +12 -1
- data/lib/rails_webhook_outbox/secret_rotation_error.rb +8 -0
- data/lib/rails_webhook_outbox/sender.rb +2 -1
- data/lib/rails_webhook_outbox/signature.rb +2 -2
- data/lib/rails_webhook_outbox/version.rb +1 -1
- data/lib/rails_webhook_outbox.rb +1 -0
- data/lib/tasks/rails_webhook_outbox_tasks.rake +51 -4
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72234fe9faabd82b982170783792dd9d50188b8a727b72f46ab30e5f8d36929a
|
|
4
|
+
data.tar.gz: bf7c3b453aaf3c66d1ce4a06fb2a72d169dfcae330cc3ba8819dbe75de020e67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
202
|
-
|
|
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
|
-
|
|
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 ||=
|
|
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
|
data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb.tt
CHANGED
|
@@ -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
|
|
@@ -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.
|
|
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,
|
|
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
|
data/lib/rails_webhook_outbox.rb
CHANGED
|
@@ -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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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.
|
|
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
|