philiprehberger-webhook_builder 0.1.0 → 0.2.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: f8ace17a470352fbec4b6af92b0e625b346bc6c0580c9e7a53c8ff8ea2c30260
4
- data.tar.gz: c279d283cb23ac437bd9496b21788de77defe91b68027ae462d47197dd27b0b7
3
+ metadata.gz: 9992d034a4e76ce146dae4dfba7d503b3eb27cb2bc12722655ffa1f84a24b6ab
4
+ data.tar.gz: 112d676f685b96e900a9a96adcf529165a346b28c587c6d1a76ac1f1fe333fc1
5
5
  SHA512:
6
- metadata.gz: 8fcc581ae60c693c6e5ddaa623fc446e80cb237cad545ae13e142c629903624395c9d189a8228056e63193c15e838c2725137914538bc9e8b1c876e9f36a0b28
7
- data.tar.gz: ddddb6c9797f16e38dd7797fb4eee25bc265ea903816772b86b92e5a9fca52d50fbbf362d5491ace4368fd2b5152bae635caaeaf693f61ed18ae072408c33f12
6
+ metadata.gz: a4fff7580a5193f2e2260eeb6400f3dfd06173643e909e9e06a6d88960a8237a3e132ccde8a27d263215bec87e11a1966aaecf5ee8f17d0cfe422c1b45c5603f
7
+ data.tar.gz: 76166bc2d3ce885fc1106600864f4edfaa8b358588b00ee2982125ca7fc069a907c4a22a2babc55905180b63fef5e7de04f1e4188a9e59410e558026a5bf028b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-03-29
11
+
12
+ ### Added
13
+ - Batch delivery via `client.deliver_batch(events)` with configurable `concurrency:` option
14
+ - Retry backoff strategies: `:exponential` (default), `:linear`, `:fixed`, or custom Proc via `backoff:` option
15
+ - Backoff strategy classes in `Philiprehberger::WebhookBuilder::Backoff` module
16
+ - Header customization with per-delivery `headers:` parameter and client-level `default_headers:` option
17
+ - Per-delivery headers override default headers
18
+
19
+ ## [0.1.1] - 2026-03-22
20
+
21
+ ### Changed
22
+ - Expand test coverage
23
+
10
24
  ## [0.1.0] - 2026-03-22
11
25
 
12
26
  ### Added
data/README.md CHANGED
@@ -2,7 +2,12 @@
2
2
 
3
3
  [![Tests](https://github.com/philiprehberger/rb-webhook-builder/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-webhook-builder/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-webhook_builder.svg)](https://rubygems.org/gems/philiprehberger-webhook_builder)
5
+ [![GitHub release](https://img.shields.io/github/v/release/philiprehberger/rb-webhook-builder)](https://github.com/philiprehberger/rb-webhook-builder/releases)
6
+ [![Last updated](https://img.shields.io/github/last-commit/philiprehberger/rb-webhook-builder)](https://github.com/philiprehberger/rb-webhook-builder/commits/main)
5
7
  [![License](https://img.shields.io/github/license/philiprehberger/rb-webhook-builder)](LICENSE)
8
+ [![Bug Reports](https://img.shields.io/github/issues/philiprehberger/rb-webhook-builder/bug)](https://github.com/philiprehberger/rb-webhook-builder/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
9
+ [![Feature Requests](https://img.shields.io/github/issues/philiprehberger/rb-webhook-builder/enhancement)](https://github.com/philiprehberger/rb-webhook-builder/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
10
+ [![Sponsor](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-ec6cb9)](https://github.com/sponsors/philiprehberger)
6
11
 
7
12
  Webhook delivery client with HMAC signing, retry, and tracking
8
13
 
@@ -39,20 +44,91 @@ delivery.success? # => true
39
44
  delivery.response_code # => 200
40
45
  ```
41
46
 
42
- ### Custom Options
47
+ ### Batch Delivery
43
48
 
44
49
  ```ruby
50
+ require "philiprehberger/webhook_builder"
51
+
52
+ client = Philiprehberger::WebhookBuilder.new(
53
+ url: "https://example.com/webhooks",
54
+ secret: "your-signing-secret",
55
+ concurrency: 8
56
+ )
57
+
58
+ events = [
59
+ { event: "order.created", payload: { id: 1 } },
60
+ { event: "order.updated", payload: { id: 2 } },
61
+ { event: "order.deleted", payload: { id: 3 } }
62
+ ]
63
+
64
+ results = client.deliver_batch(events)
65
+ results.each { |d| puts "#{d.response_code}: #{d.success?}" }
66
+ ```
67
+
68
+ ### Backoff Strategies
69
+
70
+ ```ruby
71
+ require "philiprehberger/webhook_builder"
72
+
73
+ # Exponential backoff (default): 1s, 2s, 4s, 8s, ...
74
+ client = Philiprehberger::WebhookBuilder.new(
75
+ url: "https://example.com/webhooks",
76
+ secret: "secret",
77
+ backoff: :exponential
78
+ )
79
+
80
+ # Linear backoff: 1s, 2s, 3s, 4s, ...
81
+ client = Philiprehberger::WebhookBuilder.new(
82
+ url: "https://example.com/webhooks",
83
+ secret: "secret",
84
+ backoff: :linear
85
+ )
86
+
87
+ # Fixed backoff: 1s, 1s, 1s, ...
88
+ client = Philiprehberger::WebhookBuilder.new(
89
+ url: "https://example.com/webhooks",
90
+ secret: "secret",
91
+ backoff: :fixed
92
+ )
93
+
94
+ # Custom Proc backoff
95
+ client = Philiprehberger::WebhookBuilder.new(
96
+ url: "https://example.com/webhooks",
97
+ secret: "secret",
98
+ backoff: ->(attempt) { attempt * 0.5 }
99
+ )
100
+ ```
101
+
102
+ ### Header Customization
103
+
104
+ ```ruby
105
+ require "philiprehberger/webhook_builder"
106
+
107
+ # Default headers on all deliveries
45
108
  client = Philiprehberger::WebhookBuilder.new(
46
- url: "https://api.example.com/hooks",
47
- secret: "hmac-secret",
48
- timeout: 10,
49
- max_retries: 5
109
+ url: "https://example.com/webhooks",
110
+ secret: "secret",
111
+ default_headers: { "X-Tenant" => "acme" }
112
+ )
113
+
114
+ # Per-delivery headers (override defaults)
115
+ client.deliver(
116
+ event: "order.created",
117
+ payload: { id: 1 },
118
+ headers: { "X-Priority" => "high" }
50
119
  )
51
120
  ```
52
121
 
53
122
  ### Delivery Tracking
54
123
 
55
124
  ```ruby
125
+ require "philiprehberger/webhook_builder"
126
+
127
+ client = Philiprehberger::WebhookBuilder.new(
128
+ url: "https://example.com/webhooks",
129
+ secret: "secret"
130
+ )
131
+
56
132
  delivery = client.deliver(event: "user.updated", payload: { id: 42 })
57
133
 
58
134
  delivery.success? # => true/false
@@ -63,18 +139,15 @@ delivery.response_body # => '{"ok":true}'
63
139
  delivery.error # => nil or error message
64
140
  ```
65
141
 
66
- ### HMAC Signing
67
-
68
- Every request includes an `X-Webhook-Signature` header with an HMAC-SHA256 hex digest of the JSON body, signed with the configured secret. The `X-Webhook-Event` header contains the event type.
69
-
70
142
  ## API
71
143
 
72
144
  ### `Client`
73
145
 
74
146
  | Method | Description |
75
147
  |--------|-------------|
76
- | `.new(url:, secret:, timeout:, max_retries:)` | Create a webhook client (timeout defaults to 30s, retries to 3) |
77
- | `#deliver(event:, payload:)` | Deliver a webhook event and return a Delivery |
148
+ | `.new(url:, secret:, timeout:, max_retries:, backoff:, concurrency:, default_headers:)` | Create a webhook client |
149
+ | `#deliver(event:, payload:, headers:)` | Deliver a webhook event and return a Delivery |
150
+ | `#deliver_batch(events)` | Deliver multiple events concurrently and return an array of Delivery results |
78
151
 
79
152
  ### `Delivery`
80
153
 
@@ -87,6 +160,27 @@ Every request includes an `X-Webhook-Signature` header with an HMAC-SHA256 hex d
87
160
  | `#response_body` | The response body string |
88
161
  | `#error` | Error message if delivery failed |
89
162
 
163
+ ### `Backoff::Exponential`
164
+
165
+ | Method | Description |
166
+ |--------|-------------|
167
+ | `.new(base:, max_delay:, jitter:)` | Create exponential strategy (defaults: base=1, max_delay=30, jitter=false) |
168
+ | `#call(attempt)` | Calculate delay for given attempt |
169
+
170
+ ### `Backoff::Linear`
171
+
172
+ | Method | Description |
173
+ |--------|-------------|
174
+ | `.new(base:, max_delay:)` | Create linear strategy (defaults: base=1, max_delay=30) |
175
+ | `#call(attempt)` | Calculate delay for given attempt |
176
+
177
+ ### `Backoff::Fixed`
178
+
179
+ | Method | Description |
180
+ |--------|-------------|
181
+ | `.new(delay:)` | Create fixed strategy (default: delay=1) |
182
+ | `#call(attempt)` | Returns constant delay |
183
+
90
184
  ## Development
91
185
 
92
186
  ```bash
@@ -95,6 +189,13 @@ bundle exec rspec
95
189
  bundle exec rubocop
96
190
  ```
97
191
 
192
+ ## Support
193
+
194
+ If you find this package useful, consider giving it a star on GitHub — it helps motivate continued maintenance and development.
195
+
196
+ [![LinkedIn](https://img.shields.io/badge/Philip%20Rehberger-LinkedIn-0A66C2?logo=linkedin)](https://www.linkedin.com/in/philiprehberger)
197
+ [![More packages](https://img.shields.io/badge/more-open%20source%20packages-blue)](https://philiprehberger.com/open-source-packages)
198
+
98
199
  ## License
99
200
 
100
- MIT
201
+ [MIT](LICENSE)
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module WebhookBuilder
5
+ module Backoff
6
+ # Exponential backoff: base * 2^attempt, capped at max_delay.
7
+ class Exponential
8
+ # @param base [Numeric] base delay in seconds (default: 1)
9
+ # @param max_delay [Numeric] maximum delay in seconds (default: 30)
10
+ # @param jitter [Boolean] whether to add random jitter (default: false)
11
+ def initialize(base: 1, max_delay: 30, jitter: false)
12
+ @base = base
13
+ @max_delay = max_delay
14
+ @jitter = jitter
15
+ end
16
+
17
+ # @param attempt [Integer] the current attempt number (1-based)
18
+ # @return [Float] delay in seconds
19
+ def call(attempt)
20
+ delay = [@base * (2**(attempt - 1)), @max_delay].min.to_f
21
+ delay *= rand if @jitter
22
+ delay
23
+ end
24
+ end
25
+
26
+ # Linear backoff: base * attempt, capped at max_delay.
27
+ class Linear
28
+ # @param base [Numeric] base delay in seconds (default: 1)
29
+ # @param max_delay [Numeric] maximum delay in seconds (default: 30)
30
+ def initialize(base: 1, max_delay: 30)
31
+ @base = base
32
+ @max_delay = max_delay
33
+ end
34
+
35
+ # @param attempt [Integer] the current attempt number (1-based)
36
+ # @return [Float] delay in seconds
37
+ def call(attempt)
38
+ [@base * attempt, @max_delay].min.to_f
39
+ end
40
+ end
41
+
42
+ # Fixed backoff: constant delay.
43
+ class Fixed
44
+ # @param delay [Numeric] delay in seconds (default: 1)
45
+ def initialize(delay: 1)
46
+ @delay = delay
47
+ end
48
+
49
+ # @param _attempt [Integer] ignored
50
+ # @return [Float] delay in seconds
51
+ def call(_attempt)
52
+ @delay.to_f
53
+ end
54
+ end
55
+
56
+ # Resolve a backoff option into a callable strategy.
57
+ #
58
+ # @param option [Symbol, Proc, nil] the backoff strategy
59
+ # @return [#call] a callable backoff strategy
60
+ def self.resolve(option)
61
+ case option
62
+ when :exponential, nil
63
+ Exponential.new
64
+ when :linear
65
+ Linear.new
66
+ when :fixed
67
+ Fixed.new
68
+ when Proc
69
+ option
70
+ else
71
+ raise ArgumentError, "Unknown backoff strategy: #{option.inspect}. Use :exponential, :linear, :fixed, or a Proc."
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
4
- require "uri"
5
- require "json"
6
- require "openssl"
7
- require "time"
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'openssl'
7
+ require 'time'
8
8
 
9
9
  module Philiprehberger
10
10
  module WebhookBuilder
@@ -19,27 +19,42 @@ module Philiprehberger
19
19
  # @return [Integer] the maximum number of delivery attempts
20
20
  attr_reader :max_retries
21
21
 
22
+ # @return [Integer] the maximum number of concurrent batch deliveries
23
+ attr_reader :concurrency
24
+
25
+ # @return [Hash] default headers included in every delivery
26
+ attr_reader :default_headers
27
+
22
28
  # Create a new webhook client.
23
29
  #
24
30
  # @param url [String] the webhook endpoint URL
25
31
  # @param secret [String] the HMAC-SHA256 signing secret
26
32
  # @param timeout [Integer] HTTP timeout in seconds (default: 30)
27
33
  # @param max_retries [Integer] maximum retry attempts on failure (default: 3)
28
- def initialize(url:, secret:, timeout: 30, max_retries: 3)
34
+ # @param backoff [Symbol, Proc] backoff strategy — :exponential (default), :linear, :fixed, or a Proc
35
+ # @param concurrency [Integer] maximum concurrent threads for batch delivery (default: 4)
36
+ # @param default_headers [Hash] headers to include in every delivery
37
+ def initialize(url:, secret:, timeout: 30, max_retries: 3, backoff: :exponential, concurrency: 4,
38
+ default_headers: {})
29
39
  @url = url
30
40
  @secret = secret
31
41
  @timeout = timeout
32
42
  @max_retries = max_retries
43
+ @backoff_strategy = Backoff.resolve(backoff)
44
+ @concurrency = concurrency
45
+ @default_headers = default_headers.dup.freeze
33
46
  end
34
47
 
35
48
  # Deliver a webhook event.
36
49
  #
37
50
  # @param event [String] the event type (e.g., "order.created")
38
51
  # @param payload [Hash] the event payload
52
+ # @param headers [Hash] per-delivery headers (override default_headers)
39
53
  # @return [Delivery] the delivery result
40
- def deliver(event:, payload:)
54
+ def deliver(event:, payload:, headers: {})
41
55
  body = JSON.generate({ event: event, payload: payload, timestamp: Time.now.utc.iso8601 })
42
56
  signature = sign(body)
57
+ merged_headers = @default_headers.merge(headers)
43
58
 
44
59
  attempts = 0
45
60
  start_time = monotonic_now
@@ -50,7 +65,7 @@ module Philiprehberger
50
65
  loop do
51
66
  attempts += 1
52
67
  begin
53
- response = send_request(body, signature, event)
68
+ response = send_request(body, signature, event, merged_headers)
54
69
  last_response_code = response.code.to_i
55
70
  last_response_body = response.body
56
71
 
@@ -71,7 +86,7 @@ module Philiprehberger
71
86
 
72
87
  break if attempts > @max_retries
73
88
 
74
- sleep(backoff_delay(attempts))
89
+ sleep(@backoff_strategy.call(attempts))
75
90
  end
76
91
 
77
92
  Delivery.new(
@@ -84,6 +99,44 @@ module Philiprehberger
84
99
  )
85
100
  end
86
101
 
102
+ # Deliver multiple webhook events concurrently.
103
+ #
104
+ # @param events [Array<Hash>] array of { event:, payload: } hashes, optionally with headers:
105
+ # @return [Array<Delivery>] delivery results in the same order as input
106
+ def deliver_batch(events)
107
+ results = Array.new(events.length)
108
+ mutex = Mutex.new
109
+ queue = Queue.new
110
+
111
+ events.each_with_index do |item, index|
112
+ queue << [item, index]
113
+ end
114
+
115
+ threads = Array.new([@concurrency, events.length].min) do
116
+ Thread.new do
117
+ loop do
118
+ pair = begin
119
+ queue.pop(true)
120
+ rescue ThreadError
121
+ nil
122
+ end
123
+ break unless pair
124
+
125
+ item, index = pair
126
+ delivery = deliver(
127
+ event: item[:event],
128
+ payload: item[:payload],
129
+ headers: item.fetch(:headers, {})
130
+ )
131
+ mutex.synchronize { results[index] = delivery }
132
+ end
133
+ end
134
+ end
135
+
136
+ threads.each(&:join)
137
+ results
138
+ end
139
+
87
140
  private
88
141
 
89
142
  # Sign the request body with HMAC-SHA256.
@@ -91,7 +144,7 @@ module Philiprehberger
91
144
  # @param body [String] the JSON body
92
145
  # @return [String] the hex-encoded HMAC signature
93
146
  def sign(body)
94
- OpenSSL::HMAC.hexdigest("SHA256", @secret, body)
147
+ OpenSSL::HMAC.hexdigest('SHA256', @secret, body)
95
148
  end
96
149
 
97
150
  # Send the HTTP POST request.
@@ -99,32 +152,28 @@ module Philiprehberger
99
152
  # @param body [String] the JSON body
100
153
  # @param signature [String] the HMAC signature
101
154
  # @param event [String] the event type
155
+ # @param extra_headers [Hash] additional headers to include
102
156
  # @return [Net::HTTPResponse]
103
- def send_request(body, signature, event)
157
+ def send_request(body, signature, event, extra_headers = {})
104
158
  uri = URI.parse(@url)
105
159
  http = Net::HTTP.new(uri.host, uri.port)
106
- http.use_ssl = uri.scheme == "https"
160
+ http.use_ssl = uri.scheme == 'https'
107
161
  http.open_timeout = @timeout
108
162
  http.read_timeout = @timeout
109
163
 
110
164
  request = Net::HTTP::Post.new(uri.request_uri)
111
- request["Content-Type"] = "application/json"
112
- request["X-Webhook-Signature"] = signature
113
- request["X-Webhook-Event"] = event
114
- request["User-Agent"] = "philiprehberger-webhook_builder/#{VERSION}"
165
+ request['Content-Type'] = 'application/json'
166
+ request['X-Webhook-Signature'] = signature
167
+ request['X-Webhook-Event'] = event
168
+ request['User-Agent'] = "philiprehberger-webhook_builder/#{VERSION}"
169
+
170
+ extra_headers.each { |key, value| request[key] = value }
171
+
115
172
  request.body = body
116
173
 
117
174
  http.request(request)
118
175
  end
119
176
 
120
- # Calculate exponential backoff delay.
121
- #
122
- # @param attempt [Integer] the current attempt number
123
- # @return [Float] delay in seconds
124
- def backoff_delay(attempt)
125
- [2**(attempt - 1), 30].min
126
- end
127
-
128
177
  def monotonic_now
129
178
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
130
179
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module WebhookBuilder
5
- VERSION = "0.1.0"
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -15,6 +15,7 @@ module Philiprehberger
15
15
  end
16
16
  end
17
17
 
18
- require_relative "webhook_builder/version"
19
- require_relative "webhook_builder/delivery"
20
- require_relative "webhook_builder/client"
18
+ require_relative 'webhook_builder/version'
19
+ require_relative 'webhook_builder/delivery'
20
+ require_relative 'webhook_builder/backoff'
21
+ require_relative 'webhook_builder/client'
metadata CHANGED
@@ -1,18 +1,19 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-webhook_builder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-22 00:00:00.000000000 Z
11
+ date: 2026-03-30 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A webhook delivery client that signs payloads with HMAC-SHA256, retries
14
- failed deliveries with exponential backoff, and tracks delivery status including
15
- response codes, attempts, and duration.
14
+ failed deliveries with configurable backoff strategies, supports batch delivery,
15
+ custom headers, and tracks delivery status including response codes, attempts, and
16
+ duration.
16
17
  email:
17
18
  - me@philiprehberger.com
18
19
  executables: []
@@ -23,6 +24,7 @@ files:
23
24
  - LICENSE
24
25
  - README.md
25
26
  - lib/philiprehberger/webhook_builder.rb
27
+ - lib/philiprehberger/webhook_builder/backoff.rb
26
28
  - lib/philiprehberger/webhook_builder/client.rb
27
29
  - lib/philiprehberger/webhook_builder/delivery.rb
28
30
  - lib/philiprehberger/webhook_builder/version.rb