sneakers_max_retry_handler 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 11c75e850f1367f8a75940dba6f75c3652c92f2d4cbfde8ebe89a60dc671660a
4
+ data.tar.gz: 7642b2c40070abc4207de8fbbc8831d654cef6a7d484762f65ee383bf9de433d
5
+ SHA512:
6
+ metadata.gz: '080715a56d2c622df989e2f292d4c462927a7fe749624c159889b09138b9f077326512ccb0aed36b42a124bd895a4b69008ad11a999c80bd6d5303cb1f85841d'
7
+ data.tar.gz: bda03c7af5ff41b9beefd68d3e9b45bdb04340b1c20ed81e1537079c1f12cd163bfba3f5d9af88c80481d7d1038826313cc9b0dab14ad8ece1751bfcd24f0d2d
data/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in sneakers_max_retry_handler.gemspec
6
+ gemspec
7
+
8
+ gem "irb"
9
+ gem "rake", "~> 13.0"
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 KlimSem
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # Sneakers-Max-Retry-Handler
2
+
3
+ A modified max retry handler for RabbitMQ and [Sneakers](https://github.com/ruby-amqp/kicks/)
4
+
5
+ A small modification of Sneaker's built-in [Maxretry class](https://github.com/ruby-amqp/kicks/blob/main/lib/sneakers/handlers/maxretry.rb)
6
+ the replacement class is called ```SneakersMaxRetryHandler::Maxretry```
7
+ has the exact same logic, but you can pass arguments for handler created queues
8
+
9
+
10
+ # Installation
11
+
12
+ Include it in your Gemfile.
13
+
14
+ ```ruby
15
+ gem 'sneakers_max_retry_handler'
16
+ ```
17
+
18
+ Next install it with Bundler.
19
+
20
+ ```bash
21
+ $ bundle install
22
+ ```
23
+
24
+
25
+ # Use:
26
+
27
+ initialize your worker with the Maxretry handler:
28
+
29
+ ```ruby
30
+ class MyWorker
31
+ from_queue 'audit_service', {
32
+ handler: SneakersMaxRetryHandler::Maxretry,
33
+ }
34
+ end
35
+ ```
data/bin/console ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "sneakers_max_retry_handler"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ require "irb"
11
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,216 @@
1
+ require 'base64'
2
+ require 'json'
3
+
4
+ module SneakersMaxRetryHandler
5
+ #
6
+ # Maxretry uses dead letter policies on Rabbitmq to requeue and retry
7
+ # messages after failure (rejections and errors). When the maximum
8
+ # number of retries is reached it will put the message on an error queue.
9
+ # This handler will only retry at the queue level. To accomplish that, the
10
+ # setup is a bit complex.
11
+ #
12
+ # Input:
13
+ # worker_exchange (eXchange)
14
+ # worker_queue (Queue)
15
+ # We create:
16
+ # worker_queue-retry - (X) where we setup the worker queue to dead-letter.
17
+ # worker_queue-retry - (Q) queue bound to ^ exchange, dead-letters to
18
+ # worker_queue-retry-requeue.
19
+ # worker_queue-error - (X) where to send max-retry failures
20
+ # worker_queue-error - (Q) bound to worker_queue-error.
21
+ # worker_queue-retry-requeue - (X) exchange to bind worker_queue to for
22
+ # requeuing directly to the worker_queue.
23
+ #
24
+ # This requires that you setup arguments to the worker queue to line up the
25
+ # dead letter queue. See the example for more information.
26
+ #
27
+ # Many of these can be override with options:
28
+ # - retry_exchange - sets retry exchange & queue
29
+ # - retry_error_exchange - sets error exchange and queue
30
+ # - retry_requeue_exchange - sets the exchange created to re-queue things
31
+ # back to the worker queue.
32
+ #
33
+ class Maxretry
34
+
35
+ def initialize(channel, queue, opts)
36
+ @worker_queue_name = queue.name
37
+ Sneakers.logger.debug do
38
+ "#{log_prefix} creating handler, opts=#{opts}"
39
+ end
40
+
41
+ @channel = channel
42
+ @opts = opts
43
+
44
+ # Construct names, defaulting where suitable
45
+ retry_name = @opts[:retry_exchange] || "#{@worker_queue_name}-retry"
46
+ error_name = @opts[:retry_error_exchange] || "#{@worker_queue_name}-error"
47
+ requeue_name = @opts[:retry_requeue_exchange] || "#{@worker_queue_name}-retry-requeue"
48
+ retry_routing_key = @opts[:retry_routing_key] || "#"
49
+
50
+ # Create the exchanges
51
+ @retry_exchange, @error_exchange, @requeue_exchange = [retry_name, error_name, requeue_name].map do |name|
52
+ Sneakers.logger.debug { "#{log_prefix} creating exchange=#{name}" }
53
+ @channel.exchange(name,
54
+ :type => 'topic',
55
+ :durable => exchange_durable?)
56
+ end
57
+
58
+ # Create the queues and bindings
59
+ Sneakers.logger.debug do
60
+ "#{log_prefix} creating queue=#{retry_name} x-dead-letter-exchange=#{requeue_name}"
61
+ end
62
+ @retry_queue = @channel.queue(retry_name,
63
+ :durable => queue_durable?,
64
+ :arguments => {
65
+ :'x-dead-letter-exchange' => requeue_name,
66
+ :'x-message-ttl' => @opts[:retry_timeout] || 60000
67
+ })
68
+ @retry_queue.bind(@retry_exchange, :routing_key => '#')
69
+
70
+ Sneakers.logger.debug do
71
+ "#{log_prefix} creating queue=#{error_name}"
72
+ end
73
+ @error_queue = @channel.queue(error_name,
74
+ :durable => queue_durable?)
75
+ @error_queue.bind(@error_exchange, :routing_key => '#')
76
+
77
+ # Finally, bind the worker queue to our requeue exchange
78
+ queue.bind(@requeue_exchange, :routing_key => retry_routing_key)
79
+
80
+ @max_retries = @opts[:retry_max_times] || 5
81
+ end
82
+
83
+ def self.configure_queue(name, opts)
84
+ retry_name = opts.fetch(:retry_exchange, "#{name}-retry")
85
+ opt_args = opts[:queue_options][:arguments] ? opts[:queue_options][:arguments].inject({}){|memo,(k,v)| memo[k.to_sym] = v; memo} : {}
86
+ opts[:queue_options][:arguments] = { :'x-dead-letter-exchange' => retry_name }.merge(opt_args)
87
+ opts[:queue_options]
88
+ end
89
+
90
+ def acknowledge(hdr, props, msg)
91
+ @channel.acknowledge(hdr.delivery_tag, false)
92
+ end
93
+
94
+ def reject(hdr, props, msg, requeue = false)
95
+ if requeue
96
+ # This was explicitly rejected specifying it be requeued so we do not
97
+ # want it to pass through our retry logic.
98
+ @channel.reject(hdr.delivery_tag, requeue)
99
+ else
100
+ handle_retry(hdr, props, msg, :reject)
101
+ end
102
+ end
103
+
104
+
105
+ def error(hdr, props, msg, err)
106
+ handle_retry(hdr, props, msg, err)
107
+ end
108
+
109
+ def noop(hdr, props, msg)
110
+
111
+ end
112
+
113
+ # Helper logic for retry handling. This will reject the message if there
114
+ # are remaining retries left on it, otherwise it will publish it to the
115
+ # error exchange along with the reason.
116
+ # @param hdr [Bunny::DeliveryInfo]
117
+ # @param props [Bunny::MessageProperties]
118
+ # @param msg [String] The message
119
+ # @param reason [String, Symbol, Exception] Reason for the retry, included
120
+ # in the JSON we put on the error exchange.
121
+ def handle_retry(hdr, props, msg, reason)
122
+ # +1 for the current attempt
123
+ num_attempts = failure_count(props[:headers]) + 1
124
+ if num_attempts <= @max_retries
125
+ # We call reject which will route the message to the
126
+ # x-dead-letter-exchange (ie. retry exchange) on the queue
127
+ Sneakers.logger.info do
128
+ "#{log_prefix} msg=retrying, count=#{num_attempts}, headers=#{props[:headers]}"
129
+ end
130
+ @channel.reject(hdr.delivery_tag, false)
131
+ # TODO: metrics
132
+ else
133
+ # Retried more than the max times
134
+ # Publish the original message with the routing_key to the error exchange
135
+ Sneakers.logger.info do
136
+ "#{log_prefix} msg=failing, retry_count=#{num_attempts}, reason=#{reason}"
137
+ end
138
+ data = {
139
+ error: reason.to_s,
140
+ num_attempts: num_attempts,
141
+ failed_at: Time.now.iso8601,
142
+ properties: props.to_hash
143
+ }.tap do |hash|
144
+ if reason.is_a?(Exception)
145
+ hash[:error_class] = reason.class.to_s
146
+ hash[:error_message] = "#{reason}"
147
+ if reason.backtrace
148
+ hash[:backtrace] = reason.backtrace.take(10)
149
+ end
150
+ end
151
+ end
152
+
153
+ # Preserve retry log in a list
154
+ if retry_info = props[:headers]['retry_info']
155
+ old_retry0 = JSON.parse(retry_info) rescue {error: "Failed to parse retry info"}
156
+ old_retry = Array(old_retry0)
157
+ # Prevent old retry from nesting
158
+ data[:properties][:headers].delete('retry_info')
159
+ data = old_retry.unshift(data)
160
+ end
161
+
162
+ @error_exchange.publish(msg, {
163
+ routing_key: hdr.routing_key,
164
+ headers: {
165
+ retry_info: data.to_json
166
+ }
167
+ })
168
+ @channel.acknowledge(hdr.delivery_tag, false)
169
+ # TODO: metrics
170
+ end
171
+ end
172
+ private :handle_retry
173
+
174
+ # Uses the x-death header to determine the number of failures this job has
175
+ # seen in the past. This does not count the current failure. So for
176
+ # instance, the first time the job fails, this will return 0, the second
177
+ # time, 1, etc.
178
+ # @param headers [Hash] Hash of headers that Rabbit delivers as part of
179
+ # the message
180
+ # @return [Integer] Count of number of failures.
181
+ def failure_count(headers)
182
+ if headers.nil? || headers['x-death'].nil?
183
+ 0
184
+ else
185
+ x_death_array = headers['x-death'].select do |x_death|
186
+ x_death['queue'] == @worker_queue_name
187
+ end
188
+ if x_death_array.count > 0 && x_death_array.first['count']
189
+ # Newer versions of RabbitMQ return headers with a count key
190
+ x_death_array.inject(0) {|sum, x_death| sum + x_death['count']}
191
+ else
192
+ # Older versions return a separate x-death header for each failure
193
+ x_death_array.count
194
+ end
195
+ end
196
+ end
197
+ private :failure_count
198
+
199
+ # Prefix all of our log messages so they are easier to find. We don't have
200
+ # the worker, so the next best thing is the queue name.
201
+ def log_prefix
202
+ "Maxretry handler [queue=#{@worker_queue_name}]"
203
+ end
204
+ private :log_prefix
205
+
206
+ private
207
+
208
+ def queue_durable?
209
+ @opts.fetch(:queue_options, {}).fetch(:durable, false)
210
+ end
211
+
212
+ def exchange_durable?
213
+ queue_durable?
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,3 @@
1
+ module SneakersMaxRetryHandler
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1 @@
1
+ require 'sneakers_max_retry_handler/maxretry.rb'
@@ -0,0 +1,22 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'sneakers_max_retry_handler/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "sneakers_max_retry_handler"
7
+ spec.version = SneakersMaxRetryHandler::VERSION
8
+ spec.authors = ["KlimSem"]
9
+ spec.email = ["klimsemikin@gmail.com"]
10
+ spec.licenses = ['MIT']
11
+
12
+ spec.summary = %q{Adds MaxRetry Handler to use with Kicks}
13
+ spec.description = %q{Adds MaxRetry handler which supports additional settings}
14
+ spec.homepage = "https://github.com/KlimSemikin/sneakers_max_retry_handler"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.require_paths = ["lib"]
18
+
19
+ spec.add_dependency "kicks", "~> 3.2.0"
20
+ spec.add_development_dependency "bundler", "~> 2.0"
21
+ spec.add_development_dependency "pry-byebug", "~> 3.5"
22
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sneakers_max_retry_handler
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - KlimSem
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-08-29 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: kicks
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 3.2.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 3.2.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: bundler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: pry-byebug
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.5'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.5'
54
+ description: Adds MaxRetry handler which supports additional settings
55
+ email:
56
+ - klimsemikin@gmail.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - ".gitignore"
62
+ - Gemfile
63
+ - LICENSE
64
+ - README.md
65
+ - bin/console
66
+ - bin/setup
67
+ - lib/sneakers_max_retry_handler.rb
68
+ - lib/sneakers_max_retry_handler/maxretry.rb
69
+ - lib/sneakers_max_retry_handler/version.rb
70
+ - sneakers_max_retry_handler.gemspec
71
+ homepage: https://github.com/KlimSemikin/sneakers_max_retry_handler
72
+ licenses:
73
+ - MIT
74
+ metadata: {}
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 3.6.6
90
+ specification_version: 4
91
+ summary: Adds MaxRetry Handler to use with Kicks
92
+ test_files: []