bug_bunny 4.11.1 → 4.13.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: fc50591b1ca19df77acdb6233edef462736da631e6e81b9fa24f203ac3c67b39
4
- data.tar.gz: 5abcaec4329fcda360d7b88854e249b9582ca2399203fef0e7edf2eefdde3dbc
3
+ metadata.gz: d1bcda002a00d8ebc93786a496f8649d43e82d58645aa2b77e3d9b72044e7e07
4
+ data.tar.gz: b49f4c724ae39592d6c1a1774873fc8b272cf853688d998cbfb2f22d721d3589
5
5
  SHA512:
6
- metadata.gz: 29a3b9cc5385e33b3755224af8829d68f6b7a1cad08dcdeed79375004202827c7ae3c314141a39581076fb77f8d94b26a77777ca2d5c5f3f91eb91bd2460f7b9
7
- data.tar.gz: 6e3ee7a4845f5ce8c3e37f1ee0624a84693692ef348b0836ae800088a1307e2ef91bfaf985be4d6a9ad28422400151316f1882c3da564aa210b3d244d659751c
6
+ metadata.gz: 3b16b769d198b82f5455294bfca2c9c157f6dc7cb34a4cceedf9441d11677035a51c97b1d29e7cb59994d7d954072062919c24958ac340d9da018dded6c9e199
7
+ data.tar.gz: fdea4a6b6d49dd032787c520e5516045812d94afa5ba4dc8a6ff818e7e89bc6263f92877266e82a40a3789907a2aafd9727ecd2770949511b3e8584915cc87ab
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.13.0] - 2026-05-11
4
+
5
+ ### Nuevas funcionalidades
6
+ - **NACK explícito como excepción en modo `:confirmed` (#37):** `Producer#confirmed` ahora levanta `BugBunny::PublishNacked` cuando el broker NACKea la publicación, en lugar de retornar 202 silenciosamente. La excepción expone `path` y `nacked_count` para que callers críticos (auditoría, billing, RADIUS accounting) puedan escalar a HTTP 5xx y permitir retries upstream. Configurable globalmente vía `BugBunny.configuration.nack_raise` (default `true`) y por request via `client.publish(..., nack_raise: false)`. El evento `producer.confirms_nacked` se sigue logueando para observabilidad. — @Gabriel
7
+
8
+ ### Cambios de comportamiento (semi-breaking)
9
+ - **Default `nack_raise: true`:** Publicaciones con `confirmed: true` que reciben NACK del broker ahora levantan excepción por default. En 4.12.0, el NACK se logueaba pero retornaba 202 igualmente — comportamiento que ocultaba pérdida de mensajes desde la perspectiva del publisher. Para mantener el comportamiento previo: `BugBunny.configuration.nack_raise = false` o `nack_raise: false` per request.
10
+
11
+ ## [4.12.0] - 2026-05-11
12
+
13
+ ### Nuevas funcionalidades
14
+ - **`:confirmed` delivery mode con Publisher Confirms (#36):** `Client#publish(..., confirmed: true)` activa Publisher Confirms síncronos — bloquea hasta que el broker confirme la recepción del mensaje. Soporta `mandatory: true` con callback `BugBunny.configuration.on_return` para mensajes no ruteables, `confirm_timeout` opcional (vía `Concurrent::IVar` ya que Bunny 2.24 no soporta timeout nativo en `wait_for_confirms`) y logging de NACKs. Útil para eventos críticos (auditoría, billing) sin el overhead de un RPC completo. — @Gabriel
15
+
16
+ ### Correcciones
17
+ - **`on_return` registrado sobre Exchange, no Channel:** El handler `basic.return` se registra ahora vía `Bunny::Exchange#on_return` (la API real de Bunny 2.24) en lugar de `Bunny::Channel#on_return`, que no existe y rompía la creación del canal con `NoMethodError: undefined method 'on_return' for an instance of Bunny::Channel`. Cada exchange se configura una sola vez por nombre; el set se resetea al recrear el canal. — @Gabriel
18
+
3
19
  ## [4.11.1] - 2026-04-09
4
20
 
5
21
  ### Correcciones
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  RESTful messaging over RabbitMQ for Ruby microservices.
6
6
 
7
- BugBunny maps AMQP messages to controllers, routes, and models using the same patterns as Rails. Services communicate through RabbitMQ without HTTP coupling, with full support for synchronous RPC and fire-and-forget publishing.
7
+ BugBunny maps AMQP messages to controllers, routes, and models using the same patterns as Rails. Services communicate through RabbitMQ without HTTP coupling, with full support for synchronous RPC, fire-and-forget publishing, and sync publisher confirms for delivery-critical events.
8
8
 
9
9
  ---
10
10
 
@@ -141,6 +141,13 @@ BugBunny.configure do |config|
141
141
 
142
142
  # Health check file for Kubernetes / Docker Swarm liveness probes
143
143
  config.health_check_file = '/tmp/bug_bunny_health'
144
+
145
+ # Callback invoked when the broker returns an unroutable mandatory message.
146
+ # When nil (default), BugBunny logs the return as `session.broker_return` at :warn.
147
+ # Signature: ->(return_info, properties, body) { ... }
148
+ config.on_return = ->(return_info, _props, body) {
149
+ MyAlerts.publish_unroutable(rk: return_info.routing_key, body: body)
150
+ }
144
151
  end
145
152
  ```
146
153
 
@@ -193,6 +200,32 @@ client.publish('events', body: { type: 'user.signed_in', user_id: 42 })
193
200
  client.request('users', method: :get, params: { role: 'admin', page: 2 })
194
201
  ```
195
202
 
203
+ ### Publisher Confirms (delivery-critical events)
204
+
205
+ For events where you need a delivery guarantee from the broker (auditing, billing, accounting) without the cost of a full RPC, use `publish` with `confirmed: true`. The call blocks until the broker acknowledges receipt:
206
+
207
+ ```ruby
208
+ client.publish('acct.start',
209
+ exchange: 'acct_events',
210
+ exchange_type: 'topic',
211
+ body: { tenant_id: 42, plan: 'pro' },
212
+ confirmed: true,
213
+ mandatory: true, # broker returns the message if no queue is bound
214
+ confirm_timeout: 0.5) # seconds; nil waits forever
215
+ # => { 'status' => 202, 'body' => nil } # broker confirmed
216
+ ```
217
+
218
+ | Option | Type | Default | Purpose |
219
+ |---|---|---|---|
220
+ | `confirmed` | Boolean | `false` | Block until `wait_for_confirms` returns. |
221
+ | `mandatory` | Boolean | `false` | Broker returns the message if it cannot be routed to any queue. Requires `confirmed: true` to be useful. |
222
+ | `confirm_timeout` | Float | `nil` | Seconds to wait for the broker ACK. Raises `BugBunny::RequestTimeout` if exceeded. |
223
+ | `nack_raise` | Boolean | `nil` | Per-request override of `config.nack_raise`. When `nil`, falls back to the global flag. |
224
+
225
+ If the broker NACKs the publish (explicit rejection — disk full, internal confirm policy, etc.), the call raises `BugBunny::PublishNacked` by default. The exception exposes `path` and `nacked_count`. Critical publishers (audit, billing, RADIUS accounting) can let it bubble up to translate into HTTP 5xx so upstream systems retry. To restore the legacy "log-only" behaviour, set `BugBunny.configuration.nack_raise = false` globally or pass `nack_raise: false` per request.
226
+
227
+ Unroutable returned messages are handled by a single global callback (see `config.on_return` below). The default handler logs them as `session.broker_return` at `warn` level — nothing is dropped silently.
228
+
196
229
  ---
197
230
 
198
231
  ## Consumer Middleware
@@ -14,6 +14,10 @@ module BugBunny
14
14
  #
15
15
  # @example Publicación Fire-and-Forget (POST)
16
16
  # client.publish('logs', method: :post, body: { msg: 'Error' })
17
+ #
18
+ # @example Publicación con Publisher Confirms sincrónicos
19
+ # client.publish('acct.start', exchange: 'acct_x', body: payload,
20
+ # confirmed: true, mandatory: true, confirm_timeout: 0.5)
17
21
  class Client
18
22
  # @return [ConnectionPool] El pool de conexiones subyacente a RabbitMQ.
19
23
  attr_reader :pool
@@ -21,9 +25,15 @@ module BugBunny
21
25
  # @return [BugBunny::Middleware::Stack] La pila de middlewares configurada.
22
26
  attr_reader :stack
23
27
 
24
- # @return [Symbol] El modo de entrega por defecto para este cliente (:rpc o :publish).
28
+ # @return [Symbol] El modo de entrega por defecto para este cliente (:rpc, :publish o :confirmed).
25
29
  attr_accessor :delivery_mode
26
30
 
31
+ # Argumentos del cliente que se mapean 1:1 a setters del Request.
32
+ REQUEST_ATTRS = %i[
33
+ delivery_mode method body exchange exchange_type routing_key
34
+ timeout exchange_options queue_options params
35
+ ].freeze
36
+
27
37
  # Inicializa un nuevo cliente.
28
38
  #
29
39
  # @param pool [ConnectionPool] Pool de conexiones a RabbitMQ configurado previamente.
@@ -71,15 +81,27 @@ module BugBunny
71
81
  end
72
82
  end
73
83
 
74
- # Realiza una publicación Asíncrona (Fire-and-Forget).
84
+ # Realiza una publicación Fire-and-Forget. Por default es asíncrono (no espera confirmación).
85
+ #
86
+ # Pasando `confirmed: true` activa Publisher Confirms síncronos: el método bloquea hasta
87
+ # que el broker confirme la recepción del mensaje. Útil para eventos críticos (auditoría,
88
+ # billing) donde se requiere garantía de entrega sin el overhead de un RPC completo.
75
89
  #
76
90
  # @param url [String] La ruta del evento/recurso.
77
- # @param args [Hash] Mismas opciones que {#request}, excepto `:timeout`.
91
+ # @param args [Hash] Mismas opciones que {#request}, excepto `:timeout`. Adicionales:
92
+ # @option args [Boolean] :confirmed Si `true`, espera `wait_for_confirms` del broker.
93
+ # @option args [Boolean] :mandatory Si `true`, el broker retorna el mensaje si no es ruteable.
94
+ # Para procesar retornos, configurar {BugBunny.configuration.on_return}.
95
+ # @option args [Float] :confirm_timeout Segundos a esperar el confirm. `nil` espera indefinidamente.
96
+ # @option args [Boolean] :nack_raise Override per-request del flag
97
+ # `BugBunny.configuration.nack_raise`. Si `nil` (default), se usa la configuración global.
78
98
  # @yield [req] Bloque para configurar el objeto Request.
79
- # @return [void]
99
+ # @return [Hash] `{ 'status' => 202, 'body' => nil }`.
100
+ # @raise [BugBunny::RequestTimeout] Si `confirmed: true` y el broker no confirma a tiempo.
101
+ # @raise [BugBunny::PublishNacked] Si `confirmed: true`, el broker NACKea, y `nack_raise` resuelto es true.
80
102
  def publish(url, **args)
81
103
  send(url, **args) do |req|
82
- req.delivery_mode = :publish
104
+ req.delivery_mode = args[:confirmed] ? :confirmed : :publish
83
105
  yield req if block_given?
84
106
  end
85
107
  end
@@ -93,47 +115,72 @@ module BugBunny
93
115
  # @param args [Hash] Argumentos pasados a los métodos públicos.
94
116
  # @yield [req] Bloque para configuración adicional del Request.
95
117
  def run_in_pool(url, args)
96
- # 1. Builder del Request
97
- req = BugBunny::Request.new(url)
98
-
99
- # 2. Syntactic Sugar: Mapeo de argumentos a atributos del Request
100
- req.delivery_mode = delivery_mode # Default del cliente
101
- req.delivery_mode = args[:delivery_mode] if args[:delivery_mode]
102
- req.method = args[:method] if args[:method]
103
- req.body = args[:body] if args[:body]
104
- req.exchange = args[:exchange] if args[:exchange]
105
- req.exchange_type = args[:exchange_type] if args[:exchange_type]
106
- req.routing_key = args[:routing_key] if args[:routing_key]
107
- req.timeout = args[:timeout] if args[:timeout]
108
-
109
- # Inyección de opciones de infraestructura (Nivel 3 de la cascada)
110
- req.exchange_options = args[:exchange_options] if args[:exchange_options]
111
- req.queue_options = args[:queue_options] if args[:queue_options]
112
-
113
- req.params = args[:params] if args[:params]
114
- req.headers.merge!(args[:headers]) if args[:headers]
118
+ req = build_request(url, args)
115
119
 
116
- # 3. Configuración del usuario (bloque específico por request)
120
+ # Configuración del usuario (bloque específico por request)
117
121
  yield req if block_given?
118
122
 
119
- # 4. Ejecución dentro del Pool
123
+ # Ejecución dentro del Pool.
120
124
  # Session y Producer se reutilizan por slot de conexión (ver #session_for / #producer_for).
121
125
  @pool.with do |conn|
122
126
  session = session_for(conn)
123
127
  producer = producer_for(conn, session)
124
128
 
125
- # Mapeo de delivery_mode al método del productor (:rpc o :fire)
126
- method_name = req.delivery_mode == :publish ? :fire : :rpc
127
-
128
129
  # Onion Architecture: La acción final es llamar al Producer real.
129
- final_action = ->(env) { producer.send(method_name, env) }
130
+ final_action = ->(env) { producer.send(producer_method_for(req.delivery_mode), env) }
131
+
132
+ @stack.build(final_action).call(req)
133
+ end
134
+ end
135
+
136
+ # Construye y completa un Request a partir de los argumentos del usuario.
137
+ #
138
+ # @param url [String] La ruta destino.
139
+ # @param args [Hash] Argumentos pasados a los métodos públicos.
140
+ # @return [BugBunny::Request] Request listo para entrar al stack de middlewares.
141
+ def build_request(url, args)
142
+ req = BugBunny::Request.new(url)
143
+ req.delivery_mode = delivery_mode # Default del cliente
144
+ apply_args(req, args)
145
+ apply_publisher_confirms_args(req, args)
146
+ req
147
+ end
148
+
149
+ # Mapea los argumentos generales (no específicos de Publisher Confirms) sobre el Request.
150
+ #
151
+ # @param req [BugBunny::Request]
152
+ # @param args [Hash]
153
+ # @return [void]
154
+ def apply_args(req, args)
155
+ REQUEST_ATTRS.each do |key|
156
+ req.public_send("#{key}=", args[key]) if args[key]
157
+ end
158
+ req.headers.merge!(args[:headers]) if args[:headers]
159
+ end
130
160
 
131
- # Construimos y ejecutamos la cadena de middlewares
132
- app = @stack.build(final_action)
133
- app.call(req)
161
+ # Mapea un delivery_mode al nombre del método correspondiente en el Producer.
162
+ #
163
+ # @param mode [Symbol] :rpc, :publish o :confirmed.
164
+ # @return [Symbol] :rpc, :fire o :confirmed.
165
+ def producer_method_for(mode)
166
+ case mode
167
+ when :publish then :fire
168
+ when :confirmed then :confirmed
169
+ else :rpc
134
170
  end
135
171
  end
136
172
 
173
+ # Aplica los argumentos específicos del modo :confirmed sobre el Request.
174
+ #
175
+ # @param req [BugBunny::Request]
176
+ # @param args [Hash] Argumentos originales pasados al cliente.
177
+ # @return [void]
178
+ def apply_publisher_confirms_args(req, args)
179
+ req.mandatory = args[:mandatory] if args.key?(:mandatory)
180
+ req.confirm_timeout = args[:confirm_timeout] if args.key?(:confirm_timeout)
181
+ req.nack_raise = args[:nack_raise] if args.key?(:nack_raise)
182
+ end
183
+
137
184
  # Recupera o crea la Session asociada al slot de conexión dado.
138
185
  #
139
186
  # La Session (y su canal AMQP) se almacena como ivar en el objeto `conn`.
@@ -135,6 +135,33 @@ module BugBunny
135
135
  # config.on_rpc_reply = ->(headers) { ExisRay::Tracer.hydrate(headers['X-Amzn-Trace-Id']) }
136
136
  attr_accessor :on_rpc_reply
137
137
 
138
+ # @return [Proc, nil] Callback invocado cuando el broker retorna un mensaje publicado con
139
+ # `mandatory: true` que no pudo ser ruteado a ninguna cola. Si es `nil`, BugBunny logea
140
+ # el evento como `session.broker_return` con nivel `:warn` por default.
141
+ #
142
+ # Firma: `->(return_info, properties, body) { ... }` donde:
143
+ # - `return_info` es `Bunny::ReturnInfo` (reply_code, reply_text, exchange, routing_key)
144
+ # - `properties` es `Bunny::MessageProperties`
145
+ # - `body` es el payload crudo como `String`
146
+ #
147
+ # El callback se ejecuta en el hilo del consumidor interno de Bunny — debe ser rápido
148
+ # y no lanzar excepciones (BugBunny las captura, pero degradan visibilidad).
149
+ #
150
+ # @example
151
+ # config.on_return = ->(ri, _props, body) {
152
+ # MyAlerts.publish_unroutable(rk: ri.routing_key, body: body)
153
+ # }
154
+ attr_accessor :on_return
155
+
156
+ # @return [Boolean] Si `true` (default), {BugBunny::Producer#confirmed} levanta
157
+ # {BugBunny::PublishNacked} cuando el broker NACKea la publicación. Si `false`,
158
+ # el NACK solo se logea como `producer.confirms_nacked` y la llamada retorna
159
+ # `{ 'status' => 202 }` (modo legacy).
160
+ #
161
+ # El valor puede sobreescribirse por request pasando `nack_raise:` en
162
+ # `Client#publish`.
163
+ attr_accessor :nack_raise
164
+
138
165
  # @!endgroup
139
166
 
140
167
  # Inicializa la configuración con valores por defecto seguros.
@@ -176,8 +203,7 @@ module BugBunny
176
203
  @queue_options = {}
177
204
 
178
205
  @consumer_middlewares = ConsumerMiddleware::Stack.new
179
- @rpc_reply_headers = nil
180
- @on_rpc_reply = nil
206
+ init_callback_defaults
181
207
  end
182
208
 
183
209
  # Construye la URL de conexión AMQP basada en los atributos configurados.
@@ -204,6 +230,17 @@ module BugBunny
204
230
 
205
231
  private
206
232
 
233
+ # Defaults para callbacks y flags relacionados con publish/RPC.
234
+ # Extraído de {#initialize} para mantener el ABC size dentro de los límites.
235
+ #
236
+ # @return [void]
237
+ def init_callback_defaults
238
+ @rpc_reply_headers = nil
239
+ @on_rpc_reply = nil
240
+ @on_return = nil
241
+ @nack_raise = true
242
+ end
243
+
207
244
  def validate_required!(attr, value, rules)
208
245
  return unless rules[:required]
209
246
  return unless value.nil? || (value.is_a?(String) && value.empty?)
@@ -19,6 +19,37 @@ module BugBunny
19
19
  # Protege contra vulnerabilidades de RCE validando la herencia de las clases enrutadas.
20
20
  class SecurityError < Error; end
21
21
 
22
+ # Error lanzado cuando el broker responde NACK a una publicación en modo `:confirmed`.
23
+ #
24
+ # Un NACK significa que el broker rechazó explícitamente el mensaje (ej: política de
25
+ # confirms interna, disk full, replicación insuficiente). El mensaje no fue aceptado
26
+ # y se considera no entregado — equivalente a un fallo de transporte desde la
27
+ # perspectiva del publisher.
28
+ #
29
+ # Se levanta por default desde {BugBunny::Producer#confirmed}. Para opt-out,
30
+ # configurar `BugBunny.configuration.nack_raise = false` o pasar
31
+ # `nack_raise: false` por request.
32
+ #
33
+ # @example
34
+ # rescue BugBunny::PublishNacked => e
35
+ # e.path # => 'acct.start'
36
+ # e.nacked_count # => 1
37
+ class PublishNacked < Error
38
+ # @return [String] La ruta del request cuyo publish fue NACKeado.
39
+ attr_reader :path
40
+
41
+ # @return [Integer] Cantidad de mensajes NACKeados según `Bunny::Channel#nacked_set`.
42
+ attr_reader :nacked_count
43
+
44
+ # @param path [String] Ruta lógica del request (ej: 'acct.start').
45
+ # @param nacked_count [Integer] Cantidad de NACKs reportados por el broker.
46
+ def initialize(path:, nacked_count:)
47
+ @path = path
48
+ @nacked_count = nacked_count
49
+ super("broker NACK on path=#{path} (nacked=#{nacked_count})")
50
+ end
51
+ end
52
+
22
53
  # ==========================================
23
54
  # Categoría: Errores del Cliente (4xx)
24
55
  # ==========================================
@@ -38,24 +38,38 @@ module BugBunny
38
38
  # @param request [BugBunny::Request] Objeto con la configuración del envío (body, exchange_options, etc).
39
39
  # @return [Hash] Un hash de éxito simbólico ({ 'status' => 202 }).
40
40
  def fire(request)
41
- # Obtenemos el exchange pasando las opciones específicas del request para la fusión en cascada
42
- x = @session.exchange(
43
- name: request.exchange,
44
- type: request.exchange_type,
45
- opts: request.exchange_options
46
- )
47
-
48
- payload = serialize_message(request.body)
49
- opts = request.amqp_options
50
-
51
- log_request(request, payload)
52
-
53
- x.publish(payload, opts.merge(routing_key: request.final_routing_key))
54
-
41
+ publish_message(request)
55
42
  # Devolvemos un hash para evitar NoMethodError en el cliente (que espera una respuesta tipo Hash)
56
43
  { 'status' => 202, 'body' => nil }
57
44
  end
58
45
 
46
+ # Envía un mensaje con Publisher Confirms síncronos (Fire-and-Forget confirmado).
47
+ #
48
+ # Publica el mensaje y bloquea el hilo actual hasta que el broker confirme su recepción
49
+ # vía `wait_for_confirms`. Soporta `mandatory: true` con callback `on_return` para
50
+ # mensajes que no pudieron rutearse.
51
+ #
52
+ # A diferencia de {#rpc} (que espera la respuesta de un Consumer remoto), aquí solo se
53
+ # espera el ACK del propio broker — no hay round-trip al servicio destino.
54
+ #
55
+ # @param request [BugBunny::Request] Request con `mandatory`, `confirm_timeout`, `nack_raise`
56
+ # y/o `on_return` opcionales.
57
+ # @return [Hash] `{ 'status' => 202, 'body' => nil }` si el broker confirmó la recepción.
58
+ # @raise [BugBunny::RequestTimeout] Si el broker no confirma dentro de `confirm_timeout` segundos.
59
+ # @raise [BugBunny::PublishNacked] Si el broker NACKea la publicación y `nack_raise` está activo
60
+ # (default `true` — ver {BugBunny::Configuration#nack_raise}).
61
+ # @raise [BugBunny::CommunicationError] Si el canal AMQP falla durante la publicación o confirm.
62
+ def confirmed(request)
63
+ publish_message(request)
64
+ acked = wait_for_confirms!(request)
65
+ handle_confirm_result(request, acked)
66
+ { 'status' => 202, 'body' => nil }
67
+ rescue BugBunny::Error
68
+ raise
69
+ rescue StandardError => e
70
+ raise BugBunny::CommunicationError, "Publisher confirms failed: #{e.message}"
71
+ end
72
+
59
73
  # Envía un mensaje y bloquea el hilo actual esperando una respuesta (RPC).
60
74
  #
61
75
  # Implementa el mecanismo "Direct Reply-to" de RabbitMQ (`amq.rabbitmq.reply-to`).
@@ -102,6 +116,87 @@ module BugBunny
102
116
 
103
117
  private
104
118
 
119
+ # Resuelve exchange, serializa payload, logea y publica el mensaje.
120
+ # Compartido por {#fire} y {#confirmed}.
121
+ #
122
+ # @param request [BugBunny::Request]
123
+ # @return [void]
124
+ def publish_message(request)
125
+ x = @session.exchange(
126
+ name: request.exchange,
127
+ type: request.exchange_type,
128
+ opts: request.exchange_options
129
+ )
130
+ payload = serialize_message(request.body)
131
+ log_request(request, payload)
132
+ x.publish(payload, request.amqp_options.merge(routing_key: request.final_routing_key))
133
+ end
134
+
135
+ # Espera la confirmación del broker con timeout opcional.
136
+ # Bunny 2.24 no soporta timeout nativo en `wait_for_confirms`, así que delegamos
137
+ # la espera a un hilo auxiliar y usamos `Concurrent::IVar#value(timeout)` como reloj.
138
+ #
139
+ # @param request [BugBunny::Request]
140
+ # @raise [BugBunny::RequestTimeout] Si el broker no confirma a tiempo.
141
+ # @return [Boolean] true si todas las confirmaciones fueron positivas.
142
+ def wait_for_confirms!(request)
143
+ timeout = request.confirm_timeout
144
+ return @session.channel.wait_for_confirms if timeout.nil?
145
+
146
+ ivar = Concurrent::IVar.new
147
+ Thread.new do
148
+ ivar.set(@session.channel.wait_for_confirms)
149
+ rescue StandardError => e
150
+ ivar.fail(e)
151
+ end
152
+
153
+ result = ivar.value(timeout)
154
+ return result if ivar.complete?
155
+
156
+ raise BugBunny::RequestTimeout,
157
+ "Timeout (#{timeout}s) waiting for publisher confirms: #{request.path}"
158
+ end
159
+
160
+ # Procesa el resultado de `wait_for_confirms`.
161
+ #
162
+ # Si el broker NACKeó (`acked == false`), logea el evento `producer.confirms_nacked`
163
+ # y opcionalmente levanta {BugBunny::PublishNacked} según el flag `nack_raise`
164
+ # resuelto desde el request o la configuración global.
165
+ #
166
+ # @param request [BugBunny::Request]
167
+ # @param acked [Boolean] Resultado de `wait_for_confirms` (true = todos los confirms positivos).
168
+ # @raise [BugBunny::PublishNacked] Si hay NACK y `nack_raise?` es true.
169
+ # @return [void]
170
+ def handle_confirm_result(request, acked)
171
+ return if acked
172
+
173
+ count = nacked_count
174
+ safe_log(:warn, 'producer.confirms_nacked', count: count, path: request.path)
175
+ return unless nack_raise?(request)
176
+
177
+ raise BugBunny::PublishNacked.new(path: request.path, nacked_count: count)
178
+ end
179
+
180
+ # Cuenta los delivery tags NACKeados reportados por el canal.
181
+ #
182
+ # @return [Integer]
183
+ def nacked_count
184
+ ch = @session.channel
185
+ return 0 unless ch.respond_to?(:nacked_set)
186
+
187
+ ch.nacked_set&.size || 0
188
+ end
189
+
190
+ # Resuelve el flag `nack_raise` con prioridad request > configuración global.
191
+ #
192
+ # @param request [BugBunny::Request]
193
+ # @return [Boolean]
194
+ def nack_raise?(request)
195
+ return request.nack_raise unless request.nack_raise.nil?
196
+
197
+ BugBunny.configuration.nack_raise
198
+ end
199
+
105
200
  # Registra la petición en el log calculando las opciones de infraestructura.
106
201
  #
107
202
  # @param request [BugBunny::Request] Objeto Request que se está enviando.
@@ -19,9 +19,16 @@ module BugBunny
19
19
  # @attr routing_key [String] La routing key específica. Si es nil, se usará {#path}.
20
20
  # @attr timeout [Integer] Tiempo máximo en segundos para timeout RPC.
21
21
  #
22
- # @attr delivery_mode [Symbol] El modo de entrega (:rpc o :publish).
22
+ # @attr delivery_mode [Symbol] El modo de entrega (:rpc, :publish o :confirmed).
23
23
  # @attr exchange_options [Hash] Opciones específicas para la declaración del Exchange en esta petición.
24
24
  # @attr queue_options [Hash] Opciones específicas para la declaración de la Cola en esta petición.
25
+ # @attr mandatory [Boolean] Si `true`, el mensaje debe ser ruteable o el broker lo retorna.
26
+ # Solo aplica en modo :confirmed. El handler de retornos se configura globalmente
27
+ # vía {BugBunny.configuration.on_return}.
28
+ # @attr confirm_timeout [Float, nil] Segundos máximos a esperar el `wait_for_confirms`.
29
+ # `nil` espera indefinidamente.
30
+ # @attr nack_raise [Boolean, nil] Override per-request del flag global
31
+ # `BugBunny.configuration.nack_raise`. `nil` (default) delega a la configuración global.
25
32
  class Request
26
33
  attr_accessor :body, :headers, :params, :path, :method, :exchange, :exchange_type, :routing_key, :timeout,
27
34
  :delivery_mode, :queue_options
@@ -29,6 +36,9 @@ module BugBunny
29
36
  # Configuración de Infraestructura Específica
30
37
  attr_accessor :exchange_options
31
38
 
39
+ # Publisher Confirms (delivery_mode = :confirmed)
40
+ attr_accessor :mandatory, :confirm_timeout, :nack_raise
41
+
32
42
  # Metadatos AMQP Estándar
33
43
  attr_accessor :app_id, :content_type, :content_encoding, :priority,
34
44
  :timestamp, :expiration, :persistent, :reply_to,
@@ -51,6 +61,11 @@ module BugBunny
51
61
  # Inicialización de opciones de infraestructura para evitar errores de nil durante el merge.
52
62
  @exchange_options = {}
53
63
  @queue_options = {}
64
+
65
+ # Defaults para Publisher Confirms (modo :confirmed)
66
+ @mandatory = false
67
+ @confirm_timeout = nil
68
+ @nack_raise = nil
54
69
  end
55
70
 
56
71
  # Combina el path con los params como query string.
@@ -114,7 +129,8 @@ module BugBunny
114
129
  persistent: persistent,
115
130
  headers: final_headers,
116
131
  reply_to: reply_to,
117
- correlation_id: correlation_id
132
+ correlation_id: correlation_id,
133
+ mandatory: (true if mandatory)
118
134
  }.compact
119
135
  end
120
136
  end
@@ -35,6 +35,7 @@ module BugBunny
35
35
  @channel = nil
36
36
  @channel_mutex = Mutex.new
37
37
  @logger = BugBunny.configuration.logger
38
+ @configured_returns = {}
38
39
  end
39
40
 
40
41
  # Obtiene el canal actual o crea uno nuevo si es necesario.
@@ -80,7 +81,9 @@ module BugBunny
80
81
  .merge(opts)
81
82
 
82
83
  # public_send permite llamar a :topic, :direct, etc. dinámicamente según el tipo
83
- channel.public_send(type.to_s, name.to_s, merged_opts)
84
+ x = channel.public_send(type.to_s, name.to_s, merged_opts)
85
+ register_on_return!(x) if @publisher_confirms
86
+ x
84
87
  end
85
88
 
86
89
  # Factory method para declarar o recuperar una Cola aplicando la cascada de configuración.
@@ -108,6 +111,7 @@ module BugBunny
108
111
  @channel_mutex.synchronize do
109
112
  @channel&.close if @channel&.open?
110
113
  @channel = nil
114
+ @configured_returns.clear
111
115
  end
112
116
  end
113
117
 
@@ -119,6 +123,7 @@ module BugBunny
119
123
  # @raise [BugBunny::CommunicationError] Si falla la creación del canal.
120
124
  def create_channel!
121
125
  @channel = @connection.create_channel
126
+ @configured_returns.clear
122
127
 
123
128
  @channel.confirm_select if @publisher_confirms
124
129
 
@@ -127,6 +132,50 @@ module BugBunny
127
132
  raise BugBunny::CommunicationError, "Failed to create channel: #{e.message}"
128
133
  end
129
134
 
135
+ # Registra el handler `basic.return` sobre el `Bunny::Exchange` indicado.
136
+ #
137
+ # Bunny dispatcha `basic.return` por exchange (no por canal): el callback se setea
138
+ # con `Exchange#on_return`. Como cada `Session#exchange` resuelve la misma instancia
139
+ # cacheada en el canal, registramos una sola vez por nombre.
140
+ #
141
+ # - Si `BugBunny.configuration.on_return` está definido, lo invoca.
142
+ # - Sino, logea el retorno como `session.broker_return` con nivel `:warn`.
143
+ #
144
+ # @param exchange [Bunny::Exchange] Exchange recién resuelto vía cascada.
145
+ # @return [void]
146
+ def register_on_return!(exchange)
147
+ key = exchange.name.to_s
148
+ return if key.empty? || @configured_returns[key]
149
+
150
+ exchange.on_return do |return_info, properties, body|
151
+ handle_broker_return(return_info, properties, body)
152
+ end
153
+ @configured_returns[key] = true
154
+ end
155
+
156
+ # Procesa un evento `basic.return` del broker. Nunca propaga excepciones del callback
157
+ # de usuario para no romper el hilo de I/O de Bunny.
158
+ #
159
+ # @param return_info [Bunny::ReturnInfo]
160
+ # @param properties [Bunny::MessageProperties]
161
+ # @param body [String]
162
+ # @return [void]
163
+ def handle_broker_return(return_info, properties, body)
164
+ user_cb = BugBunny.configuration.on_return
165
+ if user_cb
166
+ user_cb.call(return_info, properties, body)
167
+ else
168
+ safe_log(:warn, 'session.broker_return',
169
+ reply_code: return_info.reply_code,
170
+ reply_text: return_info.reply_text,
171
+ exchange: return_info.exchange,
172
+ routing_key: return_info.routing_key,
173
+ body_size: body.respond_to?(:bytesize) ? body.bytesize : nil)
174
+ end
175
+ rescue StandardError => e
176
+ safe_log(:error, 'session.on_return_failed', **exception_metadata(e))
177
+ end
178
+
130
179
  # Garantiza que la conexión TCP esté abierta.
131
180
  # Si está cerrada, intenta reconectarla (Reconexión Transparente).
132
181
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = '4.11.1'
4
+ VERSION = '4.13.0'
5
5
  end