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