bug_bunny 4.6.1 → 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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/skills/rabbitmq-expert/SKILL.md +1555 -0
  3. data/.claude/commands/gem-ai-setup.md +174 -0
  4. data/.claude/commands/pr.md +53 -0
  5. data/.claude/commands/release.md +52 -0
  6. data/.claude/commands/rubocop.md +22 -0
  7. data/.claude/commands/service-ai-setup.md +168 -0
  8. data/.claude/commands/test.md +28 -0
  9. data/.claude/commands/yard.md +46 -0
  10. data/CHANGELOG.md +50 -15
  11. data/CLAUDE.md +240 -0
  12. data/README.md +154 -221
  13. data/Rakefile +19 -3
  14. data/docs/_index.md +50 -0
  15. data/docs/ai/_index.md +56 -0
  16. data/docs/ai/antipatterns.md +166 -0
  17. data/docs/ai/api.md +251 -0
  18. data/docs/ai/architecture.md +92 -0
  19. data/docs/ai/errors.md +158 -0
  20. data/docs/ai/faq_external.md +133 -0
  21. data/docs/ai/faq_internal.md +86 -0
  22. data/docs/ai/glossary.md +45 -0
  23. data/docs/concepts.md +140 -0
  24. data/docs/howto/controller.md +194 -0
  25. data/docs/howto/middleware_client.md +119 -0
  26. data/docs/howto/middleware_consumer.md +127 -0
  27. data/docs/howto/rails.md +214 -0
  28. data/docs/howto/resource.md +200 -0
  29. data/docs/howto/routing.md +133 -0
  30. data/docs/howto/testing.md +259 -0
  31. data/docs/howto/tracing.md +119 -0
  32. data/lib/bug_bunny/client.rb +45 -21
  33. data/lib/bug_bunny/configuration.rb +63 -0
  34. data/lib/bug_bunny/consumer.rb +51 -37
  35. data/lib/bug_bunny/consumer_middleware.rb +14 -5
  36. data/lib/bug_bunny/controller.rb +39 -18
  37. data/lib/bug_bunny/exception.rb +5 -1
  38. data/lib/bug_bunny/middleware/raise_error.rb +3 -3
  39. data/lib/bug_bunny/observability.rb +28 -6
  40. data/lib/bug_bunny/producer.rb +11 -13
  41. data/lib/bug_bunny/railtie.rb +8 -7
  42. data/lib/bug_bunny/request.rb +3 -11
  43. data/lib/bug_bunny/resource.rb +81 -41
  44. data/lib/bug_bunny/routing/route.rb +6 -1
  45. data/lib/bug_bunny/routing/route_set.rb +60 -22
  46. data/lib/bug_bunny/session.rb +18 -11
  47. data/lib/bug_bunny/version.rb +1 -1
  48. data/lib/bug_bunny.rb +4 -2
  49. data/lib/generators/bug_bunny/install/install_generator.rb +45 -5
  50. data/lib/tasks/bug_bunny.rake +50 -0
  51. data/plan_test.txt +63 -0
  52. data/skills-lock.json +10 -0
  53. data/spec/integration/client_spec.rb +117 -0
  54. data/spec/integration/consumer_middleware_spec.rb +86 -0
  55. data/spec/integration/controller_spec.rb +140 -0
  56. data/spec/integration/error_handling_spec.rb +57 -0
  57. data/spec/integration/infrastructure_spec.rb +52 -0
  58. data/spec/integration/resource_spec.rb +113 -0
  59. data/spec/spec_helper.rb +70 -0
  60. data/spec/support/bunny_mocks.rb +18 -0
  61. data/spec/support/integration_helper.rb +87 -0
  62. data/spec/unit/client_session_pool_spec.rb +159 -0
  63. data/spec/unit/configuration_spec.rb +164 -0
  64. data/spec/unit/consumer_middleware_spec.rb +129 -0
  65. data/spec/unit/consumer_spec.rb +90 -0
  66. data/spec/unit/controller_after_action_spec.rb +155 -0
  67. data/spec/unit/observability_spec.rb +167 -0
  68. data/spec/unit/resource_attributes_spec.rb +69 -0
  69. data/spec/unit/session_spec.rb +98 -0
  70. metadata +50 -3
  71. data/sig/bug_bunny.rbs +0 -4
@@ -0,0 +1,166 @@
1
+ ## Antipatterns
2
+
3
+ ### Running Producer and Consumer in the same process thread
4
+
5
+ **Wrong:**
6
+ ```ruby
7
+ # In the same Puma process
8
+ Thread.new { BugBunny::Consumer.new(conn).subscribe(..., block: true) }
9
+ BugBunny::Resource.find(1) # uses a different connection slot — works but is wrong architecturally
10
+ ```
11
+
12
+ **Why it's wrong:** The Consumer is a blocking loop designed to run in a dedicated worker process. Running it inside Puma wastes threads and ties service lifetime to the web server. If the web server restarts, the consumer dies too.
13
+
14
+ **Correct:** Run the Consumer as a separate process (Rake task, separate container/Dockerfile).
15
+
16
+ ---
17
+
18
+ ### Creating a new Client or Producer per request
19
+
20
+ **Wrong:**
21
+ ```ruby
22
+ def show
23
+ client = BugBunny::Client.new(pool: MY_POOL) # new client on every request
24
+ client.request(...)
25
+ end
26
+ ```
27
+
28
+ **Why it's wrong:** `Client` itself is cheap, but creating it with a block configures a new middleware stack. More importantly, if you bypass `Resource` and call `Producer` directly, creating a new `Producer` on an already-used channel causes an AMQP `basic_consume` conflict.
29
+
30
+ **Correct:** Use `Resource` (which caches Client → Session → Producer per connection slot) or create the `Client` once at application boot.
31
+
32
+ ---
33
+
34
+ ### Setting `Resource.connection_pool` inside a request
35
+
36
+ **Wrong:**
37
+ ```ruby
38
+ class OrdersController < ApplicationController
39
+ def index
40
+ BugBunny::Resource.connection_pool = ConnectionPool.new(...) { BugBunny.create_connection }
41
+ Order.all
42
+ end
43
+ end
44
+ ```
45
+
46
+ **Why it's wrong:** The pool is a global class-level setting. Reassigning it in a request creates a race condition and leaks connections.
47
+
48
+ **Correct:** Set `connection_pool` once in the initializer at boot.
49
+
50
+ ---
51
+
52
+ ### Calling `.with` proxy more than once
53
+
54
+ **Wrong:**
55
+ ```ruby
56
+ scope = Order.with(exchange: 'priority')
57
+ scope.find(1)
58
+ scope.find(2) # raises BugBunny::Error — ScopeProxy is single-use
59
+ ```
60
+
61
+ **Why it's wrong:** `ScopeProxy#method_missing` sets `@used = true` on the first call. A second call raises.
62
+
63
+ **Correct:** Use the block form of `.with` for multiple calls within the same scope:
64
+ ```ruby
65
+ Order.with(exchange: 'priority') do
66
+ Order.find(1)
67
+ Order.find(2)
68
+ end
69
+ ```
70
+
71
+ ---
72
+
73
+ ### Adding `RaiseError` or `JsonResponse` manually to a Resource middleware stack
74
+
75
+ **Wrong:**
76
+ ```ruby
77
+ class Order < BugBunny::Resource
78
+ client_middleware do |stack|
79
+ stack.use BugBunny::Middleware::RaiseError # already added by Resource
80
+ stack.use BugBunny::Middleware::JsonResponse # already added by Resource
81
+ end
82
+ end
83
+ ```
84
+
85
+ **Why it's wrong:** `Resource#bug_bunny_client` always adds `RaiseError` and `JsonResponse` as the innermost middlewares. Adding them again wraps the response in a second parsing pass, causing errors.
86
+
87
+ **Correct:** Only add custom middlewares in `client_middleware`. Never add the built-ins.
88
+
89
+ ---
90
+
91
+ ### Calling `render` multiple times in an action or filter
92
+
93
+ **Wrong:**
94
+ ```ruby
95
+ def show
96
+ render status: :ok, json: user
97
+ render status: :not_found, json: { error: 'Not found' } # second render — first one wins
98
+ end
99
+ ```
100
+
101
+ **Why it's wrong:** The second `render` call overwrites `@rendered_response`. Behavior is undefined and dependent on execution order.
102
+
103
+ **Correct:** Use early returns or `return render(...)` to ensure only one render is called.
104
+
105
+ ---
106
+
107
+ ### Raising exceptions from `rpc_reply_headers` or `on_rpc_reply`
108
+
109
+ **Wrong:**
110
+ ```ruby
111
+ config.rpc_reply_headers = -> { { 'X-Trace-Id' => Tracer.header! } } # Tracer.header! may raise
112
+ ```
113
+
114
+ **Why it's wrong:** An exception in `rpc_reply_headers` propagates into the Consumer's `reply` method, corrupting the RPC reply and causing the caller to timeout.
115
+
116
+ **Correct:** Wrap the proc body in a rescue:
117
+ ```ruby
118
+ config.rpc_reply_headers = -> { { 'X-Trace-Id' => (Tracer.header rescue nil) } }
119
+ ```
120
+
121
+ ---
122
+
123
+ ### Declaring exchanges with incompatible options after first declaration
124
+
125
+ **Wrong:**
126
+ ```ruby
127
+ # Service A declares exchange as non-durable
128
+ BugBunny.configure { |c| c.exchange_options = { durable: false } }
129
+
130
+ # Service B (or a later boot) declares the same exchange as durable
131
+ BugBunny.configure { |c| c.exchange_options = { durable: true } }
132
+ ```
133
+
134
+ **Why it's wrong:** RabbitMQ raises a channel error (406 PRECONDITION_FAILED) if you try to redeclare an exchange with different attributes. This crashes the channel.
135
+
136
+ **Correct:** Agree on exchange options across all services. Use `{ durable: true }` in all production services.
137
+
138
+ ---
139
+
140
+ ### Using `Resource` without a connection pool
141
+
142
+ **Wrong:**
143
+ ```ruby
144
+ Order.find(1) # BugBunny::Error: Connection pool missing for Order
145
+ ```
146
+
147
+ **Why it's wrong:** `Resource.bug_bunny_client` raises if `connection_pool` is nil.
148
+
149
+ **Correct:** Always set `BugBunny::Resource.connection_pool` (or a subclass-specific pool) before making calls. Do this in the initializer.
150
+
151
+ ---
152
+
153
+ ### Calling `logger.debug "..."` directly inside BugBunny classes
154
+
155
+ **Wrong:**
156
+ ```ruby
157
+ logger.debug "Processing #{message}" # eager interpolation, ignores debug level
158
+ ```
159
+
160
+ **Why it's wrong:** String interpolation happens regardless of log level, wasting CPU. Also bypasses `safe_log`'s sensitive key filtering.
161
+
162
+ **Correct:**
163
+ ```ruby
164
+ safe_log(:debug, 'component.event', key: value)
165
+ # safe_log passes blocks to logger.debug { ... } internally
166
+ ```
data/docs/ai/api.md ADDED
@@ -0,0 +1,251 @@
1
+ ## Public API
2
+
3
+ ### BugBunny.configure
4
+
5
+ ```ruby
6
+ BugBunny.configure do |config|
7
+ config.host = 'localhost' # String, required
8
+ config.port = 5672 # Integer 1..65535, required
9
+ config.username = 'guest' # String, required
10
+ config.password = 'guest' # String, required
11
+ config.vhost = '/' # String, required
12
+
13
+ config.rpc_timeout = 10 # Integer 1..3600, seconds
14
+ config.channel_prefetch = 1 # Integer 1..10000
15
+ config.max_reconnect_attempts = nil # nil = infinite
16
+ config.max_reconnect_interval = 60 # seconds, backoff ceiling
17
+ config.network_recovery_interval = 5 # seconds, backoff base
18
+
19
+ config.exchange_options = { durable: true }
20
+ config.queue_options = { durable: true }
21
+
22
+ config.logger = Rails.logger
23
+ config.health_check_file = Rails.root.join('tmp/bug_bunny_health').to_s
24
+
25
+ config.controller_namespace = 'Rabbit::Controllers'
26
+
27
+ # Trace propagation hooks
28
+ config.rpc_reply_headers = -> { { 'X-Trace-Id' => Tracer.current_header } }
29
+ config.on_rpc_reply = ->(headers) { Tracer.hydrate(headers['X-Trace-Id']) }
30
+ end
31
+ ```
32
+
33
+ `validate!` is called automatically at the end of `configure`. Raises `BugBunny::ConfigurationError` on invalid values.
34
+
35
+ ---
36
+
37
+ ### BugBunny.routes
38
+
39
+ ```ruby
40
+ BugBunny.routes.draw do
41
+ resources :users # index, show, create, update, destroy
42
+ resources :orders do
43
+ member { post :cancel } # POST orders/:id/cancel
44
+ collection { get :pending } # GET orders/pending
45
+ end
46
+ namespace :admin do
47
+ resources :reports # Admin::Controllers::ReportsController
48
+ end # path prefix: admin/reports
49
+ end
50
+ ```
51
+
52
+ **recognize:**
53
+ ```ruby
54
+ BugBunny.routes.recognize('GET', '/users/42')
55
+ # => { controller: 'users', action: 'show', params: { 'id' => '42' }, namespace: nil }
56
+ ```
57
+
58
+ ---
59
+
60
+ ### BugBunny::Controller
61
+
62
+ Controllers live in the namespace configured by `config.controller_namespace` (default: `BugBunny::Controllers`).
63
+
64
+ ```ruby
65
+ class UsersController < BugBunny::Controller
66
+ before_action :authenticate!, only: [:create, :update, :destroy]
67
+ after_action :emit_audit_event, only: [:create, :update, :destroy]
68
+ around_action :wrap_transaction, only: [:create]
69
+
70
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
71
+
72
+ def index
73
+ render status: :ok, json: User.all
74
+ end
75
+
76
+ def show
77
+ user = User.find(params[:id])
78
+ render status: :ok, json: user
79
+ end
80
+
81
+ def create
82
+ user = User.new(user_params)
83
+ if user.save
84
+ render status: :created, json: user
85
+ else
86
+ render status: :unprocessable_entity, json: { errors: user.errors }
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def authenticate!
93
+ render status: :unauthorized, json: { error: 'Unauthorized' } unless valid_token?
94
+ end
95
+
96
+ def render_not_found(e)
97
+ render status: :not_found, json: { error: e.message }
98
+ end
99
+ end
100
+ ```
101
+
102
+ **`render` signature:**
103
+ ```ruby
104
+ render(status:, json: nil, headers: {})
105
+ # status: HTTP symbol (:ok, :created, :not_found, ...) or Integer
106
+ # headers: merged into response_headers — received by on_rpc_reply
107
+ ```
108
+
109
+ **`params`** — `HashWithIndifferentAccess` containing:
110
+ - Query string parameters
111
+ - `:id` extracted from the route
112
+ - JSON body (parsed automatically if `content_type` includes `json`)
113
+
114
+ **`raw_string`** — the unparsed body when content type is not JSON.
115
+
116
+ **`self.call(headers:, body:)`** — entry point called by the Consumer; returns `{ status:, headers:, body: }`.
117
+
118
+ ---
119
+
120
+ ### BugBunny::Resource
121
+
122
+ ```ruby
123
+ class Order < BugBunny::Resource
124
+ self.exchange = 'orders_exchange'
125
+ self.exchange_type = 'direct'
126
+ self.routing_key = 'orders'
127
+
128
+ attribute :id, :integer
129
+ attribute :status, :string
130
+ attribute :total, :float
131
+
132
+ validates :status, presence: true
133
+
134
+ before_save :set_defaults
135
+ end
136
+ ```
137
+
138
+ **Class methods:**
139
+
140
+ | Method | HTTP | Description |
141
+ |---|---|---|
142
+ | `find(id)` | GET `resource/id` | Returns instance or `nil` on 404 |
143
+ | `where(filters)` | GET `resource` | Returns Array, empty on 404 |
144
+ | `all` | GET `resource` | Alias for `where({})` |
145
+ | `create(attrs)` | POST `resource` | Returns instance (may have errors) |
146
+
147
+ **Instance methods:**
148
+
149
+ | Method | HTTP | Description |
150
+ |---|---|---|
151
+ | `save` | POST or PUT | POST if new, PUT if persisted. Returns Boolean. |
152
+ | `update(attrs)` | PUT | `assign_attributes` + `save` |
153
+ | `destroy` | DELETE | Returns Boolean |
154
+ | `persisted?` | — | True after successful save or find |
155
+ | `changed?` | — | True if typed or dynamic attributes changed |
156
+
157
+ **`.with` — per-call context override:**
158
+ ```ruby
159
+ # Block form (thread-safe, restores context after block)
160
+ Order.with(exchange: 'other_exchange') do
161
+ Order.find(1)
162
+ end
163
+
164
+ # Single-call proxy (single-use, raises on second call)
165
+ Order.with(routing_key: 'vip_orders').where(status: 'pending')
166
+ ```
167
+
168
+ **Class-level config:**
169
+ ```ruby
170
+ Order.connection_pool = MY_POOL
171
+ Order.exchange = 'orders_exchange'
172
+ Order.exchange_type = 'direct'
173
+ Order.routing_key = 'orders'
174
+ Order.resource_name = 'orders' # default: class name pluralized/underscored
175
+ Order.param_key = 'order' # default: model_name.element
176
+ Order.exchange_options = { durable: true }
177
+ Order.queue_options = { durable: true }
178
+ ```
179
+
180
+ ---
181
+
182
+ ### BugBunny::Client
183
+
184
+ For cases where `Resource` is too high-level:
185
+
186
+ ```ruby
187
+ client = BugBunny::Client.new(pool: MY_POOL) do |stack|
188
+ stack.use MyTracingMiddleware
189
+ stack.use BugBunny::Middleware::RaiseError
190
+ stack.use BugBunny::Middleware::JsonResponse
191
+ end
192
+
193
+ # RPC (blocking)
194
+ response = client.request('users/1',
195
+ method: :get,
196
+ exchange: 'users_exchange',
197
+ exchange_type: 'direct',
198
+ routing_key: 'users'
199
+ )
200
+
201
+ # Fire-and-forget
202
+ client.publish('events',
203
+ method: :post,
204
+ exchange: 'events_exchange',
205
+ routing_key: 'events',
206
+ body: { type: 'user.created', user_id: 42 }
207
+ )
208
+ ```
209
+
210
+ ---
211
+
212
+ ### BugBunny::ConsumerMiddleware
213
+
214
+ ```ruby
215
+ # Register globally
216
+ BugBunny.consumer_middlewares.use MyMiddleware
217
+
218
+ # Or via configuration
219
+ BugBunny.configure do |config|
220
+ config.consumer_middlewares.use TracingMiddleware
221
+ end
222
+ ```
223
+
224
+ **Writing a middleware:**
225
+ ```ruby
226
+ class MyMiddleware
227
+ def initialize(app)
228
+ @app = app
229
+ end
230
+
231
+ def call(delivery_info, properties, body)
232
+ # before
233
+ @app.call(delivery_info, properties, body)
234
+ # after
235
+ end
236
+ end
237
+ ```
238
+
239
+ ---
240
+
241
+ ### BugBunny.create_connection
242
+
243
+ ```ruby
244
+ conn = BugBunny.create_connection # Returns a connected Bunny::Session
245
+ ```
246
+
247
+ Used to populate a `ConnectionPool`:
248
+ ```ruby
249
+ MY_POOL = ConnectionPool.new(size: 5, timeout: 5) { BugBunny.create_connection }
250
+ BugBunny::Resource.connection_pool = MY_POOL
251
+ ```
@@ -0,0 +1,92 @@
1
+ ## Architecture
2
+
3
+ ### Component Map
4
+
5
+ ```
6
+ Publisher side (Service A) Consumer side (Service B)
7
+ ───────────────────────────── ─────────────────────────────────────
8
+ Resource.find / .where / .save Consumer#subscribe (blocking loop)
9
+ └─ Client#request / #publish └─ ConsumerMiddleware::Stack#call
10
+ └─ Middleware::Stack (onion) └─ Consumer#process_message
11
+ └─ Producer#rpc / #fire └─ Router#recognize
12
+ └─ Session#exchange └─ Controller.call
13
+ └─ Bunny channel └─ action method
14
+ └─ RabbitMQ └─ render(...)
15
+ └─ Consumer#reply
16
+ ```
17
+
18
+ ### Session
19
+
20
+ `BugBunny::Session` wraps a Bunny channel. It caches exchange and queue objects by name to avoid redundant AMQP declarations. Cache writes are protected by a `Mutex` (double-checked locking pattern). One Session per connection slot — created lazily in `Client#session_for` and stored as an ivar on the Bunny connection object.
21
+
22
+ ### Producer
23
+
24
+ `BugBunny::Producer` publishes messages. One Producer per connection slot — cached alongside the Session. Caching is mandatory: the Producer registers a `basic_consume` on the channel to listen for Direct Reply-to responses. Creating a second Producer on the same channel would trigger an AMQP error (double-consumer).
25
+
26
+ **RPC flow:**
27
+ 1. `Producer#rpc` assigns a `correlation_id` and publishes with `reply_to: 'amq.rabbitmq.reply-to'`.
28
+ 2. A `Concurrent::IVar` (`future`) is registered in an in-memory hash keyed by `correlation_id`.
29
+ 3. A reply-listener thread sets `future.set(payload)` when the reply arrives.
30
+ 4. The calling thread blocks on `future.value(timeout)`.
31
+ 5. On success: `on_rpc_reply&.call(headers)` is invoked, then the response is parsed.
32
+ 6. On timeout: `BugBunny::RequestTimeout` is raised.
33
+
34
+ **Fire-and-forget flow:**
35
+ `Producer#fire` publishes without `reply_to`. No blocking.
36
+
37
+ ### Client
38
+
39
+ `BugBunny::Client` implements the Faraday-style Onion Middleware pattern. The final action in the chain is the call to `Producer#rpc` or `Producer#fire`. Middlewares wrap that action, each calling `app.call(request)` to continue the chain.
40
+
41
+ Built-in middlewares for `Resource`:
42
+ - `Middleware::RaiseError` — converts non-2xx status codes to exceptions.
43
+ - `Middleware::JsonResponse` — parses the JSON body and normalizes the response hash.
44
+
45
+ ### Consumer
46
+
47
+ `BugBunny::Consumer` is a blocking subscribe loop intended to run in a dedicated process (not inside Puma). Responsibilities:
48
+ 1. Declare exchange, queue, and binding.
49
+ 2. Start a background `Concurrent::TimerTask` as a health check.
50
+ 3. For each message: invoke `ConsumerMiddleware::Stack`, then `process_message`.
51
+
52
+ `process_message` flow:
53
+ 1. Extract `path` from `properties.type` (or `headers['path']`).
54
+ 2. Extract HTTP method from `headers['x-http-method']`.
55
+ 3. Parse query string from the path.
56
+ 4. Call `BugBunny.routes.recognize(method, path)` → controller + action + params.
57
+ 5. Constantize the controller class; verify it inherits from `BugBunny::Controller` (RCE prevention).
58
+ 6. Call `ControllerClass.call(headers:, body:)` → response hash.
59
+ 7. If `reply_to` is present: publish reply with `rpc_reply_headers` injected.
60
+ 8. ACK the delivery tag.
61
+
62
+ On any error: publish a 500 reply (so the RPC caller doesn't timeout), then NACK/reject.
63
+
64
+ ### ConsumerMiddleware::Stack
65
+
66
+ A pipeline of middleware objects. Registration is protected by a `Mutex`. Each middleware is a class implementing `#initialize(app)` and `#call(delivery_info, properties, body)`. The terminal app is the controller dispatch lambda. Middlewares run in registration order (first registered = outermost wrapper).
67
+
68
+ ### Router
69
+
70
+ `BugBunny::Routing::RouteSet` stores an array of `Route` objects. `recognize(method, path)` iterates routes looking for a match. Routes are registered via the DSL:
71
+ - `resources :users` → 5 standard routes (index, show, create, update, destroy) + member/collection.
72
+ - `namespace :admin { resources :users }` → same routes with path prefix `admin/` and namespace `Admin::Controllers`.
73
+
74
+ ### Resource
75
+
76
+ `BugBunny::Resource` is an ActiveModel class. It resolves AMQP config (exchange, routing key, pool) via a 3-level cascade: thread-local (set by `.with`) → class-level → superclass. Dirty tracking covers both typed `attribute` columns (via ActiveModel::Dirty) and dynamic attributes (via `@extra_attributes` + `@dynamic_changes`).
77
+
78
+ ### Configuration Cascade (Resource)
79
+
80
+ ```
81
+ Thread.current["bb_#{object_id}_exchange"] ← .with(exchange:) sets this
82
+ ↓ (nil fallback)
83
+ Resource.exchange= ← class-level static config
84
+ ↓ (nil fallback)
85
+ ParentResource.exchange= ← walks superclass chain
86
+ ```
87
+
88
+ Same cascade for: `routing_key`, `exchange_type`, `pool`, `exchange_options`, `queue_options`.
89
+
90
+ ### Observability
91
+
92
+ All BugBunny classes include `BugBunny::Observability`. `safe_log` formats structured log lines as `component=x event=clase.evento [key=value ...]` and filters sensitive keys (`password`, `token`, `secret`, `api_key`, `auth`, etc.). Log failures are swallowed — they never affect the main flow.
data/docs/ai/errors.md ADDED
@@ -0,0 +1,158 @@
1
+ ## Errors
2
+
3
+ All BugBunny exceptions inherit from `BugBunny::Error < StandardError`. Catch `BugBunny::Error` to handle all gem-level errors.
4
+
5
+ ---
6
+
7
+ ### BugBunny::ConfigurationError
8
+
9
+ **Cause:** `BugBunny.configure` block completed with invalid values. Triggered by `validate!` at the end of `configure`.
10
+
11
+ **Common triggers:**
12
+ - `host` is nil or empty string
13
+ - `port` is outside `1..65535`
14
+ - `rpc_timeout` is not an Integer or is outside `1..3600`
15
+
16
+ **How to reproduce:**
17
+ ```ruby
18
+ BugBunny.configure { |c| c.host = '' }
19
+ # => BugBunny::ConfigurationError: host is required
20
+ ```
21
+
22
+ **Resolution:** Check all required fields. See `Configuration::VALIDATIONS` for the full list of validated attributes and their constraints.
23
+
24
+ ---
25
+
26
+ ### BugBunny::CommunicationError
27
+
28
+ **Cause:** TCP-level failure connecting to or communicating with RabbitMQ. Usually wraps a Bunny internal exception.
29
+
30
+ **Common triggers:**
31
+ - RabbitMQ is not running
32
+ - Wrong host/port
33
+ - Firewall blocking the connection
34
+ - Network interruption during message exchange
35
+
36
+ **Resolution:** Check RabbitMQ is reachable (`telnet host 5672`). Review `network_recovery_interval` and `max_reconnect_attempts` settings. The Consumer retries automatically with exponential backoff.
37
+
38
+ ---
39
+
40
+ ### BugBunny::SecurityError
41
+
42
+ **Cause:** The Consumer received a message with a `type` header that resolves to a class not inheriting from `BugBunny::Controller`.
43
+
44
+ **How to reproduce:**
45
+ ```ruby
46
+ # Publish a message with type: "Kernel"
47
+ ```
48
+
49
+ **Resolution:** This is an intentional RCE prevention check. Ensure all controller classes inherit from `BugBunny::Controller`. If legitimate, verify `config.controller_namespace` is set correctly.
50
+
51
+ ---
52
+
53
+ ### BugBunny::RequestTimeout
54
+
55
+ **Cause:** An RPC call did not receive a reply within `config.rpc_timeout` seconds.
56
+
57
+ **Common triggers:**
58
+ - The Consumer is not running
59
+ - The Consumer is overwhelmed (increase `channel_prefetch`)
60
+ - `rpc_timeout` is too low for the workload
61
+ - The remote controller raised an exception before sending a reply (check consumer logs)
62
+
63
+ **Resolution:** Check that the Consumer process is alive. Review `consumer.execution_error` log entries on the consumer side. Increase `rpc_timeout` if the operation is legitimately slow.
64
+
65
+ ---
66
+
67
+ ### BugBunny::NotFound (404)
68
+
69
+ **Cause:** The remote service responded with HTTP 404. Raised by `Middleware::RaiseError`.
70
+
71
+ **In Resource context:** `find` and `where` catch this internally and return `nil` / `[]` respectively.
72
+
73
+ **In Client context:** Propagates unless caught by the caller.
74
+
75
+ **Resolution:** Verify the resource ID exists. Ensure the Consumer's route for that path is registered.
76
+
77
+ ---
78
+
79
+ ### BugBunny::BadRequest (400)
80
+
81
+ **Cause:** The remote service returned 400. Also raised locally if the JSON body cannot be parsed.
82
+
83
+ **Local trigger:**
84
+ ```ruby
85
+ # In Controller#prepare_params — body is not valid JSON and content_type includes 'json'
86
+ ```
87
+
88
+ **Resolution:** Verify the request body is valid JSON when `content_type: 'application/json'` is used.
89
+
90
+ ---
91
+
92
+ ### BugBunny::Conflict (409)
93
+
94
+ **Cause:** The remote service returned 409 — the request is technically valid but conflicts with business rules or existing data.
95
+
96
+ **Resolution:** Handle the conflict in application logic. Inspect `e.message` for details from the remote service.
97
+
98
+ ---
99
+
100
+ ### BugBunny::UnprocessableEntity (422)
101
+
102
+ **Cause:** The remote service returned 422 (validation failure).
103
+
104
+ **Attributes:**
105
+ - `e.error_messages` — `Hash`, `Array`, or `String` extracted from `{ "errors": ... }` in the response body.
106
+ - `e.raw_response` — The raw response body.
107
+
108
+ **In Resource context:** `save` catches this, loads errors into `resource.errors`, and returns `false`.
109
+
110
+ **In Client context:** Raised directly.
111
+
112
+ **Resolution:**
113
+ ```ruby
114
+ resource = Order.create(attrs)
115
+ unless resource.persisted?
116
+ resource.errors.full_messages # => ["Status can't be blank"]
117
+ end
118
+ ```
119
+
120
+ ---
121
+
122
+ ### BugBunny::NotAcceptable (406)
123
+
124
+ **Cause:** The remote service returned 406 — it cannot produce a response matching the requested content type.
125
+
126
+ **Resolution:** Ensure the client and server agree on content type. BugBunny uses `application/json` by default.
127
+
128
+ ---
129
+
130
+ ### BugBunny::InternalServerError (500)
131
+
132
+ **Cause:** The remote service returned 500. Also sent by the Consumer when an unhandled exception occurs during `process_message`.
133
+
134
+ **Resolution:** Check `consumer.execution_error` log entries on the consumer side for the actual exception. Fix the underlying error in the controller.
135
+
136
+ ---
137
+
138
+ ### BugBunny::Error: "Connection pool missing for ClassName"
139
+
140
+ **Cause:** `Resource.bug_bunny_client` was called before `Resource.connection_pool` was set.
141
+
142
+ **Resolution:** Set `BugBunny::Resource.connection_pool = MY_POOL` in the initializer before any Resource calls.
143
+
144
+ ---
145
+
146
+ ### BugBunny::Error: "ScopeProxy is single-use"
147
+
148
+ **Cause:** A `ScopeProxy` returned by `.with(...)` without a block was called more than once.
149
+
150
+ **Resolution:** Use the block form: `Resource.with(...) { ... }` or call `.with(...)` again for each subsequent call.
151
+
152
+ ---
153
+
154
+ ### BugBunny::Error: "Exchange not defined for ClassName"
155
+
156
+ **Cause:** `Resource.current_exchange` was called but no exchange was configured at any level (thread-local, class, or superclass).
157
+
158
+ **Resolution:** Set `self.exchange = 'exchange_name'` in the Resource class definition or use `.with(exchange:)`.