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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ef1416747a080c5d6af30ac97412688c0419e8b3635b410d93580ad869d78924
4
- data.tar.gz: e362258f131a6fefdad0b835a410218e349d8d56966bfef67586dd573375ffcb
3
+ metadata.gz: 979c38a4f25c9359afd10b0f3c728b794fdf4dee795adfaea7fd090e832134bc
4
+ data.tar.gz: 658a0088c4e8cedde115074b81a9247e6cf1c15d226e880c444bd94ed8168cc5
5
5
  SHA512:
6
- metadata.gz: e788b41069a507289245398fc56ab5829df1139665319a254b6136aa26eb44a607b0f7d0843b517f4ed3298095629bed97e64d9aaace5cc81313f4995392d12d
7
- data.tar.gz: cfff2337042002bf850200a8b63739502e2c4607d2c26d661756f980cf7c8c8986a96545811c03aba7b8fc3c23fe929e62240f63dada14c55ddb02cd38feda30
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 # Reintento de conexión
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
@@ -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
- # Declaración de Infraestructura
64
- x = session.exchange(name: exchange_name, type: exchange_type, opts: exchange_opts)
65
- q = session.queue(queue_name, queue_opts)
66
- q.bind(x, routing_key: routing_key)
67
-
68
- # 📊 LOGGING DE OBSERVABILIDAD: Calculamos las opciones finales para mostrarlas en consola
69
- final_x_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS
70
- .merge(BugBunny.configuration.exchange_options || {})
71
- .merge(exchange_opts || {})
72
- final_q_opts = BugBunny::Session::DEFAULT_QUEUE_OPTIONS
73
- .merge(BugBunny.configuration.queue_options || {})
74
- .merge(queue_opts || {})
75
-
76
- BugBunny.configuration.logger.info("[BugBunny::Consumer] 🎧 Listening on '#{queue_name}' (Opts: #{final_q_opts})")
77
- BugBunny.configuration.logger.info("[BugBunny::Consumer] 🔀 Bounded to Exchange '#{exchange_name}' (#{exchange_type}) | Opts: #{final_x_opts} | RK: '#{routing_key}'")
78
-
79
- start_health_check(queue_name)
80
-
81
- q.subscribe(manual_ack: true, block: block) do |delivery_info, properties, body|
82
- trace_id = properties.correlation_id
83
-
84
- logger = BugBunny.configuration.logger
85
-
86
- if logger.respond_to?(:tagged)
87
- logger.tagged(trace_id) { process_message(delivery_info, properties, body) }
88
- elsif defined?(Rails) && Rails.logger.respond_to?(:tagged)
89
- Rails.logger.tagged(trace_id) { process_message(delivery_info, properties, body) }
90
- else
91
- process_message(delivery_info, properties, body)
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.execute
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).
@@ -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
- parsed = begin
249
- JSON.parse(body)
250
- rescue JSON::ParserError
251
- nil
252
- end
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.map do |tag|
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)
@@ -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); @target = target; @keys = keys; @old_values = old_values; end
159
- def method_missing(method, *args, &block); @target.public_send(method, *args, &block); ensure; @keys.each { |k, v| ::Thread.current[v] = @old_values[k] }; end
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.
@@ -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
- def initialize(connection)
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
- # Configuraciones globales de BugBunny
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = "4.1.0"
4
+ VERSION = "4.1.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bug_bunny
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.0
4
+ version: 4.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - gabix