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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +31 -1
- data/lib/bug_bunny/client.rb +76 -33
- data/lib/bug_bunny/configuration.rb +19 -0
- data/lib/bug_bunny/producer.rb +82 -14
- data/lib/bug_bunny/request.rb +15 -2
- data/lib/bug_bunny/session.rb +50 -1
- data/lib/bug_bunny/version.rb +1 -1
- data/skill/SKILL.md +28 -2
- data/skill/references/client-middleware.md +86 -7
- data/skills.yml +8 -23
- data/spec/support/bunny_mocks.rb +36 -0
- data/spec/unit/client_session_pool_spec.rb +79 -2
- data/spec/unit/configuration_spec.rb +13 -0
- data/spec/unit/producer_spec.rb +104 -0
- data/spec/unit/request_spec.rb +29 -0
- data/spec/unit/session_spec.rb +82 -0
- metadata +3 -4
- data/skills.lock +0 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0e38ff38727e34da253f669937054e58f4bda0d5a42ce270cc9ac1e7853d8feb
|
|
4
|
+
data.tar.gz: 47b090304aaf7743addd2136c963d82ea8fd67bb82a17aad8958551047b79015
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
data/lib/bug_bunny/client.rb
CHANGED
|
@@ -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 :
|
|
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
|
|
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 [
|
|
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
|
-
|
|
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
|
-
#
|
|
117
|
+
# Configuración del usuario (bloque específico por request)
|
|
117
118
|
yield req if block_given?
|
|
118
119
|
|
|
119
|
-
#
|
|
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(
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
data/lib/bug_bunny/producer.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
data/lib/bug_bunny/request.rb
CHANGED
|
@@ -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 :
|
|
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
|
data/lib/bug_bunny/session.rb
CHANGED
|
@@ -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
|
#
|
data/lib/bug_bunny/version.rb
CHANGED
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
|
|
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:
|
|
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
|
-
#
|
|
20
|
-
client.
|
|
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
|
|
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 :
|
|
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
|
-
|
|
20
|
-
ai-reports:
|
|
13
|
+
matrix-element:
|
|
21
14
|
repo: sequre/ai_knowledge
|
|
22
|
-
scope: local
|
|
23
15
|
environment:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
data/spec/support/bunny_mocks.rb
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
data/spec/unit/producer_spec.rb
CHANGED
|
@@ -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
|
data/spec/unit/request_spec.rb
CHANGED
|
@@ -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
|
data/spec/unit/session_spec.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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.
|
|
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"
|