bug_bunny 4.11.1 → 4.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +34 -1
- data/lib/bug_bunny/client.rb +80 -33
- data/lib/bug_bunny/configuration.rb +39 -2
- data/lib/bug_bunny/exception.rb +31 -0
- data/lib/bug_bunny/producer.rb +109 -14
- data/lib/bug_bunny/request.rb +18 -2
- data/lib/bug_bunny/session.rb +50 -1
- data/lib/bug_bunny/version.rb +1 -1
- data/skill/SKILL.md +29 -2
- data/skill/references/client-middleware.md +91 -7
- data/skill/references/errores.md +7 -0
- data/skills.yml +8 -23
- data/spec/support/bunny_mocks.rb +36 -0
- data/spec/unit/client_session_pool_spec.rb +79 -2
- data/spec/unit/configuration_spec.rb +25 -0
- data/spec/unit/producer_spec.rb +142 -0
- data/spec/unit/request_spec.rb +29 -0
- data/spec/unit/session_spec.rb +82 -0
- metadata +3 -4
- data/skills.lock +0 -33
data/skill/SKILL.md
CHANGED
|
@@ -15,6 +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.
|
|
19
|
+
**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
|
+
**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.
|
|
18
21
|
**Resource** — ORM tipo ActiveRecord que mapea operaciones CRUD a llamadas AMQP.
|
|
19
22
|
**Consumer** — Worker bloqueante que despacha mensajes a controladores mediante un Router.
|
|
20
23
|
**Connection Pool** — Pool de conexiones (`connection_pool` gem) que comparte sessions entre threads. Cada slot cachea su `Session` y `Producer`.
|
|
@@ -59,7 +62,7 @@ Skill de conocimiento completo sobre BugBunny. Consultame para cualquier pregunt
|
|
|
59
62
|
|---|---|
|
|
60
63
|
| `BugBunny::Configuration` | Configuración global. Valida campos requeridos en `BugBunny.configure`. |
|
|
61
64
|
| `BugBunny::Session` | Wrapper de canal Bunny. Declara exchanges y queues. Thread-safe con double-checked locking. |
|
|
62
|
-
| `BugBunny::Producer` | Publica mensajes. Implementa
|
|
65
|
+
| `BugBunny::Producer` | Publica mensajes. Implementa tres modos: `#fire` (async), `#confirmed` (sync con `wait_for_confirms`) y `#rpc` (direct reply-to + `Concurrent::IVar`). |
|
|
63
66
|
| `BugBunny::Client` | API de alto nivel. Pool de conexiones y middleware stack (onion architecture). |
|
|
64
67
|
| `BugBunny::Consumer` | Subscribe loop con health check. Rutea mensajes via `BugBunny.routes`. |
|
|
65
68
|
| `BugBunny::ConsumerMiddleware::Stack` | Pipeline de middlewares antes de `process_message`. Thread-safe. |
|
|
@@ -126,6 +129,11 @@ BugBunny.configure do |config|
|
|
|
126
129
|
config.rpc_reply_headers = -> { { 'X-Trace-Id' => Tracer.id } }
|
|
127
130
|
config.on_rpc_reply = ->(h) { Tracer.hydrate(h['X-Trace-Id']) }
|
|
128
131
|
|
|
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.
|
|
134
|
+
# Firma: ->(return_info, properties, body)
|
|
135
|
+
config.on_return = ->(ri, _props, body) { MyAlerts.unroutable(rk: ri.routing_key, body: body) }
|
|
136
|
+
|
|
129
137
|
# Infraestructura (cascade level 2)
|
|
130
138
|
config.exchange_options = { durable: true }
|
|
131
139
|
config.queue_options = { auto_delete: false }
|
|
@@ -169,7 +177,13 @@ Genera rutas REST estándar (index, show, create, update, destroy) mapeadas a co
|
|
|
169
177
|
|
|
170
178
|
---
|
|
171
179
|
|
|
172
|
-
## API:
|
|
180
|
+
## API: Modos de Entrega
|
|
181
|
+
|
|
182
|
+
| Modo | Espera | Uso | Método |
|
|
183
|
+
|---|---|---|---|
|
|
184
|
+
| `:rpc` | Reply del consumer remoto | Request-response síncrono | `client.request(...)` |
|
|
185
|
+
| `:publish` | Nada | Logs, eventos best-effort | `client.publish(...)` |
|
|
186
|
+
| `:confirmed` | ACK del broker (`wait_for_confirms`) | Auditoría, billing, eventos críticos | `client.publish(..., confirmed: true)` |
|
|
173
187
|
|
|
174
188
|
**RPC síncrono** — Bloquea hasta respuesta. Usa `amq.rabbitmq.reply-to`. Timeout configurable.
|
|
175
189
|
```ruby
|
|
@@ -183,6 +197,19 @@ client.publish('events', method: :post, body: { type: 'order.placed' })
|
|
|
183
197
|
# → { 'status' => 202, 'body' => nil }
|
|
184
198
|
```
|
|
185
199
|
|
|
200
|
+
**Confirmed (Publisher Confirms)** — Bloquea hasta el ACK del broker. Garantía de entrega al broker (no al consumer remoto).
|
|
201
|
+
```ruby
|
|
202
|
+
client.publish('acct.start', exchange: 'acct_x', body: payload,
|
|
203
|
+
confirmed: true, mandatory: true, confirm_timeout: 0.5)
|
|
204
|
+
# → { '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
|
+
```
|
|
210
|
+
|
|
211
|
+
`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
|
+
|
|
186
213
|
---
|
|
187
214
|
|
|
188
215
|
## FAQ
|
|
@@ -7,7 +7,7 @@ API de alto nivel para publicar mensajes. Usa un pool de conexiones y una pila d
|
|
|
7
7
|
### Métodos Principales
|
|
8
8
|
|
|
9
9
|
```ruby
|
|
10
|
-
# RPC síncrono — bloquea hasta respuesta
|
|
10
|
+
# RPC síncrono — bloquea hasta respuesta del consumer
|
|
11
11
|
response = client.request('users/123', method: :get, timeout: 30)
|
|
12
12
|
response = client.request('users', method: :post, body: { name: 'John' })
|
|
13
13
|
# → { 'status' => 200, 'body' => {...} }
|
|
@@ -16,8 +16,17 @@ response = client.request('users', method: :post, body: { name: 'John' })
|
|
|
16
16
|
client.publish('events/user_created', method: :post, body: { user_id: 42 })
|
|
17
17
|
# → { 'status' => 202, 'body' => nil }
|
|
18
18
|
|
|
19
|
-
#
|
|
20
|
-
client.
|
|
19
|
+
# Publisher Confirms — bloquea hasta ACK del broker (no del consumer)
|
|
20
|
+
client.publish('acct.start', exchange: 'acct_x', body: payload,
|
|
21
|
+
confirmed: true, mandatory: true, confirm_timeout: 0.5)
|
|
22
|
+
# → { 'status' => 202, 'body' => nil }
|
|
23
|
+
|
|
24
|
+
# General con bloque — máximo control
|
|
25
|
+
client.send(url) do |req|
|
|
26
|
+
req.delivery_mode = :confirmed
|
|
27
|
+
req.mandatory = true
|
|
28
|
+
req.confirm_timeout = 1.0
|
|
29
|
+
end
|
|
21
30
|
```
|
|
22
31
|
|
|
23
32
|
### Argumentos de Request
|
|
@@ -34,12 +43,16 @@ client.send(url) { |req| req.delivery_mode = :publish }
|
|
|
34
43
|
| `routing_key` | String | path | Override de routing key |
|
|
35
44
|
| `exchange_options` | Hash | {} | Cascade level 3 |
|
|
36
45
|
| `queue_options` | Hash | {} | Cascade level 3 |
|
|
46
|
+
| `confirmed` | Boolean | `false` | En `Client#publish`, flipea `delivery_mode` a `:confirmed`. Bloquea hasta `wait_for_confirms`. |
|
|
47
|
+
| `mandatory` | Boolean | `false` | Pide al broker retornar el mensaje si no es ruteable. Solo útil con `confirmed: true`. |
|
|
48
|
+
| `confirm_timeout` | Float | `nil` | Segundos máximos a esperar el ACK. `nil` = espera indefinida. Excedido → `BugBunny::RequestTimeout`. |
|
|
49
|
+
| `nack_raise` | Boolean | `nil` | Override per-request de `config.nack_raise`. `nil` = usa flag global. |
|
|
37
50
|
|
|
38
51
|
## Producer (bajo nivel)
|
|
39
52
|
|
|
40
|
-
El `Producer` es usado internamente por el `Client`. Implementa
|
|
53
|
+
El `Producer` es usado internamente por el `Client`. Implementa tres patrones de entrega.
|
|
41
54
|
|
|
42
|
-
### RPC
|
|
55
|
+
### RPC (`Producer#rpc`)
|
|
43
56
|
|
|
44
57
|
- Usa `amq.rabbitmq.reply-to` (direct reply-to pattern).
|
|
45
58
|
- Tracking de `correlation_id` en `Concurrent::Map` (thread-safe).
|
|
@@ -47,11 +60,19 @@ El `Producer` es usado internamente por el `Client`. Implementa los dos patrones
|
|
|
47
60
|
- Double-checked locking mutex para seguridad del listener.
|
|
48
61
|
- Timeout lanza `BugBunny::RequestTimeout`.
|
|
49
62
|
|
|
50
|
-
### Fire-and-Forget
|
|
63
|
+
### Fire-and-Forget (`Producer#fire`)
|
|
51
64
|
|
|
52
65
|
- Publica en el exchange y retorna `{ 'status' => 202 }` inmediatamente.
|
|
53
66
|
- Sin confirmación de procesamiento.
|
|
54
67
|
|
|
68
|
+
### Confirmed (`Producer#confirmed`)
|
|
69
|
+
|
|
70
|
+
- Publica y bloquea hasta `channel.wait_for_confirms` del broker.
|
|
71
|
+
- 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
|
+
- 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.
|
|
74
|
+
- Errores del canal se envuelven en `BugBunny::CommunicationError`; errores `BugBunny::Error` pre-existentes se propagan sin envolver.
|
|
75
|
+
|
|
55
76
|
## Middleware Stack (Client-side, Onion Architecture)
|
|
56
77
|
|
|
57
78
|
```
|
|
@@ -135,15 +156,78 @@ req.body # Hash, Array, String o nil
|
|
|
135
156
|
req.headers # Hash custom
|
|
136
157
|
req.params # Hash query string
|
|
137
158
|
req.full_path # path + query string
|
|
138
|
-
req.delivery_mode # :rpc o :
|
|
159
|
+
req.delivery_mode # :rpc, :publish o :confirmed
|
|
139
160
|
req.exchange # String destino
|
|
140
161
|
req.exchange_type # 'direct', 'topic', 'fanout'
|
|
141
162
|
req.correlation_id # UUID auto-generado
|
|
142
163
|
req.reply_to # 'amq.rabbitmq.reply-to' (auto para RPC)
|
|
143
164
|
req.timestamp # Time.now.to_i
|
|
144
165
|
req.content_type # 'application/json'
|
|
166
|
+
req.mandatory # Boolean — solo modo :confirmed
|
|
167
|
+
req.confirm_timeout # Float|nil — solo modo :confirmed
|
|
168
|
+
req.nack_raise # Boolean|nil — override per-request de config.nack_raise (solo modo :confirmed)
|
|
145
169
|
```
|
|
146
170
|
|
|
171
|
+
Cuando `mandatory == true`, `Request#amqp_options` inyecta `mandatory: true` en el hash que va a `basic_publish`.
|
|
172
|
+
|
|
173
|
+
## Publisher Confirms y `basic.return`
|
|
174
|
+
|
|
175
|
+
### Flujo
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
client.publish('x', confirmed: true, mandatory: true)
|
|
179
|
+
│
|
|
180
|
+
▼
|
|
181
|
+
Producer#confirmed
|
|
182
|
+
│
|
|
183
|
+
├──> publish_message (exchange.publish con mandatory: true)
|
|
184
|
+
│
|
|
185
|
+
├──> wait_for_confirms! (espera ACK del broker, con timeout opcional)
|
|
186
|
+
│
|
|
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
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### `Configuration#on_return`
|
|
198
|
+
|
|
199
|
+
El handler se registra **una sola vez por exchange** en `Session#exchange` (cuando `publisher_confirms: true`) usando `Bunny::Exchange#on_return`. Bunny dispatcha `basic.return` por exchange, no por canal, así que el handler vive en cada `Bunny::Exchange` resuelto vía cascada. `Session` cachea los exchanges ya configurados por nombre en `@configured_returns` para no re-registrar en cada publish; el set se limpia al recrear el canal.
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
BugBunny.configure do |c|
|
|
203
|
+
c.on_return = ->(return_info, properties, body) {
|
|
204
|
+
# return_info: Bunny::ReturnInfo (reply_code, reply_text, exchange, routing_key)
|
|
205
|
+
# properties: Bunny::MessageProperties
|
|
206
|
+
# body: String (payload crudo)
|
|
207
|
+
MyAlerts.unroutable(rk: return_info.routing_key, body: body)
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Si `on_return` es `nil` (default), BugBunny logea:
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
component=bug_bunny event=session.broker_return reply_code=312
|
|
216
|
+
reply_text="NO_ROUTE" exchange=evt_x routing_key=acct.start body_size=64
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Excepciones del callback se capturan y se logean como `session.on_return_failed` para no romper el hilo I/O de Bunny.
|
|
220
|
+
|
|
221
|
+
### Cuándo usar `:confirmed`
|
|
222
|
+
|
|
223
|
+
| Escenario | Modo recomendado |
|
|
224
|
+
|---|---|
|
|
225
|
+
| Logs, eventos best-effort | `:publish` |
|
|
226
|
+
| Auditoría, billing, eventos críticos | `:confirmed` (con `mandatory: true` si es ruteable) |
|
|
227
|
+
| Request-response síncrono | `:rpc` |
|
|
228
|
+
|
|
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.
|
|
230
|
+
|
|
147
231
|
## Cascada de Configuración (3 niveles)
|
|
148
232
|
|
|
149
233
|
```ruby
|
data/skill/references/errores.md
CHANGED
|
@@ -8,6 +8,7 @@ StandardError
|
|
|
8
8
|
├── BugBunny::CommunicationError
|
|
9
9
|
├── BugBunny::ConfigurationError
|
|
10
10
|
├── BugBunny::SecurityError
|
|
11
|
+
├── BugBunny::PublishNacked
|
|
11
12
|
├── BugBunny::ClientError (4xx)
|
|
12
13
|
│ ├── BugBunny::BadRequest (400)
|
|
13
14
|
│ ├── BugBunny::NotFound (404)
|
|
@@ -37,6 +38,12 @@ StandardError
|
|
|
37
38
|
**Cuándo:** El consumer resuelve la clase pero falla la validación `is_a?(BugBunny::Controller)`.
|
|
38
39
|
**Resolución:** Verificar que el controlador herede de `BugBunny::Controller` y que `config.controller_namespace` sea correcto.
|
|
39
40
|
|
|
41
|
+
### BugBunny::PublishNacked
|
|
42
|
+
**Causa:** El broker NACKea explícitamente una publicación en modo `:confirmed` (`Client#publish(..., confirmed: true)`). Indica que el mensaje no fue aceptado — disk full, replicación insuficiente, confirm policy interna, etc.
|
|
43
|
+
**Cuándo:** `Producer#confirmed` detecta `wait_for_confirms == false`. Se levanta por default (`config.nack_raise = true`).
|
|
44
|
+
**Atributos:** `path` (ruta del request) y `nacked_count` (cantidad de delivery tags NACKeados).
|
|
45
|
+
**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
|
+
|
|
40
47
|
## Errores de Cliente (4xx)
|
|
41
48
|
|
|
42
49
|
### BugBunny::BadRequest (400)
|
data/skills.yml
CHANGED
|
@@ -2,43 +2,28 @@ mcps:
|
|
|
2
2
|
- github
|
|
3
3
|
- clickup
|
|
4
4
|
skills:
|
|
5
|
-
skill-manager:
|
|
6
|
-
repo: sequre/ai_knowledge
|
|
7
|
-
scope: local
|
|
8
5
|
yard:
|
|
9
6
|
repo: sequre/ai_knowledge
|
|
10
|
-
scope: local
|
|
11
7
|
quality-code:
|
|
12
8
|
repo: sequre/ai_knowledge
|
|
13
|
-
scope: local
|
|
14
9
|
gem-release:
|
|
15
10
|
repo: sequre/ai_knowledge
|
|
16
|
-
scope: local
|
|
17
11
|
skill-builder:
|
|
18
12
|
repo: sequre/ai_knowledge
|
|
19
|
-
|
|
20
|
-
ai-reports:
|
|
13
|
+
matrix-element:
|
|
21
14
|
repo: sequre/ai_knowledge
|
|
22
|
-
scope: local
|
|
23
15
|
environment:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
16
|
+
homeserver: "https://matrix.cloud.wispro.co"
|
|
17
|
+
auth_token: "${MATRIX_AUTH_TOKEN}"
|
|
18
|
+
rooms:
|
|
19
|
+
agents: "!VCHwQXgmXdyhhhPhoz:matrix.cloud.wispro.co"
|
|
20
|
+
skill-feedback:
|
|
21
|
+
repo: sequre/ai_knowledge
|
|
22
|
+
agent-issue:
|
|
28
23
|
repo: sequre/ai_knowledge
|
|
29
|
-
scope: local
|
|
30
|
-
environment:
|
|
31
|
-
space_id: "${AGENT_REVIEW_SAPCE_ID}"
|
|
32
|
-
list_id: "${AGENT_LIST_ID}"
|
|
33
24
|
documentation-writer:
|
|
34
25
|
repo: github/awesome-copilot
|
|
35
26
|
path: skills/documentation-writer
|
|
36
|
-
scope: local
|
|
37
|
-
find-skills:
|
|
38
|
-
repo: vercel-labs/skills
|
|
39
|
-
path: skills/find-skills
|
|
40
|
-
scope: local
|
|
41
27
|
rabbitmq-expert:
|
|
42
28
|
repo: martinholovsky/claude-skills-generator
|
|
43
29
|
path: skills/rabbitmq-expert
|
|
44
|
-
scope: local
|
data/spec/support/bunny_mocks.rb
CHANGED
|
@@ -3,11 +3,47 @@
|
|
|
3
3
|
# Stubs livianos de Bunny para specs unitarios que no necesitan RabbitMQ real.
|
|
4
4
|
|
|
5
5
|
module BunnyMocks
|
|
6
|
+
# Stub mínimo de `Bunny::Exchange`: cachea el bloque pasado a `on_return`
|
|
7
|
+
# (Bunny lo invoca al recibir un `basic.return` del broker) y permite que
|
|
8
|
+
# los specs disparen retornos sintéticos vía `fire_return`.
|
|
9
|
+
class FakeExchange
|
|
10
|
+
attr_reader :name, :type, :opts
|
|
11
|
+
|
|
12
|
+
def initialize(name, type, opts = {})
|
|
13
|
+
@name = name
|
|
14
|
+
@type = type
|
|
15
|
+
@opts = opts
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def on_return(&block)
|
|
19
|
+
@on_return_handler = block
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def publish(_payload, _opts = {}); end
|
|
23
|
+
|
|
24
|
+
def fire_return(return_info, properties, body)
|
|
25
|
+
@on_return_handler&.call(return_info, properties, body)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
6
29
|
FakeChannel = Struct.new(:open) do
|
|
7
30
|
def open? = open
|
|
8
31
|
def close = (self.open = false)
|
|
9
32
|
def confirm_select; end
|
|
10
33
|
def prefetch(_n); end
|
|
34
|
+
|
|
35
|
+
def topic(name, opts = {}) = exchange_for(name, 'topic', opts)
|
|
36
|
+
def direct(name, opts = {}) = exchange_for(name, 'direct', opts)
|
|
37
|
+
def fanout(name, opts = {}) = exchange_for(name, 'fanout', opts)
|
|
38
|
+
def headers(name, opts = {}) = exchange_for(name, 'headers', opts)
|
|
39
|
+
def default_exchange = exchange_for('', 'direct', {})
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def exchange_for(name, type, opts)
|
|
44
|
+
@exchanges ||= {}
|
|
45
|
+
@exchanges[name] ||= FakeExchange.new(name, type, opts)
|
|
46
|
+
end
|
|
11
47
|
end
|
|
12
48
|
|
|
13
49
|
FakeConnection = Struct.new(:open, :channel_to_return) do
|
|
@@ -27,7 +27,7 @@ RSpec.describe BugBunny::Client, 'session pooling' do
|
|
|
27
27
|
def client_with_pool(pool)
|
|
28
28
|
client = described_class.new(pool: pool)
|
|
29
29
|
# Stub Producer#rpc para que no toque RabbitMQ real
|
|
30
|
-
allow_any_instance_of(BugBunny::Producer).to receive(:rpc) do |_prod,
|
|
30
|
+
allow_any_instance_of(BugBunny::Producer).to receive(:rpc) do |_prod, _req|
|
|
31
31
|
{ 'status' => 200, 'body' => '{"ok":true}' }
|
|
32
32
|
end
|
|
33
33
|
allow_any_instance_of(BugBunny::Producer).to receive(:fire) do |_prod, _req|
|
|
@@ -115,7 +115,7 @@ RSpec.describe BugBunny::Client, 'session pooling' do
|
|
|
115
115
|
|
|
116
116
|
describe 'thread-safety' do
|
|
117
117
|
it 'múltiples threads con la misma conexión no generan Sessions duplicadas' do
|
|
118
|
-
conn
|
|
118
|
+
conn = fake_conn
|
|
119
119
|
# Pool siempre devuelve la misma conexión — simula concurrencia en el mismo slot
|
|
120
120
|
pool = Object.new
|
|
121
121
|
mutex = Mutex.new
|
|
@@ -138,6 +138,83 @@ RSpec.describe BugBunny::Client, 'session pooling' do
|
|
|
138
138
|
end
|
|
139
139
|
end
|
|
140
140
|
|
|
141
|
+
describe 'Delivery mode routing' do
|
|
142
|
+
def fake_conn_local
|
|
143
|
+
channel = BunnyMocks::FakeChannel.new(true)
|
|
144
|
+
BunnyMocks::FakeConnection.new(true, channel)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'publish con confirmed: true enruta a Producer#confirmed' do
|
|
148
|
+
conn = fake_conn_local
|
|
149
|
+
client = described_class.new(pool: fake_pool(conn))
|
|
150
|
+
|
|
151
|
+
confirmed_called = false
|
|
152
|
+
allow_any_instance_of(BugBunny::Producer).to receive(:confirmed) do |_prod, _req|
|
|
153
|
+
confirmed_called = true
|
|
154
|
+
{ 'status' => 202, 'body' => nil }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
client.publish('acct.start',
|
|
158
|
+
exchange: 'x', exchange_type: 'direct', body: { a: 1 },
|
|
159
|
+
confirmed: true)
|
|
160
|
+
|
|
161
|
+
expect(confirmed_called).to be(true)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it 'propaga mandatory y confirm_timeout al Request' do
|
|
165
|
+
conn = fake_conn_local
|
|
166
|
+
client = described_class.new(pool: fake_pool(conn))
|
|
167
|
+
|
|
168
|
+
captured = nil
|
|
169
|
+
allow_any_instance_of(BugBunny::Producer).to receive(:confirmed) do |_prod, req|
|
|
170
|
+
captured = req
|
|
171
|
+
{ 'status' => 202, 'body' => nil }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
client.publish('acct.start',
|
|
175
|
+
exchange: 'x', exchange_type: 'direct',
|
|
176
|
+
confirmed: true, mandatory: true, confirm_timeout: 0.5)
|
|
177
|
+
|
|
178
|
+
expect(captured.delivery_mode).to eq(:confirmed)
|
|
179
|
+
expect(captured.mandatory).to be(true)
|
|
180
|
+
expect(captured.confirm_timeout).to eq(0.5)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'publish sin confirmed: true sigue invocando #fire (backward compat)' do
|
|
184
|
+
conn = fake_conn_local
|
|
185
|
+
client = described_class.new(pool: fake_pool(conn))
|
|
186
|
+
|
|
187
|
+
fire_called = false
|
|
188
|
+
allow_any_instance_of(BugBunny::Producer).to receive(:fire) do |_prod, _req|
|
|
189
|
+
fire_called = true
|
|
190
|
+
{ 'status' => 202, 'body' => nil }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
client.publish('evt', exchange: 'x', exchange_type: 'direct', body: {})
|
|
194
|
+
|
|
195
|
+
expect(fire_called).to be(true)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it 'send con bloque permite setear delivery_mode = :confirmed' do
|
|
199
|
+
conn = fake_conn_local
|
|
200
|
+
client = described_class.new(pool: fake_pool(conn))
|
|
201
|
+
|
|
202
|
+
confirmed_called = false
|
|
203
|
+
allow_any_instance_of(BugBunny::Producer).to receive(:confirmed) do |_prod, _req|
|
|
204
|
+
confirmed_called = true
|
|
205
|
+
{ 'status' => 202, 'body' => nil }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
client.send('evt.x', exchange: 'x', exchange_type: 'direct') do |req|
|
|
209
|
+
req.delivery_mode = :confirmed
|
|
210
|
+
req.mandatory = true
|
|
211
|
+
req.confirm_timeout = 0.2
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
expect(confirmed_called).to be(true)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
141
218
|
describe 'Session no se cierra entre requests' do
|
|
142
219
|
it 'no invoca close en la Session al terminar el request' do
|
|
143
220
|
conn = fake_conn
|
|
@@ -149,6 +149,31 @@ RSpec.describe BugBunny::Configuration do
|
|
|
149
149
|
end
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
+
describe 'on_return callback' do
|
|
153
|
+
it 'tiene default nil' do
|
|
154
|
+
expect(BugBunny::Configuration.new.on_return).to be_nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it 'acepta un Proc' do
|
|
158
|
+
cb = ->(_, _, _) { :noop }
|
|
159
|
+
configure_with(on_return: cb)
|
|
160
|
+
|
|
161
|
+
expect(BugBunny.configuration.on_return).to be(cb)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
describe 'nack_raise flag' do
|
|
166
|
+
it 'tiene default true (raise PublishNacked en NACK)' do
|
|
167
|
+
expect(BugBunny::Configuration.new.nack_raise).to be(true)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it 'acepta false para opt-out (modo legacy)' do
|
|
171
|
+
configure_with(nack_raise: false)
|
|
172
|
+
|
|
173
|
+
expect(BugBunny.configuration.nack_raise).to be(false)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
152
177
|
describe '.validate! directamente' do
|
|
153
178
|
it 'es invocable directamente sobre la instancia' do
|
|
154
179
|
config = BugBunny::Configuration.new
|
data/spec/unit/producer_spec.rb
CHANGED
|
@@ -184,4 +184,146 @@ RSpec.describe BugBunny::Producer do
|
|
|
184
184
|
end
|
|
185
185
|
end
|
|
186
186
|
end
|
|
187
|
+
|
|
188
|
+
describe '#confirmed' do
|
|
189
|
+
let(:fake_exchange) { double('exchange') }
|
|
190
|
+
|
|
191
|
+
let(:mock_channel) do
|
|
192
|
+
ch = double('channel')
|
|
193
|
+
allow(ch).to receive(:publish)
|
|
194
|
+
allow(ch).to receive(:open?).and_return(true)
|
|
195
|
+
allow(ch).to receive(:wait_for_confirms).and_return(true)
|
|
196
|
+
allow(ch).to receive(:nacked_set).and_return(Set.new)
|
|
197
|
+
ch
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
let(:mock_session) do
|
|
201
|
+
s = instance_double(BugBunny::Session)
|
|
202
|
+
allow(s).to receive(:exchange).and_return(fake_exchange)
|
|
203
|
+
allow(s).to receive(:channel).and_return(mock_channel)
|
|
204
|
+
s
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
let(:confirmed_producer) { described_class.new(mock_session) }
|
|
208
|
+
|
|
209
|
+
before do
|
|
210
|
+
allow(confirmed_producer).to receive(:safe_log) do |level, event, **kwargs|
|
|
211
|
+
logged_events << { level: level, event: event, kwargs: kwargs }
|
|
212
|
+
end
|
|
213
|
+
allow(fake_exchange).to receive(:publish)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def build_request
|
|
217
|
+
req = BugBunny::Request.new('acct.start')
|
|
218
|
+
req.exchange = 'acct_x'
|
|
219
|
+
req.method = :post
|
|
220
|
+
req.body = { tenant: 42 }
|
|
221
|
+
req
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it 'retorna { status: 202 } cuando el broker confirma' do
|
|
225
|
+
result = confirmed_producer.confirmed(build_request)
|
|
226
|
+
|
|
227
|
+
expect(result).to eq('status' => 202, 'body' => nil)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
it 'invoca wait_for_confirms en el canal' do
|
|
231
|
+
confirmed_producer.confirmed(build_request)
|
|
232
|
+
|
|
233
|
+
expect(mock_channel).to have_received(:wait_for_confirms)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it 'publica con mandatory: true cuando el request lo activa' do
|
|
237
|
+
req = build_request
|
|
238
|
+
req.mandatory = true
|
|
239
|
+
|
|
240
|
+
confirmed_producer.confirmed(req)
|
|
241
|
+
|
|
242
|
+
expect(fake_exchange).to have_received(:publish).with(
|
|
243
|
+
anything,
|
|
244
|
+
hash_including(mandatory: true, routing_key: 'acct.start')
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it 'NO logea producer.confirms_nacked cuando wait_for_confirms devuelve true' do
|
|
249
|
+
confirmed_producer.confirmed(build_request)
|
|
250
|
+
|
|
251
|
+
nack_event = logged_events.find { |e| e[:event] == 'producer.confirms_nacked' }
|
|
252
|
+
expect(nack_event).to be_nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
context 'cuando el broker NACKea (wait_for_confirms devuelve false)' do
|
|
256
|
+
before do
|
|
257
|
+
allow(mock_channel).to receive(:wait_for_confirms).and_return(false)
|
|
258
|
+
allow(mock_channel).to receive(:nacked_set).and_return(Set.new([1, 2]))
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it 'levanta BugBunny::PublishNacked por default (config.nack_raise = true)' do
|
|
262
|
+
expect { confirmed_producer.confirmed(build_request) }.to raise_error(BugBunny::PublishNacked) do |err|
|
|
263
|
+
expect(err.path).to eq('acct.start')
|
|
264
|
+
expect(err.nacked_count).to eq(2)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
it 'logea producer.confirms_nacked antes de levantar' do
|
|
269
|
+
expect { confirmed_producer.confirmed(build_request) }.to raise_error(BugBunny::PublishNacked)
|
|
270
|
+
|
|
271
|
+
nack_event = logged_events.find { |e| e[:event] == 'producer.confirms_nacked' }
|
|
272
|
+
expect(nack_event).not_to be_nil
|
|
273
|
+
expect(nack_event[:kwargs]).to include(count: 2, path: 'acct.start')
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
it 'no levanta si el request override `nack_raise = false`' do
|
|
277
|
+
req = build_request
|
|
278
|
+
req.nack_raise = false
|
|
279
|
+
|
|
280
|
+
result = confirmed_producer.confirmed(req)
|
|
281
|
+
|
|
282
|
+
expect(result).to eq('status' => 202, 'body' => nil)
|
|
283
|
+
expect(logged_events.find { |e| e[:event] == 'producer.confirms_nacked' }).not_to be_nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it 'no levanta si la configuración global tiene `nack_raise = false`' do
|
|
287
|
+
allow(BugBunny.configuration).to receive(:nack_raise).and_return(false)
|
|
288
|
+
|
|
289
|
+
result = confirmed_producer.confirmed(build_request)
|
|
290
|
+
|
|
291
|
+
expect(result).to eq('status' => 202, 'body' => nil)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
it 'el override per-request gana sobre la configuración global' do
|
|
295
|
+
allow(BugBunny.configuration).to receive(:nack_raise).and_return(false)
|
|
296
|
+
req = build_request
|
|
297
|
+
req.nack_raise = true
|
|
298
|
+
|
|
299
|
+
expect { confirmed_producer.confirmed(req) }.to raise_error(BugBunny::PublishNacked)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
it 'levanta BugBunny::RequestTimeout si wait_for_confirms excede confirm_timeout' do
|
|
304
|
+
allow(mock_channel).to receive(:wait_for_confirms) {
|
|
305
|
+
sleep 1
|
|
306
|
+
true
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
req = build_request
|
|
310
|
+
req.confirm_timeout = 0.05
|
|
311
|
+
|
|
312
|
+
expect { confirmed_producer.confirmed(req) }.to raise_error(BugBunny::RequestTimeout, /Timeout/)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it 'envuelve errores del canal como BugBunny::CommunicationError' do
|
|
316
|
+
allow(mock_channel).to receive(:wait_for_confirms).and_raise(StandardError, 'boom')
|
|
317
|
+
|
|
318
|
+
expect { confirmed_producer.confirmed(build_request) }
|
|
319
|
+
.to raise_error(BugBunny::CommunicationError, /boom/)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
it 'propaga BugBunny::Error sin envolver' do
|
|
323
|
+
allow(fake_exchange).to receive(:publish).and_raise(BugBunny::CommunicationError, 'chan dead')
|
|
324
|
+
|
|
325
|
+
expect { confirmed_producer.confirmed(build_request) }
|
|
326
|
+
.to raise_error(BugBunny::CommunicationError, 'chan dead')
|
|
327
|
+
end
|
|
328
|
+
end
|
|
187
329
|
end
|
data/spec/unit/request_spec.rb
CHANGED
|
@@ -47,5 +47,34 @@ RSpec.describe BugBunny::Request do
|
|
|
47
47
|
|
|
48
48
|
expect(request.amqp_options[:headers]['x-http-method']).to eq('POST')
|
|
49
49
|
end
|
|
50
|
+
|
|
51
|
+
it 'omite :mandatory cuando no fue activado' do
|
|
52
|
+
expect(request.amqp_options).not_to have_key(:mandatory)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'incluye :mandatory => true cuando se activa' do
|
|
56
|
+
request.mandatory = true
|
|
57
|
+
|
|
58
|
+
expect(request.amqp_options[:mandatory]).to be(true)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe 'Publisher Confirms attributes' do
|
|
63
|
+
it 'tiene mandatory=false y confirm_timeout=nil por defecto' do
|
|
64
|
+
req = described_class.new('foo')
|
|
65
|
+
|
|
66
|
+
expect(req.mandatory).to be(false)
|
|
67
|
+
expect(req.confirm_timeout).to be_nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'permite asignar mandatory y confirm_timeout' do
|
|
71
|
+
req = described_class.new('foo')
|
|
72
|
+
|
|
73
|
+
req.mandatory = true
|
|
74
|
+
req.confirm_timeout = 0.5
|
|
75
|
+
|
|
76
|
+
expect(req.mandatory).to be(true)
|
|
77
|
+
expect(req.confirm_timeout).to eq(0.5)
|
|
78
|
+
end
|
|
50
79
|
end
|
|
51
80
|
end
|