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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/README.md +102 -4
- data/lib/bug_bunny/client.rb +66 -4
- data/lib/bug_bunny/configuration.rb +14 -0
- data/lib/bug_bunny/exception.rb +61 -0
- data/lib/bug_bunny/producer.rb +104 -0
- data/lib/bug_bunny/request.rb +5 -1
- data/lib/bug_bunny/session.rb +98 -3
- data/lib/bug_bunny/version.rb +1 -1
- data/skill/SKILL.md +80 -7
- data/skill/references/client-middleware.md +75 -15
- data/skill/references/errores.md +7 -0
- data/spec/integration/publisher_confirms_spec.rb +304 -0
- data/spec/spec_helper.rb +4 -1
- data/spec/support/integration_helper.rb +8 -1
- data/spec/unit/client_session_pool_spec.rb +174 -0
- data/spec/unit/configuration_spec.rb +12 -0
- data/spec/unit/producer_spec.rb +207 -0
- data/spec/unit/request_spec.rb +16 -0
- data/spec/unit/session_spec.rb +72 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec5013f4d782766d388826265255b008a9c17119b15acef95a05fcb01a9722d5
|
|
4
|
+
data.tar.gz: 0cd930d1d4c982a0ba9bf73e9562504d0e776c88c760e427dc440a1399b57a13
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 800c22370b7e39812cc1d90ca74db9ea4736e4354e4061d39a0e0e9d5fbb7fa5ff05c56474f61056a7fe072144339c48d725f040c34c16ba7f0805a314881d66
|
|
7
|
+
data.tar.gz: 921e0359f5426db49beaf327e67885568313282c3972e9bc2e9d7f88e28fdad032f3258ab73e4cca16f9b3f70cdbf5f42311e3d4f910e4430494ebb4ea8e05a8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,76 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [4.17.0] - 2026-05-13
|
|
4
|
+
|
|
5
|
+
### Nuevas funcionalidades
|
|
6
|
+
- **`Client::REQUEST_ATTRS` extendido con metadata AMQP estándar (#45):** Los siguientes atributos del Request ahora pueden pasarse como kwargs directos en `client.publish` / `client.request` sin necesidad del block API:
|
|
7
|
+
- `persistent` (Boolean) — `delivery_mode: 2` AMQP. Critical: por default es `false`; `confirmed: true` **NO** lo implica.
|
|
8
|
+
- `correlation_id` (String) — Tracing explícito (sobreescribe el auto-asignado por RPC y `return_raise`).
|
|
9
|
+
- `priority` (Integer 0-255).
|
|
10
|
+
- `app_id` (String).
|
|
11
|
+
- `content_type` (String, default `'application/json'`).
|
|
12
|
+
- `content_encoding` (String).
|
|
13
|
+
- `expiration` (String, TTL en ms).
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Antes (4.16): requería block API
|
|
17
|
+
client.publish('evt', exchange: 'x', confirmed: true) do |req|
|
|
18
|
+
req.persistent = true
|
|
19
|
+
req.correlation_id = 'cid-123'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Ahora (4.17): kwargs directos
|
|
23
|
+
client.publish('evt', exchange: 'x', confirmed: true,
|
|
24
|
+
persistent: true, correlation_id: 'cid-123')
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
El block API sigue funcionando para overrides puntuales o para atributos no expuestos (`timestamp`, `type`, `reply_to`).
|
|
28
|
+
|
|
29
|
+
### Correcciones
|
|
30
|
+
- **`apply_args` usa `args.key?` en lugar de truthy check:** Permite pasar valores falsy explícitos (ej. `persistent: false`, `priority: 0`) que antes se filtraban silenciosamente como si no se hubieran pasado.
|
|
31
|
+
|
|
32
|
+
### Documentación (motivado por #45 — adopción real en sequre/box_radius_manager#18)
|
|
33
|
+
- README + SKILL.md cubren cuatro gotchas detectados solo en integration tests:
|
|
34
|
+
1. `url` es positional, no kwarg `:path`. Splatear un hash con `path:` rompe.
|
|
35
|
+
2. `confirmed: true` no implica `persistent: true` — son flags ortogonales.
|
|
36
|
+
3. Default `exchange_options` es `{ durable: false }` — publishers a exchange compartido deben pasar `exchange_options: { durable: true }` explícito.
|
|
37
|
+
4. `instance_double` no detecta arity mismatch con splat de kwargs — recomendación de smoke test integration para cada publisher nuevo.
|
|
38
|
+
- Nueva sección "Production publisher recipe" en README y receta canónica en SKILL.md con la combinación recomendada para auditoría/billing/accounting.
|
|
39
|
+
- `skill/references/client-middleware.md` con tabla completa de kwargs de Request actualizada.
|
|
40
|
+
|
|
41
|
+
## [4.16.0] - 2026-05-13
|
|
42
|
+
|
|
43
|
+
### Cambios de comportamiento (semi-breaking)
|
|
44
|
+
- **`DEFAULT_QUEUE_OPTIONS` cambió a `{ exclusive: false, durable: true, auto_delete: false }`** (#42). En versiones previas el default era `{ exclusive: false, durable: false, auto_delete: true }` — la combinación `transient_nonexcl_queues` que **RabbitMQ 4.x deprecó por default**: el broker rechaza la declaración matando la conexión. El nuevo default es el patrón "queue compartida duradera": sobrevive restart del broker, múltiples consumers pueden compartirla, no se elimina cuando se desconecta el último consumer. Esto matchea cómo la mayoría de servicios Wispro ya configuran sus queues explícitamente.
|
|
45
|
+
|
|
46
|
+
**Para restaurar el comportamiento anterior** (servicios sobre RabbitMQ 3.x con queues legacy efímeras pre-existentes):
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
BugBunny.configure do |c|
|
|
50
|
+
c.queue_options = { exclusive: false, durable: false, auto_delete: true }
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Síntoma si se necesita override y no se aplicó:** `Bunny::PreconditionFailed - inequivalent arg 'durable' for queue 'foo'`. Indica que la queue existe en el broker con `durable: false` pero el nuevo default intenta declararla con `durable: true`. Aplicar el override de arriba o borrar manualmente la queue legacy en el broker.
|
|
55
|
+
|
|
56
|
+
### Documentación
|
|
57
|
+
- README + SKILL.md actualizados con sección "queue_options recomendadas" cubriendo patrones worker-pool (default nuevo) y single-instance (`exclusive: true`).
|
|
58
|
+
|
|
59
|
+
## [4.15.0] - 2026-05-13
|
|
60
|
+
|
|
61
|
+
### Nuevas funcionalidades
|
|
62
|
+
- **`return_raise` flag para mandatory + basic.return (#38):** `Producer#confirmed` ahora levanta `BugBunny::PublishUnroutable` cuando el broker retorna un mensaje publicado con `mandatory: true` que no pudo rutearse a ninguna cola. Espejo simétrico de `nack_raise`/`PublishNacked`. La excepción expone `path`, `exchange`, `routing_key`, `reply_code`, `reply_text` y `correlation_id`. Internamente la gema implementa el bridge cross-thread (reader thread → publish thread) que antes cada caller tenía que replicar manualmente con `Concurrent::Map` + lambda. Configurable globalmente vía `BugBunny.configuration.return_raise` (default `true`) y por request via `client.publish(..., return_raise: false)`. El callback global `on_return` se sigue invocando antes del raise. — @Gabriel
|
|
63
|
+
|
|
64
|
+
### Cambios de comportamiento (semi-breaking)
|
|
65
|
+
- **Default `return_raise: true`:** Publicaciones con `confirmed: true, mandatory: true` que reciben `basic.return` del broker ahora levantan excepción por default. En 4.14.0 el return solo se logueaba (o invocaba el callback `on_return`) y la llamada retornaba 202 silenciosamente — ocultando pérdida de mensajes. Para mantener el comportamiento previo: `BugBunny.configuration.return_raise = false` o `return_raise: false` per request. El flag es **inerte cuando `mandatory: false`** — sin mandatory el broker nunca emite return.
|
|
66
|
+
|
|
67
|
+
### Detalles internos
|
|
68
|
+
- `Producer#confirmed` auto-asigna `correlation_id` (UUID) cuando falta y `mandatory + return_raise` están activos — la correlación bridge↔return depende del cid.
|
|
69
|
+
- Nuevo bound de espera `Producer::RETURN_RACE_WINDOW_S = 0.05` tras un ack positivo: tolera el race scheduling entre reader thread (donde Bunny invoca `on_return`) y publish thread (donde se devuelve `wait_for_confirms`). AMQP garantiza orden wire (return precede a ack), pero defendemos contra GVL.
|
|
70
|
+
- `Session` ahora mantiene un registry interno `@pending_returns` (`Concurrent::Map` de cid → `{event, info}`). `handle_broker_return` setea el event *antes* de invocar el user_cb global — una excepción del callback no impide el raise en el caller.
|
|
71
|
+
- Nuevo evento de log `producer.publish_unroutable` (WARN) con `path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `messaging_message_id`. Se emite antes de levantar `PublishUnroutable`.
|
|
72
|
+
- Nuevo evento de log `client.return_raise_ignored` (WARN) cuando se pasa `return_raise: true` sin `confirmed: true` o sin `mandatory: true` — el flag se ignora.
|
|
73
|
+
|
|
3
74
|
## [4.14.0] - 2026-05-12
|
|
4
75
|
|
|
5
76
|
### Nuevas funcionalidades
|
data/README.md
CHANGED
|
@@ -129,7 +129,11 @@ BugBunny.configure do |config|
|
|
|
129
129
|
config.read_timeout = 10
|
|
130
130
|
config.write_timeout = 10
|
|
131
131
|
|
|
132
|
-
# AMQP defaults applied to all exchanges and queues
|
|
132
|
+
# AMQP defaults applied to all exchanges and queues.
|
|
133
|
+
# Gem defaults (since 4.16):
|
|
134
|
+
# DEFAULT_EXCHANGE_OPTIONS = { durable: false, auto_delete: false }
|
|
135
|
+
# DEFAULT_QUEUE_OPTIONS = { exclusive: false, durable: true, auto_delete: false }
|
|
136
|
+
# Override only if your service needs different infrastructure semantics.
|
|
133
137
|
config.exchange_options = { durable: true }
|
|
134
138
|
config.queue_options = { durable: true }
|
|
135
139
|
|
|
@@ -142,7 +146,13 @@ BugBunny.configure do |config|
|
|
|
142
146
|
# Health check file for Kubernetes / Docker Swarm liveness probes
|
|
143
147
|
config.health_check_file = '/tmp/bug_bunny_health'
|
|
144
148
|
|
|
149
|
+
# Publisher Confirms — fail-loud defaults (both flags default to true).
|
|
150
|
+
# Set to false to restore legacy log-only behavior.
|
|
151
|
+
config.nack_raise = true # broker NACK → raise BugBunny::PublishNacked
|
|
152
|
+
config.return_raise = true # broker basic.return (mandatory) → raise BugBunny::PublishUnroutable
|
|
153
|
+
|
|
145
154
|
# Callback invoked when the broker returns an unroutable mandatory message.
|
|
155
|
+
# Runs BEFORE PublishUnroutable is raised (if return_raise is true).
|
|
146
156
|
# When nil (default), BugBunny logs the return as `session.broker_return` at :warn.
|
|
147
157
|
# Signature: ->(return_info, properties, body) { ... }
|
|
148
158
|
config.on_return = ->(return_info, _props, body) {
|
|
@@ -200,6 +210,73 @@ client.publish('events', body: { type: 'user.signed_in', user_id: 42 })
|
|
|
200
210
|
client.request('users', method: :get, params: { role: 'admin', page: 2 })
|
|
201
211
|
```
|
|
202
212
|
|
|
213
|
+
### Gotchas
|
|
214
|
+
|
|
215
|
+
**URL is positional, not a kwarg.** The first argument of `client.request` / `client.publish` is positional. There is **no** `path:` kwarg, splatting a hash with `path:` will fail silently or raise `ArgumentError`:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
args = { exchange: 'ingest.x', body: payload }
|
|
219
|
+
client.publish(**args) # ❌ ArgumentError: wrong number of arguments
|
|
220
|
+
client.publish('event.name', **args) # ✅
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Block runs after kwargs.** Keyword args are applied first; the block (if given) can override them. Use kwargs for the common case and block for atypical setup:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
client.publish('evt', exchange: 'x', persistent: true) do |req|
|
|
227
|
+
req.timestamp = some_past_time # only via block — not in REQUEST_ATTRS
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Production publisher recipe
|
|
232
|
+
|
|
233
|
+
Defaults aimed at sane microservices — declare durable exchanges, persistent messages, confirmed delivery with mandatory routing, explicit correlation id:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
client.publish('acct.start',
|
|
237
|
+
exchange: 'ingest.radius',
|
|
238
|
+
exchange_type: :topic,
|
|
239
|
+
exchange_options: { durable: true }, # match consumer-declared exchange
|
|
240
|
+
body: payload,
|
|
241
|
+
confirmed: true,
|
|
242
|
+
mandatory: true, # raise PublishUnroutable if no binding
|
|
243
|
+
persistent: true, # delivery_mode: 2 — survives broker restart
|
|
244
|
+
correlation_id: SecureRandom.uuid,
|
|
245
|
+
app_id: 'radius_manager')
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
| AMQP property | Kwarg | Reason it matters for critical publishers |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| `delivery_mode` | `persistent: true` | Without it, the message lives only in broker RAM (lost on restart). Default `false`. |
|
|
251
|
+
| `confirmation` | `confirmed: true` | Block until the broker acks. Without it, `client.publish` returns 202 before the broker sees the message. |
|
|
252
|
+
| `mandatory` | `mandatory: true` | Catches misrouted publishes. Combined with `return_raise` (default `true`), raises `PublishUnroutable` instead of silently dropping. |
|
|
253
|
+
| `exchange durable` | `exchange_options: { durable: true }` | Match the exchange definition that consumers declare. Mismatch raises `Bunny::PreconditionFailed`. |
|
|
254
|
+
| `correlation_id` | `correlation_id:` | Tracing. Auto-generated when missing for RPC and for `confirmed + mandatory + return_raise`, but explicit is preferred. |
|
|
255
|
+
|
|
256
|
+
### Testing publishers
|
|
257
|
+
|
|
258
|
+
Mocks of `Client` (via `instance_double`) **do not catch arity mismatches** when the caller does splat (`**args`). Signature errors like passing `path:` as kwarg or unknown keys won't surface in unit tests with mocks. **Add a smoke integration test** for new publishers — declare an exclusive queue, bind to the exchange, publish, `queue.pop`, assert `correlation_id`, `headers`, `routing_key`, `delivery_mode`:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
RSpec.describe 'MyPublisher', :integration do
|
|
262
|
+
it 'publishes with correct AMQP metadata' do
|
|
263
|
+
conn = BugBunny.create_connection
|
|
264
|
+
ch = conn.create_channel
|
|
265
|
+
x = ch.topic('ingest.radius', durable: true)
|
|
266
|
+
q = ch.queue('', exclusive: true).bind(x, routing_key: 'acct.#')
|
|
267
|
+
|
|
268
|
+
MyPublisher.call(payload)
|
|
269
|
+
|
|
270
|
+
_delivery, props, body = q.pop(manual_ack: false)
|
|
271
|
+
expect(props.correlation_id).not_to be_nil
|
|
272
|
+
expect(props.delivery_mode).to eq(2) # persistent
|
|
273
|
+
expect(JSON.parse(body)).to include(...)
|
|
274
|
+
ensure
|
|
275
|
+
conn&.close
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
203
280
|
### Publisher Confirms (delivery-critical events)
|
|
204
281
|
|
|
205
282
|
For events where you need a delivery guarantee from the broker (auditing, billing, accounting) without the cost of a full RPC, use `publish` with `confirmed: true`. The call blocks until the broker acknowledges receipt:
|
|
@@ -220,11 +297,26 @@ client.publish('acct.start',
|
|
|
220
297
|
| `confirmed` | Boolean | `false` | Block until `wait_for_confirms` returns. |
|
|
221
298
|
| `mandatory` | Boolean | `false` | Broker returns the message if it cannot be routed to any queue. Requires `confirmed: true` to be useful. |
|
|
222
299
|
| `confirm_timeout` | Float | `nil` | Seconds to wait for the broker ACK. Raises `BugBunny::RequestTimeout` if exceeded. |
|
|
223
|
-
| `nack_raise` | Boolean | `nil` | Per-request override of `config.nack_raise`. When `nil`, falls back to the global flag. |
|
|
300
|
+
| `nack_raise` | Boolean | `nil` | Per-request override of `config.nack_raise`. When `nil`, falls back to the global flag (default `true`). |
|
|
301
|
+
| `return_raise` | Boolean | `nil` | Per-request override of `config.return_raise`. When `nil`, falls back to the global flag (default `true`). Requires `confirmed: true` and `mandatory: true` to take effect. |
|
|
302
|
+
|
|
303
|
+
**Two broker signals, two exceptions:**
|
|
304
|
+
|
|
305
|
+
| Broker signal | Default behavior | Exception class | Fields |
|
|
306
|
+
|---|---|---|---|
|
|
307
|
+
| `basic.nack` (explicit rejection) | Raises | `BugBunny::PublishNacked` | `path`, `nacked_count` |
|
|
308
|
+
| `basic.return` (unroutable + `mandatory: true`) | Raises | `BugBunny::PublishUnroutable` | `path`, `exchange`, `routing_key`, `reply_code`, `reply_text`, `correlation_id` |
|
|
224
309
|
|
|
225
|
-
|
|
310
|
+
Both exceptions translate naturally into HTTP 5xx in critical publishers (audit, billing, RADIUS accounting) so upstream systems retry. The `config.on_return` callback (if defined) still runs before `PublishUnroutable` is raised — useful for alerting/metrics. To restore the legacy "log-only" behaviour:
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
BugBunny.configure do |c|
|
|
314
|
+
c.nack_raise = false # or pass `nack_raise: false` per request
|
|
315
|
+
c.return_raise = false # or pass `return_raise: false` per request
|
|
316
|
+
end
|
|
317
|
+
```
|
|
226
318
|
|
|
227
|
-
|
|
319
|
+
When `mandatory: false` (the default), `return_raise` is inert — the broker never emits `basic.return` without mandatory.
|
|
228
320
|
|
|
229
321
|
---
|
|
230
322
|
|
|
@@ -285,9 +377,15 @@ BugBunny maps RabbitMQ responses to a semantic exception hierarchy, similar to h
|
|
|
285
377
|
|
|
286
378
|
```
|
|
287
379
|
BugBunny::Error
|
|
380
|
+
├── CommunicationError (network / channel failure)
|
|
381
|
+
├── ConfigurationError (invalid config attribute)
|
|
382
|
+
├── SecurityError (unauthorized controller resolution)
|
|
383
|
+
├── PublishNacked (broker basic.nack on :confirmed publish)
|
|
384
|
+
├── PublishUnroutable (broker basic.return on mandatory + :confirmed)
|
|
288
385
|
├── ClientError (4xx)
|
|
289
386
|
│ ├── BadRequest (400)
|
|
290
387
|
│ ├── NotFound (404)
|
|
388
|
+
│ │ └── RoutingError (consumer-side: no route for verb + path)
|
|
291
389
|
│ ├── NotAcceptable (406)
|
|
292
390
|
│ ├── RequestTimeout (408)
|
|
293
391
|
│ ├── Conflict (409)
|
data/lib/bug_bunny/client.rb
CHANGED
|
@@ -29,9 +29,17 @@ module BugBunny
|
|
|
29
29
|
attr_accessor :delivery_mode
|
|
30
30
|
|
|
31
31
|
# Argumentos del cliente que se mapean 1:1 a setters del Request.
|
|
32
|
+
#
|
|
33
|
+
# Incluye opciones de enrutamiento (`delivery_mode`, `method`, `body`, `exchange`,
|
|
34
|
+
# `exchange_type`, `routing_key`, `timeout`, `params`), de infraestructura
|
|
35
|
+
# (`exchange_options`, `queue_options`) y de metadata AMQP estándar
|
|
36
|
+
# (`persistent`, `correlation_id`, `priority`, `app_id`, `content_type`,
|
|
37
|
+
# `content_encoding`, `expiration`). Lo que no esté acá debe setearse via
|
|
38
|
+
# block API (`do |req| ... end`).
|
|
32
39
|
REQUEST_ATTRS = %i[
|
|
33
40
|
delivery_mode method body exchange exchange_type routing_key
|
|
34
41
|
timeout exchange_options queue_options params
|
|
42
|
+
persistent correlation_id priority app_id content_type content_encoding expiration
|
|
35
43
|
].freeze
|
|
36
44
|
|
|
37
45
|
# Inicializa un nuevo cliente.
|
|
@@ -64,7 +72,8 @@ module BugBunny
|
|
|
64
72
|
#
|
|
65
73
|
# Envía un mensaje y bloquea la ejecución del hilo actual hasta recibir respuesta.
|
|
66
74
|
#
|
|
67
|
-
# @param url [String] La ruta del recurso (ej: 'users/1').
|
|
75
|
+
# @param url [String] La ruta del recurso (ej: 'users/1'). **Argumento posicional** —
|
|
76
|
+
# no existe el kwarg `:path`. Splatear un hash con `path:` falla silencioso.
|
|
68
77
|
# @param args [Hash] Opciones de configuración.
|
|
69
78
|
# @option args [Symbol] :method El verbo HTTP (:get, :post, :put, :delete). Default: :get.
|
|
70
79
|
# @option args [Object] :body El cuerpo del mensaje.
|
|
@@ -72,7 +81,18 @@ module BugBunny
|
|
|
72
81
|
# @option args [Integer] :timeout Tiempo máximo de espera.
|
|
73
82
|
# @option args [Hash] :exchange_options Opciones específicas para la declaración del Exchange.
|
|
74
83
|
# @option args [Hash] :queue_options Opciones específicas para la declaración de la Cola.
|
|
75
|
-
# @
|
|
84
|
+
# @option args [Boolean] :persistent Si `true`, `delivery_mode: 2` (mensaje persiste en disco
|
|
85
|
+
# del broker). Default `false`. Útil para mensajes críticos sobre queues durables.
|
|
86
|
+
# @option args [String] :correlation_id ID de correlación AMQP. Útil para tracing custom.
|
|
87
|
+
# Si no se setea, `Producer#rpc` y `Producer#confirmed` (con return_raise) auto-asignan UUID.
|
|
88
|
+
# @option args [Integer] :priority Prioridad del mensaje (0-255). Requiere queue declarada con
|
|
89
|
+
# `x-max-priority` para que el broker la respete.
|
|
90
|
+
# @option args [String] :app_id Identificador del publisher.
|
|
91
|
+
# @option args [String] :content_type MIME type. Default `'application/json'`.
|
|
92
|
+
# @option args [String] :content_encoding Encoding del payload (ej: 'gzip').
|
|
93
|
+
# @option args [String] :expiration TTL del mensaje en ms (formato AMQP).
|
|
94
|
+
# @yield [req] Bloque para configurar el objeto Request directamente. Necesario para
|
|
95
|
+
# atributos no mapeados como kwarg (ej: `req.timestamp = ...`).
|
|
76
96
|
# @return [Hash] La respuesta del servidor.
|
|
77
97
|
def request(url, **args)
|
|
78
98
|
send(url, **args) do |req|
|
|
@@ -87,7 +107,8 @@ module BugBunny
|
|
|
87
107
|
# que el broker confirme la recepción del mensaje. Útil para eventos críticos (auditoría,
|
|
88
108
|
# billing) donde se requiere garantía de entrega sin el overhead de un RPC completo.
|
|
89
109
|
#
|
|
90
|
-
# @param url [String] La ruta del evento/recurso.
|
|
110
|
+
# @param url [String] La ruta del evento/recurso. **Argumento posicional** — no
|
|
111
|
+
# existe el kwarg `:path`. Splatear un hash con `path:` falla silencioso.
|
|
91
112
|
# @param args [Hash] Mismas opciones que {#request}, excepto `:timeout`. Adicionales:
|
|
92
113
|
# @option args [Boolean] :confirmed Si `true`, espera `wait_for_confirms` del broker.
|
|
93
114
|
# @option args [Boolean] :mandatory Si `true`, el broker retorna el mensaje si no es ruteable.
|
|
@@ -95,10 +116,16 @@ module BugBunny
|
|
|
95
116
|
# @option args [Float] :confirm_timeout Segundos a esperar el confirm. `nil` espera indefinidamente.
|
|
96
117
|
# @option args [Boolean] :nack_raise Override per-request del flag
|
|
97
118
|
# `BugBunny.configuration.nack_raise`. Si `nil` (default), se usa la configuración global.
|
|
119
|
+
# @option args [Boolean] :return_raise Override per-request del flag
|
|
120
|
+
# `BugBunny.configuration.return_raise`. Si `nil` (default), se usa la configuración global.
|
|
121
|
+
# Requiere `mandatory: true` y `confirmed: true` para tener efecto — sino se emite un
|
|
122
|
+
# warning y el flag se ignora.
|
|
98
123
|
# @yield [req] Bloque para configurar el objeto Request.
|
|
99
124
|
# @return [Hash] `{ 'status' => 202, 'body' => nil }`.
|
|
100
125
|
# @raise [BugBunny::RequestTimeout] Si `confirmed: true` y el broker no confirma a tiempo.
|
|
101
126
|
# @raise [BugBunny::PublishNacked] Si `confirmed: true`, el broker NACKea, y `nack_raise` resuelto es true.
|
|
127
|
+
# @raise [BugBunny::PublishUnroutable] Si `confirmed: true`, `mandatory: true`, el broker retorna el
|
|
128
|
+
# mensaje como no-ruteable, y `return_raise` resuelto es true.
|
|
102
129
|
def publish(url, **args)
|
|
103
130
|
send(url, **args) do |req|
|
|
104
131
|
req.delivery_mode = args[:confirmed] ? :confirmed : :publish
|
|
@@ -120,6 +147,10 @@ module BugBunny
|
|
|
120
147
|
# Configuración del usuario (bloque específico por request)
|
|
121
148
|
yield req if block_given?
|
|
122
149
|
|
|
150
|
+
# Check post-block: el block API puede setear delivery_mode/mandatory después
|
|
151
|
+
# de los keyword args. Evaluamos el warning sobre el estado final del Request.
|
|
152
|
+
warn_return_raise_misuse(req)
|
|
153
|
+
|
|
123
154
|
# Ejecución dentro del Pool.
|
|
124
155
|
# Session y Producer se reutilizan por slot de conexión (ver #session_for / #producer_for).
|
|
125
156
|
@pool.with do |conn|
|
|
@@ -148,12 +179,16 @@ module BugBunny
|
|
|
148
179
|
|
|
149
180
|
# Mapea los argumentos generales (no específicos de Publisher Confirms) sobre el Request.
|
|
150
181
|
#
|
|
182
|
+
# Usa `args.key?` en lugar de truthy check para que `persistent: false`,
|
|
183
|
+
# `priority: 0` u otros valores falsy explícitos del caller sean honrados
|
|
184
|
+
# (no se filtran como si no hubieran sido pasados).
|
|
185
|
+
#
|
|
151
186
|
# @param req [BugBunny::Request]
|
|
152
187
|
# @param args [Hash]
|
|
153
188
|
# @return [void]
|
|
154
189
|
def apply_args(req, args)
|
|
155
190
|
REQUEST_ATTRS.each do |key|
|
|
156
|
-
req.public_send("#{key}=", args[key]) if args
|
|
191
|
+
req.public_send("#{key}=", args[key]) if args.key?(key)
|
|
157
192
|
end
|
|
158
193
|
req.headers.merge!(args[:headers]) if args[:headers]
|
|
159
194
|
end
|
|
@@ -179,6 +214,33 @@ module BugBunny
|
|
|
179
214
|
req.mandatory = args[:mandatory] if args.key?(:mandatory)
|
|
180
215
|
req.confirm_timeout = args[:confirm_timeout] if args.key?(:confirm_timeout)
|
|
181
216
|
req.nack_raise = args[:nack_raise] if args.key?(:nack_raise)
|
|
217
|
+
req.return_raise = args[:return_raise] if args.key?(:return_raise)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Emite un warning si el Request final tiene `return_raise: true` pero le falta
|
|
221
|
+
# `delivery_mode == :confirmed` o `mandatory: true`. El flag requiere ambos para
|
|
222
|
+
# tener efecto: sin confirmed no hay synchronization point sobre el cual levantar,
|
|
223
|
+
# y sin mandatory el broker nunca retorna.
|
|
224
|
+
#
|
|
225
|
+
# Se evalúa sobre el Request post-block (no sobre args) para no producir falsos
|
|
226
|
+
# positivos cuando el caller usa el block API para setear `req.delivery_mode` o
|
|
227
|
+
# `req.mandatory` después de los keyword args.
|
|
228
|
+
#
|
|
229
|
+
# Solo warnea cuando `request.return_raise` fue explícitamente `true` por request —
|
|
230
|
+
# ignora el default global (que también puede ser `true`) para no inundar logs en
|
|
231
|
+
# publishes regulares sin mandatory.
|
|
232
|
+
#
|
|
233
|
+
# @param request [BugBunny::Request]
|
|
234
|
+
# @return [void]
|
|
235
|
+
def warn_return_raise_misuse(request)
|
|
236
|
+
return unless request.return_raise == true
|
|
237
|
+
return if request.delivery_mode == :confirmed && request.mandatory
|
|
238
|
+
|
|
239
|
+
BugBunny.configuration.logger&.warn do
|
|
240
|
+
'component=bug_bunny event=client.return_raise_ignored ' \
|
|
241
|
+
'reason=requires_confirmed_and_mandatory ' \
|
|
242
|
+
"delivery_mode=#{request.delivery_mode} mandatory=#{!!request.mandatory}"
|
|
243
|
+
end
|
|
182
244
|
end
|
|
183
245
|
|
|
184
246
|
# Recupera o crea la Session asociada al slot de conexión dado.
|
|
@@ -162,6 +162,19 @@ module BugBunny
|
|
|
162
162
|
# `Client#publish`.
|
|
163
163
|
attr_accessor :nack_raise
|
|
164
164
|
|
|
165
|
+
# @return [Boolean] Si `true` (default), {BugBunny::Producer#confirmed} levanta
|
|
166
|
+
# {BugBunny::PublishUnroutable} cuando el broker retorna un mensaje publicado
|
|
167
|
+
# con `mandatory: true` que no pudo rutearse. Si `false`, el return solo se
|
|
168
|
+
# procesa via {#on_return} callback (modo legacy) y la llamada retorna
|
|
169
|
+
# `{ 'status' => 202 }`.
|
|
170
|
+
#
|
|
171
|
+
# El flag es inerte cuando `mandatory: false` (sin mandatory, el broker nunca
|
|
172
|
+
# retorna). El callback {#on_return} se sigue invocando antes del raise.
|
|
173
|
+
#
|
|
174
|
+
# El valor puede sobreescribirse por request pasando `return_raise:` en
|
|
175
|
+
# `Client#publish`.
|
|
176
|
+
attr_accessor :return_raise
|
|
177
|
+
|
|
165
178
|
# @!endgroup
|
|
166
179
|
|
|
167
180
|
# Inicializa la configuración con valores por defecto seguros.
|
|
@@ -239,6 +252,7 @@ module BugBunny
|
|
|
239
252
|
@on_rpc_reply = nil
|
|
240
253
|
@on_return = nil
|
|
241
254
|
@nack_raise = true
|
|
255
|
+
@return_raise = true
|
|
242
256
|
end
|
|
243
257
|
|
|
244
258
|
def validate_required!(attr, value, rules)
|
data/lib/bug_bunny/exception.rb
CHANGED
|
@@ -50,6 +50,67 @@ module BugBunny
|
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# Error lanzado cuando el broker retorna un mensaje publicado con `mandatory: true`
|
|
54
|
+
# que no pudo rutearse a ninguna cola en modo `:confirmed`.
|
|
55
|
+
#
|
|
56
|
+
# Un return implica que el publish llegó al broker pero ninguna binding aceptó la
|
|
57
|
+
# routing key — el mensaje se considera no entregado desde la perspectiva del
|
|
58
|
+
# publisher. Espejo simétrico de {PublishNacked} pero para la señal `basic.return`
|
|
59
|
+
# en lugar de `basic.nack`.
|
|
60
|
+
#
|
|
61
|
+
# Se levanta por default desde {BugBunny::Producer#confirmed} cuando el request
|
|
62
|
+
# tiene `mandatory: true`. Para opt-out, configurar
|
|
63
|
+
# `BugBunny.configuration.return_raise = false` o pasar `return_raise: false`
|
|
64
|
+
# por request. El callback `BugBunny.configuration.on_return` se sigue invocando
|
|
65
|
+
# antes del raise (orden: registro interno → user_cb → raise en el caller).
|
|
66
|
+
#
|
|
67
|
+
# @example
|
|
68
|
+
# rescue BugBunny::PublishUnroutable => e
|
|
69
|
+
# e.path # => 'acct.start'
|
|
70
|
+
# e.exchange # => 'acct_x'
|
|
71
|
+
# e.routing_key # => 'acct.unbound'
|
|
72
|
+
# e.reply_code # => 312
|
|
73
|
+
# e.reply_text # => 'NO_ROUTE'
|
|
74
|
+
# e.correlation_id # => 'corr-uuid-...'
|
|
75
|
+
class PublishUnroutable < Error
|
|
76
|
+
# @return [String] Ruta lógica del request cuyo publish fue retornado.
|
|
77
|
+
attr_reader :path
|
|
78
|
+
|
|
79
|
+
# @return [String] Nombre del exchange destino.
|
|
80
|
+
attr_reader :exchange
|
|
81
|
+
|
|
82
|
+
# @return [String] Routing key utilizada en el publish.
|
|
83
|
+
attr_reader :routing_key
|
|
84
|
+
|
|
85
|
+
# @return [Integer, nil] Código AMQP de la razón (ej: 312 = NO_ROUTE).
|
|
86
|
+
attr_reader :reply_code
|
|
87
|
+
|
|
88
|
+
# @return [String, nil] Texto humano-legible que describe la razón.
|
|
89
|
+
attr_reader :reply_text
|
|
90
|
+
|
|
91
|
+
# @return [String, nil] Correlation ID del request retornado.
|
|
92
|
+
attr_reader :correlation_id
|
|
93
|
+
|
|
94
|
+
# @param path [String] Ruta lógica del request (ej: 'acct.start').
|
|
95
|
+
# @param exchange [String] Nombre del exchange destino.
|
|
96
|
+
# @param routing_key [String] Routing key del publish.
|
|
97
|
+
# @param reply_code [Integer, nil] Código AMQP del return.
|
|
98
|
+
# @param reply_text [String, nil] Texto del return.
|
|
99
|
+
# @param correlation_id [String, nil] Correlation ID del mensaje retornado.
|
|
100
|
+
# rubocop:disable Metrics/ParameterLists
|
|
101
|
+
def initialize(path:, exchange:, routing_key:, reply_code: nil, reply_text: nil, correlation_id: nil)
|
|
102
|
+
@path = path
|
|
103
|
+
@exchange = exchange
|
|
104
|
+
@routing_key = routing_key
|
|
105
|
+
@reply_code = reply_code
|
|
106
|
+
@reply_text = reply_text
|
|
107
|
+
@correlation_id = correlation_id
|
|
108
|
+
super("broker returned unroutable message on path=#{path} exchange=#{exchange} " \
|
|
109
|
+
"routing_key=#{routing_key} reply_code=#{reply_code} reply_text=#{reply_text}")
|
|
110
|
+
end
|
|
111
|
+
# rubocop:enable Metrics/ParameterLists
|
|
112
|
+
end
|
|
113
|
+
|
|
53
114
|
# ==========================================
|
|
54
115
|
# Categoría: Errores del Cliente (4xx)
|
|
55
116
|
# ==========================================
|
data/lib/bug_bunny/producer.rb
CHANGED
|
@@ -15,6 +15,13 @@ module BugBunny
|
|
|
15
15
|
class Producer
|
|
16
16
|
include BugBunny::Observability
|
|
17
17
|
|
|
18
|
+
# Bound de espera (segundos) que el publish thread aplica tras un ack positivo para
|
|
19
|
+
# tolerar el scheduling race entre reader thread (donde Bunny invoca `on_return`) y
|
|
20
|
+
# publish thread (donde se devuelve `wait_for_confirms`). AMQP garantiza que el
|
|
21
|
+
# `basic.return` precede al `basic.ack` en la wire, así que en la práctica el event
|
|
22
|
+
# ya está seteado al llegar acá; este wait es defensa contra GVL.
|
|
23
|
+
RETURN_RACE_WINDOW_S = 0.05
|
|
24
|
+
|
|
18
25
|
# Inicializa el productor.
|
|
19
26
|
#
|
|
20
27
|
# Prepara las estructuras de concurrencia necesarias para manejar múltiples
|
|
@@ -58,8 +65,13 @@ module BugBunny
|
|
|
58
65
|
# @raise [BugBunny::RequestTimeout] Si el broker no confirma dentro de `confirm_timeout` segundos.
|
|
59
66
|
# @raise [BugBunny::PublishNacked] Si el broker NACKea la publicación y `nack_raise` está activo
|
|
60
67
|
# (default `true` — ver {BugBunny::Configuration#nack_raise}).
|
|
68
|
+
# @raise [BugBunny::PublishUnroutable] Si `mandatory: true` y el broker retorna el mensaje como
|
|
69
|
+
# no-ruteable, con `return_raise` activo (default `true` — ver
|
|
70
|
+
# {BugBunny::Configuration#return_raise}).
|
|
61
71
|
# @raise [BugBunny::CommunicationError] Si el canal AMQP falla durante la publicación o confirm.
|
|
62
72
|
def confirmed(request)
|
|
73
|
+
return_listener = nil
|
|
74
|
+
return_listener = setup_return_listener(request)
|
|
63
75
|
started_at = monotonic_now
|
|
64
76
|
publish_duration = publish_message(request)
|
|
65
77
|
|
|
@@ -68,6 +80,7 @@ module BugBunny
|
|
|
68
80
|
confirm_duration = duration_s(confirm_started_at)
|
|
69
81
|
|
|
70
82
|
handle_confirm_result(request, acked)
|
|
83
|
+
handle_return_result(request, return_listener)
|
|
71
84
|
log_confirmed(request, publish_duration, confirm_duration, started_at)
|
|
72
85
|
|
|
73
86
|
{ 'status' => 202, 'body' => nil }
|
|
@@ -75,6 +88,8 @@ module BugBunny
|
|
|
75
88
|
raise
|
|
76
89
|
rescue StandardError => e
|
|
77
90
|
raise BugBunny::CommunicationError, "Publisher confirms failed: #{e.message}"
|
|
91
|
+
ensure
|
|
92
|
+
teardown_return_listener(request, return_listener)
|
|
78
93
|
end
|
|
79
94
|
|
|
80
95
|
# Envía un mensaje y bloquea el hilo actual esperando una respuesta (RPC).
|
|
@@ -240,6 +255,95 @@ module BugBunny
|
|
|
240
255
|
BugBunny.configuration.nack_raise
|
|
241
256
|
end
|
|
242
257
|
|
|
258
|
+
# Resuelve el flag `return_raise` con prioridad request > configuración global.
|
|
259
|
+
# El flag solo tiene efecto cuando `mandatory: true` — sin mandatory el broker
|
|
260
|
+
# nunca emite `basic.return`.
|
|
261
|
+
#
|
|
262
|
+
# @param request [BugBunny::Request]
|
|
263
|
+
# @return [Boolean]
|
|
264
|
+
def return_raise?(request)
|
|
265
|
+
return false unless request.mandatory
|
|
266
|
+
|
|
267
|
+
return request.return_raise unless request.return_raise.nil?
|
|
268
|
+
|
|
269
|
+
BugBunny.configuration.return_raise
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Registra un listener de `basic.return` en la Session asociada al `correlation_id`
|
|
273
|
+
# del request. Si return_raise? es false (mandatory off o flag desactivado), retorna
|
|
274
|
+
# `nil` y el resto del flow ignora la lógica de unroutable.
|
|
275
|
+
#
|
|
276
|
+
# Auto-asigna `correlation_id` si falta — sin cid no hay clave de correlación.
|
|
277
|
+
#
|
|
278
|
+
# @param request [BugBunny::Request]
|
|
279
|
+
# @return [Hash, nil] Hash con `:cid` y `:slot` (el slot expone `:event` y `:info`),
|
|
280
|
+
# o `nil` si no aplica.
|
|
281
|
+
def setup_return_listener(request)
|
|
282
|
+
return nil unless return_raise?(request)
|
|
283
|
+
|
|
284
|
+
request.correlation_id ||= SecureRandom.uuid
|
|
285
|
+
cid = request.correlation_id.to_s
|
|
286
|
+
_event, slot = @session.register_return_listener(cid)
|
|
287
|
+
{ cid: cid, slot: slot }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Tras un ack positivo, espera brevemente al event del listener para tolerar el
|
|
291
|
+
# scheduling race entre reader thread (donde corre `on_return`) y publish thread.
|
|
292
|
+
# En AMQP el `basic.return` precede al `basic.ack`, así que normalmente el event
|
|
293
|
+
# ya está seteado al llegar acá; el bounded wait defiende contra GVL/scheduling.
|
|
294
|
+
#
|
|
295
|
+
# @param request [BugBunny::Request]
|
|
296
|
+
# @param return_listener [Hash, nil] Resultado de {#setup_return_listener}.
|
|
297
|
+
# @return [void]
|
|
298
|
+
# @raise [BugBunny::PublishUnroutable] Si el listener recibió un return.
|
|
299
|
+
def handle_return_result(request, return_listener)
|
|
300
|
+
return unless return_listener
|
|
301
|
+
|
|
302
|
+
slot = return_listener[:slot]
|
|
303
|
+
slot[:event].wait(RETURN_RACE_WINDOW_S)
|
|
304
|
+
info = slot[:info]
|
|
305
|
+
return if info.nil?
|
|
306
|
+
|
|
307
|
+
raise_unroutable!(request, return_listener[:cid], info)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Logea y levanta {BugBunny::PublishUnroutable} con los datos del return.
|
|
311
|
+
#
|
|
312
|
+
# @param request [BugBunny::Request]
|
|
313
|
+
# @param cid [String]
|
|
314
|
+
# @param info [Bunny::ReturnInfo, #exchange, #routing_key, #reply_code, #reply_text]
|
|
315
|
+
# @raise [BugBunny::PublishUnroutable]
|
|
316
|
+
def raise_unroutable!(request, cid, info)
|
|
317
|
+
safe_log(:warn, 'producer.publish_unroutable',
|
|
318
|
+
path: request.path,
|
|
319
|
+
exchange: info.exchange,
|
|
320
|
+
routing_key: info.routing_key,
|
|
321
|
+
reply_code: info.reply_code,
|
|
322
|
+
reply_text: info.reply_text,
|
|
323
|
+
messaging_message_id: cid)
|
|
324
|
+
|
|
325
|
+
raise BugBunny::PublishUnroutable.new(
|
|
326
|
+
path: request.path,
|
|
327
|
+
exchange: info.exchange,
|
|
328
|
+
routing_key: info.routing_key,
|
|
329
|
+
reply_code: info.reply_code,
|
|
330
|
+
reply_text: info.reply_text,
|
|
331
|
+
correlation_id: cid
|
|
332
|
+
)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Cleanup del listener en la Session. Siempre se llama desde el `ensure` de
|
|
336
|
+
# {#confirmed}, sea cual sea el outcome (ack, nack, timeout, return).
|
|
337
|
+
#
|
|
338
|
+
# @param request [BugBunny::Request]
|
|
339
|
+
# @param return_listener [Hash, nil]
|
|
340
|
+
# @return [void]
|
|
341
|
+
def teardown_return_listener(_request, return_listener)
|
|
342
|
+
return unless return_listener
|
|
343
|
+
|
|
344
|
+
@session.unregister_return_listener(return_listener[:cid])
|
|
345
|
+
end
|
|
346
|
+
|
|
243
347
|
# Registra la petición en el log calculando las opciones de infraestructura.
|
|
244
348
|
#
|
|
245
349
|
# @param request [BugBunny::Request] Objeto Request que se está enviando.
|
data/lib/bug_bunny/request.rb
CHANGED
|
@@ -29,6 +29,9 @@ module BugBunny
|
|
|
29
29
|
# `nil` espera indefinidamente.
|
|
30
30
|
# @attr nack_raise [Boolean, nil] Override per-request del flag global
|
|
31
31
|
# `BugBunny.configuration.nack_raise`. `nil` (default) delega a la configuración global.
|
|
32
|
+
# @attr return_raise [Boolean, nil] Override per-request del flag global
|
|
33
|
+
# `BugBunny.configuration.return_raise`. `nil` (default) delega a la configuración global.
|
|
34
|
+
# Requiere `mandatory: true` y `delivery_mode = :confirmed` para tener efecto.
|
|
32
35
|
class Request
|
|
33
36
|
attr_accessor :body, :headers, :params, :path, :method, :exchange, :exchange_type, :routing_key, :timeout,
|
|
34
37
|
:delivery_mode, :queue_options
|
|
@@ -37,7 +40,7 @@ module BugBunny
|
|
|
37
40
|
attr_accessor :exchange_options
|
|
38
41
|
|
|
39
42
|
# Publisher Confirms (delivery_mode = :confirmed)
|
|
40
|
-
attr_accessor :mandatory, :confirm_timeout, :nack_raise
|
|
43
|
+
attr_accessor :mandatory, :confirm_timeout, :nack_raise, :return_raise
|
|
41
44
|
|
|
42
45
|
# Metadatos AMQP Estándar
|
|
43
46
|
attr_accessor :app_id, :content_type, :content_encoding, :priority,
|
|
@@ -66,6 +69,7 @@ module BugBunny
|
|
|
66
69
|
@mandatory = false
|
|
67
70
|
@confirm_timeout = nil
|
|
68
71
|
@nack_raise = nil
|
|
72
|
+
@return_raise = nil
|
|
69
73
|
end
|
|
70
74
|
|
|
71
75
|
# Combina el path con los params como query string.
|