brainzlab 0.1.10 → 0.1.12

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.
@@ -4,6 +4,46 @@
4
4
  (function() {
5
5
  'use strict';
6
6
 
7
+ // ============================================
8
+ // DARK MODE SUPPORT
9
+ // ============================================
10
+ // Sync with brainzlab-theme localStorage key (used across all BrainzLab products)
11
+ function initDarkMode() {
12
+ const theme = localStorage.getItem('brainzlab-theme');
13
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
14
+
15
+ if (theme === 'dark' || (!theme && prefersDark)) {
16
+ document.documentElement.classList.add('dark');
17
+ } else {
18
+ document.documentElement.classList.remove('dark');
19
+ }
20
+ }
21
+
22
+ // Listen for theme changes from other windows/tabs
23
+ function setupThemeListener() {
24
+ window.addEventListener('storage', function(e) {
25
+ if (e.key === 'brainzlab-theme') {
26
+ initDarkMode();
27
+ }
28
+ });
29
+
30
+ // Also listen for system preference changes
31
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
32
+ // Only react if no explicit theme is set
33
+ if (!localStorage.getItem('brainzlab-theme')) {
34
+ if (e.matches) {
35
+ document.documentElement.classList.add('dark');
36
+ } else {
37
+ document.documentElement.classList.remove('dark');
38
+ }
39
+ }
40
+ });
41
+ }
42
+
43
+ // Initialize dark mode immediately
44
+ initDarkMode();
45
+ setupThemeListener();
46
+
7
47
  // Load Stimulus if not available
8
48
  let stimulusApp = null;
9
49
  let StimulusController = null;
@@ -31,7 +71,7 @@
31
71
  // Stimulus Controller Definition
32
72
  const DevtoolsController = {
33
73
  static: {
34
- targets: ['panel', 'tab', 'pane', 'toast']
74
+ targets: ['panel', 'tab', 'pane', 'toast', 'restoreBtn']
35
75
  },
36
76
 
37
77
  // Lifecycle
@@ -46,10 +86,32 @@
46
86
 
47
87
  // Actions
48
88
  togglePanel() {
89
+ // Don't toggle if minimized - use restorePanel instead
90
+ if (this.element.classList.contains('minimized')) {
91
+ return;
92
+ }
49
93
  this.element.classList.toggle('collapsed');
50
94
  this.saveState();
51
95
  },
52
96
 
97
+ minimizePanel(event) {
98
+ if (event) {
99
+ event.stopPropagation();
100
+ event.preventDefault();
101
+ }
102
+ this.element.classList.add('minimized');
103
+ this.saveState();
104
+ },
105
+
106
+ restorePanel(event) {
107
+ if (event) {
108
+ event.stopPropagation();
109
+ event.preventDefault();
110
+ }
111
+ this.element.classList.remove('minimized');
112
+ this.saveState();
113
+ },
114
+
53
115
  switchTab(event) {
54
116
  event.stopPropagation();
55
117
  const tabName = event.currentTarget.dataset.tab;
@@ -242,6 +304,7 @@ Controller: ${controllerName || 'unknown'}#${actionName || 'unknown'}
242
304
  const activeTab = this.element.querySelector('[data-devtools-target="tab"].active');
243
305
  sessionStorage.setItem('brainz-devtools-state', JSON.stringify({
244
306
  collapsed: this.element.classList.contains('collapsed'),
307
+ minimized: this.element.classList.contains('minimized'),
245
308
  activeTab: activeTab?.dataset.tab || 'request'
246
309
  }));
247
310
  } catch (e) { /* ignore */ }
@@ -250,7 +313,11 @@ Controller: ${controllerName || 'unknown'}#${actionName || 'unknown'}
250
313
  loadState() {
251
314
  try {
252
315
  const state = JSON.parse(sessionStorage.getItem('brainz-devtools-state'));
253
- if (state?.collapsed) this.element.classList.add('collapsed');
316
+ if (state?.minimized) {
317
+ this.element.classList.add('minimized');
318
+ } else if (state?.collapsed) {
319
+ this.element.classList.add('collapsed');
320
+ }
254
321
  if (state?.activeTab) {
255
322
  const tab = this.element.querySelector(`[data-tab="${state.activeTab}"]`);
256
323
  if (tab) tab.click();
@@ -263,9 +330,14 @@ Controller: ${controllerName || 'unknown'}#${actionName || 'unknown'}
263
330
  this._keyHandler = (e) => {
264
331
  if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'B') {
265
332
  e.preventDefault();
266
- this.togglePanel();
333
+ // If minimized, restore first
334
+ if (this.element.classList.contains('minimized')) {
335
+ this.restorePanel();
336
+ } else {
337
+ this.togglePanel();
338
+ }
267
339
  }
268
- if (e.key === 'Escape' && !this.element.classList.contains('collapsed')) {
340
+ if (e.key === 'Escape' && !this.element.classList.contains('collapsed') && !this.element.classList.contains('minimized')) {
269
341
  this.togglePanel();
270
342
  }
271
343
  };
@@ -289,11 +361,13 @@ Controller: ${controllerName || 'unknown'}#${actionName || 'unknown'}
289
361
 
290
362
  // Register the devtools controller
291
363
  app.register('devtools', class extends Controller {
292
- static targets = ['tab', 'pane'];
364
+ static targets = ['tab', 'pane', 'restoreBtn'];
293
365
 
294
366
  connect() { DevtoolsController.connect.call(this); }
295
367
  disconnect() { DevtoolsController.disconnect.call(this); }
296
368
  togglePanel() { DevtoolsController.togglePanel.call(this); }
369
+ minimizePanel(e) { DevtoolsController.minimizePanel.call(this, e); }
370
+ restorePanel(e) { DevtoolsController.restorePanel.call(this, e); }
297
371
  switchTab(e) { DevtoolsController.switchTab.call(this, e); }
298
372
  copyToAi(e) { DevtoolsController.copyToAi.call(this, e); }
299
373
  copySql(e) { DevtoolsController.copySql.call(this, e); }
@@ -97,6 +97,12 @@
97
97
  <path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
98
98
  </svg>
99
99
  </span>
100
+
101
+ <button class="brainz-debug-minimize" data-action="click->devtools#minimizePanel" title="Minimize panel">
102
+ <svg width="12" height="12" viewBox="0 0 12 12">
103
+ <path d="M2 6h8" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/>
104
+ </svg>
105
+ </button>
100
106
  </div>
101
107
 
102
108
  <!-- Tabs -->
@@ -494,6 +500,11 @@
494
500
  </div>
495
501
  </div>
496
502
  </div>
503
+
504
+ <!-- Floating Restore Button (visible when minimized) -->
505
+ <button class="brainz-debug-restore" data-devtools-target="restoreBtn" data-action="click->devtools#restorePanel" title="Restore BrainzLab DevTools (Ctrl+Shift+B)">
506
+ <img src="<%= asset_url('logo.svg') %>" alt="BrainzLab" width="20" height="20">
507
+ </button>
497
508
  </div>
498
509
 
499
510
  <link rel="stylesheet" href="<%= asset_url('devtools.css') %>">
@@ -0,0 +1,490 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ # Base error class for all BrainzLab SDK errors.
5
+ # Provides structured error information including hints and documentation links.
6
+ #
7
+ # @example Raising a structured error
8
+ # raise BrainzLab::Error.new(
9
+ # "Operation failed",
10
+ # hint: "Check your network connection",
11
+ # docs_url: "https://docs.brainzlab.io/troubleshooting",
12
+ # code: "operation_failed"
13
+ # )
14
+ #
15
+ # @example Catching and inspecting errors
16
+ # begin
17
+ # BrainzLab::Vault.get("secret")
18
+ # rescue BrainzLab::Error => e
19
+ # puts e.message # What went wrong
20
+ # puts e.hint # How to fix it
21
+ # puts e.docs_url # Where to learn more
22
+ # puts e.code # Machine-readable code
23
+ # end
24
+ #
25
+ class Error < StandardError
26
+ # @return [String, nil] A helpful hint on how to resolve the error
27
+ attr_reader :hint
28
+
29
+ # @return [String, nil] URL to relevant documentation
30
+ attr_reader :docs_url
31
+
32
+ # @return [String, nil] Machine-readable error code for programmatic handling
33
+ attr_reader :code
34
+
35
+ # @return [Hash, nil] Additional context about the error
36
+ attr_reader :context
37
+
38
+ DOCS_BASE_URL = 'https://docs.brainzlab.io'
39
+
40
+ # Initialize a new BrainzLab error.
41
+ #
42
+ # @param message [String] The error message describing what went wrong
43
+ # @param hint [String, nil] A helpful hint on how to resolve the error
44
+ # @param docs_url [String, nil] URL to relevant documentation
45
+ # @param code [String, nil] Machine-readable error code
46
+ # @param context [Hash, nil] Additional context about the error
47
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil)
48
+ @message = message
49
+ @hint = hint
50
+ @docs_url = docs_url
51
+ @code = code
52
+ @context = context
53
+ super(message)
54
+ end
55
+
56
+ # Format the error as a detailed string with hints and documentation links.
57
+ #
58
+ # @return [String] Formatted error message
59
+ def to_s
60
+ super
61
+ end
62
+
63
+ # Return a detailed formatted version of the error with hints and documentation links.
64
+ # Use this method when you want the full structured output.
65
+ #
66
+ # @return [String] Detailed formatted error message
67
+ def detailed_message(highlight: false, **_kwargs)
68
+ # Get the base message without class name duplication
69
+ base_msg = @message || super
70
+
71
+ parts = ["#{self.class.name}: #{base_msg}"]
72
+
73
+ parts << "" << "Hint: #{hint}" if hint
74
+ parts << "Docs: #{docs_url}" if docs_url
75
+ parts << "Code: #{code}" if code
76
+
77
+ if context && !context.empty?
78
+ parts << "" << "Context:"
79
+ context.each do |key, value|
80
+ parts << " #{key}: #{value}"
81
+ end
82
+ end
83
+
84
+ parts.join("\n")
85
+ end
86
+
87
+ # Inspect the error for debugging
88
+ #
89
+ # @return [String] Inspection output
90
+ def inspect
91
+ "#<#{self.class.name}: #{message}#{" (#{code})" if code}>"
92
+ end
93
+
94
+ # Return a hash representation of the error for logging/serialization.
95
+ #
96
+ # @return [Hash] Error details as a hash
97
+ def to_h
98
+ {
99
+ error_class: self.class.name,
100
+ message: message,
101
+ hint: hint,
102
+ docs_url: docs_url,
103
+ code: code,
104
+ context: context
105
+ }.compact
106
+ end
107
+
108
+ # Alias for to_h
109
+ def as_json
110
+ to_h
111
+ end
112
+ end
113
+
114
+ # Raised when the SDK is misconfigured or required configuration is missing.
115
+ #
116
+ # @example Missing API key
117
+ # raise BrainzLab::ConfigurationError.new(
118
+ # "API key is required",
119
+ # hint: "Set BRAINZLAB_SECRET_KEY environment variable or configure via BrainzLab.configure",
120
+ # code: "missing_api_key"
121
+ # )
122
+ #
123
+ class ConfigurationError < Error
124
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil)
125
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/configuration"
126
+ code ||= 'configuration_error'
127
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context)
128
+ end
129
+ end
130
+
131
+ # Raised when authentication fails due to invalid or expired credentials.
132
+ #
133
+ # @example Invalid API key
134
+ # raise BrainzLab::AuthenticationError.new(
135
+ # "Invalid API key",
136
+ # hint: "Check that your API key is correct and has not expired",
137
+ # code: "invalid_api_key"
138
+ # )
139
+ #
140
+ class AuthenticationError < Error
141
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil)
142
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/authentication"
143
+ code ||= 'authentication_error'
144
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context)
145
+ end
146
+ end
147
+
148
+ # Raised when a connection to BrainzLab services cannot be established.
149
+ #
150
+ # @example Connection timeout
151
+ # raise BrainzLab::ConnectionError.new(
152
+ # "Connection timed out",
153
+ # hint: "Check your network connection and firewall settings",
154
+ # code: "connection_timeout"
155
+ # )
156
+ #
157
+ class ConnectionError < Error
158
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil)
159
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/troubleshooting#connection-issues"
160
+ code ||= 'connection_error'
161
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context)
162
+ end
163
+ end
164
+
165
+ # Raised when the rate limit for API requests has been exceeded.
166
+ #
167
+ # @example Rate limit exceeded
168
+ # raise BrainzLab::RateLimitError.new(
169
+ # "Rate limit exceeded",
170
+ # hint: "Wait before retrying or consider upgrading your plan",
171
+ # code: "rate_limit_exceeded",
172
+ # context: { retry_after: 60, limit: 1000, remaining: 0 }
173
+ # )
174
+ #
175
+ class RateLimitError < Error
176
+ # @return [Integer, nil] Seconds to wait before retrying
177
+ attr_reader :retry_after
178
+
179
+ # @return [Integer, nil] The rate limit ceiling
180
+ attr_reader :limit
181
+
182
+ # @return [Integer, nil] Remaining requests in the current window
183
+ attr_reader :remaining
184
+
185
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil, retry_after: nil, limit: nil, remaining: nil)
186
+ @retry_after = retry_after
187
+ @limit = limit
188
+ @remaining = remaining
189
+
190
+ hint ||= retry_after ? "Wait #{retry_after} seconds before retrying" : 'Reduce request frequency or upgrade your plan'
191
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/rate-limits"
192
+ code ||= 'rate_limit_exceeded'
193
+
194
+ context ||= {}
195
+ context[:retry_after] = retry_after if retry_after
196
+ context[:limit] = limit if limit
197
+ context[:remaining] = remaining if remaining
198
+
199
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context.empty? ? nil : context)
200
+ end
201
+ end
202
+
203
+ # Raised when request parameters or data fail validation.
204
+ #
205
+ # @example Invalid parameter
206
+ # raise BrainzLab::ValidationError.new(
207
+ # "Invalid email format",
208
+ # hint: "Provide a valid email address (e.g., user@example.com)",
209
+ # code: "invalid_email",
210
+ # context: { field: "email", value: "invalid" }
211
+ # )
212
+ #
213
+ class ValidationError < Error
214
+ # @return [String, nil] The field that failed validation
215
+ attr_reader :field
216
+
217
+ # @return [Array<Hash>, nil] List of validation errors for multiple fields
218
+ attr_reader :errors
219
+
220
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil, field: nil, errors: nil)
221
+ @field = field
222
+ @errors = errors
223
+
224
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/api-reference"
225
+ code ||= 'validation_error'
226
+
227
+ context ||= {}
228
+ context[:field] = field if field
229
+ context[:errors] = errors if errors
230
+
231
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context.empty? ? nil : context)
232
+ end
233
+ end
234
+
235
+ # Raised when a requested resource is not found.
236
+ #
237
+ # @example Resource not found
238
+ # raise BrainzLab::NotFoundError.new(
239
+ # "Secret 'database_url' not found",
240
+ # hint: "Verify the secret name and environment",
241
+ # code: "secret_not_found"
242
+ # )
243
+ #
244
+ class NotFoundError < Error
245
+ # @return [String, nil] The type of resource that was not found
246
+ attr_reader :resource_type
247
+
248
+ # @return [String, nil] The identifier of the resource that was not found
249
+ attr_reader :resource_id
250
+
251
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil, resource_type: nil, resource_id: nil)
252
+ @resource_type = resource_type
253
+ @resource_id = resource_id
254
+
255
+ code ||= 'not_found'
256
+
257
+ context ||= {}
258
+ context[:resource_type] = resource_type if resource_type
259
+ context[:resource_id] = resource_id if resource_id
260
+
261
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context.empty? ? nil : context)
262
+ end
263
+ end
264
+
265
+ # Raised when a server-side error occurs.
266
+ #
267
+ # @example Server error
268
+ # raise BrainzLab::ServerError.new(
269
+ # "Internal server error",
270
+ # hint: "This is a temporary issue. Please retry your request.",
271
+ # code: "internal_server_error"
272
+ # )
273
+ #
274
+ class ServerError < Error
275
+ # @return [Integer, nil] HTTP status code from the server
276
+ attr_reader :status_code
277
+
278
+ # @return [String, nil] Request ID for support reference
279
+ attr_reader :request_id
280
+
281
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil, status_code: nil, request_id: nil)
282
+ @status_code = status_code
283
+ @request_id = request_id
284
+
285
+ hint ||= 'This is a temporary issue. Please retry your request. If the problem persists, contact support.'
286
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/troubleshooting#server-errors"
287
+ code ||= 'server_error'
288
+
289
+ context ||= {}
290
+ context[:status_code] = status_code if status_code
291
+ context[:request_id] = request_id if request_id
292
+
293
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context.empty? ? nil : context)
294
+ end
295
+ end
296
+
297
+ # Raised when an operation times out.
298
+ #
299
+ # @example Request timeout
300
+ # raise BrainzLab::TimeoutError.new(
301
+ # "Request timed out after 30 seconds",
302
+ # hint: "The operation took too long. Try again or increase timeout settings.",
303
+ # code: "request_timeout"
304
+ # )
305
+ #
306
+ class TimeoutError < Error
307
+ # @return [Integer, nil] Timeout duration in seconds
308
+ attr_reader :timeout_seconds
309
+
310
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil, timeout_seconds: nil)
311
+ @timeout_seconds = timeout_seconds
312
+
313
+ hint ||= 'The operation took too long. Try again or increase timeout settings.'
314
+ docs_url ||= "#{DOCS_BASE_URL}/sdk/ruby/configuration#timeouts"
315
+ code ||= 'timeout'
316
+
317
+ context ||= {}
318
+ context[:timeout_seconds] = timeout_seconds if timeout_seconds
319
+
320
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context.empty? ? nil : context)
321
+ end
322
+ end
323
+
324
+ # Raised when a service is temporarily unavailable.
325
+ #
326
+ # @example Service unavailable
327
+ # raise BrainzLab::ServiceUnavailableError.new(
328
+ # "Vault service is currently unavailable",
329
+ # hint: "The service is undergoing maintenance. Please try again later.",
330
+ # code: "vault_unavailable"
331
+ # )
332
+ #
333
+ class ServiceUnavailableError < Error
334
+ # @return [String, nil] The name of the unavailable service
335
+ attr_reader :service_name
336
+
337
+ def initialize(message = nil, hint: nil, docs_url: nil, code: nil, context: nil, service_name: nil)
338
+ @service_name = service_name
339
+
340
+ hint ||= 'The service is temporarily unavailable. Please try again later.'
341
+ docs_url ||= "#{DOCS_BASE_URL}/status"
342
+ code ||= 'service_unavailable'
343
+
344
+ context ||= {}
345
+ context[:service_name] = service_name if service_name
346
+
347
+ super(message, hint: hint, docs_url: docs_url, code: code, context: context.empty? ? nil : context)
348
+ end
349
+ end
350
+
351
+ # Helper module for wrapping low-level errors into structured BrainzLab errors
352
+ module ErrorHandler
353
+ module_function
354
+
355
+ # Wrap a standard error into a structured BrainzLab error.
356
+ #
357
+ # @param error [StandardError] The original error
358
+ # @param service [String] The service name (e.g., 'Vault', 'Cortex')
359
+ # @param operation [String] The operation being performed
360
+ # @return [BrainzLab::Error] A structured BrainzLab error
361
+ def wrap(error, service:, operation:)
362
+ case error
363
+ when Net::OpenTimeout, Net::ReadTimeout, Timeout::Error
364
+ TimeoutError.new(
365
+ "#{service} #{operation} timed out: #{error.message}",
366
+ hint: 'Check your network connection or increase timeout settings.',
367
+ code: "#{service.downcase}_timeout",
368
+ context: { service: service, operation: operation }
369
+ )
370
+ when Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH
371
+ ConnectionError.new(
372
+ "Unable to connect to #{service}: #{error.message}",
373
+ hint: 'Check that the service is running and accessible.',
374
+ code: "#{service.downcase}_connection_failed",
375
+ context: { service: service, operation: operation }
376
+ )
377
+ when SocketError
378
+ ConnectionError.new(
379
+ "DNS resolution failed for #{service}: #{error.message}",
380
+ hint: 'Check your network connection and DNS settings.',
381
+ code: "#{service.downcase}_dns_error",
382
+ context: { service: service, operation: operation }
383
+ )
384
+ when JSON::ParserError
385
+ ServerError.new(
386
+ "Invalid response from #{service}: #{error.message}",
387
+ hint: 'The server returned an unexpected response format.',
388
+ code: "#{service.downcase}_invalid_response",
389
+ context: { service: service, operation: operation }
390
+ )
391
+ when OpenSSL::SSL::SSLError
392
+ ConnectionError.new(
393
+ "SSL error connecting to #{service}: #{error.message}",
394
+ hint: 'Check SSL certificates and ensure the connection is secure.',
395
+ code: "#{service.downcase}_ssl_error",
396
+ context: { service: service, operation: operation }
397
+ )
398
+ else
399
+ Error.new(
400
+ "#{service} #{operation} failed: #{error.message}",
401
+ hint: 'An unexpected error occurred. Check the logs for more details.',
402
+ code: "#{service.downcase}_error",
403
+ context: { service: service, operation: operation, original_error: error.class.name }
404
+ )
405
+ end
406
+ end
407
+
408
+ # Convert an HTTP response to a structured error.
409
+ #
410
+ # @param response [Net::HTTPResponse] The HTTP response
411
+ # @param service [String] The service name
412
+ # @param operation [String] The operation being performed
413
+ # @return [BrainzLab::Error] A structured BrainzLab error
414
+ def from_response(response, service:, operation:)
415
+ status_code = response.code.to_i
416
+ body = parse_response_body(response)
417
+ message = body[:message] || body[:error] || "HTTP #{status_code}"
418
+ request_id = response['X-Request-Id']
419
+
420
+ case status_code
421
+ when 400
422
+ ValidationError.new(
423
+ message,
424
+ hint: body[:hint] || 'Check the request parameters.',
425
+ code: body[:code] || 'bad_request',
426
+ context: { service: service, operation: operation, status_code: status_code }
427
+ )
428
+ when 401
429
+ AuthenticationError.new(
430
+ message,
431
+ hint: body[:hint] || "Verify your #{service} API key is correct and active.",
432
+ code: body[:code] || 'unauthorized',
433
+ context: { service: service, operation: operation }
434
+ )
435
+ when 403
436
+ AuthenticationError.new(
437
+ message,
438
+ hint: body[:hint] || 'Your API key does not have permission for this operation.',
439
+ code: body[:code] || 'forbidden',
440
+ context: { service: service, operation: operation }
441
+ )
442
+ when 404
443
+ NotFoundError.new(
444
+ message,
445
+ hint: body[:hint] || 'The requested resource does not exist.',
446
+ code: body[:code] || 'not_found',
447
+ context: { service: service, operation: operation }
448
+ )
449
+ when 422
450
+ ValidationError.new(
451
+ message,
452
+ hint: body[:hint] || 'The request was well-formed but contained invalid data.',
453
+ code: body[:code] || 'unprocessable_entity',
454
+ errors: body[:errors],
455
+ context: { service: service, operation: operation, status_code: status_code }
456
+ )
457
+ when 429
458
+ RateLimitError.new(
459
+ message,
460
+ retry_after: response['Retry-After']&.to_i,
461
+ limit: response['X-RateLimit-Limit']&.to_i,
462
+ remaining: response['X-RateLimit-Remaining']&.to_i,
463
+ context: { service: service, operation: operation }
464
+ )
465
+ when 500..599
466
+ ServerError.new(
467
+ message,
468
+ hint: body[:hint] || 'A server error occurred. Please retry your request.',
469
+ code: body[:code] || "server_error_#{status_code}",
470
+ status_code: status_code,
471
+ request_id: request_id,
472
+ context: { service: service, operation: operation }
473
+ )
474
+ else
475
+ Error.new(
476
+ message,
477
+ hint: body[:hint],
478
+ code: body[:code] || "http_#{status_code}",
479
+ context: { service: service, operation: operation, status_code: status_code }
480
+ )
481
+ end
482
+ end
483
+
484
+ def parse_response_body(response)
485
+ JSON.parse(response.body, symbolize_names: true)
486
+ rescue JSON::ParserError, TypeError
487
+ {}
488
+ end
489
+ end
490
+ end
@@ -208,7 +208,27 @@ module BrainzLab
208
208
  end
209
209
 
210
210
  def log_error(operation, error)
211
- BrainzLab.debug_log("[Nerve::Client] #{operation} failed: #{error.message}")
211
+ structured_error = ErrorHandler.wrap(error, service: 'Nerve', operation: operation)
212
+ BrainzLab.debug_log("[Nerve::Client] #{operation} failed: #{structured_error.message}")
213
+
214
+ # Call on_error callback if configured
215
+ if @config.on_error
216
+ @config.on_error.call(structured_error, { service: 'Nerve', operation: operation })
217
+ end
218
+ end
219
+
220
+ def handle_response_error(response, operation)
221
+ return if response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPCreated) || response.is_a?(Net::HTTPNoContent)
222
+
223
+ structured_error = ErrorHandler.from_response(response, service: 'Nerve', operation: operation)
224
+ BrainzLab.debug_log("[Nerve::Client] #{operation} failed: #{structured_error.message}")
225
+
226
+ # Call on_error callback if configured
227
+ if @config.on_error
228
+ @config.on_error.call(structured_error, { service: 'Nerve', operation: operation })
229
+ end
230
+
231
+ structured_error
212
232
  end
213
233
  end
214
234
  end