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.
- checksums.yaml +4 -4
- data/.agents/skills/rabbitmq-expert/SKILL.md +1555 -0
- data/.claude/commands/gem-ai-setup.md +174 -0
- data/.claude/commands/pr.md +53 -0
- data/.claude/commands/release.md +52 -0
- data/.claude/commands/rubocop.md +22 -0
- data/.claude/commands/service-ai-setup.md +168 -0
- data/.claude/commands/test.md +28 -0
- data/.claude/commands/yard.md +46 -0
- data/CHANGELOG.md +50 -15
- data/CLAUDE.md +240 -0
- data/README.md +154 -221
- data/Rakefile +19 -3
- data/docs/_index.md +50 -0
- data/docs/ai/_index.md +56 -0
- data/docs/ai/antipatterns.md +166 -0
- data/docs/ai/api.md +251 -0
- data/docs/ai/architecture.md +92 -0
- data/docs/ai/errors.md +158 -0
- data/docs/ai/faq_external.md +133 -0
- data/docs/ai/faq_internal.md +86 -0
- data/docs/ai/glossary.md +45 -0
- data/docs/concepts.md +140 -0
- data/docs/howto/controller.md +194 -0
- data/docs/howto/middleware_client.md +119 -0
- data/docs/howto/middleware_consumer.md +127 -0
- data/docs/howto/rails.md +214 -0
- data/docs/howto/resource.md +200 -0
- data/docs/howto/routing.md +133 -0
- data/docs/howto/testing.md +259 -0
- data/docs/howto/tracing.md +119 -0
- data/lib/bug_bunny/client.rb +45 -21
- data/lib/bug_bunny/configuration.rb +63 -0
- data/lib/bug_bunny/consumer.rb +51 -37
- data/lib/bug_bunny/consumer_middleware.rb +14 -5
- data/lib/bug_bunny/controller.rb +39 -18
- data/lib/bug_bunny/exception.rb +5 -1
- data/lib/bug_bunny/middleware/raise_error.rb +3 -3
- data/lib/bug_bunny/observability.rb +28 -6
- data/lib/bug_bunny/producer.rb +11 -13
- data/lib/bug_bunny/railtie.rb +8 -7
- data/lib/bug_bunny/request.rb +3 -11
- data/lib/bug_bunny/resource.rb +81 -41
- data/lib/bug_bunny/routing/route.rb +6 -1
- data/lib/bug_bunny/routing/route_set.rb +60 -22
- data/lib/bug_bunny/session.rb +18 -11
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +4 -2
- data/lib/generators/bug_bunny/install/install_generator.rb +45 -5
- data/lib/tasks/bug_bunny.rake +50 -0
- data/plan_test.txt +63 -0
- data/skills-lock.json +10 -0
- data/spec/integration/client_spec.rb +117 -0
- data/spec/integration/consumer_middleware_spec.rb +86 -0
- data/spec/integration/controller_spec.rb +140 -0
- data/spec/integration/error_handling_spec.rb +57 -0
- data/spec/integration/infrastructure_spec.rb +52 -0
- data/spec/integration/resource_spec.rb +113 -0
- data/spec/spec_helper.rb +70 -0
- data/spec/support/bunny_mocks.rb +18 -0
- data/spec/support/integration_helper.rb +87 -0
- data/spec/unit/client_session_pool_spec.rb +159 -0
- data/spec/unit/configuration_spec.rb +164 -0
- data/spec/unit/consumer_middleware_spec.rb +129 -0
- data/spec/unit/consumer_spec.rb +90 -0
- data/spec/unit/controller_after_action_spec.rb +155 -0
- data/spec/unit/observability_spec.rb +167 -0
- data/spec/unit/resource_attributes_spec.rb +69 -0
- data/spec/unit/session_spec.rb +98 -0
- metadata +50 -3
- 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:)`.
|