bug_bunny 4.11.1 → 4.12.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: 0e38ff38727e34da253f669937054e58f4bda0d5a42ce270cc9ac1e7853d8feb
4
+ data.tar.gz: 47b090304aaf7743addd2136c963d82ea8fd67bb82a17aad8958551047b79015
5
5
  SHA512:
6
- metadata.gz: 29a3b9cc5385e33b3755224af8829d68f6b7a1cad08dcdeed79375004202827c7ae3c314141a39581076fb77f8d94b26a77777ca2d5c5f3f91eb91bd2460f7b9
7
- data.tar.gz: 6e3ee7a4845f5ce8c3e37f1ee0624a84693692ef348b0836ae800088a1307e2ef91bfaf985be4d6a9ad28422400151316f1882c3da564aa210b3d244d659751c
6
+ metadata.gz: 1811143e41e170668c68f8a4b57721c4e9e6111145c253f62e09c5653027e75ee32d6432ac950a54b80a5e4f282ca1a64f184535935c5b1dad7f8df4bac12593
7
+ data.tar.gz: b896459d25cea7925eaa3a010aa85cd5bcd302ab6f4551eb5b6d4cad554f4478050476e7cd9a4980590343d6a65e9822b10a7f864378117778b543f665ce9eb2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.12.0] - 2026-05-11
4
+
5
+ ### Nuevas funcionalidades
6
+ - **`: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
7
+
8
+ ### Correcciones
9
+ - **`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
10
+
3
11
  ## [4.11.1] - 2026-04-09
4
12
 
5
13
  ### 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,29 @@ 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
+
224
+ 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.
225
+
196
226
  ---
197
227
 
198
228
  ## 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,24 @@ 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.
78
96
  # @yield [req] Bloque para configurar el objeto Request.
79
- # @return [void]
97
+ # @return [Hash] `{ 'status' => 202, 'body' => nil }`.
98
+ # @raise [BugBunny::RequestTimeout] Si `confirmed: true` y el broker no confirma a tiempo.
80
99
  def publish(url, **args)
81
100
  send(url, **args) do |req|
82
- req.delivery_mode = :publish
101
+ req.delivery_mode = args[:confirmed] ? :confirmed : :publish
83
102
  yield req if block_given?
84
103
  end
85
104
  end
@@ -93,47 +112,71 @@ module BugBunny
93
112
  # @param args [Hash] Argumentos pasados a los métodos públicos.
94
113
  # @yield [req] Bloque para configuración adicional del Request.
95
114
  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]
115
+ req = build_request(url, args)
115
116
 
116
- # 3. Configuración del usuario (bloque específico por request)
117
+ # Configuración del usuario (bloque específico por request)
117
118
  yield req if block_given?
118
119
 
119
- # 4. Ejecución dentro del Pool
120
+ # Ejecución dentro del Pool.
120
121
  # Session y Producer se reutilizan por slot de conexión (ver #session_for / #producer_for).
121
122
  @pool.with do |conn|
122
123
  session = session_for(conn)
123
124
  producer = producer_for(conn, session)
124
125
 
125
- # Mapeo de delivery_mode al método del productor (:rpc o :fire)
126
- method_name = req.delivery_mode == :publish ? :fire : :rpc
127
-
128
126
  # Onion Architecture: La acción final es llamar al Producer real.
129
- final_action = ->(env) { producer.send(method_name, env) }
127
+ final_action = ->(env) { producer.send(producer_method_for(req.delivery_mode), env) }
128
+
129
+ @stack.build(final_action).call(req)
130
+ end
131
+ end
132
+
133
+ # Construye y completa un Request a partir de los argumentos del usuario.
134
+ #
135
+ # @param url [String] La ruta destino.
136
+ # @param args [Hash] Argumentos pasados a los métodos públicos.
137
+ # @return [BugBunny::Request] Request listo para entrar al stack de middlewares.
138
+ def build_request(url, args)
139
+ req = BugBunny::Request.new(url)
140
+ req.delivery_mode = delivery_mode # Default del cliente
141
+ apply_args(req, args)
142
+ apply_publisher_confirms_args(req, args)
143
+ req
144
+ end
145
+
146
+ # Mapea los argumentos generales (no específicos de Publisher Confirms) sobre el Request.
147
+ #
148
+ # @param req [BugBunny::Request]
149
+ # @param args [Hash]
150
+ # @return [void]
151
+ def apply_args(req, args)
152
+ REQUEST_ATTRS.each do |key|
153
+ req.public_send("#{key}=", args[key]) if args[key]
154
+ end
155
+ req.headers.merge!(args[:headers]) if args[:headers]
156
+ end
130
157
 
131
- # Construimos y ejecutamos la cadena de middlewares
132
- app = @stack.build(final_action)
133
- app.call(req)
158
+ # Mapea un delivery_mode al nombre del método correspondiente en el Producer.
159
+ #
160
+ # @param mode [Symbol] :rpc, :publish o :confirmed.
161
+ # @return [Symbol] :rpc, :fire o :confirmed.
162
+ def producer_method_for(mode)
163
+ case mode
164
+ when :publish then :fire
165
+ when :confirmed then :confirmed
166
+ else :rpc
134
167
  end
135
168
  end
136
169
 
170
+ # Aplica los argumentos específicos del modo :confirmed sobre el Request.
171
+ #
172
+ # @param req [BugBunny::Request]
173
+ # @param args [Hash] Argumentos originales pasados al cliente.
174
+ # @return [void]
175
+ def apply_publisher_confirms_args(req, args)
176
+ req.mandatory = args[:mandatory] if args.key?(:mandatory)
177
+ req.confirm_timeout = args[:confirm_timeout] if args.key?(:confirm_timeout)
178
+ end
179
+
137
180
  # Recupera o crea la Session asociada al slot de conexión dado.
138
181
  #
139
182
  # La Session (y su canal AMQP) se almacena como ivar en el objeto `conn`.
@@ -135,6 +135,24 @@ 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
+
138
156
  # @!endgroup
139
157
 
140
158
  # Inicializa la configuración con valores por defecto seguros.
@@ -178,6 +196,7 @@ module BugBunny
178
196
  @consumer_middlewares = ConsumerMiddleware::Stack.new
179
197
  @rpc_reply_headers = nil
180
198
  @on_rpc_reply = nil
199
+ @on_return = nil
181
200
  end
182
201
 
183
202
  # Construye la URL de conexión AMQP basada en los atributos configurados.
@@ -38,24 +38,35 @@ 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` y/o `on_return` opcionales.
56
+ # @return [Hash] `{ 'status' => 202, 'body' => nil }` si el broker confirmó la recepción.
57
+ # @raise [BugBunny::RequestTimeout] Si el broker no confirma dentro de `confirm_timeout` segundos.
58
+ # @raise [BugBunny::CommunicationError] Si el canal AMQP falla durante la publicación o confirm.
59
+ def confirmed(request)
60
+ publish_message(request)
61
+ wait_for_confirms!(request)
62
+ log_nacks_if_any(request)
63
+ { 'status' => 202, 'body' => nil }
64
+ rescue BugBunny::Error
65
+ raise
66
+ rescue StandardError => e
67
+ raise BugBunny::CommunicationError, "Publisher confirms failed: #{e.message}"
68
+ end
69
+
59
70
  # Envía un mensaje y bloquea el hilo actual esperando una respuesta (RPC).
60
71
  #
61
72
  # Implementa el mecanismo "Direct Reply-to" de RabbitMQ (`amq.rabbitmq.reply-to`).
@@ -102,6 +113,63 @@ module BugBunny
102
113
 
103
114
  private
104
115
 
116
+ # Resuelve exchange, serializa payload, logea y publica el mensaje.
117
+ # Compartido por {#fire} y {#confirmed}.
118
+ #
119
+ # @param request [BugBunny::Request]
120
+ # @return [void]
121
+ def publish_message(request)
122
+ x = @session.exchange(
123
+ name: request.exchange,
124
+ type: request.exchange_type,
125
+ opts: request.exchange_options
126
+ )
127
+ payload = serialize_message(request.body)
128
+ log_request(request, payload)
129
+ x.publish(payload, request.amqp_options.merge(routing_key: request.final_routing_key))
130
+ end
131
+
132
+ # Espera la confirmación del broker con timeout opcional.
133
+ # Bunny 2.24 no soporta timeout nativo en `wait_for_confirms`, así que delegamos
134
+ # la espera a un hilo auxiliar y usamos `Concurrent::IVar#value(timeout)` como reloj.
135
+ #
136
+ # @param request [BugBunny::Request]
137
+ # @raise [BugBunny::RequestTimeout] Si el broker no confirma a tiempo.
138
+ # @return [Boolean] true si todas las confirmaciones fueron positivas.
139
+ def wait_for_confirms!(request)
140
+ timeout = request.confirm_timeout
141
+ return @session.channel.wait_for_confirms if timeout.nil?
142
+
143
+ ivar = Concurrent::IVar.new
144
+ Thread.new do
145
+ ivar.set(@session.channel.wait_for_confirms)
146
+ rescue StandardError => e
147
+ ivar.fail(e)
148
+ end
149
+
150
+ result = ivar.value(timeout)
151
+ return result if ivar.complete?
152
+
153
+ raise BugBunny::RequestTimeout,
154
+ "Timeout (#{timeout}s) waiting for publisher confirms: #{request.path}"
155
+ end
156
+
157
+ # Logea las nack-eadas del canal si las hay.
158
+ # NACK no es un error fatal: el broker rechazó rutear (ej. confirm policy interna),
159
+ # pero el mensaje no se perdió silenciosamente — queda en el set para auditoría.
160
+ #
161
+ # @param request [BugBunny::Request]
162
+ # @return [void]
163
+ def log_nacks_if_any(request)
164
+ ch = @session.channel
165
+ return unless ch.respond_to?(:nacked_set)
166
+
167
+ nacked = ch.nacked_set
168
+ return if nacked.nil? || nacked.empty?
169
+
170
+ safe_log(:warn, 'producer.confirms_nacked', count: nacked.size, path: request.path)
171
+ end
172
+
105
173
  # Registra la petición en el log calculando las opciones de infraestructura.
106
174
  #
107
175
  # @param request [BugBunny::Request] Objeto Request que se está enviando.
@@ -19,9 +19,14 @@ 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.
25
30
  class Request
26
31
  attr_accessor :body, :headers, :params, :path, :method, :exchange, :exchange_type, :routing_key, :timeout,
27
32
  :delivery_mode, :queue_options
@@ -29,6 +34,9 @@ module BugBunny
29
34
  # Configuración de Infraestructura Específica
30
35
  attr_accessor :exchange_options
31
36
 
37
+ # Publisher Confirms (delivery_mode = :confirmed)
38
+ attr_accessor :mandatory, :confirm_timeout
39
+
32
40
  # Metadatos AMQP Estándar
33
41
  attr_accessor :app_id, :content_type, :content_encoding, :priority,
34
42
  :timestamp, :expiration, :persistent, :reply_to,
@@ -51,6 +59,10 @@ module BugBunny
51
59
  # Inicialización de opciones de infraestructura para evitar errores de nil durante el merge.
52
60
  @exchange_options = {}
53
61
  @queue_options = {}
62
+
63
+ # Defaults para Publisher Confirms (modo :confirmed)
64
+ @mandatory = false
65
+ @confirm_timeout = nil
54
66
  end
55
67
 
56
68
  # Combina el path con los params como query string.
@@ -114,7 +126,8 @@ module BugBunny
114
126
  persistent: persistent,
115
127
  headers: final_headers,
116
128
  reply_to: reply_to,
117
- correlation_id: correlation_id
129
+ correlation_id: correlation_id,
130
+ mandatory: (true if mandatory)
118
131
  }.compact
119
132
  end
120
133
  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.12.0'
5
5
  end
data/skill/SKILL.md CHANGED
@@ -15,6 +15,9 @@ Skill de conocimiento completo sobre BugBunny. Consultame para cualquier pregunt
15
15
  **Session** — `BugBunny::Session` envuelve canales de Bunny con thread-safety y double-checked locking.
16
16
  **RPC** — Patrón síncrono que usa la pseudo-cola `amq.rabbitmq.reply-to` para respuestas sin crear queues temporales.
17
17
  **Fire-and-Forget** — Patrón asíncrono donde el producer publica y continúa sin esperar respuesta. Retorna `{ 'status' => 202 }`.
18
+ **Publisher Confirms** — Extensión de RabbitMQ que confirma al publisher que el broker recibió el mensaje. BugBunny lo expone como `confirmed: true` en `Client#publish`: el publish bloquea hasta `wait_for_confirms`. NACK no es fatal; se logea pero el método retorna 202.
19
+ **Mandatory** — Flag de `basic.publish` que pide al broker retornar el mensaje al publisher si no es ruteable a ninguna cola. Se procesa via `basic.return` (no es respuesta del request).
20
+ **basic.return** — Evento asincrónico que Bunny dispatcha por exchange (`Bunny::Exchange#on_return`). BugBunny registra un handler único por nombre de exchange al resolverlo en `Session#exchange` y lo delega a `Configuration#on_return` o al default que logea.
18
21
  **Resource** — ORM tipo ActiveRecord que mapea operaciones CRUD a llamadas AMQP.
19
22
  **Consumer** — Worker bloqueante que despacha mensajes a controladores mediante un Router.
20
23
  **Connection Pool** — Pool de conexiones (`connection_pool` gem) que comparte sessions entre threads. Cada slot cachea su `Session` y `Producer`.
@@ -59,7 +62,7 @@ Skill de conocimiento completo sobre BugBunny. Consultame para cualquier pregunt
59
62
  |---|---|
60
63
  | `BugBunny::Configuration` | Configuración global. Valida campos requeridos en `BugBunny.configure`. |
61
64
  | `BugBunny::Session` | Wrapper de canal Bunny. Declara exchanges y queues. Thread-safe con double-checked locking. |
62
- | `BugBunny::Producer` | Publica mensajes. Implementa RPC con `Concurrent::IVar` y direct reply-to. |
65
+ | `BugBunny::Producer` | Publica mensajes. Implementa tres modos: `#fire` (async), `#confirmed` (sync con `wait_for_confirms`) y `#rpc` (direct reply-to + `Concurrent::IVar`). |
63
66
  | `BugBunny::Client` | API de alto nivel. Pool de conexiones y middleware stack (onion architecture). |
64
67
  | `BugBunny::Consumer` | Subscribe loop con health check. Rutea mensajes via `BugBunny.routes`. |
65
68
  | `BugBunny::ConsumerMiddleware::Stack` | Pipeline de middlewares antes de `process_message`. Thread-safe. |
@@ -126,6 +129,11 @@ BugBunny.configure do |config|
126
129
  config.rpc_reply_headers = -> { { 'X-Trace-Id' => Tracer.id } }
127
130
  config.on_rpc_reply = ->(h) { Tracer.hydrate(h['X-Trace-Id']) }
128
131
 
132
+ # Publisher Confirms — handler global para basic.return (mensajes mandatory no ruteados).
133
+ # Si es nil, BugBunny logea como `session.broker_return` con nivel :warn.
134
+ # Firma: ->(return_info, properties, body)
135
+ config.on_return = ->(ri, _props, body) { MyAlerts.unroutable(rk: ri.routing_key, body: body) }
136
+
129
137
  # Infraestructura (cascade level 2)
130
138
  config.exchange_options = { durable: true }
131
139
  config.queue_options = { auto_delete: false }
@@ -169,7 +177,13 @@ Genera rutas REST estándar (index, show, create, update, destroy) mapeadas a co
169
177
 
170
178
  ---
171
179
 
172
- ## API: RPC vs Fire-and-Forget
180
+ ## API: Modos de Entrega
181
+
182
+ | Modo | Espera | Uso | Método |
183
+ |---|---|---|---|
184
+ | `:rpc` | Reply del consumer remoto | Request-response síncrono | `client.request(...)` |
185
+ | `:publish` | Nada | Logs, eventos best-effort | `client.publish(...)` |
186
+ | `:confirmed` | ACK del broker (`wait_for_confirms`) | Auditoría, billing, eventos críticos | `client.publish(..., confirmed: true)` |
173
187
 
174
188
  **RPC síncrono** — Bloquea hasta respuesta. Usa `amq.rabbitmq.reply-to`. Timeout configurable.
175
189
  ```ruby
@@ -183,6 +197,18 @@ client.publish('events', method: :post, body: { type: 'order.placed' })
183
197
  # → { 'status' => 202, 'body' => nil }
184
198
  ```
185
199
 
200
+ **Confirmed (Publisher Confirms)** — Bloquea hasta el ACK del broker. Garantía de entrega al broker (no al consumer remoto).
201
+ ```ruby
202
+ client.publish('acct.start', exchange: 'acct_x', body: payload,
203
+ confirmed: true, mandatory: true, confirm_timeout: 0.5)
204
+ # → { 'status' => 202, 'body' => nil } # broker ACK confirmado
205
+ # Si broker NACK: logea `producer.confirms_nacked` y retorna 202 igual (NACK ≠ pérdida).
206
+ # Si timeout: raise BugBunny::RequestTimeout.
207
+ # Si mandatory + no ruteable: dispara `Configuration#on_return` (default: logea `session.broker_return`).
208
+ ```
209
+
210
+ `Bunny::Channel#wait_for_confirms` no soporta timeout nativo en Bunny 2.x. BugBunny lo implementa lanzando la espera en un hilo auxiliar y usando `Concurrent::IVar#value(timeout)` como reloj.
211
+
186
212
  ---
187
213
 
188
214
  ## FAQ
@@ -7,7 +7,7 @@ API de alto nivel para publicar mensajes. Usa un pool de conexiones y una pila d
7
7
  ### Métodos Principales
8
8
 
9
9
  ```ruby
10
- # RPC síncrono — bloquea hasta respuesta
10
+ # RPC síncrono — bloquea hasta respuesta del consumer
11
11
  response = client.request('users/123', method: :get, timeout: 30)
12
12
  response = client.request('users', method: :post, body: { name: 'John' })
13
13
  # → { 'status' => 200, 'body' => {...} }
@@ -16,8 +16,17 @@ response = client.request('users', method: :post, body: { name: 'John' })
16
16
  client.publish('events/user_created', method: :post, body: { user_id: 42 })
17
17
  # → { 'status' => 202, 'body' => nil }
18
18
 
19
- # General con bloque
20
- client.send(url) { |req| req.delivery_mode = :publish }
19
+ # Publisher Confirms — bloquea hasta ACK del broker (no del consumer)
20
+ client.publish('acct.start', exchange: 'acct_x', body: payload,
21
+ confirmed: true, mandatory: true, confirm_timeout: 0.5)
22
+ # → { 'status' => 202, 'body' => nil }
23
+
24
+ # General con bloque — máximo control
25
+ client.send(url) do |req|
26
+ req.delivery_mode = :confirmed
27
+ req.mandatory = true
28
+ req.confirm_timeout = 1.0
29
+ end
21
30
  ```
22
31
 
23
32
  ### Argumentos de Request
@@ -34,12 +43,15 @@ client.send(url) { |req| req.delivery_mode = :publish }
34
43
  | `routing_key` | String | path | Override de routing key |
35
44
  | `exchange_options` | Hash | {} | Cascade level 3 |
36
45
  | `queue_options` | Hash | {} | Cascade level 3 |
46
+ | `confirmed` | Boolean | `false` | En `Client#publish`, flipea `delivery_mode` a `:confirmed`. Bloquea hasta `wait_for_confirms`. |
47
+ | `mandatory` | Boolean | `false` | Pide al broker retornar el mensaje si no es ruteable. Solo útil con `confirmed: true`. |
48
+ | `confirm_timeout` | Float | `nil` | Segundos máximos a esperar el ACK. `nil` = espera indefinida. Excedido → `BugBunny::RequestTimeout`. |
37
49
 
38
50
  ## Producer (bajo nivel)
39
51
 
40
- El `Producer` es usado internamente por el `Client`. Implementa los dos patrones de entrega.
52
+ El `Producer` es usado internamente por el `Client`. Implementa tres patrones de entrega.
41
53
 
42
- ### RPC
54
+ ### RPC (`Producer#rpc`)
43
55
 
44
56
  - Usa `amq.rabbitmq.reply-to` (direct reply-to pattern).
45
57
  - Tracking de `correlation_id` en `Concurrent::Map` (thread-safe).
@@ -47,11 +59,19 @@ El `Producer` es usado internamente por el `Client`. Implementa los dos patrones
47
59
  - Double-checked locking mutex para seguridad del listener.
48
60
  - Timeout lanza `BugBunny::RequestTimeout`.
49
61
 
50
- ### Fire-and-Forget
62
+ ### Fire-and-Forget (`Producer#fire`)
51
63
 
52
64
  - Publica en el exchange y retorna `{ 'status' => 202 }` inmediatamente.
53
65
  - Sin confirmación de procesamiento.
54
66
 
67
+ ### Confirmed (`Producer#confirmed`)
68
+
69
+ - Publica y bloquea hasta `channel.wait_for_confirms` del broker.
70
+ - Bunny 2.x **no soporta timeout** nativo en `wait_for_confirms` — BugBunny envuelve la llamada en un hilo auxiliar y usa `Concurrent::IVar#value(timeout)` como reloj. Si `confirm_timeout` expira → `BugBunny::RequestTimeout`.
71
+ - NACK del broker no es fatal: se logea `producer.confirms_nacked` con `count` y `path`, y retorna 202 igual.
72
+ - Si `mandatory: true` y el mensaje no es ruteable, el broker dispara `basic.return`. El handler se atacha vía `Bunny::Exchange#on_return` en `Session#exchange` la primera vez que se resuelve cada exchange (cacheado por nombre, una sola vez por canal) y delega a `Configuration#on_return` o al logger por default.
73
+ - Errores del canal se envuelven en `BugBunny::CommunicationError`; errores `BugBunny::Error` pre-existentes se propagan sin envolver.
74
+
55
75
  ## Middleware Stack (Client-side, Onion Architecture)
56
76
 
57
77
  ```
@@ -135,15 +155,74 @@ req.body # Hash, Array, String o nil
135
155
  req.headers # Hash custom
136
156
  req.params # Hash query string
137
157
  req.full_path # path + query string
138
- req.delivery_mode # :rpc o :publish
158
+ req.delivery_mode # :rpc, :publish o :confirmed
139
159
  req.exchange # String destino
140
160
  req.exchange_type # 'direct', 'topic', 'fanout'
141
161
  req.correlation_id # UUID auto-generado
142
162
  req.reply_to # 'amq.rabbitmq.reply-to' (auto para RPC)
143
163
  req.timestamp # Time.now.to_i
144
164
  req.content_type # 'application/json'
165
+ req.mandatory # Boolean — solo modo :confirmed
166
+ req.confirm_timeout # Float|nil — solo modo :confirmed
145
167
  ```
146
168
 
169
+ Cuando `mandatory == true`, `Request#amqp_options` inyecta `mandatory: true` en el hash que va a `basic_publish`.
170
+
171
+ ## Publisher Confirms y `basic.return`
172
+
173
+ ### Flujo
174
+
175
+ ```
176
+ client.publish('x', confirmed: true, mandatory: true)
177
+
178
+
179
+ Producer#confirmed
180
+
181
+ ├──> publish_message (exchange.publish con mandatory: true)
182
+
183
+ ├──> wait_for_confirms! (espera ACK del broker, con timeout opcional)
184
+
185
+ └──> log_nacks_if_any (si nacked_set no está vacío → log WARN)
186
+
187
+ Asíncronamente, si el broker no pudo rutear:
188
+ broker ──basic.return──> Exchange#on_return ──> Session handler ──> Configuration#on_return
189
+ └──> default: log session.broker_return WARN
190
+ ```
191
+
192
+ ### `Configuration#on_return`
193
+
194
+ El handler se registra **una sola vez por exchange** en `Session#exchange` (cuando `publisher_confirms: true`) usando `Bunny::Exchange#on_return`. Bunny dispatcha `basic.return` por exchange, no por canal, así que el handler vive en cada `Bunny::Exchange` resuelto vía cascada. `Session` cachea los exchanges ya configurados por nombre en `@configured_returns` para no re-registrar en cada publish; el set se limpia al recrear el canal.
195
+
196
+ ```ruby
197
+ BugBunny.configure do |c|
198
+ c.on_return = ->(return_info, properties, body) {
199
+ # return_info: Bunny::ReturnInfo (reply_code, reply_text, exchange, routing_key)
200
+ # properties: Bunny::MessageProperties
201
+ # body: String (payload crudo)
202
+ MyAlerts.unroutable(rk: return_info.routing_key, body: body)
203
+ }
204
+ end
205
+ ```
206
+
207
+ Si `on_return` es `nil` (default), BugBunny logea:
208
+
209
+ ```
210
+ component=bug_bunny event=session.broker_return reply_code=312
211
+ reply_text="NO_ROUTE" exchange=evt_x routing_key=acct.start body_size=64
212
+ ```
213
+
214
+ Excepciones del callback se capturan y se logean como `session.on_return_failed` para no romper el hilo I/O de Bunny.
215
+
216
+ ### Cuándo usar `:confirmed`
217
+
218
+ | Escenario | Modo recomendado |
219
+ |---|---|
220
+ | Logs, eventos best-effort | `:publish` |
221
+ | Auditoría, billing, eventos críticos | `:confirmed` (con `mandatory: true` si es ruteable) |
222
+ | Request-response síncrono | `:rpc` |
223
+
224
+ `:confirmed` cuesta un round-trip al broker pero **no** al consumer remoto — más rápido que RPC, con garantía de entrega al broker. NACK del broker es raro (típicamente por confirm policies internas) y no implica pérdida del mensaje.
225
+
147
226
  ## Cascada de Configuración (3 niveles)
148
227
 
149
228
  ```ruby
data/skills.yml CHANGED
@@ -2,43 +2,28 @@ mcps:
2
2
  - github
3
3
  - clickup
4
4
  skills:
5
- skill-manager:
6
- repo: sequre/ai_knowledge
7
- scope: local
8
5
  yard:
9
6
  repo: sequre/ai_knowledge
10
- scope: local
11
7
  quality-code:
12
8
  repo: sequre/ai_knowledge
13
- scope: local
14
9
  gem-release:
15
10
  repo: sequre/ai_knowledge
16
- scope: local
17
11
  skill-builder:
18
12
  repo: sequre/ai_knowledge
19
- scope: local
20
- ai-reports:
13
+ matrix-element:
21
14
  repo: sequre/ai_knowledge
22
- scope: local
23
15
  environment:
24
- space_id: "${AI_REPORTS_SPACE_ID}"
25
- bug_reports_list_id: "${AI_REPORTS_BUG_REPORTS_LIST_ID}"
26
- improvements_list_id: "${AI_REPORTS_IMPROVEMENTS_LIST_ID}"
27
- agent-review:
16
+ homeserver: "https://matrix.cloud.wispro.co"
17
+ auth_token: "${MATRIX_AUTH_TOKEN}"
18
+ rooms:
19
+ agents: "!VCHwQXgmXdyhhhPhoz:matrix.cloud.wispro.co"
20
+ skill-feedback:
21
+ repo: sequre/ai_knowledge
22
+ agent-issue:
28
23
  repo: sequre/ai_knowledge
29
- scope: local
30
- environment:
31
- space_id: "${AGENT_REVIEW_SAPCE_ID}"
32
- list_id: "${AGENT_LIST_ID}"
33
24
  documentation-writer:
34
25
  repo: github/awesome-copilot
35
26
  path: skills/documentation-writer
36
- scope: local
37
- find-skills:
38
- repo: vercel-labs/skills
39
- path: skills/find-skills
40
- scope: local
41
27
  rabbitmq-expert:
42
28
  repo: martinholovsky/claude-skills-generator
43
29
  path: skills/rabbitmq-expert
44
- scope: local
@@ -3,11 +3,47 @@
3
3
  # Stubs livianos de Bunny para specs unitarios que no necesitan RabbitMQ real.
4
4
 
5
5
  module BunnyMocks
6
+ # Stub mínimo de `Bunny::Exchange`: cachea el bloque pasado a `on_return`
7
+ # (Bunny lo invoca al recibir un `basic.return` del broker) y permite que
8
+ # los specs disparen retornos sintéticos vía `fire_return`.
9
+ class FakeExchange
10
+ attr_reader :name, :type, :opts
11
+
12
+ def initialize(name, type, opts = {})
13
+ @name = name
14
+ @type = type
15
+ @opts = opts
16
+ end
17
+
18
+ def on_return(&block)
19
+ @on_return_handler = block
20
+ end
21
+
22
+ def publish(_payload, _opts = {}); end
23
+
24
+ def fire_return(return_info, properties, body)
25
+ @on_return_handler&.call(return_info, properties, body)
26
+ end
27
+ end
28
+
6
29
  FakeChannel = Struct.new(:open) do
7
30
  def open? = open
8
31
  def close = (self.open = false)
9
32
  def confirm_select; end
10
33
  def prefetch(_n); end
34
+
35
+ def topic(name, opts = {}) = exchange_for(name, 'topic', opts)
36
+ def direct(name, opts = {}) = exchange_for(name, 'direct', opts)
37
+ def fanout(name, opts = {}) = exchange_for(name, 'fanout', opts)
38
+ def headers(name, opts = {}) = exchange_for(name, 'headers', opts)
39
+ def default_exchange = exchange_for('', 'direct', {})
40
+
41
+ private
42
+
43
+ def exchange_for(name, type, opts)
44
+ @exchanges ||= {}
45
+ @exchanges[name] ||= FakeExchange.new(name, type, opts)
46
+ end
11
47
  end
12
48
 
13
49
  FakeConnection = Struct.new(:open, :channel_to_return) do
@@ -27,7 +27,7 @@ RSpec.describe BugBunny::Client, 'session pooling' do
27
27
  def client_with_pool(pool)
28
28
  client = described_class.new(pool: pool)
29
29
  # Stub Producer#rpc para que no toque RabbitMQ real
30
- allow_any_instance_of(BugBunny::Producer).to receive(:rpc) do |_prod, req|
30
+ allow_any_instance_of(BugBunny::Producer).to receive(:rpc) do |_prod, _req|
31
31
  { 'status' => 200, 'body' => '{"ok":true}' }
32
32
  end
33
33
  allow_any_instance_of(BugBunny::Producer).to receive(:fire) do |_prod, _req|
@@ -115,7 +115,7 @@ RSpec.describe BugBunny::Client, 'session pooling' do
115
115
 
116
116
  describe 'thread-safety' do
117
117
  it 'múltiples threads con la misma conexión no generan Sessions duplicadas' do
118
- conn = fake_conn
118
+ conn = fake_conn
119
119
  # Pool siempre devuelve la misma conexión — simula concurrencia en el mismo slot
120
120
  pool = Object.new
121
121
  mutex = Mutex.new
@@ -138,6 +138,83 @@ RSpec.describe BugBunny::Client, 'session pooling' do
138
138
  end
139
139
  end
140
140
 
141
+ describe 'Delivery mode routing' do
142
+ def fake_conn_local
143
+ channel = BunnyMocks::FakeChannel.new(true)
144
+ BunnyMocks::FakeConnection.new(true, channel)
145
+ end
146
+
147
+ it 'publish con confirmed: true enruta a Producer#confirmed' do
148
+ conn = fake_conn_local
149
+ client = described_class.new(pool: fake_pool(conn))
150
+
151
+ confirmed_called = false
152
+ allow_any_instance_of(BugBunny::Producer).to receive(:confirmed) do |_prod, _req|
153
+ confirmed_called = true
154
+ { 'status' => 202, 'body' => nil }
155
+ end
156
+
157
+ client.publish('acct.start',
158
+ exchange: 'x', exchange_type: 'direct', body: { a: 1 },
159
+ confirmed: true)
160
+
161
+ expect(confirmed_called).to be(true)
162
+ end
163
+
164
+ it 'propaga mandatory y confirm_timeout al Request' do
165
+ conn = fake_conn_local
166
+ client = described_class.new(pool: fake_pool(conn))
167
+
168
+ captured = nil
169
+ allow_any_instance_of(BugBunny::Producer).to receive(:confirmed) do |_prod, req|
170
+ captured = req
171
+ { 'status' => 202, 'body' => nil }
172
+ end
173
+
174
+ client.publish('acct.start',
175
+ exchange: 'x', exchange_type: 'direct',
176
+ confirmed: true, mandatory: true, confirm_timeout: 0.5)
177
+
178
+ expect(captured.delivery_mode).to eq(:confirmed)
179
+ expect(captured.mandatory).to be(true)
180
+ expect(captured.confirm_timeout).to eq(0.5)
181
+ end
182
+
183
+ it 'publish sin confirmed: true sigue invocando #fire (backward compat)' do
184
+ conn = fake_conn_local
185
+ client = described_class.new(pool: fake_pool(conn))
186
+
187
+ fire_called = false
188
+ allow_any_instance_of(BugBunny::Producer).to receive(:fire) do |_prod, _req|
189
+ fire_called = true
190
+ { 'status' => 202, 'body' => nil }
191
+ end
192
+
193
+ client.publish('evt', exchange: 'x', exchange_type: 'direct', body: {})
194
+
195
+ expect(fire_called).to be(true)
196
+ end
197
+
198
+ it 'send con bloque permite setear delivery_mode = :confirmed' do
199
+ conn = fake_conn_local
200
+ client = described_class.new(pool: fake_pool(conn))
201
+
202
+ confirmed_called = false
203
+ allow_any_instance_of(BugBunny::Producer).to receive(:confirmed) do |_prod, _req|
204
+ confirmed_called = true
205
+ { 'status' => 202, 'body' => nil }
206
+ end
207
+
208
+ client.send('evt.x', exchange: 'x', exchange_type: 'direct') do |req|
209
+ req.delivery_mode = :confirmed
210
+ req.mandatory = true
211
+ req.confirm_timeout = 0.2
212
+ end
213
+
214
+ expect(confirmed_called).to be(true)
215
+ end
216
+ end
217
+
141
218
  describe 'Session no se cierra entre requests' do
142
219
  it 'no invoca close en la Session al terminar el request' do
143
220
  conn = fake_conn
@@ -149,6 +149,19 @@ RSpec.describe BugBunny::Configuration do
149
149
  end
150
150
  end
151
151
 
152
+ describe 'on_return callback' do
153
+ it 'tiene default nil' do
154
+ expect(BugBunny::Configuration.new.on_return).to be_nil
155
+ end
156
+
157
+ it 'acepta un Proc' do
158
+ cb = ->(_, _, _) { :noop }
159
+ configure_with(on_return: cb)
160
+
161
+ expect(BugBunny.configuration.on_return).to be(cb)
162
+ end
163
+ end
164
+
152
165
  describe '.validate! directamente' do
153
166
  it 'es invocable directamente sobre la instancia' do
154
167
  config = BugBunny::Configuration.new
@@ -184,4 +184,108 @@ RSpec.describe BugBunny::Producer do
184
184
  end
185
185
  end
186
186
  end
187
+
188
+ describe '#confirmed' do
189
+ let(:fake_exchange) { double('exchange') }
190
+
191
+ let(:mock_channel) do
192
+ ch = double('channel')
193
+ allow(ch).to receive(:publish)
194
+ allow(ch).to receive(:open?).and_return(true)
195
+ allow(ch).to receive(:wait_for_confirms).and_return(true)
196
+ allow(ch).to receive(:nacked_set).and_return(Set.new)
197
+ ch
198
+ end
199
+
200
+ let(:mock_session) do
201
+ s = instance_double(BugBunny::Session)
202
+ allow(s).to receive(:exchange).and_return(fake_exchange)
203
+ allow(s).to receive(:channel).and_return(mock_channel)
204
+ s
205
+ end
206
+
207
+ let(:confirmed_producer) { described_class.new(mock_session) }
208
+
209
+ before do
210
+ allow(confirmed_producer).to receive(:safe_log) do |level, event, **kwargs|
211
+ logged_events << { level: level, event: event, kwargs: kwargs }
212
+ end
213
+ allow(fake_exchange).to receive(:publish)
214
+ end
215
+
216
+ def build_request
217
+ req = BugBunny::Request.new('acct.start')
218
+ req.exchange = 'acct_x'
219
+ req.method = :post
220
+ req.body = { tenant: 42 }
221
+ req
222
+ end
223
+
224
+ it 'retorna { status: 202 } cuando el broker confirma' do
225
+ result = confirmed_producer.confirmed(build_request)
226
+
227
+ expect(result).to eq('status' => 202, 'body' => nil)
228
+ end
229
+
230
+ it 'invoca wait_for_confirms en el canal' do
231
+ confirmed_producer.confirmed(build_request)
232
+
233
+ expect(mock_channel).to have_received(:wait_for_confirms)
234
+ end
235
+
236
+ it 'publica con mandatory: true cuando el request lo activa' do
237
+ req = build_request
238
+ req.mandatory = true
239
+
240
+ confirmed_producer.confirmed(req)
241
+
242
+ expect(fake_exchange).to have_received(:publish).with(
243
+ anything,
244
+ hash_including(mandatory: true, routing_key: 'acct.start')
245
+ )
246
+ end
247
+
248
+ it 'logea producer.confirms_nacked cuando hay nacks' do
249
+ allow(mock_channel).to receive(:nacked_set).and_return(Set.new([1, 2]))
250
+
251
+ confirmed_producer.confirmed(build_request)
252
+
253
+ nack_event = logged_events.find { |e| e[:event] == 'producer.confirms_nacked' }
254
+ expect(nack_event).not_to be_nil
255
+ expect(nack_event[:kwargs]).to include(count: 2, path: 'acct.start')
256
+ end
257
+
258
+ it 'NO logea producer.confirms_nacked cuando nacked_set está vacío' do
259
+ confirmed_producer.confirmed(build_request)
260
+
261
+ nack_event = logged_events.find { |e| e[:event] == 'producer.confirms_nacked' }
262
+ expect(nack_event).to be_nil
263
+ end
264
+
265
+ it 'levanta BugBunny::RequestTimeout si wait_for_confirms excede confirm_timeout' do
266
+ allow(mock_channel).to receive(:wait_for_confirms) {
267
+ sleep 1
268
+ true
269
+ }
270
+
271
+ req = build_request
272
+ req.confirm_timeout = 0.05
273
+
274
+ expect { confirmed_producer.confirmed(req) }.to raise_error(BugBunny::RequestTimeout, /Timeout/)
275
+ end
276
+
277
+ it 'envuelve errores del canal como BugBunny::CommunicationError' do
278
+ allow(mock_channel).to receive(:wait_for_confirms).and_raise(StandardError, 'boom')
279
+
280
+ expect { confirmed_producer.confirmed(build_request) }
281
+ .to raise_error(BugBunny::CommunicationError, /boom/)
282
+ end
283
+
284
+ it 'propaga BugBunny::Error sin envolver' do
285
+ allow(fake_exchange).to receive(:publish).and_raise(BugBunny::CommunicationError, 'chan dead')
286
+
287
+ expect { confirmed_producer.confirmed(build_request) }
288
+ .to raise_error(BugBunny::CommunicationError, 'chan dead')
289
+ end
290
+ end
187
291
  end
@@ -47,5 +47,34 @@ RSpec.describe BugBunny::Request do
47
47
 
48
48
  expect(request.amqp_options[:headers]['x-http-method']).to eq('POST')
49
49
  end
50
+
51
+ it 'omite :mandatory cuando no fue activado' do
52
+ expect(request.amqp_options).not_to have_key(:mandatory)
53
+ end
54
+
55
+ it 'incluye :mandatory => true cuando se activa' do
56
+ request.mandatory = true
57
+
58
+ expect(request.amqp_options[:mandatory]).to be(true)
59
+ end
60
+ end
61
+
62
+ describe 'Publisher Confirms attributes' do
63
+ it 'tiene mandatory=false y confirm_timeout=nil por defecto' do
64
+ req = described_class.new('foo')
65
+
66
+ expect(req.mandatory).to be(false)
67
+ expect(req.confirm_timeout).to be_nil
68
+ end
69
+
70
+ it 'permite asignar mandatory y confirm_timeout' do
71
+ req = described_class.new('foo')
72
+
73
+ req.mandatory = true
74
+ req.confirm_timeout = 0.5
75
+
76
+ expect(req.mandatory).to be(true)
77
+ expect(req.confirm_timeout).to eq(0.5)
78
+ end
50
79
  end
51
80
  end
@@ -69,6 +69,88 @@ RSpec.describe BugBunny::Session do
69
69
  end
70
70
  end
71
71
 
72
+ describe '#on_return handler' do
73
+ let(:return_info) do
74
+ Struct.new(:reply_code, :reply_text, :exchange, :routing_key)
75
+ .new(312, 'NO_ROUTE', 'evt_x', 'unbound.key')
76
+ end
77
+ let(:properties) { double('properties') }
78
+ let(:body) { '{"a":1}' }
79
+
80
+ before do
81
+ # Logger en memoria para inspeccionar el default
82
+ @log_io = StringIO.new
83
+ BugBunny.configuration.logger = Logger.new(@log_io)
84
+ BugBunny.configuration.on_return = nil
85
+ end
86
+
87
+ after do
88
+ BugBunny.configuration.logger = Logger.new($stdout).tap { |l| l.level = Logger::INFO }
89
+ BugBunny.configuration.on_return = nil
90
+ end
91
+
92
+ it 'se registra sobre el exchange cuando publisher_confirms está activo' do
93
+ exchange = session.exchange(name: 'evt_x', type: 'topic')
94
+
95
+ expect { exchange.fire_return(return_info, properties, body) }.not_to raise_error
96
+ end
97
+
98
+ it 'registra una sola vez por nombre de exchange' do
99
+ first = session.exchange(name: 'evt_x', type: 'topic')
100
+ second = session.exchange(name: 'evt_x', type: 'topic')
101
+
102
+ expect(second).to be(first)
103
+ expect(session.instance_variable_get(:@configured_returns)).to include('evt_x' => true)
104
+ end
105
+
106
+ it 'NO se registra cuando publisher_confirms está desactivado' do
107
+ fresh_channel = BunnyMocks::FakeChannel.new(true)
108
+ fresh_conn = BunnyMocks::FakeConnection.new(true, fresh_channel)
109
+ no_confirms = described_class.new(fresh_conn, publisher_confirms: false)
110
+ exchange = no_confirms.exchange(name: 'evt_x', type: 'topic')
111
+
112
+ expect(exchange.instance_variable_get(:@on_return_handler)).to be_nil
113
+ end
114
+
115
+ it 'NO se registra sobre el default exchange (name vacío)' do
116
+ default = session.exchange # name nil → default_exchange
117
+
118
+ expect(default.instance_variable_get(:@on_return_handler)).to be_nil
119
+ end
120
+
121
+ it 'invoca el callback de Configuration#on_return cuando está definido' do
122
+ received = nil
123
+ BugBunny.configuration.on_return = lambda { |ri, props, b|
124
+ received = { rk: ri.routing_key, props: props, body: b }
125
+ }
126
+
127
+ exchange = session.exchange(name: 'evt_x', type: 'topic')
128
+ exchange.fire_return(return_info, properties, body)
129
+
130
+ expect(received).to include(rk: 'unbound.key', body: '{"a":1}')
131
+ end
132
+
133
+ it 'logea session.broker_return como :warn cuando no hay callback' do
134
+ exchange = session.exchange(name: 'evt_x', type: 'topic')
135
+ exchange.fire_return(return_info, properties, body)
136
+
137
+ log = @log_io.string
138
+ expect(log).to include('event=session.broker_return')
139
+ expect(log).to include('reply_code=312')
140
+ expect(log).to include('routing_key=unbound.key')
141
+ expect(log).to include('body_size=7')
142
+ end
143
+
144
+ it 'no propaga excepciones del callback de usuario' do
145
+ BugBunny.configuration.on_return = ->(_, _, _) { raise 'boom' }
146
+
147
+ exchange = session.exchange(name: 'evt_x', type: 'topic')
148
+
149
+ expect { exchange.fire_return(return_info, properties, body) }.not_to raise_error
150
+ expect(@log_io.string).to include('event=session.on_return_failed')
151
+ end
152
+ end
153
+
72
154
  describe '#close' do
73
155
  it 'cierra el canal y lo nilifica' do
74
156
  session.channel
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bug_bunny
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.11.1
4
+ version: 4.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - gabix
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-09 00:00:00.000000000 Z
11
+ date: 2026-05-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny
@@ -269,7 +269,6 @@ files:
269
269
  - skill/references/routing.md
270
270
  - skill/references/testing.md
271
271
  - skills-lock.json
272
- - skills.lock
273
272
  - skills.yml
274
273
  - spec/integration/client_spec.rb
275
274
  - spec/integration/consumer_middleware_spec.rb
@@ -304,7 +303,7 @@ metadata:
304
303
  homepage_uri: https://github.com/gedera/bug_bunny
305
304
  source_code_uri: https://github.com/gedera/bug_bunny
306
305
  changelog_uri: https://github.com/gedera/bug_bunny/blob/main/CHANGELOG.md
307
- documentation_uri: https://github.com/gedera/bug_bunny/blob/v4.11.1/skill
306
+ documentation_uri: https://github.com/gedera/bug_bunny/blob/v4.12.0/skill
308
307
  post_install_message:
309
308
  rdoc_options: []
310
309
  require_paths:
data/skills.lock DELETED
@@ -1,33 +0,0 @@
1
- ---
2
- synced_at: '2026-04-08 09:13:30'
3
- skills:
4
- - name: agent-review
5
- scope: local
6
- path: "/Users/gabriel/src/gems/bug_bunny/.agents/skills/agent-review"
7
- - name: ai-reports
8
- scope: local
9
- path: "/Users/gabriel/src/gems/bug_bunny/.agents/skills/ai-reports"
10
- - name: documentation-writer
11
- scope: local
12
- path: "/Users/gabriel/src/gems/bug_bunny/.agents/skills/documentation-writer"
13
- - name: find-skills
14
- scope: local
15
- path: "/Users/gabriel/src/gems/bug_bunny/.agents/skills/find-skills"
16
- - name: gem-release
17
- scope: local
18
- path: "/Users/gabriel/src/gems/bug_bunny/.agents/skills/gem-release"
19
- - name: quality-code
20
- scope: local
21
- path: "/Users/gabriel/src/gems/bug_bunny/.agents/skills/quality-code"
22
- - name: rabbitmq-expert
23
- scope: local
24
- path: "/Users/gabriel/src/gems/bug_bunny/.agents/skills/rabbitmq-expert"
25
- - name: skill-builder
26
- scope: local
27
- path: "/Users/gabriel/src/gems/bug_bunny/.agents/skills/skill-builder"
28
- - name: skill-manager
29
- scope: local
30
- path: "/Users/gabriel/src/gems/bug_bunny/.agents/skills/skill-manager"
31
- - name: yard
32
- scope: local
33
- path: "/Users/gabriel/src/gems/bug_bunny/.agents/skills/yard"