bug_bunny 4.7.0 → 4.8.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.
@@ -0,0 +1,133 @@
1
+ ## FAQ — External (Integrator / Consumer of BugBunny)
2
+
3
+ ### How do I set up BugBunny in a Rails app?
4
+
5
+ Add to Gemfile: `gem 'bug_bunny'` and `gem 'connection_pool'`. Run `rails generate bug_bunny:install`. Edit `config/initializers/bug_bunny.rb` with your RabbitMQ credentials. Create a pool: `MY_POOL = ConnectionPool.new(size: 5) { BugBunny.create_connection }`. Assign it: `BugBunny::Resource.connection_pool = MY_POOL`. See `docs/howto/rails.md` for the complete setup.
6
+
7
+ ---
8
+
9
+ ### How do I make an RPC call (blocking request)?
10
+
11
+ Use `Resource.find` / `Resource.where` / `Resource.create` — they all do RPC internally. For lower-level control: `client.request('users/1', method: :get, exchange: 'users_exchange', routing_key: 'users')`. The call blocks until the remote service replies or `rpc_timeout` expires.
12
+
13
+ ---
14
+
15
+ ### How do I publish without waiting for a reply?
16
+
17
+ Use `client.publish('events', method: :post, exchange: 'events_exchange', routing_key: 'events', body: { ... })`. Fire-and-forget — does not block. No RPC timeout applies.
18
+
19
+ ---
20
+
21
+ ### What happens when `find` returns nil vs raises?
22
+
23
+ `Resource.find` returns `nil` on 404 (does not raise). `Resource.where` returns `[]` on 404. Both raise `BugBunny::RequestTimeout` if the remote service does not reply within `rpc_timeout`. They raise `BugBunny::ServerError` on 5xx responses.
24
+
25
+ ---
26
+
27
+ ### How do I handle validation errors from the remote service?
28
+
29
+ `resource.save` returns `false` on 422 and loads the remote errors into `resource.errors`. Check `resource.valid?` then `resource.errors.full_messages`. The remote service must render `{ errors: { field: ['message'] } }` for the errors to be auto-loaded.
30
+
31
+ ---
32
+
33
+ ### How do I use `.with` to override the exchange per call?
34
+
35
+ ```ruby
36
+ # Block form (preferred — thread-safe, restores after block)
37
+ Order.with(exchange: 'priority_exchange', routing_key: 'priority') do
38
+ Order.find(id)
39
+ end
40
+
41
+ # Single-call proxy
42
+ Order.with(exchange: 'priority_exchange').find(id)
43
+ # ScopeProxy is single-use — calling a second method raises BugBunny::Error
44
+ ```
45
+
46
+ ---
47
+
48
+ ### How do I define a Resource with typed attributes?
49
+
50
+ ```ruby
51
+ class User < BugBunny::Resource
52
+ self.exchange = 'users_exchange'
53
+
54
+ attribute :id, :integer
55
+ attribute :name, :string
56
+ attribute :email, :string
57
+
58
+ validates :name, presence: true
59
+ end
60
+ ```
61
+
62
+ Typed attributes get ActiveModel coercion and dirty tracking. Attributes not declared with `attribute` are handled dynamically via `method_missing` and tracked in `@extra_attributes`.
63
+
64
+ ---
65
+
66
+ ### How do I add client-side middleware to a Resource?
67
+
68
+ ```ruby
69
+ class Order < BugBunny::Resource
70
+ client_middleware do |stack|
71
+ stack.use MyRetryMiddleware
72
+ end
73
+ end
74
+ ```
75
+
76
+ Middlewares are inherited by subclasses and applied in registration order (first = outermost). `RaiseError` and `JsonResponse` are always added as the innermost middlewares by `Resource` — do not add them manually.
77
+
78
+ ---
79
+
80
+ ### How do I run the Consumer in a Rails app?
81
+
82
+ The Consumer is a blocking loop. Run it in a separate process, not inside Puma:
83
+
84
+ ```ruby
85
+ # lib/tasks/rabbit.rake
86
+ task consumer: :environment do
87
+ conn = BugBunny.create_connection
88
+ consumer = BugBunny::Consumer.new(conn)
89
+ trap('TERM') { consumer.shutdown; exit }
90
+ trap('INT') { consumer.shutdown; exit }
91
+ consumer.subscribe(
92
+ queue_name: 'my_queue', exchange_name: 'my_exchange', routing_key: 'my_key'
93
+ )
94
+ end
95
+ ```
96
+
97
+ ---
98
+
99
+ ### How do I write a Controller?
100
+
101
+ Subclass `BugBunny::Controller`. Place it in the namespace from `config.controller_namespace` (default: `BugBunny::Controllers`). Implement action methods (`index`, `show`, `create`, `update`, `destroy` or custom). Call `render(status:, json:)` to respond. The Consumer routes messages to `YourController.call(headers:, body:)`.
102
+
103
+ ---
104
+
105
+ ### How do I propagate trace context through RabbitMQ?
106
+
107
+ On the consumer side, inject headers into replies:
108
+ ```ruby
109
+ config.rpc_reply_headers = -> { { 'X-Trace-Id' => Tracer.current } }
110
+ ```
111
+ On the producer side, hydrate context from the reply:
112
+ ```ruby
113
+ config.on_rpc_reply = ->(headers) { Tracer.hydrate(headers['X-Trace-Id']) }
114
+ ```
115
+ For consumer-side middleware (propagating from incoming request), use `ConsumerMiddleware`.
116
+
117
+ ---
118
+
119
+ ### How do I configure health checks for Kubernetes?
120
+
121
+ Set `config.health_check_file = '/app/tmp/bug_bunny_health'`. BugBunny touches that file every `health_check_interval` seconds (default: 60) after verifying the RabbitMQ connection. Add a `livenessProbe` in your Kubernetes manifest checking for that file's existence.
122
+
123
+ ---
124
+
125
+ ### What is the default RPC timeout and how do I change it?
126
+
127
+ Default: 10 seconds. Override globally: `config.rpc_timeout = 30`. Override per request: `client.request('users/1', method: :get, exchange: ..., timeout: 5)`. When exceeded, `BugBunny::RequestTimeout` is raised.
128
+
129
+ ---
130
+
131
+ ### Do I need to declare exchanges and queues manually?
132
+
133
+ No. BugBunny declares them automatically when `subscribe` (consumer) or the first RPC call (producer) is made. Use `config.exchange_options` and `config.queue_options` for global defaults (e.g., `{ durable: true }`). Override per resource with `Resource.exchange_options=` or per call with `.with(exchange_options:)`.
@@ -0,0 +1,86 @@
1
+ ## FAQ — Internal (Maintainer / Developer of BugBunny)
2
+
3
+ ### Why is Producer cached per connection slot instead of instantiated per request?
4
+
5
+ `Producer#initialize` calls `channel.basic_consume` on the `amq.rabbitmq.reply-to` pseudo-queue to listen for RPC replies. Creating a second `basic_consume` on the same channel raises an AMQP error. Therefore, one Producer per channel (= per connection slot) is mandatory. The cache is stored as `@_bug_bunny_producer` ivar on the Bunny connection object, which is safe because `ConnectionPool` guarantees each slot is used by one thread at a time.
6
+
7
+ ---
8
+
9
+ ### Why is Session cached per connection slot?
10
+
11
+ Same reason as Producer — and the Session wraps the channel. Recreating a Session would create a new channel and invalidate the cached exchange/queue objects. Caching is stored as `@_bug_bunny_session` on the Bunny connection object.
12
+
13
+ ---
14
+
15
+ ### How does the Session handle thread safety for exchange/queue declarations?
16
+
17
+ Session uses a double-checked locking pattern with a `Mutex` for its caches (`@exchange_cache`, `@queue_cache`). The read path (fast path) checks without locking; the write path acquires the mutex and checks again before writing. This avoids redundant AMQP declarations without making every read a mutex acquisition.
18
+
19
+ ---
20
+
21
+ ### Why does Resource have two dirty tracking mechanisms?
22
+
23
+ ActiveModel::Dirty only tracks attributes declared with `attribute :name, :type`. BugBunny Resources allow dynamic attributes (unknown at class definition time) stored in `@extra_attributes`. These are tracked manually via `@dynamic_changes` (a `Set`). The `changed?` and `changed` methods merge both to produce a unified change list.
24
+
25
+ ---
26
+
27
+ ### How does the 3-level config cascade work in Resource?
28
+
29
+ `resolve_config(key, instance_var)` checks in order:
30
+ 1. `Thread.current["bb_#{object_id}_#{key}"]` — set by `.with(...)`.
31
+ 2. `self.instance_variable_get(instance_var)` — class-level static config.
32
+ 3. Walk `superclass` chain up to (but not including) `BugBunny::Resource`.
33
+
34
+ This means a subclass can override the parent's config, and `.with` overrides everything for the duration of its block.
35
+
36
+ ---
37
+
38
+ ### How does ConsumerMiddleware::Stack protect against concurrent registration?
39
+
40
+ `Stack#use` wraps the append in a `@mutex.synchronize` block. The execution path (`call`) does not use the mutex — it reads `@middlewares` once at call time (snapshot). This is safe because Rails/Zeitwerk loads middleware registrations at boot, before any concurrent requests.
41
+
42
+ ---
43
+
44
+ ### How does routing with namespaces work internally?
45
+
46
+ `namespace :admin { resources :users }` generates `Route` objects with:
47
+ - `path_prefix: 'admin'` — prepended to the path pattern.
48
+ - `namespace: 'Admin::Controllers'` — overrides the default namespace for `constantize`.
49
+
50
+ In `process_message`, the Consumer resolves `base_namespace = route_info[:namespace] || config.controller_namespace` before calling `constantize`.
51
+
52
+ ---
53
+
54
+ ### What is the RCE prevention in the Consumer?
55
+
56
+ After `constantize`, the Consumer checks `controller_class < BugBunny::Controller`. This prevents an attacker from crafting a message with a `type` header pointing to an arbitrary Ruby class (e.g., `Kernel` or `File`) and triggering its methods. Any class not inheriting from `BugBunny::Controller` gets a 403 reply and the message is rejected.
57
+
58
+ ---
59
+
60
+ ### How does `before_action` halt chain propagation?
61
+
62
+ `run_before_actions` iterates before-actions and calls each. After each call, it checks `rendered_response`. If a filter called `render(...)`, `@rendered_response` is set. The method returns `false`, and `core_execution` skips the action and `after_actions`. After-actions do not run if the before-action chain was halted.
63
+
64
+ ---
65
+
66
+ ### How does `after_action` differ from `around_action`?
67
+
68
+ `after_action` runs after the action method returns, but only if no `before_action` halted the chain and no exception was raised. `around_action` wraps the entire execution including before/after actions and is responsible for yielding. Use `around_action` when you need cleanup regardless of exceptions.
69
+
70
+ ---
71
+
72
+ ### How does `safe_log` prevent log failures from affecting main flow?
73
+
74
+ `safe_log` wraps every logger call in `rescue StandardError`. It also filters sensitive keys by checking if the key string matches a predefined set of patterns (`password`, `pass`, `passwd`, `secret`, `token`, `api_key`, `auth`) before emitting values. Blocks are always passed to `logger.debug` to avoid string interpolation cost at non-debug levels.
75
+
76
+ ---
77
+
78
+ ### How is the Consumer health check implemented?
79
+
80
+ `start_health_check` creates a `Concurrent::TimerTask` that runs every `health_check_interval` seconds. Each tick calls `channel.queue_declare(queue_name, passive: true)` — a passive declare that verifies the queue exists without creating it. On failure, it calls `session.close`, which triggers the reconnect loop in `subscribe`. The health file is touched on success.
81
+
82
+ ---
83
+
84
+ ### What triggers the reconnect loop in Consumer?
85
+
86
+ Any `StandardError` raised inside the `subscribe` rescue block. The loop uses exponential backoff: `wait = [interval * 2^(attempt-1), max_interval].min`. If `max_reconnect_attempts` is set and exceeded, the error is re-raised, killing the process.
@@ -0,0 +1,45 @@
1
+ ## Glossary
2
+
3
+ **AMQP** — Advanced Message Queuing Protocol. The binary wire protocol used by RabbitMQ. BugBunny uses Bunny as its AMQP client.
4
+
5
+ **Exchange** — A RabbitMQ routing point. Publishers send messages to exchanges; exchanges route them to queues based on bindings and routing keys. BugBunny supports `direct`, `topic`, and `fanout` types.
6
+
7
+ **Queue** — A buffer that holds messages until they are consumed. A queue is bound to an exchange with a routing key pattern.
8
+
9
+ **Routing key** — A string label attached to a published message. The exchange uses it to decide which queues receive the message. In BugBunny, the routing key defaults to the pluralized resource name (e.g., `users`).
10
+
11
+ **Binding** — The link between an exchange and a queue, specifying which routing key patterns match.
12
+
13
+ **Session** — `BugBunny::Session`. A wrapper around a Bunny channel. Declares exchanges and queues, caches their AMQP objects, and handles double-checked locking for thread safety.
14
+
15
+ **Producer** — `BugBunny::Producer`. Publishes messages to RabbitMQ. Implements both RPC (blocking) and fire-and-forget (non-blocking) modes. One Producer is cached per connection slot.
16
+
17
+ **Consumer** — `BugBunny::Consumer`. The subscribe loop that runs in a worker process. Receives messages, routes them through middleware and the router, dispatches to a controller, and sends RPC replies.
18
+
19
+ **RPC (Remote Procedure Call)** — A synchronous request-response pattern over RabbitMQ. The producer publishes with `reply_to: 'amq.rabbitmq.reply-to'` and blocks on a `Concurrent::IVar` until the consumer replies to that pseudo-queue.
20
+
21
+ **Fire-and-forget** — An asynchronous publish with no reply expected. The producer does not block. Used for events, notifications, and side effects.
22
+
23
+ **Direct Reply-to** — A RabbitMQ pseudo-queue (`amq.rabbitmq.reply-to`) that allows RPC replies without declaring a temporary queue. BugBunny uses this for all RPC responses.
24
+
25
+ **Controller** — `BugBunny::Controller`. A class that handles an incoming routed message. Mirrors Rails ActionController: supports `before_action`, `around_action`, `after_action`, `rescue_from`, `params`, and `render`.
26
+
27
+ **Resource** — `BugBunny::Resource`. An ActiveRecord-like ORM over AMQP. Wraps CRUD operations as RPC calls. Used by the publishing side to interact with a remote service.
28
+
29
+ **Client** — `BugBunny::Client`. High-level API for the publisher side. Implements the Onion Middleware (Faraday-style) pattern around the Producer.
30
+
31
+ **ConsumerMiddleware** — A pipeline of middleware objects that runs before each message is dispatched to a controller. Used for cross-cutting concerns: tracing, authentication, logging.
32
+
33
+ **ConnectionPool** — A `connection_pool` gem pool of Bunny connections. Each slot holds one connection, one Session, and one Producer. `Resource.connection_pool` must be set before making AMQP calls.
34
+
35
+ **IVar (Ivar / Concurrent::IVar)** — An immutable variable from the `concurrent-ruby` gem. Used to block the calling thread until the RPC reply arrives or the timeout expires.
36
+
37
+ **health_check_file** — A file path that BugBunny touches periodically (every `health_check_interval` seconds) after verifying the RabbitMQ connection. Used as a liveness probe in Docker/Kubernetes.
38
+
39
+ **rpc_reply_headers** — A `Proc` returning a `Hash` of AMQP headers injected into every RPC reply. Used to propagate trace context from consumer back to producer.
40
+
41
+ **on_rpc_reply** — A `Proc` called on the producer's thread when an RPC reply arrives, with the reply's AMQP headers. Used to hydrate trace context in the calling service.
42
+
43
+ **Namespace routing** — A DSL feature that prefixes a group of routes with a module namespace and a path prefix. Example: `namespace :admin` generates routes under `Admin::Controllers::*Controller` with path prefix `admin/`.
44
+
45
+ **param_key** — The root key used to wrap the payload in POST/PUT requests. Defaults to the singularized resource name (e.g., `user` for `UsersResource`).
@@ -31,6 +31,7 @@ module BugBunny
31
31
  # @raise [ArgumentError] Si no se proporciona un `pool`.
32
32
  def initialize(pool:)
33
33
  raise ArgumentError, "BugBunny::Client requiere un 'pool:'" if pool.nil?
34
+
34
35
  @pool = pool
35
36
  @stack = BugBunny::Middleware::Stack.new
36
37
  @delivery_mode = :rpc
@@ -96,7 +97,7 @@ module BugBunny
96
97
  req = BugBunny::Request.new(url)
97
98
 
98
99
  # 2. Syntactic Sugar: Mapeo de argumentos a atributos del Request
99
- req.delivery_mode = delivery_mode # Default del cliente
100
+ req.delivery_mode = delivery_mode # Default del cliente
100
101
  req.delivery_mode = args[:delivery_mode] if args[:delivery_mode]
101
102
  req.method = args[:method] if args[:method]
102
103
  req.body = args[:body] if args[:body]
@@ -109,8 +110,8 @@ module BugBunny
109
110
  req.exchange_options = args[:exchange_options] if args[:exchange_options]
110
111
  req.queue_options = args[:queue_options] if args[:queue_options]
111
112
 
112
- req.params = args[:params] if args[:params]
113
- req.headers.merge!(args[:headers]) if args[:headers]
113
+ req.params = args[:params] if args[:params]
114
+ req.headers.merge!(args[:headers]) if args[:headers]
114
115
 
115
116
  # 3. Configuración del usuario (bloque específico por request)
116
117
  yield req if block_given?
@@ -40,7 +40,6 @@ module BugBunny
40
40
 
41
41
  # @!endgroup
42
42
 
43
-
44
43
  # ==========================================
45
44
  # INFRAESTRUCTURA DE FILTROS Y LOGS (HEREDABLES)
46
45
  # ==========================================
@@ -102,7 +101,7 @@ module BugBunny
102
101
  raise ArgumentError, "Need a handler. Supply 'with: :method' or a block." unless handler
103
102
 
104
103
  # Duplicamos el array del padre para no mutarlo al registrar reglas en el hijo
105
- new_handlers = self.rescue_handlers.dup
104
+ new_handlers = rescue_handlers.dup
106
105
 
107
106
  klasses.each do |klass|
108
107
  new_handlers.unshift([klass, handler])
@@ -133,7 +132,6 @@ module BugBunny
133
132
  # Aplicamos automáticamente las etiquetas de logs a todas las acciones.
134
133
  around_action :apply_log_tags
135
134
 
136
-
137
135
  # ==========================================
138
136
  # INICIALIZACIÓN Y CICLO DE VIDA
139
137
  # ==========================================
@@ -170,25 +168,22 @@ module BugBunny
170
168
  core_execution = lambda do
171
169
  return unless run_before_actions(action_name)
172
170
 
173
- if respond_to?(action_name)
174
- public_send(action_name)
175
- else
176
- raise NameError, "Action '#{action_name}' not found in #{self.class.name}"
177
- end
171
+ raise NameError, "Action '#{action_name}' not found in #{self.class.name}" unless respond_to?(action_name)
172
+
173
+ public_send(action_name)
178
174
 
179
175
  run_after_actions(action_name)
180
176
  end
181
177
 
182
178
  # Construir e invocar la cadena de responsabilidad (Middlewares/Around Actions)
183
179
  execution_chain = current_arounds.reverse.inject(core_execution) do |next_step, method_name|
184
- lambda { send(method_name, &next_step) }
180
+ -> { send(method_name, &next_step) }
185
181
  end
186
182
 
187
183
  execution_chain.call
188
184
 
189
185
  # Si no hubo renderización explícita, devuelve 204 No Content
190
186
  rendered_response || { status: 204, headers: response_headers, body: nil }
191
-
192
187
  rescue StandardError => e
193
188
  handle_exception(e)
194
189
  end
@@ -223,12 +218,13 @@ module BugBunny
223
218
  end
224
219
 
225
220
  # Fallback genérico si la excepción no fue mapeada
226
- safe_log(:error, "controller.unhandled_exception", backtrace: exception.backtrace.first(5).join(" | "), **exception_metadata(exception))
221
+ safe_log(:error, 'controller.unhandled_exception', backtrace: exception.backtrace.first(5).join(' | '),
222
+ **exception_metadata(exception))
227
223
 
228
224
  {
229
225
  status: 500,
230
226
  headers: response_headers,
231
- body: { error: "Internal Server Error", detail: exception.message, type: exception.class.name }
227
+ body: { error: 'Internal Server Error', detail: exception.message, type: exception.class.name }
232
228
  }
233
229
  end
234
230
 
@@ -294,10 +290,10 @@ module BugBunny
294
290
 
295
291
  # --- LÓGICA DE LOGGING ENCAPSULADA ---
296
292
 
297
- def apply_log_tags
293
+ def apply_log_tags(&block)
298
294
  tags = compute_tags
299
295
  if defined?(Rails) && Rails.logger.respond_to?(:tagged) && tags.any?
300
- Rails.logger.tagged(*tags) { yield }
296
+ Rails.logger.tagged(*tags, &block)
301
297
  else
302
298
  yield
303
299
  end
@@ -67,7 +67,7 @@ module BugBunny
67
67
  # Indica que la solicitud fue bien formada pero contenía errores semánticos,
68
68
  # típicamente fallos de validación en el modelo remoto (ActiveRecord).
69
69
  #
70
- # Esta excepción es "inteligente": intenta parsear automáticamente el cuerpo
70
+ # Esta excepción es "inteligente": intenta parsear automáticamente el cuerpo
71
71
  # de la respuesta para extraer y exponer los mensajes de error de forma estructurada,
72
72
  # buscando por convención la clave `errors`.
73
73
  class UnprocessableEntity < ClientError
@@ -35,7 +35,7 @@ module BugBunny
35
35
 
36
36
  case status
37
37
  when 200..299
38
- return # Flujo normal (Success)
38
+ nil # Flujo normal (Success)
39
39
  when 400
40
40
  raise BugBunny::BadRequest, format_error_message(body)
41
41
  when 404
@@ -66,12 +66,12 @@ module BugBunny
66
66
  # @param body [Hash, String, nil] El cuerpo de la respuesta.
67
67
  # @return [String] Un mensaje de error limpio y estructurado.
68
68
  def format_error_message(body)
69
- return "Unknown Error" if body.nil? || (body.respond_to?(:empty?) && body.empty?)
69
+ return 'Unknown Error' if body.nil? || (body.respond_to?(:empty?) && body.empty?)
70
70
  return body if body.is_a?(String)
71
71
 
72
72
  # Si el worker devolvió un JSON con una key 'error' (nuestra convención en Controller)
73
73
  if body.is_a?(Hash) && body['error']
74
- detail = body['detail'] ? " - #{body['detail']}" : ""
74
+ detail = body['detail'] ? " - #{body['detail']}" : ''
75
75
  "#{body['error']}#{detail}"
76
76
  else
77
77
  # Fallback: Convertir todo el Hash a JSON string para que se vea claro en Sentry/Logs
@@ -47,11 +47,11 @@ module BugBunny
47
47
  when Numeric then val
48
48
  when Hash
49
49
  val.to_json
50
- when String then val.include?(" ") ? val.inspect : val
51
- else val.to_s.include?(" ") ? val.to_s.inspect : val
50
+ when String then val.include?(' ') ? val.inspect : val
51
+ else val.to_s.include?(' ') ? val.to_s.inspect : val
52
52
  end
53
53
  "#{k}=#{formatted}"
54
- end.compact.join(" ")
54
+ end.compact.join(' ')
55
55
 
56
56
  @logger.send(level) { log_line }
57
57
  rescue StandardError
@@ -89,9 +89,9 @@ module BugBunny
89
89
  # @return [String] Nombre del componente en snake_case.
90
90
  def observability_name
91
91
  klass = is_a?(Class) ? self : self.class
92
- klass.name.split("::").first.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
92
+ klass.name.split('::').first.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
93
93
  rescue StandardError
94
- "unknown"
94
+ 'unknown'
95
95
  end
96
96
  end
97
97
  end
@@ -79,18 +79,16 @@ module BugBunny
79
79
  begin
80
80
  fire(request)
81
81
 
82
- safe_log(:debug, "producer.rpc_waiting", correlation_id: cid, timeout_s: wait_timeout)
82
+ safe_log(:debug, 'producer.rpc_waiting', correlation_id: cid, timeout_s: wait_timeout)
83
83
 
84
84
  # Bloqueamos el hilo aquí hasta que llegue la respuesta o expire el timeout
85
85
  result = future.value(wait_timeout)
86
86
 
87
- if result.nil?
88
- raise BugBunny::RequestTimeout, "Timeout waiting for RPC: #{request.path} [#{request.method}]"
89
- end
87
+ raise BugBunny::RequestTimeout, "Timeout waiting for RPC: #{request.path} [#{request.method}]" if result.nil?
90
88
 
91
89
  BugBunny.configuration.on_rpc_reply&.call(result[:headers])
92
90
 
93
- safe_log(:debug, "producer.rpc_response_received", correlation_id: cid)
91
+ safe_log(:debug, 'producer.rpc_response_received', correlation_id: cid)
94
92
 
95
93
  parse_response(result[:body])
96
94
  ensure
@@ -113,12 +111,12 @@ module BugBunny
113
111
 
114
112
  # 📊 LOGGING DE OBSERVABILIDAD: Calculamos las opciones finales para mostrarlas en consola
115
113
  final_x_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS
116
- .merge(BugBunny.configuration.exchange_options || {})
117
- .merge(request.exchange_options || {})
114
+ .merge(BugBunny.configuration.exchange_options || {})
115
+ .merge(request.exchange_options || {})
118
116
 
119
- safe_log(:info, "producer.publish", method: verb, path: target, routing_key: rk, correlation_id: id)
120
- safe_log(:debug, "producer.publish_detail", exchange: request.exchange, exchange_opts: final_x_opts)
121
- safe_log(:debug, "producer.publish_payload", payload: payload.truncate(300)) if payload.is_a?(String)
117
+ safe_log(:info, 'producer.publish', method: verb, path: target, routing_key: rk, correlation_id: id)
118
+ safe_log(:debug, 'producer.publish_detail', exchange: request.exchange, exchange_opts: final_x_opts)
119
+ safe_log(:debug, 'producer.publish_payload', payload: payload.truncate(300)) if payload.is_a?(String)
122
120
  end
123
121
 
124
122
  # Serializa el mensaje para su transporte.
@@ -137,7 +135,7 @@ module BugBunny
137
135
  def parse_response(payload)
138
136
  JSON.parse(payload)
139
137
  rescue JSON::ParserError
140
- raise BugBunny::InternalServerError, "Invalid JSON response"
138
+ raise BugBunny::InternalServerError, 'Invalid JSON response'
141
139
  end
142
140
 
143
141
  # Inicia el consumidor de respuestas RPC de forma perezosa (Lazy Initialization).
@@ -152,7 +150,7 @@ module BugBunny
152
150
  @reply_listener_mutex.synchronize do
153
151
  return if @reply_listener_started
154
152
 
155
- safe_log(:debug, "producer.reply_listener_start")
153
+ safe_log(:debug, 'producer.reply_listener_start')
156
154
 
157
155
  # Consumimos sin ack (auto-ack) porque reply-to no soporta acks manuales de forma estándar
158
156
  @session.channel.basic_consume('amq.rabbitmq.reply-to', '', true, false, nil) do |_, props, body|
@@ -160,7 +158,7 @@ module BugBunny
160
158
  if (future = @pending_requests[cid])
161
159
  future.set({ body: body, headers: props.headers || {} })
162
160
  else
163
- safe_log(:warn, "producer.rpc_response_orphaned", correlation_id: cid)
161
+ safe_log(:warn, 'producer.rpc_response_orphaned', correlation_id: cid)
164
162
  end
165
163
  end
166
164
  @reply_listener_started = true
@@ -14,9 +14,7 @@ module BugBunny
14
14
  # 1. Configuración de Autoload
15
15
  initializer 'bug_bunny.add_autoload_paths' do |app|
16
16
  rabbit_path = File.join(app.root, 'app', 'rabbit')
17
- if Dir.exist?(rabbit_path)
18
- app.config.paths.add 'app/rabbit', eager_load: true
19
- end
17
+ app.config.paths.add 'app/rabbit', eager_load: true if Dir.exist?(rabbit_path)
20
18
  end
21
19
 
22
20
  # 2. Gestión de Forks (Puma / Spring / otros)
@@ -25,9 +23,7 @@ module BugBunny
25
23
  # el hijo empiece a trabajar, para evitar compartir el mismo socket TCP.
26
24
  config.after_initialize do
27
25
  # Estrategia 1: Rails 7.1+ ForkTracker (La forma estándar moderna)
28
- if defined?(ActiveSupport::ForkTracker)
29
- ActiveSupport::ForkTracker.after_fork { BugBunny.disconnect }
30
- end
26
+ ActiveSupport::ForkTracker.after_fork { BugBunny.disconnect } if defined?(ActiveSupport::ForkTracker)
31
27
 
32
28
  # Estrategia 2: Hook específico de Puma (Legacy)
33
29
  # Solo intentamos usarlo si la API 'events' está disponible (Puma < 5).
@@ -38,7 +34,12 @@ module BugBunny
38
34
  end
39
35
  end
40
36
 
41
- # 3. Hook de Spring (Preloader)
37
+ # 3. Rake tasks (bug_bunny:sync)
38
+ rake_tasks do
39
+ load File.expand_path('../tasks/bug_bunny.rake', __dir__)
40
+ end
41
+
42
+ # 4. Hook de Spring (Preloader)
42
43
  if defined?(Spring)
43
44
  Spring.after_fork do
44
45
  BugBunny.disconnect
@@ -23,20 +23,11 @@ module BugBunny
23
23
  # @attr exchange_options [Hash] Opciones específicas para la declaración del Exchange en esta petición.
24
24
  # @attr queue_options [Hash] Opciones específicas para la declaración de la Cola en esta petición.
25
25
  class Request
26
- attr_accessor :body
27
- attr_accessor :headers
28
- attr_accessor :params
29
- attr_accessor :path
30
- attr_accessor :method
31
- attr_accessor :exchange
32
- attr_accessor :exchange_type
33
- attr_accessor :routing_key
34
- attr_accessor :timeout
35
- attr_accessor :delivery_mode
26
+ attr_accessor :body, :headers, :params, :path, :method, :exchange, :exchange_type, :routing_key, :timeout,
27
+ :delivery_mode, :queue_options
36
28
 
37
29
  # Configuración de Infraestructura Específica
38
30
  attr_accessor :exchange_options
39
- attr_accessor :queue_options
40
31
 
41
32
  # Metadatos AMQP Estándar
42
33
  attr_accessor :app_id, :content_type, :content_encoding, :priority,
@@ -61,6 +52,7 @@ module BugBunny
61
52
  @exchange_options = {}
62
53
  @queue_options = {}
63
54
  end
55
+
64
56
  # Combina el path con los params como query string.
65
57
  #
66
58
  # @return [String] El path completo con query string si hay params, o solo el path.