bug_bunny 4.18.0 → 5.0.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/AGENTS.md +43 -0
- data/CHANGELOG.md +31 -0
- data/CLAUDE.md +14 -4
- data/README.md +0 -1
- data/docs/config/configuracion.md +172 -0
- data/docs/consumed/rabbitmq.md +102 -0
- data/docs/errors/errors.md +196 -0
- data/docs/release/release.md +108 -0
- data/docs/test/test.md +110 -0
- data/lib/bug_bunny/exception.rb +33 -7
- data/lib/bug_bunny/middleware/raise_error.rb +97 -43
- data/lib/bug_bunny/version.rb +1 -1
- data/skill/SKILL.md +7 -1
- data/skill/references/consumer.md +1 -1
- data/skill/references/errores.md +36 -7
- data/skill/references/routing.md +1 -1
- data/skills.yml +5 -21
- data/spec/unit/raise_error_spec.rb +176 -0
- metadata +9 -3
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Release — bug_bunny
|
|
2
|
+
|
|
3
|
+
> meta: artefacto release · RFC-014 (`accepted`) · generado por `arch-structure`
|
|
4
|
+
> + `arch-enrich` (híbrido RFC-014 §2; nació como piloto manual #51, re-anclado
|
|
5
|
+
> a la RFC vigente) · anclado a `.github/workflows/release.yml`, `*.gemspec`,
|
|
6
|
+
> `lib/bug_bunny/version.rb`, `CHANGELOG.md`, git · fecha 2026-06-30 · cobertura:
|
|
7
|
+
> completa (régimen gema: build→publish, §e/f/g n/a).
|
|
8
|
+
|
|
9
|
+
## 1. Resumen
|
|
10
|
+
|
|
11
|
+
Cómo se libera esta gema. **Patrón 1 (gema-tag):** push de tag `vX.X.X` → un
|
|
12
|
+
**GitHub Actions workflow que vive en el repo** (`.github/workflows/release.yml`)
|
|
13
|
+
→ `gem build` + `gem push` a **RubyGems público**. A diferencia de un servicio,
|
|
14
|
+
el pipeline **no es opaco**: vive en `.github/`, es auditable y versionado con el
|
|
15
|
+
código (`dueño: per-repo-visible`). Una gema **no despliega — publica**: §e
|
|
16
|
+
(deploy), §f (ambientes), §g (rollback) son **n/a** (rollback = `gem yank`,
|
|
17
|
+
out-of-repo).
|
|
18
|
+
|
|
19
|
+
## 2. Cuerpo
|
|
20
|
+
|
|
21
|
+
### a. Hecho verificable
|
|
22
|
+
|
|
23
|
+
- **Convención de versión:** SemVer `vX.X.X`. Actual: **4.19.0**.
|
|
24
|
+
- **Source of truth:** tag remoto (`v4.19.0`) + **triple mirror**
|
|
25
|
+
`lib/bug_bunny/version.rb` (`VERSION = '4.19.0'`) ← `bug_bunny.gemspec:7`
|
|
26
|
+
(`spec.version = BugBunny::VERSION`).
|
|
27
|
+
- **Changelog canónico:** `CHANGELOG.md` único.
|
|
28
|
+
- **Patrón de trigger:** `gema-tag` (patrón 1).
|
|
29
|
+
- **Salida:** gema publicada en **RubyGems público**.
|
|
30
|
+
- **Deploy / ambientes / rollback:** **n/a** (gema publica, no despliega).
|
|
31
|
+
|
|
32
|
+
### b. Versionado
|
|
33
|
+
|
|
34
|
+
- **Convención:** SemVer `vX.X.X` (**con `v`** — distinto al servicio).
|
|
35
|
+
- **Source of truth:** tag remoto canónico (`git tag --sort=-v:refname` →
|
|
36
|
+
`v4.19.0`).
|
|
37
|
+
- **Mirror:** `lib/bug_bunny/version.rb` (`VERSION`), leído por
|
|
38
|
+
`bug_bunny.gemspec:7` (`spec.version = BugBunny::VERSION`).
|
|
39
|
+
`required_ruby_version >= 2.6.0` (`bug_bunny.gemspec:17`).
|
|
40
|
+
- **Política de divergencia:** `gem-release` lee el tag remoto; si `version.rb`
|
|
41
|
+
difiere → warn (posible release sin taguear). El bump se calcula sobre el tag.
|
|
42
|
+
|
|
43
|
+
### c. Changelog
|
|
44
|
+
|
|
45
|
+
- **Canónico único:** `CHANGELOG.md` ✓.
|
|
46
|
+
- **Formato:** `## [X.X.X] - YYYY-MM-DD` + nota de behavior-change si aplica +
|
|
47
|
+
categorías (`### Correcciones`), atribución `— @autor`, **link al issue**
|
|
48
|
+
(ej. `(#49)`). Cruza RFC-013 §h: el changelog ya referencia el incidente que
|
|
49
|
+
motivó el fix.
|
|
50
|
+
|
|
51
|
+
### d. Trigger → pipeline → salida
|
|
52
|
+
|
|
53
|
+
| Patrón | Trigger (señal per-repo) | Pipeline | Salida | Dueño |
|
|
54
|
+
|---|---|---|---|---|
|
|
55
|
+
| `gema-tag` | push tag `vX.X.X` | GH Actions `.github/workflows/release.yml` | gema en **RubyGems público** | **`per-repo-visible`** |
|
|
56
|
+
|
|
57
|
+
- **Pipeline NO opaco:** `.github/workflows/release.yml` —
|
|
58
|
+
`on: push: tags: ['v*']` → `ruby/setup-ruby@v1` → `gem build *.gemspec` +
|
|
59
|
+
`gem push *.gem` (auth `secrets.RUBYGEMS_API_KEY`). Auditable y versionado con
|
|
60
|
+
el código; se ancla a `file:line`, no se referencia como caja negra.
|
|
61
|
+
- **Consumo:** los servicios la pinnean por versión (`gem "bug_bunny", "~> 4.19.0"`)
|
|
62
|
+
desde RubyGems — **no** git-source.
|
|
63
|
+
|
|
64
|
+
### e. Deploy / publish
|
|
65
|
+
|
|
66
|
+
**n/a (deploy)** — una gema no despliega. El "release" es **publish** a RubyGems,
|
|
67
|
+
cubierto en §d. No hay ambiente de runtime propio.
|
|
68
|
+
|
|
69
|
+
### f. Ambientes
|
|
70
|
+
|
|
71
|
+
**n/a** — no hay `dev`/`staging`/`prod`. La única salida es la gema publicada.
|
|
72
|
+
|
|
73
|
+
### g. Rollback
|
|
74
|
+
|
|
75
|
+
**n/a en el repo** — el rollback de una gema es **`gem yank <version>`** en
|
|
76
|
+
RubyGems (admin out-of-repo), no un cambio de código. No se documenta como
|
|
77
|
+
procedimiento per-repo porque no vive acá. Una versión yankeada se anotaría en
|
|
78
|
+
`CHANGELOG.md` (con su razón) si pasara.
|
|
79
|
+
|
|
80
|
+
### h. Dependencias de deploy inter-servicio
|
|
81
|
+
|
|
82
|
+
- **Consumidores** (cruza RFC-018): servicios del fleet la pinnean
|
|
83
|
+
`~> 4.19.0` (semántica minor-compatible). Un cambio de contrato del gem
|
|
84
|
+
(ej. el behavior-change de `4.18.0` — `Bunny::Exception` → `CommunicationError`)
|
|
85
|
+
obliga a los consumidores a migrar; el `CHANGELOG.md` lo documenta como
|
|
86
|
+
breaking note. **Orden de deploy:** los consumidores adoptan al hacer `bundle
|
|
87
|
+
update bug_bunny` — no hay deploy coordinado (cada servicio elige cuándo).
|
|
88
|
+
|
|
89
|
+
### i. Contrato con la skill productora
|
|
90
|
+
|
|
91
|
+
- **Skill:** `gem-release` (tag-based, **aplica** — patrón 1 emite tag).
|
|
92
|
+
- **Qué espera del repo:** `lib/bug_bunny/version.rb` como source, `*.gemspec`
|
|
93
|
+
que lo lee, `CHANGELOG.md` canónico, tag `vX.X.X`, gate `quality-code` verde,
|
|
94
|
+
y el `.github/workflows/release.yml` que publica en el tag.
|
|
95
|
+
- **Estado:** conforma — layout estándar de gema, workflow per-repo presente.
|
|
96
|
+
|
|
97
|
+
## 3. Inferencias
|
|
98
|
+
|
|
99
|
+
- Ninguna relevante: el pipeline de gema es **visible** (`.github/`), así que el
|
|
100
|
+
artefacto se ancla a archivos reales, no a inferencia sobre cajas negras.
|
|
101
|
+
|
|
102
|
+
## 4. Cobertura y fronteras
|
|
103
|
+
|
|
104
|
+
- Cobertura **completa** del régimen gema (build→publish). §e/f/g `n/a` honesto.
|
|
105
|
+
- Contraste validado con el piloto servicio (`box_radius_manager`): allá el
|
|
106
|
+
pipeline es **opaco** (`fleet-link` Codefresh); acá es **per-repo-visible**
|
|
107
|
+
(GH Actions en el repo). Misma RFC, dos loci de dueño — el valor de la columna
|
|
108
|
+
`dueño` de §d.
|
data/docs/test/test.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Test — bug_bunny
|
|
2
|
+
|
|
3
|
+
> meta: artefacto test · RFC-013 · generado `arch-structure` (§a-§d) +
|
|
4
|
+
> `arch-enrich` (§e-§h) · anclado a `24ea397`, `Rakefile`, `bug_bunny.gemspec`,
|
|
5
|
+
> `spec/spec_helper.rb`, `spec/support/integration_helper.rb`,
|
|
6
|
+
> `.github/workflows/main.yml`, `CHANGELOG.md` · fecha 2026-06-30 · cobertura:
|
|
7
|
+
> §a-§d (estructura) + §e-§h (enrich, anclado a specs/CHANGELOG) completas.
|
|
8
|
+
|
|
9
|
+
## 1. Resumen
|
|
10
|
+
|
|
11
|
+
Suite principal **RSpec** (`spec/`, 22 specs: 15 unit + 7 integration). Tarea
|
|
12
|
+
`:test` legacy de **Minitest** (`test/`, 2 archivos) fuera del default y del CI.
|
|
13
|
+
CI corre `bundle exec rake` (= `:spec`) en Ruby 3.4.4. Sin coverage tool
|
|
14
|
+
configurado.
|
|
15
|
+
|
|
16
|
+
## 2. Cuerpo
|
|
17
|
+
|
|
18
|
+
### a. Suites, frameworks y niveles
|
|
19
|
+
|
|
20
|
+
| framework | dir | nivel | nº | propósito |
|
|
21
|
+
|---|---|---|---|---|
|
|
22
|
+
| **RSpec** `~> 3.0` | `spec/unit/` | unit | 15 | client/session pool, configuration, consumer, producer, controller, raise_error, remote_error, request, route, observability, otel, resource, middleware |
|
|
23
|
+
| **RSpec** | `spec/integration/` | integration | 7 | client, consumer_middleware, controller, error_handling, infrastructure, publisher_confirms, resource — **requieren RabbitMQ real** (usan `BugBunny.create_connection` + pool) |
|
|
24
|
+
| **Minitest** `~> 5.0` (+ `mocha`, `minitest-reporters`) | `test/integration/` | integration (legacy) | 2 | `manual_client_test.rb`, `infrastructure_test.rb` — tarea `:test`, **no** en default ni CI |
|
|
25
|
+
|
|
26
|
+
Sin tags declarados (`:slow`/`:js`) en la config de RSpec.
|
|
27
|
+
|
|
28
|
+
### b. Comando de corrida
|
|
29
|
+
|
|
30
|
+
| objetivo | comando | qué corre |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| default / CI | `bundle exec rake` | tarea `:spec` (toda la suite RSpec) — `Rakefile:` `task default: :spec` |
|
|
33
|
+
| solo unit | `bundle exec rake spec:unit` | `spec/unit/**/*_spec.rb` |
|
|
34
|
+
| solo integration | `bundle exec rake spec:integration` | `spec/integration/**/*_spec.rb` (requiere broker) |
|
|
35
|
+
| Minitest legacy | `bundle exec rake test` | `test/**/*_test.rb` |
|
|
36
|
+
| CI | `.github/workflows/main.yml` → `bundle exec rake` | matrix Ruby `3.4.4`, `ruby/setup-ruby@v1` + `bundler-cache`, on push `main` + PR |
|
|
37
|
+
|
|
38
|
+
> Todas las tareas RSpec corren con `--require spec_helper` (`Rakefile`).
|
|
39
|
+
|
|
40
|
+
### c. Fixtures / Factories
|
|
41
|
+
|
|
42
|
+
- **Sin FactoryBot ni fixtures YAML.** El setup vive en `spec/spec_helper.rb`:
|
|
43
|
+
configura `BugBunny` (host/user/pass desde ENV con default `guest`), arma un
|
|
44
|
+
`ConnectionPool` de test (`TEST_POOL`, size 5) y declara rutas globales para
|
|
45
|
+
todos los specs (`resources :ping/:node/:user`, `get 'around'/'rescue'/'boom'/
|
|
46
|
+
'echo'`, `post 'events'`).
|
|
47
|
+
- **Carga `.env`** si existe (parse manual, `spec_helper.rb`).
|
|
48
|
+
- **Soporte:** `spec/support/integration_helper.rb` (helpers + `TEST_WORKER_QUEUE_OPTS`
|
|
49
|
+
para colas efímeras), `spec/support/bunny_mocks.rb` (mocks de Bunny para unit).
|
|
50
|
+
- `exchange_options = { durable: false, auto_delete: true }` → exchanges efímeros
|
|
51
|
+
en test.
|
|
52
|
+
|
|
53
|
+
### d. Configuración de coverage
|
|
54
|
+
|
|
55
|
+
**n/a** — sin SimpleCov ni `.simplecov` ni `SimpleCov.start` en el repo. No hay
|
|
56
|
+
umbral de coverage declarado.
|
|
57
|
+
|
|
58
|
+
### e. Gaps de cobertura
|
|
59
|
+
|
|
60
|
+
- **Integration specs no corren en CI:** `main.yml` no declara servicio RabbitMQ;
|
|
61
|
+
las 7 integration specs **se skipean** vía `rabbitmq_available?`
|
|
62
|
+
(`spec/support/integration_helper.rb:14`, ver `publisher_confirms_spec.rb:10`).
|
|
63
|
+
En CI solo se ejercitan las **15 unit specs** → el contrato AMQP real (publish/
|
|
64
|
+
consume/confirms contra broker) **no se valida en pipeline**, solo localmente
|
|
65
|
+
con broker. Gap relevante.
|
|
66
|
+
- **Sin medición de cobertura:** no hay SimpleCov ni umbral → la cobertura no está
|
|
67
|
+
cuantificada (no se sabe el % de líneas ejercidas).
|
|
68
|
+
|
|
69
|
+
### f. Contract-assessment
|
|
70
|
+
|
|
71
|
+
¿Los tests cubren los contratos públicos? (RFC-020/018/012/003)
|
|
72
|
+
|
|
73
|
+
| contrato | specs que lo cubren | veredicto |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| **Errores RFC-020** (status→excepción, materia prima) | `raise_error_spec`, `remote_error_spec`, `communication_error_wrapping_spec`, `error_handling_spec` (integration) | **bien cubierto** (unit) |
|
|
76
|
+
| **Consumed RFC-018** (Bunny::Exception→`CommunicationError`) | `communication_error_wrapping_spec`, `client_session_pool_spec` | cubierto (unit) |
|
|
77
|
+
| **Config RFC-012** (validaciones de `Configuration`) | `configuration_spec` | cubierto |
|
|
78
|
+
| **Confirms** (`PublishNacked`/`PublishUnroutable`) | `producer_spec`, `publisher_confirms_spec` (integration) | parcial en CI (la parte integration skipea sin broker) |
|
|
79
|
+
| **Operaciones/routing** (RFC-003, capa F2) | `route_spec`, `request_spec`, `controller_spec`, `controller_after_action_spec`, `resource_spec` | cubierto (unit) |
|
|
80
|
+
|
|
81
|
+
### g. Link a incidente → test de regresión
|
|
82
|
+
|
|
83
|
+
| incidente | test de regresión | ancla |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| **#52** (`status`/`raw_response` en toda la jerarquía + hardening de `format_error_message`) | `raise_error_spec` — comentarios explícitos "Alcance issue #52" | `spec/unit/raise_error_spec.rb:59,120,169` |
|
|
86
|
+
| **#49** (leak de `Bunny::TCPConnectionFailedForAllHosts` en `try_create`) | `communication_error_wrapping_spec` — "Client#publish — TCP fail en try_create (issue #49 caso original)" | `spec/unit/communication_error_wrapping_spec.rb:41` |
|
|
87
|
+
|
|
88
|
+
### h. PII en fixtures / factories
|
|
89
|
+
|
|
90
|
+
- **Sin PII.** No hay fixtures con datos personales: `spec_helper` usa
|
|
91
|
+
placeholders (`guest`/`localhost`), las rutas de test son `ping`/`node`/`user`
|
|
92
|
+
**sin payloads de datos reales**, y `bunny_mocks.rb` mockea el driver. Cruza
|
|
93
|
+
RFC-026: nada que clasificar/anonimizar. `confidence: high`.
|
|
94
|
+
|
|
95
|
+
## 3. Inferencias
|
|
96
|
+
|
|
97
|
+
- **Doble framework:** RSpec es el primario (default + CI); Minitest (`test/`,
|
|
98
|
+
`mocha`, `minitest-reporters` en gemspec) es **legacy** — el comentario del
|
|
99
|
+
`Rakefile` lo declara explícito ("tests de integración legacy de Minitest").
|
|
100
|
+
`confidence: high`.
|
|
101
|
+
- ~~CI on `master` pero la rama principal es `main` (drift de naming)~~ →
|
|
102
|
+
**resuelto** (`main.yml` ahora dispara `push: branches: [main]`). El trigger
|
|
103
|
+
`push` corre en la rama por default; `pull_request` cubre los PRs.
|
|
104
|
+
|
|
105
|
+
## 4. Cobertura y fronteras
|
|
106
|
+
|
|
107
|
+
- §a-§d (estructura) + §e-§h (enrich, anclado a specs/CHANGELOG) **completas**.
|
|
108
|
+
- **Integration specs requieren un RabbitMQ vivo** y se skipean sin broker (§e) —
|
|
109
|
+
por eso no se ejercitan en CI.
|
|
110
|
+
- **Contenido de cada test case** queda en el código, no se inventaria acá.
|
data/lib/bug_bunny/exception.rb
CHANGED
|
@@ -5,7 +5,36 @@ require 'json'
|
|
|
5
5
|
module BugBunny
|
|
6
6
|
# Clase base para todas las excepciones lanzadas por la gema BugBunny.
|
|
7
7
|
# Permite capturar cualquier error de la librería con un `rescue BugBunny::Error`.
|
|
8
|
-
|
|
8
|
+
#
|
|
9
|
+
# Para los errores derivados de una respuesta RPC (los que levanta
|
|
10
|
+
# {BugBunny::Middleware::RaiseError}), expone de forma uniforme la **materia
|
|
11
|
+
# prima** del error: el `status` y el `raw_response` (cuerpo crudo). La gema es
|
|
12
|
+
# **agnóstica al payload**: no interpreta la estructura del cuerpo de error: es
|
|
13
|
+
# el boundary de cada servicio quien lee `raw_response` y decide la semántica
|
|
14
|
+
# de dominio (códigos, detalles de validación, etc.).
|
|
15
|
+
#
|
|
16
|
+
# @example Leer la materia prima en el boundary del servicio
|
|
17
|
+
# rescue BugBunny::Error => e
|
|
18
|
+
# e.status # => 409
|
|
19
|
+
# e.raw_response # => { "error" => { "code" => "...", "message" => "...", "details" => {} } }
|
|
20
|
+
class Error < ::StandardError
|
|
21
|
+
# @return [Hash, String, nil] El cuerpo crudo de la respuesta de error, tal
|
|
22
|
+
# como llegó por el wire. `nil` para errores que no provienen de una
|
|
23
|
+
# respuesta RPC (ej: {CommunicationError}, {ConfigurationError}).
|
|
24
|
+
#
|
|
25
|
+
# @note **No loguear ni enviar a sinks (Sentry/logs) sin sanitizar.** El
|
|
26
|
+
# cuerpo crudo puede contener datos sensibles (p. ej. en `details`). Antes
|
|
27
|
+
# de cualquier sink, filtrar las claves sensibles del fleet
|
|
28
|
+
# (`password|pass|passwd|secret|token|api_key|auth`) → `[FILTERED]`. La
|
|
29
|
+
# gema entrega el cuerpo crudo a propósito; sanitizarlo es responsabilidad
|
|
30
|
+
# del consumidor.
|
|
31
|
+
attr_accessor :raw_response
|
|
32
|
+
|
|
33
|
+
# @return [Integer, nil] El código de estado de la respuesta que originó el
|
|
34
|
+
# error (ej: 400, 404, 409, 422, 500). `nil` para errores que no provienen
|
|
35
|
+
# de una respuesta RPC.
|
|
36
|
+
attr_accessor :status
|
|
37
|
+
end
|
|
9
38
|
|
|
10
39
|
# Error lanzado cuando ocurren problemas de red, conexión o protocolo AMQP con RabbitMQ.
|
|
11
40
|
#
|
|
@@ -31,10 +60,6 @@ module BugBunny
|
|
|
31
60
|
# Se levanta al final de {BugBunny.configure} si algún atributo no pasa las validaciones.
|
|
32
61
|
class ConfigurationError < Error; end
|
|
33
62
|
|
|
34
|
-
# Error lanzado cuando ocurren un acceso no permitido a controladores.
|
|
35
|
-
# Protege contra vulnerabilidades de RCE validando la herencia de las clases enrutadas.
|
|
36
|
-
class SecurityError < Error; end
|
|
37
|
-
|
|
38
63
|
# Error lanzado cuando el broker responde NACK a una publicación en modo `:confirmed`.
|
|
39
64
|
#
|
|
40
65
|
# Un NACK significa que el broker rechazó explícitamente el mensaje (ej: política de
|
|
@@ -191,8 +216,9 @@ module BugBunny
|
|
|
191
216
|
# @return [Hash, Array, String] Los mensajes de error listos para ser iterados.
|
|
192
217
|
attr_reader :error_messages
|
|
193
218
|
|
|
194
|
-
#
|
|
195
|
-
|
|
219
|
+
# `raw_response` y `status` se heredan de {BugBunny::Error} (poblados por
|
|
220
|
+
# {BugBunny::Middleware::RaiseError}); `raw_response` además se setea acá en
|
|
221
|
+
# el constructor para mantener el comportamiento histórico.
|
|
196
222
|
|
|
197
223
|
# Inicializa la excepción procesando el cuerpo de la respuesta.
|
|
198
224
|
#
|
|
@@ -34,71 +34,125 @@ module BugBunny
|
|
|
34
34
|
body = response['body']
|
|
35
35
|
|
|
36
36
|
case status
|
|
37
|
-
when 200..299
|
|
38
|
-
|
|
39
|
-
when
|
|
40
|
-
|
|
41
|
-
when
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
when
|
|
46
|
-
|
|
47
|
-
when 409
|
|
48
|
-
raise BugBunny::Conflict, format_error_message(body)
|
|
49
|
-
when 422
|
|
50
|
-
# Pasamos el body crudo; UnprocessableEntity lo procesará en exception.rb
|
|
51
|
-
raise BugBunny::UnprocessableEntity, body
|
|
52
|
-
when 500..599
|
|
53
|
-
if body.is_a?(Hash) && body['bug_bunny_exception']
|
|
54
|
-
exception_data = body['bug_bunny_exception']
|
|
55
|
-
raise BugBunny::RemoteError.new(
|
|
56
|
-
exception_data['class'],
|
|
57
|
-
exception_data['message'],
|
|
58
|
-
exception_data['backtrace'] || []
|
|
59
|
-
)
|
|
60
|
-
end
|
|
61
|
-
raise BugBunny::InternalServerError, format_error_message(body)
|
|
62
|
-
else
|
|
63
|
-
handle_unknown_error(status, body)
|
|
37
|
+
when 200..299 then nil # Flujo normal (Success)
|
|
38
|
+
when 400 then raise_typed(BugBunny::BadRequest.new(format_error_message(body)), status, body)
|
|
39
|
+
when 404 then raise_not_found(status, body)
|
|
40
|
+
when 406 then raise_typed(BugBunny::NotAcceptable.new, status, body)
|
|
41
|
+
when 408 then raise_typed(BugBunny::RequestTimeout.new, status, body)
|
|
42
|
+
when 409 then raise_typed(BugBunny::Conflict.new(format_error_message(body)), status, body)
|
|
43
|
+
# Pasamos el body crudo; UnprocessableEntity lo procesará en exception.rb
|
|
44
|
+
when 422 then raise_typed(BugBunny::UnprocessableEntity.new(body), status, body)
|
|
45
|
+
when 500..599 then raise_server_error(status, body)
|
|
46
|
+
else handle_unknown_error(status, body)
|
|
64
47
|
end
|
|
65
48
|
end
|
|
66
49
|
|
|
67
50
|
private
|
|
68
51
|
|
|
69
|
-
#
|
|
52
|
+
# Levanta el error de servidor (5xx). Si el worker remoto serializó su
|
|
53
|
+
# excepción en `bug_bunny_exception`, propaga un {BugBunny::RemoteError} con
|
|
54
|
+
# la traza original; si no, un {BugBunny::InternalServerError} genérico.
|
|
70
55
|
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
56
|
+
# @param status [Integer] El código de estado (rango 500..599).
|
|
57
|
+
# @param body [Hash, String, nil] El cuerpo crudo de la respuesta.
|
|
58
|
+
# @raise [BugBunny::RemoteError] Si el cuerpo trae `bug_bunny_exception`.
|
|
59
|
+
# @raise [BugBunny::InternalServerError] Para 5xx genéricos.
|
|
60
|
+
def raise_server_error(status, body)
|
|
61
|
+
if body.is_a?(Hash) && body['bug_bunny_exception']
|
|
62
|
+
data = body['bug_bunny_exception']
|
|
63
|
+
remote = BugBunny::RemoteError.new(data['class'], data['message'], data['backtrace'] || [])
|
|
64
|
+
raise_typed(remote, status, body)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
raise_typed(BugBunny::InternalServerError.new(format_error_message(body)), status, body)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Puebla la materia prima del error (`status` + `raw_response`) en la
|
|
71
|
+
# excepción y la levanta. Aplica de forma uniforme a **todas** las clases de
|
|
72
|
+
# error derivadas de una respuesta RPC, no solo a {BugBunny::UnprocessableEntity}.
|
|
73
|
+
#
|
|
74
|
+
# La gema es agnóstica al payload: entrega el cuerpo crudo tal cual para que
|
|
75
|
+
# el boundary del servicio lo interprete; no parsea su estructura.
|
|
76
|
+
#
|
|
77
|
+
# @param error [BugBunny::Error] La excepción ya construida.
|
|
78
|
+
# @param status [Integer] El código de estado de la respuesta.
|
|
79
|
+
# @param body [Hash, String, nil] El cuerpo crudo de la respuesta.
|
|
80
|
+
# @raise [BugBunny::Error] Siempre levanta la excepción recibida.
|
|
81
|
+
# @return [void]
|
|
82
|
+
def raise_typed(error, status, body)
|
|
83
|
+
error.status = status
|
|
84
|
+
error.raw_response = body
|
|
85
|
+
raise error
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Formatea el cuerpo de la respuesta de error en un **string humano**
|
|
89
|
+
# best-effort para logs/Sentry. Es de mejor esfuerzo, **no un contrato**: la
|
|
90
|
+
# gema no expone `code`/`details` como API; quien los necesite los lee desde
|
|
91
|
+
# `raw_response` en el boundary del servicio.
|
|
92
|
+
#
|
|
93
|
+
# Soporta dos shapes del cuerpo, en orden de prioridad:
|
|
94
|
+
#
|
|
95
|
+
# 1. **Envelope anidado** `{ "error": { "message": "...", ... } }`: extrae
|
|
96
|
+
# `error.message`.
|
|
97
|
+
# 2. **Shape plano** `{ "error": "texto", "detail": "..." }`: concatena
|
|
98
|
+
# `error` + `detail`.
|
|
99
|
+
#
|
|
100
|
+
# Si ninguno aplica, cae a `body.to_json` para no volcar un `Hash#inspect`
|
|
101
|
+
# ilegible en los logs.
|
|
102
|
+
#
|
|
103
|
+
# @note Esta función **solo** arma el mensaje humano y no interpreta el
|
|
104
|
+
# detalle estructurado del cuerpo (claves como `code`/`details`/`detail`).
|
|
105
|
+
# Ese contenido vive en `raw_response` y lo interpreta el boundary del
|
|
106
|
+
# servicio; la gema se mantiene agnóstica al payload.
|
|
73
107
|
#
|
|
74
108
|
# @param body [Hash, String, nil] El cuerpo de la respuesta.
|
|
75
|
-
# @return [String] Un mensaje de error limpio y
|
|
109
|
+
# @return [String] Un mensaje de error limpio y legible.
|
|
76
110
|
def format_error_message(body)
|
|
77
111
|
return 'Unknown Error' if body.nil? || (body.respond_to?(:empty?) && body.empty?)
|
|
78
112
|
return body if body.is_a?(String)
|
|
113
|
+
return body.to_json unless body.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
human_message_from(body) || body.to_json
|
|
116
|
+
end
|
|
79
117
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
118
|
+
# Extrae el string humano del cuerpo según el shape, sin volcar Hashes.
|
|
119
|
+
#
|
|
120
|
+
# @param body [Hash] El cuerpo de la respuesta.
|
|
121
|
+
# @return [String, nil] El mensaje humano, o `nil` si el shape no lo provee.
|
|
122
|
+
# @api private
|
|
123
|
+
def human_message_from(body)
|
|
124
|
+
err = body['error']
|
|
125
|
+
|
|
126
|
+
# Envelope canónico anidado: { error: { message: "..." } }
|
|
127
|
+
if err.is_a?(Hash)
|
|
128
|
+
nested = err['message']
|
|
129
|
+
return nested if nested.is_a?(String) && !nested.empty?
|
|
130
|
+
|
|
131
|
+
return nil
|
|
87
132
|
end
|
|
133
|
+
|
|
134
|
+
# Shape plano histórico: { error: "texto", detail: "..." }.
|
|
135
|
+
# Cualquier String (incluido "") entra por acá para preservar el
|
|
136
|
+
# comportamiento histórico (la key 'error' siempre fue truthy).
|
|
137
|
+
return nil unless err.is_a?(String)
|
|
138
|
+
|
|
139
|
+
detail = body['detail'] ? " - #{body['detail']}" : ''
|
|
140
|
+
"#{err}#{detail}"
|
|
88
141
|
end
|
|
89
142
|
|
|
90
143
|
# Distingue un error de routing (ruta/controller no existe en el servicio remoto)
|
|
91
144
|
# de un 404 genérico de recurso no encontrado.
|
|
92
145
|
#
|
|
146
|
+
# @param status [Integer] El código de estado (404).
|
|
93
147
|
# @param body [Hash, String, nil] El cuerpo de la respuesta 404.
|
|
94
148
|
# @raise [BugBunny::RoutingError] Si el consumer marcó `error_type: 'routing_error'`.
|
|
95
149
|
# @raise [BugBunny::NotFound] Para 404 genéricos.
|
|
96
|
-
def raise_not_found(body)
|
|
150
|
+
def raise_not_found(status, body)
|
|
97
151
|
if body.is_a?(Hash) && body['error_type'] == 'routing_error'
|
|
98
|
-
|
|
152
|
+
raise_typed(BugBunny::RoutingError.new(format_error_message(body)), status, body)
|
|
99
153
|
end
|
|
100
154
|
|
|
101
|
-
|
|
155
|
+
raise_typed(BugBunny::NotFound.new(format_error_message(body)), status, body)
|
|
102
156
|
end
|
|
103
157
|
|
|
104
158
|
# Maneja códigos de error genéricos no mapeados explícitamente en el `case`.
|
|
@@ -111,9 +165,9 @@ module BugBunny
|
|
|
111
165
|
msg = format_error_message(body)
|
|
112
166
|
|
|
113
167
|
if status >= 500
|
|
114
|
-
|
|
168
|
+
raise_typed(BugBunny::ServerError.new("Server Error (#{status}): #{msg}"), status, body)
|
|
115
169
|
elsif status >= 400
|
|
116
|
-
|
|
170
|
+
raise_typed(BugBunny::ClientError.new("Client Error (#{status}): #{msg}"), status, body)
|
|
117
171
|
end
|
|
118
172
|
end
|
|
119
173
|
end
|
data/lib/bug_bunny/version.rb
CHANGED
data/skill/SKILL.md
CHANGED
|
@@ -415,8 +415,9 @@ Limitación de RSpec: `instance_double` valida que el método exista pero **no**
|
|
|
415
415
|
**Causa:** No hubo respuesta en `config.rpc_timeout` segundos.
|
|
416
416
|
**Resolución:** Verificar que el worker esté activo y que el controlador remoto no lance excepciones silenciosas.
|
|
417
417
|
|
|
418
|
-
###
|
|
418
|
+
### Guard anti-RCE (403, no es excepción)
|
|
419
419
|
**Causa:** El mensaje intenta ejecutar un controlador que no hereda de `BugBunny::Controller`.
|
|
420
|
+
**Comportamiento:** El worker responde **403 Forbidden** + reject + log `event=consumer.security_violation` (`consumer.rb:222-228`); no levanta una excepción dedicada.
|
|
420
421
|
**Resolución:** Verificar la jerarquía de controladores y que `config.controller_namespace` coincida.
|
|
421
422
|
|
|
422
423
|
### BugBunny::RouteNotFoundError (404)
|
|
@@ -435,6 +436,11 @@ Limitación de RSpec: `instance_double` valida que el método exista pero **no**
|
|
|
435
436
|
**Causa:** Fallo de transporte AMQP — envuelve cualquier `Bunny::Exception` que escape en la frontera del gem (`Client#publish`/`#request`/`#send`, `Producer#confirmed`, `BugBunny.create_connection`). Cubre TCP fail (`Bunny::TCPConnectionFailed`), conn rota in-flight (`ConnectionClosedError`), canal cerrado (`ChannelAlreadyClosed`), auth fail, etc. La excepción original queda en `.cause`.
|
|
436
437
|
**Resolución:** Verificar conectividad a RabbitMQ (host/port/auth/vhost). Inspeccionar `e.cause` para clasificar el fallo concreto. Revisar `max_reconnect_attempts` y logs de reconexión.
|
|
437
438
|
|
|
439
|
+
**Materia prima (desde 4.19):** todo `BugBunny::Error` de respuesta RPC expone
|
|
440
|
+
`e.status` y `e.raw_response` (cuerpo crudo) de forma uniforme — no solo 422. La
|
|
441
|
+
gema es agnóstica al payload: el envelope de dominio se parsea en el boundary del
|
|
442
|
+
servicio. No loguear `raw_response` sin sanitizar.
|
|
443
|
+
|
|
438
444
|
Ver catálogo completo en [Errores](references/errores.md).
|
|
439
445
|
|
|
440
446
|
---
|
|
@@ -119,5 +119,5 @@ livenessProbe:
|
|
|
119
119
|
|-----------|-----------|
|
|
120
120
|
| Ruta no encontrada | 404 + log `event=consumer.route_not_found` |
|
|
121
121
|
| Controller no encontrado (namespace) | 404 + log `event=consumer.controller_not_found` |
|
|
122
|
-
| Controller no hereda de BugBunny::Controller | `
|
|
122
|
+
| Controller no hereda de BugBunny::Controller | 403 Forbidden + reject + log `event=consumer.security_violation` (guard anti-RCE) |
|
|
123
123
|
| Excepción no capturada en controller | 500 + log `event=controller.unhandled_exception` con backtrace |
|
data/skill/references/errores.md
CHANGED
|
@@ -7,7 +7,6 @@ StandardError
|
|
|
7
7
|
└── BugBunny::Error
|
|
8
8
|
├── BugBunny::CommunicationError
|
|
9
9
|
├── BugBunny::ConfigurationError
|
|
10
|
-
├── BugBunny::SecurityError
|
|
11
10
|
├── BugBunny::PublishNacked
|
|
12
11
|
├── BugBunny::PublishUnroutable
|
|
13
12
|
├── BugBunny::ClientError (4xx)
|
|
@@ -22,6 +21,30 @@ StandardError
|
|
|
22
21
|
└── BugBunny::RemoteError (500)
|
|
23
22
|
```
|
|
24
23
|
|
|
24
|
+
## Materia prima del error (`status` + `raw_response`) — desde 4.19
|
|
25
|
+
|
|
26
|
+
Todos los errores derivados de una respuesta RPC exponen de forma **uniforme**
|
|
27
|
+
el `status` y el `raw_response` (cuerpo crudo), no solo `UnprocessableEntity`.
|
|
28
|
+
La gema es **agnóstica al payload**: entrega el cuerpo crudo tal cual y el
|
|
29
|
+
boundary del servicio decide la semántica de dominio (códigos, detalles, etc.).
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
begin
|
|
33
|
+
client.request('clusters/42', method: :get)
|
|
34
|
+
rescue BugBunny::Error => e
|
|
35
|
+
e.status # => 409 (Integer, nil si el error no viene de una respuesta RPC)
|
|
36
|
+
e.raw_response # => cuerpo crudo tal como llegó por el wire (Hash/String/nil)
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
> ⚠️ **No loguear `raw_response` crudo.** Puede contener datos sensibles. Filtrar
|
|
41
|
+
> claves (`password|pass|passwd|secret|token|api_key|auth`) → `[FILTERED]` antes
|
|
42
|
+
> de Sentry/logs. La gema lo entrega a propósito; sanitizar es del consumidor.
|
|
43
|
+
|
|
44
|
+
Para consumir un envelope estructurado de dominio (ej. `{ error: { code,
|
|
45
|
+
message, details } }`), parsealo en el boundary del servicio desde
|
|
46
|
+
`e.raw_response` — la gema **no** expone `code`/`details` como contrato.
|
|
47
|
+
|
|
25
48
|
## Errores de Infraestructura
|
|
26
49
|
|
|
27
50
|
### BugBunny::CommunicationError
|
|
@@ -34,9 +57,10 @@ StandardError
|
|
|
34
57
|
**Validaciones:** host (String no vacío), port (1-65535), username/password (no nil), heartbeat (0-3600), rpc_timeout (>0), channel_prefetch (1-10000).
|
|
35
58
|
**Resolución:** Revisar el bloque `BugBunny.configure` y corregir valores.
|
|
36
59
|
|
|
37
|
-
###
|
|
60
|
+
### Guard anti-RCE (403, no es excepción)
|
|
38
61
|
**Causa:** Un mensaje intenta ejecutar un controlador que no hereda de `BugBunny::Controller`.
|
|
39
|
-
**Cuándo:** El consumer resuelve la clase pero falla
|
|
62
|
+
**Cuándo:** El consumer resuelve la clase (`constantize`) pero falla `controller_class < BugBunny::Controller` (`consumer.rb:222-228`).
|
|
63
|
+
**Comportamiento:** El worker **no levanta una excepción** — loguea `event=consumer.security_violation`, responde **403 Forbidden** al caller RPC y rechaza el mensaje sin requeue.
|
|
40
64
|
**Resolución:** Verificar que el controlador herede de `BugBunny::Controller` y que `config.controller_namespace` sea correcto.
|
|
41
65
|
|
|
42
66
|
### BugBunny::PublishNacked
|
|
@@ -133,10 +157,15 @@ end
|
|
|
133
157
|
|
|
134
158
|
## Formato de Mensajes de Error
|
|
135
159
|
|
|
136
|
-
El middleware `RaiseError` construye el
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
160
|
+
El middleware `RaiseError` construye el `.message` humano (best-effort, para
|
|
161
|
+
logs/Sentry — **no es contrato**) así:
|
|
162
|
+
1. Envelope anidado `{ "error": { "message": "..." } }` → extrae `error.message`.
|
|
163
|
+
2. Shape plano `{ "error": "...", "detail": "..." }` → concatena `error` + `detail`.
|
|
164
|
+
3. Si ninguno aplica, usa el cuerpo completo como JSON (nunca un `Hash#inspect`).
|
|
165
|
+
4. Si el body está vacío, usa `"Unknown Error"`.
|
|
166
|
+
|
|
167
|
+
Para detalle estructurado (`code`/`details`), leé `e.raw_response` en el
|
|
168
|
+
boundary del servicio (ver "Materia prima del error").
|
|
140
169
|
|
|
141
170
|
## Connection Pool Missing
|
|
142
171
|
|
data/skill/references/routing.md
CHANGED
|
@@ -69,7 +69,7 @@ El consumer resuelve el controlador concatenando:
|
|
|
69
69
|
|
|
70
70
|
Ejemplo: namespace `:admin`, controller `:reports` → `BugBunny::Controllers::Admin::ReportsController`
|
|
71
71
|
|
|
72
|
-
Valida que el controlador sea subclase de `BugBunny::Controller
|
|
72
|
+
Valida que el controlador sea subclase de `BugBunny::Controller` (guard anti-RCE). Si no, el worker loguea `consumer.security_violation`, responde **403 Forbidden** y rechaza el mensaje sin requeue (`consumer.rb:222-228`) — no levanta una excepción dedicada.
|
|
73
73
|
|
|
74
74
|
## Route Object
|
|
75
75
|
|
data/skills.yml
CHANGED
|
@@ -2,34 +2,18 @@ mcps:
|
|
|
2
2
|
- github
|
|
3
3
|
- clickup
|
|
4
4
|
skills:
|
|
5
|
+
multi-vendor-feedback:
|
|
5
6
|
yard:
|
|
6
|
-
repo: sequre/ai_knowledge
|
|
7
7
|
quality-code:
|
|
8
|
-
repo: sequre/ai_knowledge
|
|
9
8
|
gem-release:
|
|
10
|
-
repo: sequre/ai_knowledge
|
|
11
9
|
dev-structure:
|
|
12
|
-
repo: sequre/ai_knowledge
|
|
13
10
|
dev-compose:
|
|
14
|
-
repo: sequre/ai_knowledge
|
|
15
11
|
dev-enrich:
|
|
16
|
-
repo: sequre/ai_knowledge
|
|
17
12
|
skill-feedback:
|
|
18
|
-
repo: sequre/ai_knowledge
|
|
19
13
|
agent-issue:
|
|
20
|
-
|
|
14
|
+
bug-report:
|
|
21
15
|
dev-flow:
|
|
22
|
-
repo: sequre/ai_knowledge
|
|
23
16
|
matrix-element:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
auth_token: "${MATRIX_AUTH_TOKEN}"
|
|
28
|
-
rooms:
|
|
29
|
-
agents: "!VCHwQXgmXdyhhhPhoz:matrix.cloud.wispro.co"
|
|
30
|
-
documentation-writer:
|
|
31
|
-
repo: github/awesome-copilot
|
|
32
|
-
path: skills/documentation-writer
|
|
33
|
-
rabbitmq-expert:
|
|
34
|
-
repo: martinholovsky/claude-skills-generator
|
|
35
|
-
path: skills/rabbitmq-expert
|
|
17
|
+
# rabbitmq-expert:
|
|
18
|
+
# repo: martinholovsky/claude-skills-generator
|
|
19
|
+
# path: skills/rabbitmq-expert
|