bug_bunny 4.10.0 → 4.10.2

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: af9746f85e544f059873513a7ace9b478acb6664b9053bb90d1963afb1d72848
4
- data.tar.gz: 32854fb9e2a67f8e5cb537550d957704443d95183d22d5305b48c3207edaf05b
3
+ metadata.gz: '08e0d33ac09d046d3de52d3d7f75b7b87df8d6e96f495ab37c7433fe9f4add33'
4
+ data.tar.gz: '09373953a7dc292c97363e3514f9e3db1b9b041527a9e26e09e52089abfda0b9'
5
5
  SHA512:
6
- metadata.gz: 90169fb942803e87f47cf1af2fb2b4cb4d66112aaf4ab6632bb6a8d5031383e1fc0105a4cf2449eed847822509a71c1e4695c8cacb9907889c286cc5cfefe70b
7
- data.tar.gz: 39262e92b89fb583769bcfaa8a17972584b0aaeda9f197ff3cf7cf4c45941ca0ee0b3aa154e8073d4004f5213bb1852aa16cc6c6f809951b2bcf3c76b85fa66d
6
+ metadata.gz: 34799b71420bb76ca71439e80d5b27a5d2ed38f424b16ba914b85566c5277279d3f4e1743caa4f2e8d932d125ceb019479add38df49573ab42880ef103cf1c34
7
+ data.tar.gz: 7b8cceae4b29ff12e9e42fc51ee3b68ec5a1ae92122a91f35c6bb132f09edf1b5171785ef6494d3580e86da715218f1602f2ef8071a2e9b77b0e0123c28795ff
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.10.2] - 2026-04-08
4
+
5
+ ### Correcciones
6
+ - **RemoteError#to_s recursión infinita:** Corregir `SystemStackError` al invocar `to_s` en `BugBunny::RemoteError` en IRB. Antes, `to_s` llamaba a `message`, que en Ruby delega a `to_s`, generando recursión infinita. Ahora usa `super` para invocar `Exception#to_s` directamente. — @Gabriel
7
+
8
+ ## [4.10.1] - 2026-04-08
9
+
10
+ ### Correcciones
11
+ - Corregir route matching 404: el path del cliente ahora se normaliza antes de pasarlo a `RouteSet#recognize`. Antes, `URI.parse("http://dummy/#{path}")` prependead un `/` extra al path, causando que rutas existentes no hicieran match. Ahora se usa `path.gsub(%r{^/|/$}, '')` antes del recognize. — @Gabriel
12
+
3
13
  ## [4.9.1] - 2026-04-06
4
14
 
5
15
  ### Correcciones
@@ -181,18 +181,20 @@ module BugBunny
181
181
  # ===================================================================
182
182
  # 3. Ruteo Declarativo
183
183
  # ===================================================================
184
- uri = URI.parse("http://dummy/#{path}")
184
+ normalized_path = path.gsub(%r{^/|/$}, '')
185
+
186
+ uri = URI.parse("http://dummy/#{normalized_path}")
185
187
 
186
188
  # Extraemos query params (ej. /nodes?status=active)
187
189
  query_params = uri.query ? Rack::Utils.parse_nested_query(uri.query) : {}
188
190
  query_params = query_params.with_indifferent_access if defined?(ActiveSupport::HashWithIndifferentAccess)
189
191
 
190
192
  # Le preguntamos al motor de rutas global quién debe manejar esto
191
- route_info = BugBunny.routes.recognize(http_method, uri.path)
193
+ route_info = BugBunny.routes.recognize(http_method, normalized_path)
192
194
 
193
195
  if route_info.nil?
194
- safe_log(:warn, 'consumer.route_not_found', method: http_method, path: uri.path)
195
- handle_fatal_error(properties, 404, 'Not Found', "No route matches [#{http_method}] \"/#{uri.path}\"")
196
+ safe_log(:warn, 'consumer.route_not_found', method: http_method, path: normalized_path)
197
+ handle_fatal_error(properties, 404, 'Not Found', "No route matches [#{http_method}] \"#{normalized_path}\"")
196
198
  session.channel.reject(delivery_info.delivery_tag, false)
197
199
  return
198
200
  end
@@ -49,7 +49,7 @@ module BugBunny
49
49
 
50
50
  # @return [String] Representación legible de la excepción.
51
51
  def to_s
52
- "#{self.class.name}(#{original_class}): #{message}"
52
+ "#{self.class.name}(#{original_class}): #{super}"
53
53
  end
54
54
  end
55
55
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = '4.10.0'
4
+ VERSION = '4.10.2'
5
5
  end
data/skill/SKILL.md CHANGED
@@ -230,6 +230,10 @@ No registrar consumer middlewares durante la ejecución de `call()`. El stack to
230
230
  **Causa:** El mensaje intenta ejecutar un controlador que no hereda de `BugBunny::Controller`.
231
231
  **Resolución:** Verificar la jerarquía de controladores y que `config.controller_namespace` coincida.
232
232
 
233
+ ### BugBunny::RouteNotFoundError (404)
234
+ **Causa:** El path del mensaje no coincide con ninguna ruta registrada. El path debe estar normalizado (sin slashes iniciales/trailing).
235
+ **Resolución:** Verificar que el cliente envíe el path sin leading/trailing slashes (ej: `users/42`, no `/users/42/`).
236
+
233
237
  ### BugBunny::UnprocessableEntity (422)
234
238
  **Causa:** Fallo de validación en el servicio remoto.
235
239
  **Resolución:** `resource.save` devuelve `false`. Acceder a `resource.errors` o `rescue` con `e.error_messages`.
@@ -21,13 +21,14 @@ consumer = BugBunny::Consumer.subscribe(
21
21
  2. Extrae campos **OTel messaging** del mensaje para logs estructurados (sin mutar headers).
22
22
  3. Valida que el mensaje tenga header `type` (path).
23
23
  4. Parsea el método HTTP de headers (`x-http-method` o `method`).
24
- 5. Emite log `consumer.message_received` con campos OTel (`messaging_operation: 'process'`).
25
- 6. Reconoce la ruta con `BugBunny.routes.recognize(method, path)`.
26
- 7. Resuelve el controlador validando herencia de `BugBunny::Controller`.
27
- 8. Ejecuta consumer middlewares controller callbacks → acción.
28
- 9. Responde via `reply_to` si está presente (RPC), inyectando campos OTel (`messaging_operation: 'publish'`).
29
- 10. Emite log `consumer.message_processed` con campos OTel y duraciones.
30
- 11. Hace `ack` del mensaje. En caso de error, `reject`.
24
+ 5. **Normaliza el path**: remueve slashes iniciales/trailing (`path.gsub(%r{^/|/$}, '')`).
25
+ 6. Emite log `consumer.message_received` con campos OTel (`messaging_operation: 'process'`).
26
+ 7. Reconoce la ruta con `BugBunny.routes.recognize(method, normalized_path)`.
27
+ 8. Resuelve el controlador validando herencia de `BugBunny::Controller`.
28
+ 9. Ejecuta consumer middlewares controller callbacks acción.
29
+ 10. Responde via `reply_to` si está presente (RPC), inyectando campos OTel (`messaging_operation: 'publish'`).
30
+ 11. Emite log `consumer.message_processed` con campos OTel y duraciones.
31
+ 12. Hace `ack` del mensaje. En caso de error, `reject`.
31
32
 
32
33
  ## Observability: OTel Fields
33
34
 
@@ -16,7 +16,8 @@ StandardError
16
16
  │ ├── BugBunny::Conflict (409)
17
17
  │ └── BugBunny::UnprocessableEntity (422)
18
18
  └── BugBunny::ServerError (5xx)
19
- └── BugBunny::InternalServerError (500+)
19
+ ├── BugBunny::InternalServerError (500+)
20
+ └── BugBunny::RemoteError (500)
20
21
  ```
21
22
 
22
23
  ## Errores de Infraestructura
@@ -83,6 +84,21 @@ end
83
84
  **Causa:** Cualquier error de servidor no mapeado a InternalServerError.
84
85
  **Resolución:** Similar a InternalServerError.
85
86
 
87
+ ### BugBunny::RemoteError (500)
88
+ **Causa:** Excepción no manejada en el controller remoto. El error se serializa y propaga al cliente RPC.
89
+ **Acceso a detalles:**
90
+ ```ruby
91
+ begin
92
+ client.request('users/42')
93
+ rescue BugBunny::RemoteError => e
94
+ e.original_class # String: clase original (ej: "TypeError")
95
+ e.original_message # String: mensaje original
96
+ e.original_backtrace # Array<String>: backtrace original
97
+ end
98
+ ```
99
+ **Serialización:** El controller captura excepciones con `rescue_from` → `handle_exception` → serializa con clase, mensaje y primeras 25 líneas del backtrace.
100
+ **Propagación:** El middleware `RaiseError` del cliente reconstituye `RemoteError` y la lanza localmente.
101
+
86
102
  ## Formato de Mensajes de Error
87
103
 
88
104
  El middleware `RaiseError` construye el mensaje así:
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe BugBunny::RemoteError do
6
+ subject(:error) do
7
+ described_class.new('TypeError', 'nil can\'t be coerced into Integer', [
8
+ "/app/controllers/services_controller.rb:5:in 'Integer#*'",
9
+ "/app/controllers/services_controller.rb:5:in 'ServicesController#index'"
10
+ ])
11
+ end
12
+
13
+ describe '#to_s' do
14
+ it 'does not cause infinite recursion (message -> to_s -> message)' do
15
+ expect(error.to_s).to eq("BugBunny::RemoteError(TypeError): nil can't be coerced into Integer")
16
+ end
17
+ end
18
+
19
+ describe '#message' do
20
+ it 'returns the formatted string without stack overflow' do
21
+ expect(error.message).to eq("BugBunny::RemoteError(TypeError): nil can't be coerced into Integer")
22
+ end
23
+ end
24
+
25
+ describe '#inspect' do
26
+ it 'is renderable by IRB without raising' do
27
+ expect { error.inspect }.not_to raise_error
28
+ end
29
+ end
30
+
31
+ describe '.serialize' do
32
+ it 'serializes an exception with class, message and backtrace' do
33
+ exception = TypeError.new('test error')
34
+ exception.set_backtrace(%w[line1 line2])
35
+
36
+ result = described_class.serialize(exception)
37
+
38
+ expect(result).to eq(class: 'TypeError', message: 'test error', backtrace: %w[line1 line2])
39
+ end
40
+
41
+ it 'truncates backtrace to max_lines' do
42
+ exception = TypeError.new('test')
43
+ exception.set_backtrace(Array.new(30) { |i| "line#{i}" })
44
+
45
+ result = described_class.serialize(exception, max_lines: 5)
46
+
47
+ expect(result[:backtrace].size).to eq(5)
48
+ end
49
+ end
50
+
51
+ describe 'attributes' do
52
+ it 'exposes the original exception class' do
53
+ expect(error.original_class).to eq('TypeError')
54
+ end
55
+
56
+ it 'exposes the original message' do
57
+ expect(error.original_message).to eq("nil can't be coerced into Integer")
58
+ end
59
+
60
+ it 'exposes the original backtrace' do
61
+ expect(error.original_backtrace.size).to eq(2)
62
+ end
63
+
64
+ it 'sets the Ruby backtrace to the remote backtrace' do
65
+ expect(error.backtrace).to eq(error.original_backtrace)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe BugBunny::Routing::Route do
6
+ describe '#match? and #extract_params — path normalization' do
7
+ let(:route) { described_class.new('GET', 'services/otaigccd59q0k7kxb1h193go2/restart', to: 'services#restart') }
8
+
9
+ it 'matches path without leading slash' do
10
+ expect(route.match?('GET', 'services/otaigccd59q0k7kxb1h193go2/restart')).to be true
11
+ end
12
+
13
+ it 'matches path with leading slash' do
14
+ expect(route.match?('GET', '/services/otaigccd59q0k7kxb1h193go2/restart')).to be true
15
+ end
16
+
17
+ it 'matches path with trailing slash' do
18
+ expect(route.match?('GET', 'services/otaigccd59q0k7kxb1h193go2/restart/')).to be true
19
+ end
20
+
21
+ it 'matches path with leading and trailing slash' do
22
+ expect(route.match?('GET', '/services/otaigccd59q0k7kxb1h193go2/restart/')).to be true
23
+ end
24
+
25
+ it 'extracts params from path without slash' do
26
+ params = route.extract_params('services/otaigccd59q0k7kxb1h193go2/restart')
27
+ expect(params).to eq({})
28
+ end
29
+
30
+ it 'extracts params from path with slash' do
31
+ params = route.extract_params('/services/otaigccd59q0k7kxb1h193go2/restart')
32
+ expect(params).to eq({})
33
+ end
34
+
35
+ context 'route with dynamic segment' do
36
+ let(:route_with_id) { described_class.new('GET', 'nodes/:id', to: 'nodes#show') }
37
+
38
+ it 'extracts params from path without slash' do
39
+ params = route_with_id.extract_params('nodes/123')
40
+ expect(params).to eq({ 'id' => '123' })
41
+ end
42
+
43
+ it 'extracts params from path with slash' do
44
+ params = route_with_id.extract_params('/nodes/123')
45
+ expect(params).to eq({ 'id' => '123' })
46
+ end
47
+ end
48
+
49
+ context 'route defined with leading slash' do
50
+ let(:route_with_leading_slash) do
51
+ described_class.new('GET', '/services/otaigccd59q0k7kxb1h193go2/restart', to: 'services#restart')
52
+ end
53
+
54
+ it 'matches path without slash' do
55
+ expect(route_with_leading_slash.match?('GET', 'services/otaigccd59q0k7kxb1h193go2/restart')).to be true
56
+ end
57
+
58
+ it 'matches path with slash' do
59
+ expect(route_with_leading_slash.match?('GET', '/services/otaigccd59q0k7kxb1h193go2/restart')).to be true
60
+ end
61
+ end
62
+ end
63
+
64
+ describe 'RouteSet#recognize path normalization' do
65
+ let(:route_set) { BugBunny::Routing::RouteSet.new }
66
+
67
+ before do
68
+ route_set.draw do
69
+ get 'services/otaigccd59q0k7kxb1h193go2/restart', to: 'services#restart'
70
+ end
71
+ end
72
+
73
+ it 'recognizes path without slash' do
74
+ result = route_set.recognize('GET', 'services/otaigccd59q0k7kxb1h193go2/restart')
75
+ expect(result).not_to be_nil
76
+ expect(result[:controller]).to eq('services')
77
+ expect(result[:action]).to eq('restart')
78
+ end
79
+
80
+ it 'recognizes path with leading slash' do
81
+ result = route_set.recognize('GET', '/services/otaigccd59q0k7kxb1h193go2/restart')
82
+ expect(result).not_to be_nil
83
+ expect(result[:controller]).to eq('services')
84
+ expect(result[:action]).to eq('restart')
85
+ end
86
+
87
+ it 'returns nil for non-matching path' do
88
+ result = route_set.recognize('GET', 'services/nonexistent')
89
+ expect(result).to be_nil
90
+ end
91
+ end
92
+
93
+ describe 'Consumer path handling — URI parsing edge case' do
94
+ let(:channel) { BunnyMocks::FakeChannel.new(true) }
95
+ let(:connection) { BunnyMocks::FakeConnection.new(true, channel) }
96
+
97
+ let(:mock_channel) do
98
+ ch = double('channel')
99
+ allow(ch).to receive(:reject)
100
+ allow(ch).to receive(:ack)
101
+ allow(ch).to receive(:open?).and_return(true)
102
+ default_ex = double('default_exchange')
103
+ allow(default_ex).to receive(:publish)
104
+ allow(ch).to receive(:default_exchange).and_return(default_ex)
105
+ ch
106
+ end
107
+
108
+ let(:mock_session) do
109
+ s = instance_double(BugBunny::Session)
110
+ allow(s).to receive(:exchange).and_return(double('exchange'))
111
+ allow(s).to receive(:queue).and_return(double('queue'))
112
+ allow(s).to receive(:close)
113
+ allow(s).to receive(:channel).and_return(mock_channel)
114
+ s
115
+ end
116
+
117
+ let(:test_consumer) do
118
+ c = BugBunny::Consumer.new(connection)
119
+ c.instance_variable_set(:@session, mock_session)
120
+ c
121
+ end
122
+
123
+ let(:delivery_info) do
124
+ double('delivery_info',
125
+ exchange: 'events_x',
126
+ routing_key: 'users.created',
127
+ delivery_tag: 'delivery-tag-1',
128
+ redelivered?: false)
129
+ end
130
+
131
+ let(:properties) do
132
+ double('properties',
133
+ type: 'services/otaigccd59q0k7kxb1h193go2/restart',
134
+ headers: { 'x-http-method' => 'GET' },
135
+ correlation_id: 'corr-abc-123',
136
+ reply_to: nil,
137
+ content_type: 'application/json')
138
+ end
139
+
140
+ let(:logged_events) { [] }
141
+
142
+ before do
143
+ allow(test_consumer).to receive(:safe_log) do |level, event, **kwargs|
144
+ logged_events << { level: level, event: event, kwargs: kwargs }
145
+ end
146
+ allow(test_consumer).to receive(:handle_fatal_error)
147
+ end
148
+
149
+ it 'logs route_not_found with normalized path (without extra slash)' do
150
+ allow(BugBunny.routes).to receive(:recognize).and_return(nil)
151
+
152
+ test_consumer.send(:process_message, delivery_info, properties, '{}')
153
+
154
+ route_not_found_event = logged_events.find { |e| e[:event] == 'consumer.route_not_found' }
155
+ expect(route_not_found_event).not_to be_nil
156
+ expect(route_not_found_event[:kwargs][:path]).to eq('services/otaigccd59q0k7kxb1h193go2/restart')
157
+ end
158
+
159
+ context 'when properties.type has leading slash' do
160
+ let(:properties) do
161
+ double('properties',
162
+ type: '/services/otaigccd59q0k7kxb1h193go2/restart',
163
+ headers: { 'x-http-method' => 'GET' },
164
+ correlation_id: 'corr-abc-123',
165
+ reply_to: nil,
166
+ content_type: 'application/json')
167
+ end
168
+
169
+ it 'logs route_not_found with normalized path (stripped leading slash)' do
170
+ allow(BugBunny.routes).to receive(:recognize).and_return(nil)
171
+
172
+ test_consumer.send(:process_message, delivery_info, properties, '{}')
173
+
174
+ route_not_found_event = logged_events.find { |e| e[:event] == 'consumer.route_not_found' }
175
+ expect(route_not_found_event).not_to be_nil
176
+ expect(route_not_found_event[:kwargs][:path]).to eq('services/otaigccd59q0k7kxb1h193go2/restart')
177
+ end
178
+ end
179
+ end
180
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bug_bunny
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.10.0
4
+ version: 4.10.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - gabix
@@ -288,8 +288,10 @@ files:
288
288
  - spec/unit/observability_spec.rb
289
289
  - spec/unit/otel_spec.rb
290
290
  - spec/unit/producer_spec.rb
291
+ - spec/unit/remote_error_spec.rb
291
292
  - spec/unit/request_spec.rb
292
293
  - spec/unit/resource_attributes_spec.rb
294
+ - spec/unit/route_spec.rb
293
295
  - spec/unit/session_spec.rb
294
296
  - test/integration/infrastructure_test.rb
295
297
  - test/integration/manual_client_test.rb
@@ -301,7 +303,7 @@ metadata:
301
303
  homepage_uri: https://github.com/gedera/bug_bunny
302
304
  source_code_uri: https://github.com/gedera/bug_bunny
303
305
  changelog_uri: https://github.com/gedera/bug_bunny/blob/main/CHANGELOG.md
304
- documentation_uri: https://github.com/gedera/bug_bunny/blob/v4.10.0/skill
306
+ documentation_uri: https://github.com/gedera/bug_bunny/blob/v4.10.2/skill
305
307
  post_install_message:
306
308
  rdoc_options: []
307
309
  require_paths: