bug_bunny 4.14.0 → 4.16.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.
data/skill/SKILL.md CHANGED
@@ -15,7 +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 del broker (rechazo explícito) levanta `BugBunny::PublishNacked` por default; configurable via `config.nack_raise = false` o `nack_raise: false` per request para volver al modo log-only.
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`. Dos señales asíncronas del broker, ambas convertidas en excepciones raise-eables en el publish thread:
19
+ - **NACK** (rechazo explícito) → `BugBunny::PublishNacked` (configurable via `config.nack_raise = false`).
20
+ - **basic.return** (mandatory unroutable) → `BugBunny::PublishUnroutable` (configurable via `config.return_raise = false`).
19
21
  **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
22
  **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.
21
23
  **Resource** — ORM tipo ActiveRecord que mapea operaciones CRUD a llamadas AMQP.
@@ -109,6 +111,8 @@ BugBunny mide y emite duraciones automáticamente. **No envolver llamadas a `cli
109
111
  | `producer.published` | INFO | `Producer#publish_message` (post) | `method`, `path`, `routing_key`, `messaging_message_id`, **`duration_s`** (publish solo) |
110
112
  | `producer.confirmed` | INFO | `Producer#confirmed` (post-ACK) | `method`, `path`, `routing_key`, **`publish_duration_s`**, **`confirm_duration_s`**, **`duration_s`** (total) |
111
113
  | `producer.confirms_nacked` | WARN | `Producer#confirmed` (NACK) | `count`, `path` |
114
+ | `producer.publish_unroutable` | WARN | `Producer#confirmed` (basic.return) | `path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `messaging_message_id` |
115
+ | `client.return_raise_ignored` | WARN | `Client#publish` | `delivery_mode`, `mandatory` (cuando `return_raise:true` se pasa sin prereqs) |
112
116
  | `producer.rpc_waiting` | DEBUG | `Producer#rpc` | `messaging_message_id`, `timeout_s` |
113
117
  | `producer.rpc_response_received` | INFO | `Producer#rpc` (reply recibido) | `method`, `path`, **`duration_s`** (round-trip total), `response_body` |
114
118
  | `producer.rpc_response_orphaned` | WARN | reply listener | `correlation_id` |
@@ -160,8 +164,14 @@ BugBunny.configure do |config|
160
164
  config.rpc_reply_headers = -> { { 'X-Trace-Id' => Tracer.id } }
161
165
  config.on_rpc_reply = ->(h) { Tracer.hydrate(h['X-Trace-Id']) }
162
166
 
163
- # Publisher Confirms — handler global para basic.return (mensajes mandatory no ruteados).
164
- # Si es nil, BugBunny logea como `session.broker_return` con nivel :warn.
167
+ # Publisher Confirms — fail-loud por default (ambos true).
168
+ # Setear false para volver al comportamiento legacy "log y retorna 202".
169
+ config.nack_raise = true # NACK → raise BugBunny::PublishNacked
170
+ config.return_raise = true # basic.return (mandatory) → raise BugBunny::PublishUnroutable
171
+
172
+ # Callback global para basic.return. Corre ANTES del raise PublishUnroutable
173
+ # cuando return_raise es true. Si on_return es nil, BugBunny logea
174
+ # `session.broker_return` con nivel :warn.
165
175
  # Firma: ->(return_info, properties, body)
166
176
  config.on_return = ->(ri, _props, body) { MyAlerts.unroutable(rk: ri.routing_key, body: body) }
167
177
 
@@ -233,12 +243,33 @@ client.publish('events', method: :post, body: { type: 'order.placed' })
233
243
  client.publish('acct.start', exchange: 'acct_x', body: payload,
234
244
  confirmed: true, mandatory: true, confirm_timeout: 0.5)
235
245
  # → { 'status' => 202, 'body' => nil } # broker ACK confirmado
236
- # Si broker NACK: logea `producer.confirms_nacked` y raise BugBunny::PublishNacked
237
- # (default — opt-out con config.nack_raise = false o nack_raise: false per request).
238
- # Si timeout: raise BugBunny::RequestTimeout.
239
- # Si mandatory + no ruteable: dispara `Configuration#on_return` (default: logea `session.broker_return`).
240
246
  ```
241
247
 
248
+ **Dos señales del broker, dos excepciones simétricas:**
249
+
250
+ | Señal | Default | Excepción | Campos |
251
+ |---|---|---|---|
252
+ | `basic.nack` | raise | `BugBunny::PublishNacked` | `path`, `nacked_count` |
253
+ | `basic.return` (mandatory unrouted) | raise | `BugBunny::PublishUnroutable` | `path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `correlation_id` |
254
+
255
+ ```ruby
256
+ # Comportamiento default (4.13+ para nack_raise, 4.15+ para return_raise):
257
+ # broker NACK → PublishNacked
258
+ # mandatory + unroutable → on_return callback (si está) → PublishUnroutable
259
+ # timeout en wait_for_confirms → RequestTimeout
260
+ #
261
+ # Opt-out (modo "log + 202 silencioso"):
262
+ config.nack_raise = false # o per-request: nack_raise: false
263
+ config.return_raise = false # o per-request: return_raise: false
264
+
265
+ # Override per-request gana sobre config global:
266
+ client.publish('foo', confirmed: true, mandatory: true, return_raise: false, body: {})
267
+ ```
268
+
269
+ **Bridge cross-thread interno (basic.return):** `basic.return` viaja en el reader thread de Bunny (asíncrono). Para hacer `PublishUnroutable` raise-able en el publish thread, BugBunny mantiene un `Session#@pending_returns = Concurrent::Map` correlacionado por `correlation_id`. `Producer#confirmed` auto-asigna `correlation_id` (UUID v4) si falta, registra un `Concurrent::Event` antes del publish, y tras `wait_for_confirms` true espera `RETURN_RACE_WINDOW_S` (50ms) para tolerar GVL scheduling — AMQP garantiza orden wire `return → ack`. El `on_return` user callback se invoca igual antes del raise (orden: signal event → user_cb → raise), así una excepción en el callback no impide el raise.
270
+
271
+ **Inert cases:** `return_raise: true` sin `mandatory: true` o sin `confirmed: true` emite `client.return_raise_ignored` (WARN) y se ignora — el flag requiere ambos prereqs.
272
+
242
273
  `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.
243
274
 
244
275
  ---
@@ -257,6 +288,10 @@ Cada slot del pool cachea su `Session` y `Producer` durante su vida útil. Esto
257
288
  ### ¿Cómo funciona la cascada de configuración?
258
289
  3 niveles: Gem defaults → Global config (`BugBunny.configure`) → Per-request (args en `client.request` o `Resource.with`). Se mergean con `merge`.
259
290
 
291
+ **Defaults de la gema desde 4.16:**
292
+ - `DEFAULT_EXCHANGE_OPTIONS = { durable: false, auto_delete: false }`
293
+ - `DEFAULT_QUEUE_OPTIONS = { exclusive: false, durable: true, auto_delete: false }` — queue compartida duradera, válida en RabbitMQ 3.x y 4.x. Previo a 4.16 era `{ durable: false, auto_delete: true }` (deprecated `transient_nonexcl_queues` en RMQ 4.x). Para legacy: override explícito en `config.queue_options`.
294
+
260
295
  ### ¿Cómo funciona fork safety?
261
296
  `BugBunny::Railtie` registra hooks en `ActiveSupport::ForkTracker` (Rails 7.1+), `Puma.events.on_worker_boot` y `Spring.after_fork` para llamar `BugBunny.disconnect` y evitar sockets TCP heredados.
262
297
 
@@ -72,7 +72,7 @@ El `Producer` es usado internamente por el `Client`. Implementa tres patrones de
72
72
  - Publica y bloquea hasta `channel.wait_for_confirms` del broker.
73
73
  - 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`.
74
74
  - Si `wait_for_confirms` devuelve `false` (broker NACKea), se logea `producer.confirms_nacked` con `count` y `path`. Por default (`config.nack_raise = true`) levanta `BugBunny::PublishNacked` con `path` y `nacked_count`. Para opt-out: `config.nack_raise = false` o pasar `nack_raise: false` per request — en ese caso solo logea y retorna 202.
75
- - 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.
75
+ - 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. **Por default (`config.return_raise = true`)** además levanta `BugBunny::PublishUnroutable` en el publish thread con `path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `correlation_id`. Para opt-out: `config.return_raise = false` o pasar `return_raise: false` per request — solo logea/invoca callback y retorna 202.
76
76
  - Errores del canal se envuelven en `BugBunny::CommunicationError`; errores `BugBunny::Error` pre-existentes se propagan sin envolver.
77
77
  - **Emite `producer.confirmed` (INFO) con tres duraciones desglosadas**: `publish_duration_s` (TCP enqueue), `confirm_duration_s` (`wait_for_confirms`), `duration_s` (total). Útil para distinguir latencia de red vs latencia de confirm policy del broker.
78
78
 
@@ -183,18 +183,35 @@ client.publish('x', confirmed: true, mandatory: true)
183
183
 
184
184
  Producer#confirmed
185
185
 
186
- ├──> publish_message (exchange.publish con mandatory: true)
186
+ ├──> setup_return_listener (si return_raise? Session#register_return_listener(cid))
187
+ │ │
188
+ │ └─ @session.@pending_returns[cid] = { event:, info: nil }
187
189
 
188
- ├──> wait_for_confirms! (espera ACK del broker, con timeout opcional)
190
+ ├──> publish_message (exchange.publish con mandatory: true, correlation_id auto-asignado)
189
191
 
190
- └──> handle_confirm_result
191
- ├─ acked == true → return { status: 202 }
192
- └─ acked == false → log WARN producer.confirms_nacked
193
- └─ raise BugBunny::PublishNacked (si config.nack_raise || req.nack_raise)
194
-
195
- Asíncronamente, si el broker no pudo rutear:
196
- broker ──basic.return──> Exchange#on_return ──> Session handler ──> Configuration#on_return
197
- └──> default: log session.broker_return WARN
192
+ ├──> wait_for_confirms! (espera ACK del broker, con timeout opcional)
193
+
194
+ ├──> handle_confirm_result
195
+ │ ├─ acked == true continúa
196
+ │ └─ acked == false → log WARN producer.confirms_nacked
197
+ │ └─ raise BugBunny::PublishNacked (si config.nack_raise || req.nack_raise)
198
+
199
+ ├──> handle_return_result (si listener registrado)
200
+ │ ├─ slot.event.wait(RETURN_RACE_WINDOW_S = 0.05)
201
+ │ ├─ slot.info == nil → return (ack normal sin return)
202
+ │ └─ slot.info != nil → log WARN producer.publish_unroutable
203
+ │ └─ raise BugBunny::PublishUnroutable
204
+
205
+ └──> ensure: teardown_return_listener (Session#unregister_return_listener(cid))
206
+
207
+ Asíncronamente en el reader thread, si el broker no pudo rutear:
208
+ broker ──basic.return──> Exchange#on_return ──> Session#handle_broker_return
209
+
210
+ ├─ signal_return_listener (busca cid en @pending_returns,
211
+ │ setea slot.info + slot.event)
212
+
213
+ └─ dispatch_return_callback (invoca Configuration#on_return
214
+ o logea session.broker_return)
198
215
  ```
199
216
 
200
217
  ### `Configuration#on_return`
@@ -229,14 +246,43 @@ Excepciones del callback se capturan y se logean como `session.on_return_failed`
229
246
  | Auditoría, billing, eventos críticos | `:confirmed` (con `mandatory: true` si es ruteable) |
230
247
  | Request-response síncrono | `:rpc` |
231
248
 
232
- `: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, disk full, replicación insuficiente) pero **sí** implica que el mensaje no fue aceptado. Por default BugBunny levanta `BugBunny::PublishNacked` para que el caller pueda escalar (ej: convertir a HTTP 503 y dejar que el sistema upstream reintente). Opt-out con `config.nack_raise = false` o `nack_raise: false` per request.
249
+ `: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. Dos modos de falla broker-side, ambos raise-eables por default:
250
+
251
+ - **NACK** (raro: confirm policies internas, disk full, replicación insuficiente) → `BugBunny::PublishNacked` (`path`, `nacked_count`). Opt-out: `config.nack_raise = false`.
252
+ - **basic.return + mandatory** (queue inexistente, sin bindings, routing key sin match) → `BugBunny::PublishUnroutable` (`path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `correlation_id`). Opt-out: `config.return_raise = false`.
253
+
254
+ El `on_return` user callback se invoca igual antes del raise (orden: signal interno → user_cb → raise en caller), así que alerting/metrics siguen funcionando.
255
+
256
+ ### Bridge cross-thread (`basic.return` → publish thread)
257
+
258
+ `basic.return` llega en el reader thread de Bunny mientras el publish thread está dentro de `wait_for_confirms`. Para que `PublishUnroutable` sea raise-eable sincrónicamente desde la perspectiva del caller, `Session` mantiene un registry indexado por `correlation_id`:
259
+
260
+ ```ruby
261
+ # Session (api privada)
262
+ @pending_returns = Concurrent::Map.new
263
+ # slot = { event: Concurrent::Event.new, info: nil }
264
+ ```
265
+
266
+ `Producer#confirmed`:
267
+ 1. Si `return_raise?` resuelto es true, auto-asigna `request.correlation_id` si falta (UUID v4 vía `SecureRandom`).
268
+ 2. Registra slot vía `Session#register_return_listener(cid)`.
269
+ 3. Publica con `correlation_id` propagado a las message properties (el broker lo echo back en `basic.return.properties`).
270
+ 4. Tras `wait_for_confirms` true, `event.wait(RETURN_RACE_WINDOW_S = 0.05)` para tolerar GVL scheduling. AMQP wire garantiza `return → ack`, así que normalmente el event ya está seteado.
271
+ 5. Si `slot[:info]` no es nil, logea `producer.publish_unroutable` y raise.
272
+ 6. `ensure` block siempre llama `Session#unregister_return_listener(cid)` para cleanup.
273
+
274
+ `Session#handle_broker_return` (reader thread):
275
+ 1. `signal_return_listener` busca cid por `properties.correlation_id`, setea `slot[:info]` y `slot[:event]`. **Esto corre ANTES del user_cb** — una excepción en el callback no impide el raise.
276
+ 2. `dispatch_return_callback` invoca `Configuration#on_return` o logea `session.broker_return`.
277
+
278
+ Si `return_raise: true` se pasa sin `confirmed: true` o sin `mandatory: true`, el flag es inerte y se emite `client.return_raise_ignored` WARN. El bridge no aplica en `:publish` puro porque no hay synchronization point (`wait_for_confirms`) sobre el cual raise-ear en el caller.
233
279
 
234
280
  ## Cascada de Configuración (3 niveles)
235
281
 
236
282
  ```ruby
237
- # Level 1: Gem defaults
238
- { durable: false, auto_delete: false } # exchanges
239
- { exclusive: false, durable: false, auto_delete: true } # queues
283
+ # Level 1: Gem defaults (BugBunny::Session)
284
+ { durable: false, auto_delete: false } # DEFAULT_EXCHANGE_OPTIONS
285
+ { exclusive: false, durable: true, auto_delete: false } # DEFAULT_QUEUE_OPTIONS (cambio en 4.16)
240
286
 
241
287
  # Level 2: Global config
242
288
  BugBunny.configure { |c| c.exchange_options = { durable: true } }
@@ -246,3 +292,5 @@ client.request('users', exchange_options: { durable: true })
246
292
 
247
293
  # Merge final: Level1.merge(Level2).merge(Level3)
248
294
  ```
295
+
296
+ **Nota 4.16+:** `DEFAULT_QUEUE_OPTIONS` previamente era `{ exclusive: false, durable: false, auto_delete: true }` (combo `transient_nonexcl_queues` deprecada en RabbitMQ 4.x). El nuevo default es queue compartida duradera. Para restaurar el comportamiento previo: `c.queue_options = { exclusive: false, durable: false, auto_delete: true }`.
@@ -9,6 +9,7 @@ StandardError
9
9
  ├── BugBunny::ConfigurationError
10
10
  ├── BugBunny::SecurityError
11
11
  ├── BugBunny::PublishNacked
12
+ ├── BugBunny::PublishUnroutable
12
13
  ├── BugBunny::ClientError (4xx)
13
14
  │ ├── BugBunny::BadRequest (400)
14
15
  │ ├── BugBunny::NotFound (404)
@@ -44,6 +45,12 @@ StandardError
44
45
  **Atributos:** `path` (ruta del request) y `nacked_count` (cantidad de delivery tags NACKeados).
45
46
  **Resolución:** Para casos críticos (auditoría, billing, RADIUS accounting), dejar que la excepción bubble para que el caller upstream reintente (ej: HTTP 503 → retry). Para tolerar NACKs (eventos best-effort), `BugBunny.configuration.nack_raise = false` global o `nack_raise: false` per request — en ese caso solo se logea `producer.confirms_nacked`.
46
47
 
48
+ ### BugBunny::PublishUnroutable
49
+ **Causa:** El broker retornó un mensaje publicado con `mandatory: true` que no pudo rutearse a ninguna cola en modo `:confirmed` (queue inexistente, sin bindings que matcheen la routing key, etc.). Espejo simétrico de `PublishNacked` pero para la señal `basic.return` en lugar de `basic.nack`.
50
+ **Cuándo:** `Producer#confirmed` con `mandatory: true` detecta un `basic.return` correlacionado por `correlation_id` antes/durante el ACK. Se levanta por default (`config.return_raise = true`, introducido en 4.15).
51
+ **Atributos:** `path`, `exchange`, `routing_key`, `reply_code` (típicamente 312 = NO_ROUTE), `reply_text` y `correlation_id`.
52
+ **Resolución:** Mismo patrón que `PublishNacked` — bubble en publishers críticos para traducir a HTTP 5xx + retry upstream. El `config.on_return` callback se sigue invocando antes del raise (alerting/metrics intactos). Para opt-out: `config.return_raise = false` global o `return_raise: false` per request. Inert cuando `mandatory: false` (sin mandatory el broker nunca emite `basic.return`).
53
+
47
54
  ## Errores de Cliente (4xx)
48
55
 
49
56
  ### BugBunny::BadRequest (400)
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'support/integration_helper'
5
+
6
+ # Specs de integración para Publisher Confirms en modo :confirmed + mandatory.
7
+ # Verifican el flow end-to-end del bridge `basic.return` → `PublishUnroutable`
8
+ # contra un RabbitMQ real.
9
+ #
10
+ # Se skippean automáticamente si el broker no está disponible (ver
11
+ # `spec_helper.rb` → `before(:each, :integration)`).
12
+ RSpec.describe 'Publisher Confirms — return_raise', :integration do
13
+ let(:client) { BugBunny::Client.new(pool: TEST_POOL) }
14
+
15
+ # Exchange unbound: existe pero ninguna cola está bindeada a él.
16
+ # Cualquier publish con mandatory:true sobre este exchange retornará.
17
+ let(:unbound_exchange) { unique('unroutable_x') }
18
+ # Exchange con cola bindeada: publish con mandatory:true llega bien.
19
+ let(:routable_exchange) { unique('routable_x') }
20
+
21
+ # Declara el exchange sin bindings para asegurar que `basic.return` se dispare.
22
+ # Usa una conexión fresca para no contaminar el pool.
23
+ def declare_unbound_exchange!(name)
24
+ conn = BugBunny.create_connection
25
+ ch = conn.create_channel
26
+ ch.topic(name, BugBunny.configuration.exchange_options)
27
+ ch.close
28
+ conn.close
29
+ end
30
+
31
+ before do
32
+ declare_unbound_exchange!(unbound_exchange)
33
+ # Reset flag a default conocido por si algún spec previo lo cambió.
34
+ BugBunny.configuration.return_raise = true
35
+ BugBunny.configuration.on_return = nil
36
+ end
37
+
38
+ after do
39
+ BugBunny.configuration.return_raise = true
40
+ BugBunny.configuration.on_return = nil
41
+ end
42
+
43
+ describe 'mandatory: true sobre exchange sin bindings' do
44
+ it 'levanta BugBunny::PublishUnroutable por default (return_raise=true)' do
45
+ expect {
46
+ client.publish('acct.unbound',
47
+ exchange: unbound_exchange,
48
+ exchange_type: 'topic',
49
+ confirmed: true,
50
+ mandatory: true,
51
+ body: { tenant: 42 })
52
+ }.to raise_error(BugBunny::PublishUnroutable) do |err|
53
+ expect(err.path).to eq('acct.unbound')
54
+ expect(err.exchange).to eq(unbound_exchange)
55
+ expect(err.routing_key).to eq('acct.unbound')
56
+ expect(err.reply_code).to eq(312)
57
+ expect(err.reply_text).to match(/NO_ROUTE/i)
58
+ expect(err.correlation_id).to be_a(String)
59
+ expect(err.correlation_id).not_to be_empty
60
+ end
61
+ end
62
+
63
+ it 'NO levanta si el request override `return_raise: false`' do
64
+ result = client.publish('acct.unbound',
65
+ exchange: unbound_exchange,
66
+ exchange_type: 'topic',
67
+ confirmed: true,
68
+ mandatory: true,
69
+ return_raise: false,
70
+ body: { tenant: 42 })
71
+
72
+ expect(result).to eq('status' => 202, 'body' => nil)
73
+ end
74
+
75
+ it 'NO levanta si la config global tiene `return_raise = false`' do
76
+ BugBunny.configuration.return_raise = false
77
+
78
+ result = client.publish('acct.unbound',
79
+ exchange: unbound_exchange,
80
+ exchange_type: 'topic',
81
+ confirmed: true,
82
+ mandatory: true,
83
+ body: { tenant: 42 })
84
+
85
+ expect(result).to eq('status' => 202, 'body' => nil)
86
+ end
87
+
88
+ it 'invoca el callback global on_return antes de levantar' do
89
+ captured = nil
90
+ BugBunny.configuration.on_return = lambda { |return_info, _props, _body|
91
+ captured = { exchange: return_info.exchange, rk: return_info.routing_key }
92
+ }
93
+
94
+ expect {
95
+ client.publish('acct.unbound',
96
+ exchange: unbound_exchange,
97
+ exchange_type: 'topic',
98
+ confirmed: true,
99
+ mandatory: true,
100
+ body: { tenant: 42 })
101
+ }.to raise_error(BugBunny::PublishUnroutable)
102
+
103
+ expect(captured).not_to be_nil
104
+ expect(captured[:exchange]).to eq(unbound_exchange)
105
+ expect(captured[:rk]).to eq('acct.unbound')
106
+ end
107
+
108
+ it 'levanta igual cuando el user_cb on_return explota' do
109
+ BugBunny.configuration.on_return = ->(_, _, _) { raise 'boom in user cb' }
110
+
111
+ expect {
112
+ client.publish('acct.unbound',
113
+ exchange: unbound_exchange,
114
+ exchange_type: 'topic',
115
+ confirmed: true,
116
+ mandatory: true,
117
+ body: { tenant: 42 })
118
+ }.to raise_error(BugBunny::PublishUnroutable)
119
+ end
120
+
121
+ it 'override per-request gana sobre config global = false' do
122
+ BugBunny.configuration.return_raise = false
123
+
124
+ expect {
125
+ client.publish('acct.unbound',
126
+ exchange: unbound_exchange,
127
+ exchange_type: 'topic',
128
+ confirmed: true,
129
+ mandatory: true,
130
+ return_raise: true,
131
+ body: { tenant: 42 })
132
+ }.to raise_error(BugBunny::PublishUnroutable)
133
+ end
134
+ end
135
+
136
+ describe 'mandatory: true sobre exchange con binding (happy path)' do
137
+ # Declara queue exclusive + binding contra `routable_exchange` para que el
138
+ # publish sea ruteable. Exclusive evita la deprecación de transient_nonexcl_queues
139
+ # en versiones modernas de RabbitMQ.
140
+ def with_exclusive_binding(exchange:, routing_key:)
141
+ conn = BugBunny.create_connection
142
+ ch = conn.create_channel
143
+ x = ch.topic(exchange, BugBunny.configuration.exchange_options)
144
+ q = ch.queue('', exclusive: true, auto_delete: true)
145
+ q.bind(x, routing_key: routing_key)
146
+ yield
147
+ ensure
148
+ ch&.close
149
+ conn&.close
150
+ end
151
+
152
+ it 'retorna 202 sin levantar — el mensaje rutea normal' do
153
+ with_exclusive_binding(exchange: routable_exchange, routing_key: 'acct.#') do
154
+ result = client.publish('acct.start',
155
+ exchange: routable_exchange,
156
+ exchange_type: 'topic',
157
+ confirmed: true,
158
+ mandatory: true,
159
+ body: { tenant: 99 })
160
+
161
+ expect(result).to eq('status' => 202, 'body' => nil)
162
+ end
163
+ end
164
+ end
165
+
166
+ describe 'mandatory: false (flag inerte)' do
167
+ it 'no levanta aunque return_raise=true y la routing key no rutee a ninguna cola' do
168
+ result = client.publish('acct.unbound',
169
+ exchange: unbound_exchange,
170
+ exchange_type: 'topic',
171
+ confirmed: true,
172
+ mandatory: false,
173
+ return_raise: true,
174
+ body: { tenant: 1 })
175
+
176
+ expect(result).to eq('status' => 202, 'body' => nil)
177
+ end
178
+ end
179
+
180
+ describe 'concurrencia multi-thread sobre el mismo client' do
181
+ # Verifica que la correlación por correlation_id aísla los outcomes:
182
+ # N threads publican simultáneamente sobre el mismo exchange unbound, cada uno
183
+ # debe recibir SU propio PublishUnroutable (no el de otro thread).
184
+ it 'cada caller recibe su propio raise sin contaminación cross-thread' do
185
+ threads = 8
186
+ results = Concurrent::Array.new
187
+
188
+ pool = Array.new(threads) do |i|
189
+ Thread.new do
190
+ rk = "thread.#{i}.unbound"
191
+ client.publish(rk,
192
+ exchange: unbound_exchange,
193
+ exchange_type: 'topic',
194
+ confirmed: true,
195
+ mandatory: true,
196
+ body: { tid: i })
197
+ results << { tid: i, raised: false }
198
+ rescue BugBunny::PublishUnroutable => e
199
+ results << { tid: i, raised: true, rk: e.routing_key, cid: e.correlation_id }
200
+ end
201
+ end
202
+
203
+ pool.each(&:join)
204
+
205
+ expect(results.size).to eq(threads)
206
+ expect(results.all? { |r| r[:raised] }).to be(true), 'todos deberían haber raised'
207
+ expect(results.map { |r| r[:rk] }.sort).to eq((0...threads).map { |i| "thread.#{i}.unbound" }.sort)
208
+ # Todos los correlation_ids deben ser distintos (no hubo cross-thread leakage)
209
+ cids = results.map { |r| r[:cid] }
210
+ expect(cids.uniq.size).to eq(threads)
211
+ end
212
+ end
213
+
214
+ describe 'aislamiento entre exchanges sobre el mismo channel' do
215
+ # Publish A sobre exchange unbound (debe raisear) y publish B sobre exchange routable
216
+ # (debe pasar). Validamos que el return de A no contamina B.
217
+ it 'return en exchange A no afecta publish concurrente a exchange B' do
218
+ bound_ex = unique('bound_b_x')
219
+ bound_q = unique('bound_b_q')
220
+
221
+ # Setup: exchange routable con queue exclusive bindeada
222
+ conn = BugBunny.create_connection
223
+ ch = conn.create_channel
224
+ x = ch.topic(bound_ex, BugBunny.configuration.exchange_options)
225
+ q = ch.queue('', exclusive: true, auto_delete: true)
226
+ q.bind(x, routing_key: '#')
227
+
228
+ results = Concurrent::Array.new
229
+
230
+ t_a = Thread.new do
231
+ client.publish('a.unbound',
232
+ exchange: unbound_exchange,
233
+ exchange_type: 'topic',
234
+ confirmed: true,
235
+ mandatory: true,
236
+ body: { side: 'A' })
237
+ results << { side: 'A', raised: false }
238
+ rescue BugBunny::PublishUnroutable
239
+ results << { side: 'A', raised: true }
240
+ end
241
+
242
+ t_b = Thread.new do
243
+ client.publish('b.routable',
244
+ exchange: bound_ex,
245
+ exchange_type: 'topic',
246
+ confirmed: true,
247
+ mandatory: true,
248
+ body: { side: 'B' })
249
+ results << { side: 'B', raised: false }
250
+ rescue BugBunny::PublishUnroutable
251
+ results << { side: 'B', raised: true }
252
+ end
253
+
254
+ [t_a, t_b].each(&:join)
255
+
256
+ a = results.find { |r| r[:side] == 'A' }
257
+ b = results.find { |r| r[:side] == 'B' }
258
+ expect(a[:raised]).to be(true), 'A (unbound) debería haber raised'
259
+ expect(b[:raised]).to be(false), 'B (routable) NO debería haber raised'
260
+ ensure
261
+ ch&.close
262
+ conn&.close
263
+ end
264
+ end
265
+
266
+ describe 'no hay leak en el registry tras publishes seriales' do
267
+ # 30 publishes seriales (mix routable + unroutable). Tras todos, el registry
268
+ # @pending_returns debe estar en 0. Detecta entries colgadas por cleanup mal hecho.
269
+ it 'registry vuelve a 0 tras una serie de publishes' do
270
+ 30.times do |i|
271
+ target = i.even? ? unbound_exchange : nil
272
+ if target
273
+ begin
274
+ client.publish("serial.#{i}",
275
+ exchange: target,
276
+ exchange_type: 'topic',
277
+ confirmed: true,
278
+ mandatory: true,
279
+ body: { i: i })
280
+ rescue BugBunny::PublishUnroutable
281
+ # esperado en routes no-ruteables
282
+ end
283
+ else
284
+ # publish sin mandatory para no triggerear return — no-op para el registry
285
+ client.publish("serial.#{i}",
286
+ exchange: unbound_exchange,
287
+ exchange_type: 'topic',
288
+ confirmed: true,
289
+ mandatory: false,
290
+ body: { i: i })
291
+ end
292
+ end
293
+
294
+ # Inspeccionar cada Session del pool — el registry debe estar vacío en todas.
295
+ total_pending = 0
296
+ TEST_POOL.with do |conn|
297
+ session = conn.instance_variable_get(:@_bug_bunny_session)
298
+ registry = session.instance_variable_get(:@pending_returns)
299
+ total_pending += registry.size
300
+ end
301
+ expect(total_pending).to eq(0)
302
+ end
303
+ end
304
+ end
data/spec/spec_helper.rb CHANGED
@@ -23,8 +23,11 @@ BugBunny.configure do |config|
23
23
  config.password = ENV.fetch('RABBITMQ_PASS', 'guest')
24
24
  config.vhost = '/'
25
25
  config.logger = Logger.new($stdout).tap { |l| l.level = Logger::WARN }
26
+ # exchange_options: explícito para que los specs declaren exchanges efímeros.
27
+ # queue_options: NO override — confiamos en `DEFAULT_QUEUE_OPTIONS` (durable shared,
28
+ # válido en RMQ 3.x y 4.x). Specs que necesitan colas efímeras usan
29
+ # `TEST_WORKER_QUEUE_OPTS` desde `spec/support/integration_helper.rb`.
26
30
  config.exchange_options = { durable: false, auto_delete: true }
27
- config.queue_options = { exclusive: false, durable: false, auto_delete: true }
28
31
  end
29
32
 
30
33
  TEST_POOL ||= ConnectionPool.new(size: 5, timeout: 5) { BugBunny.create_connection }
@@ -2,6 +2,12 @@
2
2
 
3
3
  require 'timeout'
4
4
 
5
+ # Queue options usados por los workers de integración. \`exclusive: true\` evita
6
+ # la deprecación de \`transient_nonexcl_queues\` en RabbitMQ 4.x — la queue queda
7
+ # ligada a la conexión del worker y desaparece automáticamente al cerrarla.
8
+ # Sobreescribe la cascada de \`BugBunny.configuration.queue_options\`.
9
+ TEST_WORKER_QUEUE_OPTS = { exclusive: true, durable: false, auto_delete: true }.freeze unless defined?(TEST_WORKER_QUEUE_OPTS)
10
+
5
11
  # Helpers compartidos para specs de integración con RabbitMQ real.
6
12
  # Incluido automáticamente en todos los specs marcados con :integration.
7
13
  RSpec.shared_context 'integration helpers' do
@@ -31,6 +37,7 @@ RSpec.shared_context 'integration helpers' do
31
37
  exchange_name: exchange,
32
38
  exchange_type: exchange_type,
33
39
  routing_key: routing_key,
40
+ queue_opts: TEST_WORKER_QUEUE_OPTS,
34
41
  block: true
35
42
  )
36
43
  rescue StandardError => e
@@ -57,7 +64,7 @@ RSpec.shared_context 'integration helpers' do
57
64
  worker_thread = Thread.new do
58
65
  ch = conn.create_channel
59
66
  x = ch.public_send(exchange_type, exchange, BugBunny.configuration.exchange_options)
60
- q = ch.queue(queue, BugBunny.configuration.queue_options)
67
+ q = ch.queue(queue, TEST_WORKER_QUEUE_OPTS)
61
68
  q.bind(x, routing_key: routing_key)
62
69
  q.subscribe(block: true) do |delivery, props, body|
63
70
  messages << { body: body, routing_key: delivery.routing_key, headers: props.headers }
@@ -215,6 +215,78 @@ RSpec.describe BugBunny::Client, 'session pooling' do
215
215
  end
216
216
  end
217
217
 
218
+ describe 'warn_return_raise_misuse' do
219
+ let(:log_io) { StringIO.new }
220
+
221
+ before do
222
+ @prev_logger = BugBunny.configuration.logger
223
+ BugBunny.configuration.logger = Logger.new(log_io).tap { |l| l.level = Logger::WARN }
224
+ end
225
+
226
+ after do
227
+ BugBunny.configuration.logger = @prev_logger
228
+ end
229
+
230
+ def stub_producer_to_noop
231
+ allow_any_instance_of(BugBunny::Producer).to receive(:confirmed) { { 'status' => 202, 'body' => nil } }
232
+ allow_any_instance_of(BugBunny::Producer).to receive(:fire) { { 'status' => 202, 'body' => nil } }
233
+ end
234
+
235
+ it 'logea warning cuando return_raise:true se pasa sin confirmed' do
236
+ stub_producer_to_noop
237
+ client = described_class.new(pool: fake_pool(fake_conn))
238
+
239
+ client.publish('foo', exchange: 'x', exchange_type: 'direct',
240
+ return_raise: true, mandatory: true)
241
+
242
+ expect(log_io.string).to include('event=client.return_raise_ignored')
243
+ expect(log_io.string).to include('delivery_mode=publish')
244
+ end
245
+
246
+ it 'logea warning cuando return_raise:true se pasa sin mandatory' do
247
+ stub_producer_to_noop
248
+ client = described_class.new(pool: fake_pool(fake_conn))
249
+
250
+ client.publish('foo', exchange: 'x', exchange_type: 'direct',
251
+ return_raise: true, confirmed: true)
252
+
253
+ expect(log_io.string).to include('event=client.return_raise_ignored')
254
+ expect(log_io.string).to include('mandatory=false')
255
+ end
256
+
257
+ it 'NO logea warning cuando confirmed+mandatory se setean via block API' do
258
+ stub_producer_to_noop
259
+ client = described_class.new(pool: fake_pool(fake_conn))
260
+
261
+ client.publish('foo', exchange: 'x', exchange_type: 'direct', return_raise: true) do |req|
262
+ req.delivery_mode = :confirmed
263
+ req.mandatory = true
264
+ end
265
+
266
+ expect(log_io.string).not_to include('client.return_raise_ignored')
267
+ end
268
+
269
+ it 'NO logea warning cuando return_raise no fue seteado per-request (deja el default global)' do
270
+ stub_producer_to_noop
271
+ client = described_class.new(pool: fake_pool(fake_conn))
272
+
273
+ # Default global es true, pero el caller no fue explícito → no warneamos
274
+ client.publish('foo', exchange: 'x', exchange_type: 'direct')
275
+
276
+ expect(log_io.string).not_to include('client.return_raise_ignored')
277
+ end
278
+
279
+ it 'NO logea warning cuando confirmed+mandatory+return_raise:true coexisten' do
280
+ stub_producer_to_noop
281
+ client = described_class.new(pool: fake_pool(fake_conn))
282
+
283
+ client.publish('foo', exchange: 'x', exchange_type: 'direct',
284
+ confirmed: true, mandatory: true, return_raise: true)
285
+
286
+ expect(log_io.string).not_to include('client.return_raise_ignored')
287
+ end
288
+ end
289
+
218
290
  describe 'Session no se cierra entre requests' do
219
291
  it 'no invoca close en la Session al terminar el request' do
220
292
  conn = fake_conn