bug_bunny 4.18.0 → 4.19.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 030517babaaafccb4ac6b067e1c11ac1cf6d4a6a711919c8455c3dcfe6a79637
4
- data.tar.gz: fb9a551e05882e05efc3cd23215773507ae6d5d2baab70681360c04100724d29
3
+ metadata.gz: 8c751d42f963af2dc145005dba7b6245b0dc9fa549cacecb824e6a5cc2c3232d
4
+ data.tar.gz: c7c602f3f96a47a85080b287fd87435bb161abcab6b5358f51b45ed05fd1428a
5
5
  SHA512:
6
- metadata.gz: a5c4fbaade4883c361a20e705155d2b60dc6b3e6e64905afe34b02a7a7016cd7913bfd9f1f2de09979d357f72b026dbdc3129ad871d128a1b9ee901628a096b6
7
- data.tar.gz: fb7b8dd6f7b74dee3c6d13b789dd07ff3abaeda535680a1ab7ea4276269e40a238588403e5fd7c8d2cdb3188acd3b7889d867d4dffed6a19af45d91eefa68591
6
+ metadata.gz: e8e1e583486fec890b5ccdedb77f99f1954f37e9a4217afe1aee0085ca8483f242a966643bcce5da5a7a180d0223850039a883ae9ce92b8be91a02c7beaac7b7
7
+ data.tar.gz: 71305eaf4dec9b4bf3fae7bf19bbb3fd997d75c409c6d5239d52030c1c7b5833f48f675f5bbcf187ef16334c0286f7a780675add36980c5b927ca592c68ab9d7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.19.0] - 2026-06-25
4
+
5
+ > Capa de errores transport-agnostic (#52). Cambio **aditivo y retrocompatible**: `.message` no se degrada y se suman dos accesores. Bump minor.
6
+
7
+ ### Nuevas funcionalidades
8
+ - **`status` + `raw_response` uniformes en toda la jerarquía (#52):** subidos a la base `BugBunny::Error` (`attr_accessor`) y poblados por `RaiseError.on_complete` en **todas** las clases de error, no solo `UnprocessableEntity`. El consumidor accede a `e.status` / `e.raw_response` de forma uniforme; la gema queda agnóstica al payload y el boundary del servicio interpreta el envelope de dominio. — @Gabriel
9
+
10
+ ### Correcciones
11
+ - **`.message` ya no vuelca un `Hash#inspect` con el envelope anidado (#52):** ante `{ error: { code, message, details } }`, `format_error_message` extrae `error.message` best-effort en vez de interpolar el Hash; mantiene el shape plano histórico (incluido `error: ""`) y cae a JSON. Es solo el string humano para logs/Sentry, no contrato. — @Gabriel
12
+
13
+ ### Documentación
14
+ - `skill/references/errores.md` y `skill/SKILL.md`: nueva sección "Materia prima del error" (`status`/`raw_response`), formato de `.message` endurecido y advertencia de sanitización (no loguear `raw_response` crudo). — @Gabriel
15
+ - Piloto RFC-014: `docs/release/release.md` (suplemento de release de la gema) (#51) — @Gabriel
16
+
3
17
  ## [4.18.0] - 2026-05-26
4
18
 
5
19
  > Behavior change menor: cualquier `Bunny::Exception` que escape en la frontera del gem (TCP fail, conn rota, canal cerrado, auth fail) ahora se envuelve como `BugBunny::CommunicationError`. La excepción original queda accesible vía `.cause`. Callers que rescatan `Bunny::TCPConnectionFailed` directo deben migrar a `BugBunny::CommunicationError`. Callers que ya rescatan `BugBunny::Error` / `CommunicationError`: sin cambios.
@@ -0,0 +1,108 @@
1
+ # Release — bug_bunny
2
+
3
+ > meta: artefacto release · RFC-014 (shape v2.0, `proposed`) · generado
4
+ > manualmente (**piloto RFC-014** — suplemento gema, valida patrón 1 +
5
+ > per-repo-visible) · anclado a `.github/workflows/release.yml`, `*.gemspec`,
6
+ > `lib/bug_bunny/version.rb`, `CHANGELOG.md`, git · fecha 2026-06-01 · 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.18.0**.
24
+ - **Source of truth:** tag remoto (`v4.18.0`) + **triple mirror**
25
+ `lib/bug_bunny/version.rb` (`VERSION = '4.18.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.18.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.18.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.18.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.
@@ -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
- class Error < ::StandardError; end
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
  #
@@ -191,8 +220,9 @@ module BugBunny
191
220
  # @return [Hash, Array, String] Los mensajes de error listos para ser iterados.
192
221
  attr_reader :error_messages
193
222
 
194
- # @return [String, Hash] El cuerpo crudo de la respuesta original.
195
- attr_reader :raw_response
223
+ # `raw_response` y `status` se heredan de {BugBunny::Error} (poblados por
224
+ # {BugBunny::Middleware::RaiseError}); `raw_response` además se setea acá en
225
+ # el constructor para mantener el comportamiento histórico.
196
226
 
197
227
  # Inicializa la excepción procesando el cuerpo de la respuesta.
198
228
  #
@@ -34,71 +34,125 @@ module BugBunny
34
34
  body = response['body']
35
35
 
36
36
  case status
37
- when 200..299
38
- nil # Flujo normal (Success)
39
- when 400
40
- raise BugBunny::BadRequest, format_error_message(body)
41
- when 404
42
- raise_not_found(body)
43
- when 406
44
- raise BugBunny::NotAcceptable
45
- when 408
46
- raise BugBunny::RequestTimeout
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
- # Formatea el cuerpo de la respuesta de error para que sea legible en las excepciones.
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
- # Prioriza la convención `{ "error": "...", "detail": "..." }`. Si la respuesta no
72
- # sigue esta convención, convierte el Hash completo a un JSON string para mantenerlo legible.
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 estructurado.
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
- # Si el worker devolvió un JSON con una key 'error' (nuestra convención en Controller)
81
- if body.is_a?(Hash) && body['error']
82
- detail = body['detail'] ? " - #{body['detail']}" : ''
83
- "#{body['error']}#{detail}"
84
- else
85
- # Fallback: Convertir todo el Hash a JSON string para que se vea claro en Sentry/Logs
86
- body.to_json
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
- raise BugBunny::RoutingError, format_error_message(body)
152
+ raise_typed(BugBunny::RoutingError.new(format_error_message(body)), status, body)
99
153
  end
100
154
 
101
- raise BugBunny::NotFound, format_error_message(body)
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
- raise BugBunny::ServerError, "Server Error (#{status}): #{msg}"
168
+ raise_typed(BugBunny::ServerError.new("Server Error (#{status}): #{msg}"), status, body)
115
169
  elsif status >= 400
116
- raise BugBunny::ClientError, "Client Error (#{status}): #{msg}"
170
+ raise_typed(BugBunny::ClientError.new("Client Error (#{status}): #{msg}"), status, body)
117
171
  end
118
172
  end
119
173
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = '4.18.0'
4
+ VERSION = '4.19.0'
5
5
  end
data/skill/SKILL.md CHANGED
@@ -435,6 +435,11 @@ Limitación de RSpec: `instance_double` valida que el método exista pero **no**
435
435
  **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
436
  **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
437
 
438
+ **Materia prima (desde 4.19):** todo `BugBunny::Error` de respuesta RPC expone
439
+ `e.status` y `e.raw_response` (cuerpo crudo) de forma uniforme — no solo 422. La
440
+ gema es agnóstica al payload: el envelope de dominio se parsea en el boundary del
441
+ servicio. No loguear `raw_response` sin sanitizar.
442
+
438
443
  Ver catálogo completo en [Errores](references/errores.md).
439
444
 
440
445
  ---
@@ -22,6 +22,30 @@ StandardError
22
22
  └── BugBunny::RemoteError (500)
23
23
  ```
24
24
 
25
+ ## Materia prima del error (`status` + `raw_response`) — desde 4.19
26
+
27
+ Todos los errores derivados de una respuesta RPC exponen de forma **uniforme**
28
+ el `status` y el `raw_response` (cuerpo crudo), no solo `UnprocessableEntity`.
29
+ La gema es **agnóstica al payload**: entrega el cuerpo crudo tal cual y el
30
+ boundary del servicio decide la semántica de dominio (códigos, detalles, etc.).
31
+
32
+ ```ruby
33
+ begin
34
+ client.request('clusters/42', method: :get)
35
+ rescue BugBunny::Error => e
36
+ e.status # => 409 (Integer, nil si el error no viene de una respuesta RPC)
37
+ e.raw_response # => cuerpo crudo tal como llegó por el wire (Hash/String/nil)
38
+ end
39
+ ```
40
+
41
+ > ⚠️ **No loguear `raw_response` crudo.** Puede contener datos sensibles. Filtrar
42
+ > claves (`password|pass|passwd|secret|token|api_key|auth`) → `[FILTERED]` antes
43
+ > de Sentry/logs. La gema lo entrega a propósito; sanitizar es del consumidor.
44
+
45
+ Para consumir un envelope estructurado de dominio (ej. `{ error: { code,
46
+ message, details } }`), parsealo en el boundary del servicio desde
47
+ `e.raw_response` — la gema **no** expone `code`/`details` como contrato.
48
+
25
49
  ## Errores de Infraestructura
26
50
 
27
51
  ### BugBunny::CommunicationError
@@ -133,10 +157,15 @@ end
133
157
 
134
158
  ## Formato de Mensajes de Error
135
159
 
136
- El middleware `RaiseError` construye el mensaje así:
137
- 1. Busca `{ "error": "...", "detail": "..." }` en el body.
138
- 2. Si no encuentra, usa el Hash completo como JSON.
139
- 3. Si el body está vacío, usa `"Unknown Error"`.
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/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
- repo: sequre/ai_knowledge
14
+ bug-report:
21
15
  dev-flow:
22
- repo: sequre/ai_knowledge
23
16
  matrix-element:
24
- repo: sequre/ai_knowledge
25
- environment:
26
- homeserver: "https://matrix.cloud.wispro.co"
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
@@ -55,5 +55,181 @@ RSpec.describe BugBunny::Middleware::RaiseError do
55
55
  expect { middleware.on_complete(response) }.to raise_error(BugBunny::NotFound)
56
56
  end
57
57
  end
58
+
59
+ # Alcance issue #52: status + raw_response como materia prima en TODAS las
60
+ # clases de error, no solo en 422.
61
+ describe 'raw_response and status on every error class' do
62
+ def captured_error(response)
63
+ middleware.on_complete(response)
64
+ nil
65
+ rescue BugBunny::Error => e
66
+ e
67
+ end
68
+
69
+ {
70
+ 400 => BugBunny::BadRequest,
71
+ 409 => BugBunny::Conflict,
72
+ 500 => BugBunny::InternalServerError
73
+ }.each do |status, klass|
74
+ context "when status is #{status}" do
75
+ let(:body) { { 'error' => 'boom' } }
76
+ let(:error) { captured_error('status' => status, 'body' => body) }
77
+
78
+ it "raises #{klass} with status and raw_response populated" do
79
+ expect(error).to be_a(klass)
80
+ expect(error.status).to eq(status)
81
+ expect(error.raw_response).to eq(body)
82
+ end
83
+ end
84
+ end
85
+
86
+ context 'when status is 404 (NotFound)' do
87
+ let(:body) { { 'error' => 'missing' } }
88
+ let(:error) { captured_error('status' => 404, 'body' => body) }
89
+
90
+ it 'populates status and raw_response' do
91
+ expect(error.status).to eq(404)
92
+ expect(error.raw_response).to eq(body)
93
+ end
94
+ end
95
+
96
+ context 'when status is 422 (UnprocessableEntity)' do
97
+ let(:body) { { 'errors' => { 'name' => ['blank'] } } }
98
+ let(:error) { captured_error('status' => 422, 'body' => body) }
99
+
100
+ it 'keeps raw_response/error_messages and adds status from base' do
101
+ expect(error).to be_a(BugBunny::UnprocessableEntity)
102
+ expect(error.status).to eq(422)
103
+ expect(error.raw_response).to eq(body)
104
+ expect(error.error_messages).to eq('name' => ['blank'])
105
+ end
106
+ end
107
+
108
+ context 'when status is unmapped (418)' do
109
+ let(:body) { { 'error' => 'teapot' } }
110
+ let(:error) { captured_error('status' => 418, 'body' => body) }
111
+
112
+ it 'populates status and raw_response on the generic ClientError' do
113
+ expect(error).to be_a(BugBunny::ClientError)
114
+ expect(error.status).to eq(418)
115
+ expect(error.raw_response).to eq(body)
116
+ end
117
+ end
118
+ end
119
+
120
+ # Alcance issue #52: hardening de format_error_message contra el envelope
121
+ # anidado para no volcar un Hash#inspect en .message.
122
+ describe 'message hardening against the nested canonical envelope' do
123
+ def captured_error(response)
124
+ middleware.on_complete(response)
125
+ nil
126
+ rescue BugBunny::Error => e
127
+ e
128
+ end
129
+
130
+ context 'when error is the nested canonical envelope { error: { message } }' do
131
+ let(:body) { { 'error' => { 'code' => 'shortname_taken', 'message' => 'shortname already taken', 'details' => {} } } }
132
+ let(:error) { captured_error('status' => 409, 'body' => body) }
133
+
134
+ it 'extracts the human message and does not dump the Hash' do
135
+ expect(error.message).to eq('shortname already taken')
136
+ expect(error.message).not_to include('=>')
137
+ end
138
+ end
139
+
140
+ context 'when the nested envelope lacks a usable message' do
141
+ let(:body) { { 'error' => { 'code' => 'x', 'details' => {} } } }
142
+ let(:error) { captured_error('status' => 409, 'body' => body) }
143
+
144
+ it 'falls back to JSON instead of Hash#inspect' do
145
+ expect(error.message).to eq(body.to_json)
146
+ expect(error.message).not_to include('=>')
147
+ end
148
+ end
149
+
150
+ context 'when error is the flat historical shape { error: string, detail: string }' do
151
+ let(:body) { { 'error' => 'boom', 'detail' => 'because reasons' } }
152
+ let(:error) { captured_error('status' => 400, 'body' => body) }
153
+
154
+ it 'keeps concatenating error and detail (backward compatible)' do
155
+ expect(error.message).to eq('boom - because reasons')
156
+ end
157
+ end
158
+
159
+ context 'when error is an empty string (backward-compat edge case)' do
160
+ let(:body) { { 'error' => '', 'detail' => 'x' } }
161
+ let(:error) { captured_error('status' => 400, 'body' => body) }
162
+
163
+ it 'preserves the historical concatenation instead of dumping JSON' do
164
+ expect(error.message).to eq(' - x')
165
+ end
166
+ end
167
+ end
168
+
169
+ # Alcance issue #52: casos borde y paths no cubiertos arriba.
170
+ describe 'edge cases and uncovered paths' do
171
+ def captured_error(response)
172
+ middleware.on_complete(response)
173
+ nil
174
+ rescue BugBunny::Error => e
175
+ e
176
+ end
177
+
178
+ context 'when 5xx carries a serialized remote exception (bug_bunny_exception)' do
179
+ let(:body) do
180
+ { 'bug_bunny_exception' => { 'class' => 'ActiveRecord::RecordNotFound',
181
+ 'message' => 'User not found',
182
+ 'backtrace' => ['app.rb:1'] } }
183
+ end
184
+ let(:error) { captured_error('status' => 500, 'body' => body) }
185
+
186
+ it 'raises RemoteError with status and raw_response populated' do
187
+ expect(error).to be_a(BugBunny::RemoteError)
188
+ expect(error.status).to eq(500)
189
+ expect(error.raw_response).to eq(body)
190
+ expect(error.original_class).to eq('ActiveRecord::RecordNotFound')
191
+ end
192
+ end
193
+
194
+ context 'when 422 carries the nested envelope (consumer parses from raw_response)' do
195
+ let(:body) { { 'error' => { 'code' => 'taken', 'message' => 'shortname already taken', 'details' => {} } } }
196
+ let(:error) { captured_error('status' => 422, 'body' => body) }
197
+
198
+ it 'keeps raw_response intact for the service boundary' do
199
+ expect(error).to be_a(BugBunny::UnprocessableEntity)
200
+ expect(error.status).to eq(422)
201
+ expect(error.raw_response).to eq(body)
202
+ end
203
+ end
204
+
205
+ context 'when status is 406 (no-arg constructor)' do
206
+ let(:body) { { 'error' => 'nope' } }
207
+ let(:error) { captured_error('status' => 406, 'body' => body) }
208
+
209
+ it 'still populates status and raw_response' do
210
+ expect(error).to be_a(BugBunny::NotAcceptable)
211
+ expect(error.status).to eq(406)
212
+ expect(error.raw_response).to eq(body)
213
+ end
214
+ end
215
+
216
+ context 'when status is 408 (no-arg constructor)' do
217
+ let(:error) { captured_error('status' => 408, 'body' => nil) }
218
+
219
+ it 'still populates status' do
220
+ expect(error).to be_a(BugBunny::RequestTimeout)
221
+ expect(error.status).to eq(408)
222
+ end
223
+ end
224
+
225
+ context 'when body is not a Hash nor a String (e.g. Array)' do
226
+ let(:body) { [1, 2, 3] }
227
+ let(:error) { captured_error('status' => 400, 'body' => body) }
228
+
229
+ it 'falls back to JSON without raising in the formatter' do
230
+ expect(error.message).to eq(body.to_json)
231
+ end
232
+ end
233
+ end
58
234
  end
59
235
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bug_bunny
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.18.0
4
+ version: 4.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - gabix
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-26 00:00:00.000000000 Z
11
+ date: 2026-06-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny
@@ -235,6 +235,7 @@ files:
235
235
  - Rakefile
236
236
  - docs/behavior/behavior.md
237
237
  - docs/glossary/glossary.md
238
+ - docs/release/release.md
238
239
  - initializer_example.rb
239
240
  - lib/bug_bunny.rb
240
241
  - lib/bug_bunny/client.rb
@@ -307,7 +308,7 @@ metadata:
307
308
  homepage_uri: https://github.com/gedera/bug_bunny
308
309
  source_code_uri: https://github.com/gedera/bug_bunny
309
310
  changelog_uri: https://github.com/gedera/bug_bunny/blob/main/CHANGELOG.md
310
- documentation_uri: https://github.com/gedera/bug_bunny/blob/v4.18.0/skill
311
+ documentation_uri: https://github.com/gedera/bug_bunny/blob/v4.19.0/skill
311
312
  post_install_message:
312
313
  rdoc_options: []
313
314
  require_paths: