bug_bunny 4.14.0 → 4.17.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.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'concurrent'
4
+
3
5
  module BugBunny
4
6
  # Clase interna que encapsula una unidad de trabajo sobre una conexión RabbitMQ.
5
7
  #
@@ -16,7 +18,20 @@ module BugBunny
16
18
  DEFAULT_EXCHANGE_OPTIONS = { durable: false, auto_delete: false }.freeze
17
19
 
18
20
  # Opciones predeterminadas de la gema para Colas.
19
- DEFAULT_QUEUE_OPTIONS = { exclusive: false, durable: false, auto_delete: true }.freeze
21
+ #
22
+ # `durable: true, exclusive: false, auto_delete: false` es el patrón "queue compartida
23
+ # duradera" — sobrevive restart del broker, múltiples consumers (worker pool) pueden
24
+ # consumir, no se elimina cuando el último consumer se desconecta.
25
+ #
26
+ # Histórico: hasta 4.15.x el default era `{ exclusive: false, durable: false,
27
+ # auto_delete: true }` (combo `transient_nonexcl_queues` deprecada en RabbitMQ 4.x).
28
+ # Ver issue #42 para detalles de la migración. Para restaurar el comportamiento
29
+ # anterior, configurar explícitamente:
30
+ #
31
+ # BugBunny.configure do |c|
32
+ # c.queue_options = { exclusive: false, durable: false, auto_delete: true }
33
+ # end
34
+ DEFAULT_QUEUE_OPTIONS = { exclusive: false, durable: true, auto_delete: false }.freeze
20
35
 
21
36
  # @!endgroup
22
37
 
@@ -36,6 +51,35 @@ module BugBunny
36
51
  @channel_mutex = Mutex.new
37
52
  @logger = BugBunny.configuration.logger
38
53
  @configured_returns = {}
54
+ @pending_returns = Concurrent::Map.new
55
+ end
56
+
57
+ # Registra interés en una eventual señal `basic.return` correlacionada con `cid`.
58
+ #
59
+ # Devuelve un par `(event, slot)`. El caller espera el `event` tras `wait_for_confirms`;
60
+ # si el broker retorna el mensaje, {#handle_broker_return} setea el event y deposita
61
+ # la `return_info` en `slot[:info]` antes de invocar el callback global del usuario.
62
+ #
63
+ # El caller es responsable de invocar {#unregister_return_listener} en un `ensure`
64
+ # para evitar fugas en el registry interno.
65
+ #
66
+ # @param cid [String] Correlation ID del request en curso.
67
+ # @return [Array(Concurrent::Event, Hash)] Tupla `[event, slot]`. `slot[:info]` será
68
+ # poblado con la `Bunny::ReturnInfo` si el broker retorna el mensaje.
69
+ # @api private
70
+ def register_return_listener(cid)
71
+ slot = { event: Concurrent::Event.new, info: nil }
72
+ @pending_returns[cid.to_s] = slot
73
+ [slot[:event], slot]
74
+ end
75
+
76
+ # Elimina el listener registrado por {#register_return_listener}.
77
+ #
78
+ # @param cid [String]
79
+ # @return [void]
80
+ # @api private
81
+ def unregister_return_listener(cid)
82
+ @pending_returns.delete(cid.to_s)
39
83
  end
40
84
 
41
85
  # Obtiene el canal actual o crea uno nuevo si es necesario.
@@ -112,6 +156,7 @@ module BugBunny
112
156
  @channel&.close if @channel&.open?
113
157
  @channel = nil
114
158
  @configured_returns.clear
159
+ release_pending_returns!
115
160
  end
116
161
  end
117
162
 
@@ -132,6 +177,19 @@ module BugBunny
132
177
  raise BugBunny::CommunicationError, "Failed to create channel: #{e.message}"
133
178
  end
134
179
 
180
+ # Libera todos los listeners pendientes seteando sus events. Permite que los publish
181
+ # threads bloqueados en `event.wait` despierten cuando la sesión se cierra (shutdown),
182
+ # en lugar de esperar a `confirm_timeout`. Solo se invoca desde {#close} — el cleanup
183
+ # per-publish corre via `ensure` en {Producer#confirmed} → {#unregister_return_listener}.
184
+ #
185
+ # @return [void]
186
+ def release_pending_returns!
187
+ @pending_returns.each_pair do |_cid, slot|
188
+ slot[:event].set
189
+ end
190
+ @pending_returns.clear
191
+ end
192
+
135
193
  # Registra el handler `basic.return` sobre el `Bunny::Exchange` indicado.
136
194
  #
137
195
  # Bunny dispatcha `basic.return` por exchange (no por canal): el callback se setea
@@ -156,11 +214,50 @@ module BugBunny
156
214
  # Procesa un evento `basic.return` del broker. Nunca propaga excepciones del callback
157
215
  # de usuario para no romper el hilo de I/O de Bunny.
158
216
  #
217
+ # Orden de operaciones:
218
+ # 1. Si hay un listener registrado con el `correlation_id` del mensaje retornado,
219
+ # se deposita la `return_info` en su slot y se setea el event. Esto se hace
220
+ # *antes* del callback de usuario para que una excepción del user_cb no impida
221
+ # que el publish thread despierte y raisee `PublishUnroutable`.
222
+ # 2. Se invoca el callback global `configuration.on_return` (o se logea si no hay).
223
+ #
159
224
  # @param return_info [Bunny::ReturnInfo]
160
225
  # @param properties [Bunny::MessageProperties]
161
226
  # @param body [String]
162
227
  # @return [void]
163
228
  def handle_broker_return(return_info, properties, body)
229
+ signal_return_listener(properties, return_info)
230
+ dispatch_return_callback(return_info, properties, body)
231
+ rescue StandardError => e
232
+ safe_log(:error, 'session.on_return_failed', **exception_metadata(e))
233
+ end
234
+
235
+ # Deposita la info del return en el slot asociado al `correlation_id` del mensaje
236
+ # retornado y setea el event para despertar al publish thread.
237
+ #
238
+ # @param properties [Bunny::MessageProperties]
239
+ # @param return_info [Bunny::ReturnInfo]
240
+ # @return [void]
241
+ def signal_return_listener(properties, return_info)
242
+ cid = properties.respond_to?(:correlation_id) ? properties.correlation_id : nil
243
+ return if cid.nil?
244
+
245
+ slot = @pending_returns[cid.to_s]
246
+ return unless slot
247
+
248
+ slot[:info] = return_info
249
+ slot[:event].set
250
+ end
251
+
252
+ # Invoca el callback global `on_return` o logea el evento si no hay callback.
253
+ # Las excepciones del user_cb se capturan en el rescue de {#handle_broker_return}
254
+ # — el event interno ya fue seteado antes de llegar acá.
255
+ #
256
+ # @param return_info [Bunny::ReturnInfo]
257
+ # @param properties [Bunny::MessageProperties]
258
+ # @param body [String]
259
+ # @return [void]
260
+ def dispatch_return_callback(return_info, properties, body)
164
261
  user_cb = BugBunny.configuration.on_return
165
262
  if user_cb
166
263
  user_cb.call(return_info, properties, body)
@@ -172,8 +269,6 @@ module BugBunny
172
269
  routing_key: return_info.routing_key,
173
270
  body_size: body.respond_to?(:bytesize) ? body.bytesize : nil)
174
271
  end
175
- rescue StandardError => e
176
- safe_log(:error, 'session.on_return_failed', **exception_metadata(e))
177
272
  end
178
273
 
179
274
  # Garantiza que la conexión TCP esté abierta.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = '4.14.0'
4
+ VERSION = '4.17.0'
5
5
  end
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,48 @@ 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
+ **Receta canónica de publisher productivo (auditoría / billing / accounting):**
249
+ ```ruby
250
+ client.publish('acct.start',
251
+ exchange: 'ingest.radius',
252
+ exchange_type: :topic,
253
+ exchange_options: { durable: true }, # matchear declaración del consumer
254
+ body: payload,
255
+ confirmed: true, # broker ACK síncrono
256
+ mandatory: true, # raise PublishUnroutable si no hay binding
257
+ persistent: true, # delivery_mode: 2 — sobrevive restart
258
+ correlation_id: SecureRandom.uuid, # tracing explícito
259
+ app_id: 'radius_manager')
260
+ ```
261
+ A partir de 4.17, `persistent`, `correlation_id`, `priority`, `app_id`, `content_type`, `content_encoding` y `expiration` están en `Client::REQUEST_ATTRS` y se aceptan como kwargs. El block API sigue funcionando para overrides puntuales o para atributos no expuestos (`timestamp`, `type`).
262
+
263
+ **Dos señales del broker, dos excepciones simétricas:**
264
+
265
+ | Señal | Default | Excepción | Campos |
266
+ |---|---|---|---|
267
+ | `basic.nack` | raise | `BugBunny::PublishNacked` | `path`, `nacked_count` |
268
+ | `basic.return` (mandatory unrouted) | raise | `BugBunny::PublishUnroutable` | `path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `correlation_id` |
269
+
270
+ ```ruby
271
+ # Comportamiento default (4.13+ para nack_raise, 4.15+ para return_raise):
272
+ # broker NACK → PublishNacked
273
+ # mandatory + unroutable → on_return callback (si está) → PublishUnroutable
274
+ # timeout en wait_for_confirms → RequestTimeout
275
+ #
276
+ # Opt-out (modo "log + 202 silencioso"):
277
+ config.nack_raise = false # o per-request: nack_raise: false
278
+ config.return_raise = false # o per-request: return_raise: false
279
+
280
+ # Override per-request gana sobre config global:
281
+ client.publish('foo', confirmed: true, mandatory: true, return_raise: false, body: {})
282
+ ```
283
+
284
+ **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.
285
+
286
+ **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.
287
+
242
288
  `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
289
 
244
290
  ---
@@ -257,6 +303,10 @@ Cada slot del pool cachea su `Session` y `Producer` durante su vida útil. Esto
257
303
  ### ¿Cómo funciona la cascada de configuración?
258
304
  3 niveles: Gem defaults → Global config (`BugBunny.configure`) → Per-request (args en `client.request` o `Resource.with`). Se mergean con `merge`.
259
305
 
306
+ **Defaults de la gema desde 4.16:**
307
+ - `DEFAULT_EXCHANGE_OPTIONS = { durable: false, auto_delete: false }`
308
+ - `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`.
309
+
260
310
  ### ¿Cómo funciona fork safety?
261
311
  `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
312
 
@@ -276,6 +326,29 @@ No guardar el resultado de `Order.with(...)` en una variable para múltiples lla
276
326
  ### Registrar middleware durante call()
277
327
  No registrar consumer middlewares durante la ejecución de `call()`. El stack toma un snapshot al inicio; los registros concurrentes no afectan la ejecución actual.
278
328
 
329
+ ### Pasar `path:` como kwarg a `Client#publish` / `#request`
330
+ El primer argumento es **posicional** (`url`). No hay kwarg `:path`. Splatear un hash que tenga `path:` falla con `ArgumentError: wrong number of arguments`. Construir args sin path y pasar la URL aparte:
331
+ ```ruby
332
+ args = { exchange: 'x', body: payload }
333
+ client.publish('event.name', **args) # ✅
334
+ client.publish(**args.merge(path: 'event.name')) # ❌
335
+ ```
336
+
337
+ ### Asumir que `confirmed: true` implica persistencia
338
+ `confirmed: true` solo activa Publisher Confirms (broker ACK síncrono). **NO** setea `delivery_mode: 2`. El default de `Request#persistent` es `false` — el mensaje vive en RAM del broker y se pierde si reinicia. Para eventos críticos sobre queue durable hay que pasar `persistent: true` (a partir de 4.17) o setearlo via block. Tabla de decisión:
339
+
340
+ | Necesitás | Pasar |
341
+ |---|---|
342
+ | Broker confirma recepción | `confirmed: true` |
343
+ | Mensaje sobrevive restart | `persistent: true` (requiere queue `durable: true`) |
344
+ | Raise si no rutea | `mandatory: true` (+ `return_raise: true` default) |
345
+
346
+ ### Olvidar `exchange_options: { durable: true }` en publishers a exchange compartido
347
+ `DEFAULT_EXCHANGE_OPTIONS = { durable: false, auto_delete: false }`. Si un consumer previamente declaró el exchange como durable (caso normal en producción), un publisher que use el default va a recibir `Bunny::PreconditionFailed - inequivalent arg 'durable'` al re-declarar. Solución: pasar `exchange_options: { durable: true }` en el publisher, o setear global `BugBunny.configure { |c| c.exchange_options = { durable: true } }`.
348
+
349
+ ### Confiar en `instance_double(BugBunny::Client)` para detectar errores de signature
350
+ Limitación de RSpec: `instance_double` valida que el método exista pero **no** valida arity estricta cuando el caller hace `**args` splat. Tests con mocks pasan, integration con broker real rompe. Mitigación: para cada publisher nuevo, sumar un smoke spec `:integration` que declare queue exclusiva con binding, publique, haga `queue.pop`, y verifique `correlation_id`, `delivery_mode`, `headers`, `routing_key`.
351
+
279
352
  ---
280
353
 
281
354
  ## Errores Comunes
@@ -47,6 +47,18 @@ end
47
47
  | `mandatory` | Boolean | `false` | Pide al broker retornar el mensaje si no es ruteable. Solo útil con `confirmed: true`. |
48
48
  | `confirm_timeout` | Float | `nil` | Segundos máximos a esperar el ACK. `nil` = espera indefinida. Excedido → `BugBunny::RequestTimeout`. |
49
49
  | `nack_raise` | Boolean | `nil` | Override per-request de `config.nack_raise`. `nil` = usa flag global. |
50
+ | `return_raise` | Boolean | `nil` | Override per-request de `config.return_raise`. Requiere `confirmed: true` y `mandatory: true`. |
51
+ | `persistent` | Boolean | `false` | `delivery_mode: 2` AMQP. Mensaje sobrevive restart del broker. Requiere queue durable. |
52
+ | `correlation_id` | String | nil | ID de correlación AMQP. Auto-asignado UUID en RPC y en `confirmed + mandatory + return_raise`. |
53
+ | `priority` | Integer | nil | Prioridad 0-255. Requiere queue con `x-max-priority`. |
54
+ | `app_id` | String | nil | Identificador del publisher (AMQP `app-id`). |
55
+ | `content_type` | String | `'application/json'` | MIME type del payload. |
56
+ | `content_encoding` | String | nil | Encoding del payload (`'gzip'`, `'deflate'`, etc.). |
57
+ | `expiration` | String | nil | TTL del mensaje en ms (formato AMQP). |
58
+
59
+ **Gotcha:** el primer argumento de `Client#publish` / `#request` es **posicional** (`url`). No existe el kwarg `:path`. Splatear un hash con `path:` falla con `ArgumentError` o se ignora silencioso.
60
+
61
+ **Atributos no expuestos como kwarg** (solo via block API): `timestamp` (default `Time.now.to_i`), `type` (default `full_path`), `reply_to` (RPC interno).
50
62
 
51
63
  ## Producer (bajo nivel)
52
64
 
@@ -72,7 +84,7 @@ El `Producer` es usado internamente por el `Client`. Implementa tres patrones de
72
84
  - Publica y bloquea hasta `channel.wait_for_confirms` del broker.
73
85
  - 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
86
  - 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.
87
+ - 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
88
  - Errores del canal se envuelven en `BugBunny::CommunicationError`; errores `BugBunny::Error` pre-existentes se propagan sin envolver.
77
89
  - **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
90
 
@@ -183,18 +195,35 @@ client.publish('x', confirmed: true, mandatory: true)
183
195
 
184
196
  Producer#confirmed
185
197
 
186
- ├──> publish_message (exchange.publish con mandatory: true)
198
+ ├──> setup_return_listener (si return_raise? Session#register_return_listener(cid))
199
+ │ │
200
+ │ └─ @session.@pending_returns[cid] = { event:, info: nil }
201
+
202
+ ├──> publish_message (exchange.publish con mandatory: true, correlation_id auto-asignado)
203
+
204
+ ├──> wait_for_confirms! (espera ACK del broker, con timeout opcional)
187
205
 
188
- ├──> wait_for_confirms! (espera ACK del broker, con timeout opcional)
206
+ ├──> handle_confirm_result
207
+ │ ├─ acked == true → continúa
208
+ │ └─ acked == false → log WARN producer.confirms_nacked
209
+ │ └─ raise BugBunny::PublishNacked (si config.nack_raise || req.nack_raise)
189
210
 
190
- └──> handle_confirm_result
191
- ├─ acked == true → return { status: 202 }
192
- └─ acked == falselog 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
211
+ ├──> handle_return_result (si listener registrado)
212
+ ├─ slot.event.wait(RETURN_RACE_WINDOW_S = 0.05)
213
+ │ ├─ slot.info == nilreturn (ack normal sin return)
214
+ └─ slot.info != nil log WARN producer.publish_unroutable
215
+ │ └─ raise BugBunny::PublishUnroutable
216
+
217
+ └──> ensure: teardown_return_listener (Session#unregister_return_listener(cid))
218
+
219
+ Asíncronamente en el reader thread, si el broker no pudo rutear:
220
+ broker ──basic.return──> Exchange#on_return ──> Session#handle_broker_return
221
+
222
+ ├─ signal_return_listener (busca cid en @pending_returns,
223
+ │ setea slot.info + slot.event)
224
+
225
+ └─ dispatch_return_callback (invoca Configuration#on_return
226
+ o logea session.broker_return)
198
227
  ```
199
228
 
200
229
  ### `Configuration#on_return`
@@ -229,14 +258,43 @@ Excepciones del callback se capturan y se logean como `session.on_return_failed`
229
258
  | Auditoría, billing, eventos críticos | `:confirmed` (con `mandatory: true` si es ruteable) |
230
259
  | Request-response síncrono | `:rpc` |
231
260
 
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.
261
+ `: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:
262
+
263
+ - **NACK** (raro: confirm policies internas, disk full, replicación insuficiente) → `BugBunny::PublishNacked` (`path`, `nacked_count`). Opt-out: `config.nack_raise = false`.
264
+ - **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`.
265
+
266
+ 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.
267
+
268
+ ### Bridge cross-thread (`basic.return` → publish thread)
269
+
270
+ `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`:
271
+
272
+ ```ruby
273
+ # Session (api privada)
274
+ @pending_returns = Concurrent::Map.new
275
+ # slot = { event: Concurrent::Event.new, info: nil }
276
+ ```
277
+
278
+ `Producer#confirmed`:
279
+ 1. Si `return_raise?` resuelto es true, auto-asigna `request.correlation_id` si falta (UUID v4 vía `SecureRandom`).
280
+ 2. Registra slot vía `Session#register_return_listener(cid)`.
281
+ 3. Publica con `correlation_id` propagado a las message properties (el broker lo echo back en `basic.return.properties`).
282
+ 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.
283
+ 5. Si `slot[:info]` no es nil, logea `producer.publish_unroutable` y raise.
284
+ 6. `ensure` block siempre llama `Session#unregister_return_listener(cid)` para cleanup.
285
+
286
+ `Session#handle_broker_return` (reader thread):
287
+ 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.
288
+ 2. `dispatch_return_callback` invoca `Configuration#on_return` o logea `session.broker_return`.
289
+
290
+ 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
291
 
234
292
  ## Cascada de Configuración (3 niveles)
235
293
 
236
294
  ```ruby
237
- # Level 1: Gem defaults
238
- { durable: false, auto_delete: false } # exchanges
239
- { exclusive: false, durable: false, auto_delete: true } # queues
295
+ # Level 1: Gem defaults (BugBunny::Session)
296
+ { durable: false, auto_delete: false } # DEFAULT_EXCHANGE_OPTIONS
297
+ { exclusive: false, durable: true, auto_delete: false } # DEFAULT_QUEUE_OPTIONS (cambio en 4.16)
240
298
 
241
299
  # Level 2: Global config
242
300
  BugBunny.configure { |c| c.exchange_options = { durable: true } }
@@ -246,3 +304,5 @@ client.request('users', exchange_options: { durable: true })
246
304
 
247
305
  # Merge final: Level1.merge(Level2).merge(Level3)
248
306
  ```
307
+
308
+ **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)