bug_bunny 4.13.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.
@@ -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.13.0'
4
+ VERSION = '4.16.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.
@@ -97,6 +99,39 @@ BugBunny implementa las [OpenTelemetry semantic conventions for messaging](https
97
99
  - **Consumer:** Extrae los campos de los logs estructurados sin mutar los headers originales. Los eventos `consumer.message_received` y `consumer.message_processed` incluyen estos campos automáticamente.
98
100
  - **RPC Reply:** El consumer inyecta los mismos campos en el reply para cerrar el ciclo de traza del lado del cliente.
99
101
 
102
+ ### Eventos de log y duraciones internas
103
+
104
+ BugBunny mide y emite duraciones automáticamente. **No envolver llamadas a `client.publish` con `Process.clock_gettime` en código de aplicación** — duplica el trabajo. Las duraciones siguen las [OpenTelemetry metric semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/) (`duration_s` como `Float` en segundos).
105
+
106
+ | Evento | Nivel | Emitido por | Campos clave |
107
+ |---|---|---|---|
108
+ | `producer.publish` | INFO | `Producer#publish_message` (pre) | `method`, `path`, `messaging_*` |
109
+ | `producer.publish_payload` | INFO | `Producer#publish_message` | `payload` (truncado), `body_size` |
110
+ | `producer.publish_detail` | DEBUG | `Producer#publish_message` | `exchange_opts` final |
111
+ | `producer.published` | INFO | `Producer#publish_message` (post) | `method`, `path`, `routing_key`, `messaging_message_id`, **`duration_s`** (publish solo) |
112
+ | `producer.confirmed` | INFO | `Producer#confirmed` (post-ACK) | `method`, `path`, `routing_key`, **`publish_duration_s`**, **`confirm_duration_s`**, **`duration_s`** (total) |
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) |
116
+ | `producer.rpc_waiting` | DEBUG | `Producer#rpc` | `messaging_message_id`, `timeout_s` |
117
+ | `producer.rpc_response_received` | INFO | `Producer#rpc` (reply recibido) | `method`, `path`, **`duration_s`** (round-trip total), `response_body` |
118
+ | `producer.rpc_response_orphaned` | WARN | reply listener | `correlation_id` |
119
+ | `consumer.message_received` | INFO | `Consumer#process_message` | `method`, `path`, `messaging_*` |
120
+ | `consumer.message_processed` | INFO | `Consumer#process_message` (post) | `response_status`, **`duration_s`**, `controller`, `action`, `messaging_*` |
121
+ | `consumer.execution_error` | ERROR | `Consumer#process_message` (rescue) | **`duration_s`**, `error_class`, `error_message` |
122
+ | `consumer.route_not_found` | WARN | `Consumer#process_message` | `method`, `path` |
123
+ | `consumer.connection_error` | ERROR | `Consumer#subscribe` (retry loop) | `attempt_count`, `retry_in_s`, `error_*` |
124
+ | `session.broker_return` | WARN | `Session` (mandatory unrouted) | `reply_code`, `reply_text`, `exchange`, `routing_key` |
125
+
126
+ **Resumen de qué mide cada `duration_s`:**
127
+
128
+ - `producer.published.duration_s` — solo el `basic_publish` (TCP enqueue al broker).
129
+ - `producer.confirmed.publish_duration_s` — el publish.
130
+ - `producer.confirmed.confirm_duration_s` — la espera del ACK del broker (`wait_for_confirms`).
131
+ - `producer.confirmed.duration_s` — total (publish + ACK).
132
+ - `producer.rpc_response_received.duration_s` — round-trip RPC completo (publish + procesamiento remoto + reply).
133
+ - `consumer.message_processed.duration_s` — procesamiento server-side (router + controller + reply).
134
+
100
135
  ---
101
136
 
102
137
  ## API: Configuración Global
@@ -129,8 +164,14 @@ BugBunny.configure do |config|
129
164
  config.rpc_reply_headers = -> { { 'X-Trace-Id' => Tracer.id } }
130
165
  config.on_rpc_reply = ->(h) { Tracer.hydrate(h['X-Trace-Id']) }
131
166
 
132
- # Publisher Confirms — handler global para basic.return (mensajes mandatory no ruteados).
133
- # Si es nil, BugBunny logea como `session.broker_return` con nivel :warn.
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.
134
175
  # Firma: ->(return_info, properties, body)
135
176
  config.on_return = ->(ri, _props, body) { MyAlerts.unroutable(rk: ri.routing_key, body: body) }
136
177
 
@@ -202,12 +243,33 @@ client.publish('events', method: :post, body: { type: 'order.placed' })
202
243
  client.publish('acct.start', exchange: 'acct_x', body: payload,
203
244
  confirmed: true, mandatory: true, confirm_timeout: 0.5)
204
245
  # → { 'status' => 202, 'body' => nil } # broker ACK confirmado
205
- # Si broker NACK: logea `producer.confirms_nacked` y raise BugBunny::PublishNacked
206
- # (default — opt-out con config.nack_raise = false o nack_raise: false per request).
207
- # Si timeout: raise BugBunny::RequestTimeout.
208
- # Si mandatory + no ruteable: dispara `Configuration#on_return` (default: logea `session.broker_return`).
209
246
  ```
210
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
+
211
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.
212
274
 
213
275
  ---
@@ -226,6 +288,10 @@ Cada slot del pool cachea su `Session` y `Producer` durante su vida útil. Esto
226
288
  ### ¿Cómo funciona la cascada de configuración?
227
289
  3 niveles: Gem defaults → Global config (`BugBunny.configure`) → Per-request (args en `client.request` o `Resource.with`). Se mergean con `merge`.
228
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
+
229
295
  ### ¿Cómo funciona fork safety?
230
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.
231
297
 
@@ -59,19 +59,22 @@ El `Producer` es usado internamente por el `Client`. Implementa tres patrones de
59
59
  - Reply listener (`basic_consume`) auto-iniciado en el primer RPC.
60
60
  - Double-checked locking mutex para seguridad del listener.
61
61
  - Timeout lanza `BugBunny::RequestTimeout`.
62
+ - **Emite `producer.rpc_response_received` (INFO) con `duration_s` = round-trip total** (publish + procesamiento remoto + reply). No medir en código de aplicación.
62
63
 
63
64
  ### Fire-and-Forget (`Producer#fire`)
64
65
 
65
66
  - Publica en el exchange y retorna `{ 'status' => 202 }` inmediatamente.
66
67
  - Sin confirmación de procesamiento.
68
+ - **Emite `producer.published` (INFO) con `duration_s`** = solo el `basic_publish` (TCP enqueue al broker).
67
69
 
68
70
  ### Confirmed (`Producer#confirmed`)
69
71
 
70
72
  - Publica y bloquea hasta `channel.wait_for_confirms` del broker.
71
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`.
72
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.
73
- - 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.
74
76
  - Errores del canal se envuelven en `BugBunny::CommunicationError`; errores `BugBunny::Error` pre-existentes se propagan sin envolver.
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.
75
78
 
76
79
  ## Middleware Stack (Client-side, Onion Architecture)
77
80
 
@@ -180,18 +183,35 @@ client.publish('x', confirmed: true, mandatory: true)
180
183
 
181
184
  Producer#confirmed
182
185
 
183
- ├──> 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 }
184
189
 
185
- ├──> wait_for_confirms! (espera ACK del broker, con timeout opcional)
190
+ ├──> publish_message (exchange.publish con mandatory: true, correlation_id auto-asignado)
186
191
 
187
- └──> handle_confirm_result
188
- ├─ acked == true → return { status: 202 }
189
- └─ acked == false → log WARN producer.confirms_nacked
190
- └─ raise BugBunny::PublishNacked (si config.nack_raise || req.nack_raise)
191
-
192
- Asíncronamente, si el broker no pudo rutear:
193
- broker ──basic.return──> Exchange#on_return ──> Session handler ──> Configuration#on_return
194
- └──> 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)
195
215
  ```
196
216
 
197
217
  ### `Configuration#on_return`
@@ -226,14 +246,43 @@ Excepciones del callback se capturan y se logean como `session.on_return_failed`
226
246
  | Auditoría, billing, eventos críticos | `:confirmed` (con `mandatory: true` si es ruteable) |
227
247
  | Request-response síncrono | `:rpc` |
228
248
 
229
- `: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.
230
279
 
231
280
  ## Cascada de Configuración (3 niveles)
232
281
 
233
282
  ```ruby
234
- # Level 1: Gem defaults
235
- { durable: false, auto_delete: false } # exchanges
236
- { 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)
237
286
 
238
287
  # Level 2: Global config
239
288
  BugBunny.configure { |c| c.exchange_options = { durable: true } }
@@ -243,3 +292,5 @@ client.request('users', exchange_options: { durable: true })
243
292
 
244
293
  # Merge final: Level1.merge(Level2).merge(Level3)
245
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)