bug_bunny 4.1.0 → 4.1.2
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/CHANGELOG.md +12 -0
- data/README.md +27 -1
- data/lib/bug_bunny/configuration.rb +10 -1
- data/lib/bug_bunny/consumer.rb +59 -36
- data/lib/bug_bunny/controller.rb +7 -12
- data/lib/bug_bunny/resource.rb +17 -2
- data/lib/bug_bunny/session.rb +6 -3
- data/lib/bug_bunny/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 979c38a4f25c9359afd10b0f3c728b794fdf4dee795adfaea7fd090e832134bc
|
|
4
|
+
data.tar.gz: 658a0088c4e8cedde115074b81a9247e6cf1c15d226e880c444bd94ed8168cc5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d80a2015e3f26626e6f0261597a261129a4c55f87ed5556f3bab7523327a747e1238241e5c49769f22dd83d231e151913a786b34119eabe43ec858f456c60d1
|
|
7
|
+
data.tar.gz: 94635e6463d95eac5ffd65e7fe5ff3eaf3c946825052477a31b9ddde0da763b87b5e2f27e877b8c72c5a3bcb13920e28269b12f5c123567bd75ff0ed00d049ae
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [4.1.2] - 2026-03-22
|
|
4
|
+
|
|
5
|
+
### ✨ Improvements
|
|
6
|
+
* **Controller:** Ahora lanza una excepción \`BugBunny::BadRequest\` (400) si el cuerpo de la petición contiene un JSON inválido, mejorando la depuración en el cliente.
|
|
7
|
+
* **Resource:** Se añadió una protección a \`.with\` (\`ScopeProxy\`) para asegurar que el contexto sea de un solo uso, evitando efectos secundarios en llamadas encadenadas.
|
|
8
|
+
|
|
9
|
+
## [4.1.1] - 2026-03-22
|
|
10
|
+
|
|
11
|
+
### 🐛 Bug Fixes
|
|
12
|
+
* **Consumer:** Previene memory leak al detener el `TimerTask` de health check previo antes de realizar una reconexión.
|
|
13
|
+
* **Controller:** Corrige la mutación accidental de \`log_tags\` globales al usar una lógica de herencia no destructiva en \`compute_tags\`.
|
|
14
|
+
|
|
3
15
|
## [4.1.0] - 2026-03-22
|
|
4
16
|
|
|
5
17
|
### 🚀 New Features & Improvements
|
data/README.md
CHANGED
|
@@ -74,7 +74,9 @@ BugBunny.configure do |config|
|
|
|
74
74
|
|
|
75
75
|
# 2. Timeouts y Recuperación
|
|
76
76
|
config.rpc_timeout = 10 # Segundos máx para esperar respuesta (Síncrono)
|
|
77
|
-
config.network_recovery_interval = 5 #
|
|
77
|
+
config.network_recovery_interval = 5 # Base del backoff de reconexión (segundos)
|
|
78
|
+
config.max_reconnect_interval = 60 # Techo del backoff exponencial (segundos)
|
|
79
|
+
config.max_reconnect_attempts = nil # nil = reintenta infinitamente; Integer = falla hard
|
|
78
80
|
|
|
79
81
|
# 3. Health Checks (Opcional, para Docker Swarm / K8s)
|
|
80
82
|
config.health_check_file = '/tmp/bug_bunny_health'
|
|
@@ -420,6 +422,30 @@ Para máxima velocidad, BugBunny usa `amq.rabbitmq.reply-to`.
|
|
|
420
422
|
### Seguridad
|
|
421
423
|
El Router incluye protecciones contra **Remote Code Execution (RCE)**. El Consumer verifica estrictamente que el Controlador resuelto a través del archivo de rutas herede de `BugBunny::Controller` antes de ejecutarla, impidiendo la inyección de clases arbitrarias. Además, las llamadas a rutas no registradas fallan rápido con un `404 Not Found`.
|
|
422
424
|
|
|
425
|
+
### Reconexión con Backoff Exponencial
|
|
426
|
+
|
|
427
|
+
Cuando el Consumer pierde la conexión a RabbitMQ, reintenta automáticamente usando un backoff exponencial basado en `network_recovery_interval`:
|
|
428
|
+
|
|
429
|
+
| Intento | Espera (base = 5s, techo = 60s) |
|
|
430
|
+
|---------|----------------------------------|
|
|
431
|
+
| 1 | 5s |
|
|
432
|
+
| 2 | 10s |
|
|
433
|
+
| 3 | 20s |
|
|
434
|
+
| 4 | 40s |
|
|
435
|
+
| 5+ | 60s (cap) |
|
|
436
|
+
|
|
437
|
+
Por defecto reintenta indefinidamente (`max_reconnect_attempts: nil`). En entornos orquestados (Kubernetes, Docker Swarm), es preferible dejar que el orquestador reinicie el contenedor cuando la infraestructura no está disponible:
|
|
438
|
+
|
|
439
|
+
```ruby
|
|
440
|
+
BugBunny.configure do |config|
|
|
441
|
+
config.network_recovery_interval = 5 # Base del backoff
|
|
442
|
+
config.max_reconnect_interval = 60 # Techo máximo de espera
|
|
443
|
+
config.max_reconnect_attempts = 10 # Falla hard después de 10 intentos
|
|
444
|
+
end
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Con esta configuración, si RabbitMQ no vuelve en ~10 reintentos el proceso levanta la excepción y el orquestador lo reinicia con su propia política de restart.
|
|
448
|
+
|
|
423
449
|
### Health Checks en Docker Swarm / Kubernetes
|
|
424
450
|
Dado que un Worker se ejecuta en segundo plano sin exponer un servidor web tradicional, orquestadores como Docker Swarm o Kubernetes no pueden usar un endpoint HTTP para verificar si el proceso está saludable.
|
|
425
451
|
|
|
@@ -38,9 +38,16 @@ module BugBunny
|
|
|
38
38
|
# @return [Boolean] Si `true`, Bunny intentará reconectar automáticamente.
|
|
39
39
|
attr_accessor :automatically_recover
|
|
40
40
|
|
|
41
|
-
# @return [Integer] Tiempo en segundos a esperar antes de intentar reconectar.
|
|
41
|
+
# @return [Integer] Tiempo en segundos a esperar antes de intentar reconectar (base del backoff).
|
|
42
42
|
attr_accessor :network_recovery_interval
|
|
43
43
|
|
|
44
|
+
# @return [Integer, nil] Número máximo de intentos de reconexión del Consumer antes de rendirse.
|
|
45
|
+
# Si es `nil` (default), reintenta indefinidamente.
|
|
46
|
+
attr_accessor :max_reconnect_attempts
|
|
47
|
+
|
|
48
|
+
# @return [Integer] Techo en segundos para el backoff exponencial de reconexión (default: 60).
|
|
49
|
+
attr_accessor :max_reconnect_interval
|
|
50
|
+
|
|
44
51
|
# @return [Integer] Timeout en segundos para establecer la conexión TCP inicial.
|
|
45
52
|
attr_accessor :connection_timeout
|
|
46
53
|
|
|
@@ -106,6 +113,8 @@ module BugBunny
|
|
|
106
113
|
@bunny_logger.level = Logger::WARN
|
|
107
114
|
@automatically_recover = true
|
|
108
115
|
@network_recovery_interval = 5
|
|
116
|
+
@max_reconnect_attempts = nil
|
|
117
|
+
@max_reconnect_interval = 60
|
|
109
118
|
@connection_timeout = 10
|
|
110
119
|
@read_timeout = 30
|
|
111
120
|
@write_timeout = 30
|
data/lib/bug_bunny/consumer.rb
CHANGED
|
@@ -43,7 +43,8 @@ module BugBunny
|
|
|
43
43
|
#
|
|
44
44
|
# @param connection [Bunny::Session] Conexión nativa de Bunny.
|
|
45
45
|
def initialize(connection)
|
|
46
|
-
@session = BugBunny::Session.new(connection)
|
|
46
|
+
@session = BugBunny::Session.new(connection, publisher_confirms: false)
|
|
47
|
+
@health_timer = nil
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
# Inicia la suscripción a la cola y comienza el bucle de procesamiento.
|
|
@@ -60,41 +61,58 @@ module BugBunny
|
|
|
60
61
|
# @param block [Boolean] Si es `true`, bloquea el hilo actual (loop infinito).
|
|
61
62
|
# @return [void]
|
|
62
63
|
def subscribe(queue_name:, exchange_name:, routing_key:, exchange_type: 'direct', exchange_opts: {}, queue_opts: {}, block: true)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
logger
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
64
|
+
attempt = 0
|
|
65
|
+
|
|
66
|
+
begin
|
|
67
|
+
# Declaración de Infraestructura
|
|
68
|
+
x = session.exchange(name: exchange_name, type: exchange_type, opts: exchange_opts)
|
|
69
|
+
q = session.queue(queue_name, queue_opts)
|
|
70
|
+
q.bind(x, routing_key: routing_key)
|
|
71
|
+
|
|
72
|
+
# 📊 LOGGING DE OBSERVABILIDAD: Calculamos las opciones finales para mostrarlas en consola
|
|
73
|
+
final_x_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS
|
|
74
|
+
.merge(BugBunny.configuration.exchange_options || {})
|
|
75
|
+
.merge(exchange_opts || {})
|
|
76
|
+
final_q_opts = BugBunny::Session::DEFAULT_QUEUE_OPTIONS
|
|
77
|
+
.merge(BugBunny.configuration.queue_options || {})
|
|
78
|
+
.merge(queue_opts || {})
|
|
79
|
+
|
|
80
|
+
BugBunny.configuration.logger.info("[BugBunny::Consumer] 🎧 Listening on '#{queue_name}' (Opts: #{final_q_opts})")
|
|
81
|
+
BugBunny.configuration.logger.info("[BugBunny::Consumer] 🔀 Bounded to Exchange '#{exchange_name}' (#{exchange_type}) | Opts: #{final_x_opts} | RK: '#{routing_key}'")
|
|
82
|
+
|
|
83
|
+
start_health_check(queue_name)
|
|
84
|
+
|
|
85
|
+
q.subscribe(manual_ack: true, block: block) do |delivery_info, properties, body|
|
|
86
|
+
trace_id = properties.correlation_id
|
|
87
|
+
|
|
88
|
+
logger = BugBunny.configuration.logger
|
|
89
|
+
|
|
90
|
+
if logger.respond_to?(:tagged)
|
|
91
|
+
logger.tagged(trace_id) { process_message(delivery_info, properties, body) }
|
|
92
|
+
elsif defined?(Rails) && Rails.logger.respond_to?(:tagged)
|
|
93
|
+
Rails.logger.tagged(trace_id) { process_message(delivery_info, properties, body) }
|
|
94
|
+
else
|
|
95
|
+
process_message(delivery_info, properties, body)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
attempt += 1
|
|
100
|
+
max_attempts = BugBunny.configuration.max_reconnect_attempts
|
|
101
|
+
|
|
102
|
+
if max_attempts && attempt >= max_attempts
|
|
103
|
+
BugBunny.configuration.logger.error("[BugBunny::Consumer] 💥 Max reconnect attempts (#{max_attempts}) reached. Giving up: #{e.message}")
|
|
104
|
+
raise
|
|
92
105
|
end
|
|
106
|
+
|
|
107
|
+
wait = [
|
|
108
|
+
BugBunny.configuration.network_recovery_interval * (2 ** (attempt - 1)),
|
|
109
|
+
BugBunny.configuration.max_reconnect_interval
|
|
110
|
+
].min
|
|
111
|
+
|
|
112
|
+
BugBunny.configuration.logger.error("[BugBunny::Consumer] 💥 Connection Error: #{e.message}. Retrying in #{wait}s (attempt #{attempt}/#{max_attempts || '∞'})...")
|
|
113
|
+
sleep wait
|
|
114
|
+
retry
|
|
93
115
|
end
|
|
94
|
-
rescue StandardError => e
|
|
95
|
-
BugBunny.configuration.logger.error("[BugBunny::Consumer] 💥 Connection Error: #{e.message}. Retrying in #{BugBunny.configuration.network_recovery_interval}s...")
|
|
96
|
-
sleep BugBunny.configuration.network_recovery_interval
|
|
97
|
-
retry
|
|
98
116
|
end
|
|
99
117
|
|
|
100
118
|
private
|
|
@@ -244,12 +262,16 @@ module BugBunny
|
|
|
244
262
|
# @param q_name [String] Nombre de la cola a monitorear.
|
|
245
263
|
# @return [void]
|
|
246
264
|
def start_health_check(q_name)
|
|
265
|
+
# Detener el timer anterior antes de crear uno nuevo (evita leak en cada retry)
|
|
266
|
+
@health_timer&.shutdown
|
|
267
|
+
@health_timer = nil
|
|
268
|
+
|
|
247
269
|
file_path = BugBunny.configuration.health_check_file
|
|
248
270
|
|
|
249
271
|
# Toque inicial para indicar al orquestador que el worker arrancó correctamente
|
|
250
272
|
touch_health_file(file_path) if file_path
|
|
251
273
|
|
|
252
|
-
Concurrent::TimerTask.new(execution_interval: BugBunny.configuration.health_check_interval) do
|
|
274
|
+
@health_timer = Concurrent::TimerTask.new(execution_interval: BugBunny.configuration.health_check_interval) do
|
|
253
275
|
# 1. Verificamos la salud de RabbitMQ (si falla, levanta un error y corta la ejecución del bloque)
|
|
254
276
|
session.channel.queue_declare(q_name, passive: true)
|
|
255
277
|
|
|
@@ -258,7 +280,8 @@ module BugBunny
|
|
|
258
280
|
rescue StandardError => e
|
|
259
281
|
BugBunny.configuration.logger.warn("[BugBunny::Consumer] ⚠️ Queue check failed: #{e.message}. Reconnecting session...")
|
|
260
282
|
session.close
|
|
261
|
-
end
|
|
283
|
+
end
|
|
284
|
+
@health_timer.execute
|
|
262
285
|
end
|
|
263
286
|
|
|
264
287
|
# Actualiza la fecha de modificación del archivo de health check (touchfile).
|
data/lib/bug_bunny/controller.rb
CHANGED
|
@@ -146,11 +146,6 @@ module BugBunny
|
|
|
146
146
|
def process(body)
|
|
147
147
|
prepare_params(body)
|
|
148
148
|
|
|
149
|
-
# Inyección de configuración global de logs si el controlador no define propios
|
|
150
|
-
if self.class.log_tags.empty? && BugBunny.configuration.log_tags.any?
|
|
151
|
-
self.class.log_tags = BugBunny.configuration.log_tags
|
|
152
|
-
end
|
|
153
|
-
|
|
154
149
|
action_name = headers[:action].to_sym
|
|
155
150
|
current_arounds = resolve_callbacks(self.class.around_actions, action_name)
|
|
156
151
|
|
|
@@ -245,12 +240,11 @@ module BugBunny
|
|
|
245
240
|
if body.is_a?(Hash)
|
|
246
241
|
params.merge!(body)
|
|
247
242
|
elsif body.is_a?(String) && headers[:content_type].to_s.include?('json')
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
params.merge!(parsed) if parsed
|
|
243
|
+
begin
|
|
244
|
+
params.merge!(JSON.parse(body))
|
|
245
|
+
rescue JSON::ParserError => e
|
|
246
|
+
raise BugBunny::BadRequest, "Invalid JSON in request body: #{e.message}"
|
|
247
|
+
end
|
|
254
248
|
else
|
|
255
249
|
self.raw_string = body
|
|
256
250
|
end
|
|
@@ -284,7 +278,8 @@ module BugBunny
|
|
|
284
278
|
end
|
|
285
279
|
|
|
286
280
|
def compute_tags
|
|
287
|
-
self.class.log_tags.
|
|
281
|
+
tags = self.class.log_tags.presence || BugBunny.configuration.log_tags
|
|
282
|
+
tags.map do |tag|
|
|
288
283
|
case tag
|
|
289
284
|
when Proc
|
|
290
285
|
tag.call(self)
|
data/lib/bug_bunny/resource.rb
CHANGED
|
@@ -154,9 +154,24 @@ module BugBunny
|
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
# Proxy para el encadenamiento del método `.with`.
|
|
157
|
+
# Solo puede usarse para UNA llamada de método: el contexto se restaura al finalizar.
|
|
157
158
|
class ScopeProxy < BasicObject
|
|
158
|
-
def initialize(target, keys, old_values)
|
|
159
|
-
|
|
159
|
+
def initialize(target, keys, old_values)
|
|
160
|
+
@target = target
|
|
161
|
+
@keys = keys
|
|
162
|
+
@old_values = old_values
|
|
163
|
+
@used = false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def method_missing(method, *args, &block)
|
|
167
|
+
if @used
|
|
168
|
+
::Kernel.raise ::BugBunny::Error, "ScopeProxy is single-use. Call .with again for a new context."
|
|
169
|
+
end
|
|
170
|
+
@used = true
|
|
171
|
+
@target.public_send(method, *args, &block)
|
|
172
|
+
ensure
|
|
173
|
+
@keys.each { |k, v| ::Thread.current[v] = @old_values[k] }
|
|
174
|
+
end
|
|
160
175
|
end
|
|
161
176
|
|
|
162
177
|
# Calcula la routing key final.
|
data/lib/bug_bunny/session.rb
CHANGED
|
@@ -24,8 +24,12 @@ module BugBunny
|
|
|
24
24
|
# Inicializa una nueva sesión sin abrir canales todavía.
|
|
25
25
|
#
|
|
26
26
|
# @param connection [Bunny::Session] Una conexión (puede estar abierta o cerrada temporalmente).
|
|
27
|
-
|
|
27
|
+
# @param publisher_confirms [Boolean] Si es `true`, el canal se abre en modo Publisher Confirms.
|
|
28
|
+
# Activar solo en sesiones de Producer. En sesiones de Consumer genera overhead innecesario
|
|
29
|
+
# ya que los replies RPC son fire-and-forget desde la perspectiva del servidor.
|
|
30
|
+
def initialize(connection, publisher_confirms: true)
|
|
28
31
|
@connection = connection
|
|
32
|
+
@publisher_confirms = publisher_confirms
|
|
29
33
|
@channel = nil
|
|
30
34
|
end
|
|
31
35
|
|
|
@@ -105,8 +109,7 @@ module BugBunny
|
|
|
105
109
|
def create_channel!
|
|
106
110
|
@channel = @connection.create_channel
|
|
107
111
|
|
|
108
|
-
|
|
109
|
-
@channel.confirm_select
|
|
112
|
+
@channel.confirm_select if @publisher_confirms
|
|
110
113
|
|
|
111
114
|
if BugBunny.configuration.channel_prefetch
|
|
112
115
|
@channel.prefetch(BugBunny.configuration.channel_prefetch)
|
data/lib/bug_bunny/version.rb
CHANGED