brainzlab 0.1.11 → 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.
- checksums.yaml +4 -4
- data/README.md +210 -3
- data/lib/brainzlab/beacon/client.rb +21 -1
- data/lib/brainzlab/configuration.rb +51 -2
- data/lib/brainzlab/cortex/client.rb +21 -1
- data/lib/brainzlab/debug.rb +305 -0
- data/lib/brainzlab/dendrite/client.rb +21 -1
- data/lib/brainzlab/development/logger.rb +150 -0
- data/lib/brainzlab/development/store.rb +121 -0
- data/lib/brainzlab/development.rb +72 -0
- data/lib/brainzlab/devtools/assets/devtools.css +245 -109
- data/lib/brainzlab/devtools/assets/devtools.js +40 -0
- data/lib/brainzlab/errors.rb +490 -0
- data/lib/brainzlab/nerve/client.rb +21 -1
- data/lib/brainzlab/pulse/client.rb +66 -5
- data/lib/brainzlab/pulse.rb +17 -4
- data/lib/brainzlab/recall/client.rb +74 -6
- data/lib/brainzlab/recall.rb +19 -2
- data/lib/brainzlab/reflex/client.rb +66 -5
- data/lib/brainzlab/reflex.rb +40 -8
- data/lib/brainzlab/sentinel/client.rb +21 -1
- data/lib/brainzlab/synapse/client.rb +21 -1
- data/lib/brainzlab/testing/event_store.rb +377 -0
- data/lib/brainzlab/testing/helpers.rb +650 -0
- data/lib/brainzlab/testing/matchers.rb +391 -0
- data/lib/brainzlab/testing.rb +327 -0
- data/lib/brainzlab/utilities/circuit_breaker.rb +32 -3
- data/lib/brainzlab/vault/client.rb +21 -1
- data/lib/brainzlab/version.rb +1 -1
- data/lib/brainzlab/vision/client.rb +53 -6
- data/lib/brainzlab.rb +42 -0
- metadata +24 -1
|
@@ -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;
|
|
@@ -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
|
-
|
|
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
|
|
@@ -84,20 +84,29 @@ module BrainzLab
|
|
|
84
84
|
|
|
85
85
|
def post(path, body)
|
|
86
86
|
uri = URI.join(@config.pulse_url, path)
|
|
87
|
+
|
|
88
|
+
# Call on_send callback if configured
|
|
89
|
+
invoke_on_send(:pulse, :post, path, body)
|
|
90
|
+
|
|
91
|
+
# Log debug output for request
|
|
92
|
+
log_debug_request(path, body)
|
|
93
|
+
|
|
87
94
|
request = Net::HTTP::Post.new(uri)
|
|
88
95
|
request['Content-Type'] = 'application/json'
|
|
89
96
|
request['Authorization'] = "Bearer #{@config.pulse_auth_key}"
|
|
90
97
|
request['User-Agent'] = "brainzlab-sdk-ruby/#{BrainzLab::VERSION}"
|
|
91
98
|
request.body = JSON.generate(body)
|
|
92
99
|
|
|
93
|
-
execute_with_retry(uri, request)
|
|
100
|
+
execute_with_retry(uri, request, path)
|
|
94
101
|
rescue StandardError => e
|
|
95
|
-
|
|
102
|
+
handle_error(e, context: { path: path, body_size: body.to_s.length })
|
|
96
103
|
nil
|
|
97
104
|
end
|
|
98
105
|
|
|
99
|
-
def execute_with_retry(uri, request)
|
|
106
|
+
def execute_with_retry(uri, request, path)
|
|
100
107
|
retries = 0
|
|
108
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
109
|
+
|
|
101
110
|
begin
|
|
102
111
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
103
112
|
http.use_ssl = uri.scheme == 'https'
|
|
@@ -105,6 +114,10 @@ module BrainzLab
|
|
|
105
114
|
http.read_timeout = 10
|
|
106
115
|
|
|
107
116
|
response = http.request(request)
|
|
117
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
118
|
+
|
|
119
|
+
# Log debug output for response
|
|
120
|
+
log_debug_response(response.code.to_i, duration_ms)
|
|
108
121
|
|
|
109
122
|
case response.code.to_i
|
|
110
123
|
when 200..299
|
|
@@ -116,7 +129,10 @@ module BrainzLab
|
|
|
116
129
|
when 429, 500..599
|
|
117
130
|
raise RetryableError, "Server error: #{response.code}"
|
|
118
131
|
else
|
|
119
|
-
|
|
132
|
+
handle_error(
|
|
133
|
+
StandardError.new("Pulse API error: #{response.code}"),
|
|
134
|
+
context: { path: path, status: response.code, body: response.body }
|
|
135
|
+
)
|
|
120
136
|
nil
|
|
121
137
|
end
|
|
122
138
|
rescue RetryableError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
@@ -125,12 +141,57 @@ module BrainzLab
|
|
|
125
141
|
sleep(RETRY_DELAY * retries)
|
|
126
142
|
retry
|
|
127
143
|
end
|
|
128
|
-
|
|
144
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
145
|
+
log_debug_response(0, duration_ms, error: e.message)
|
|
146
|
+
handle_error(e, context: { path: path, retries: retries })
|
|
129
147
|
nil
|
|
130
148
|
end
|
|
131
149
|
end
|
|
132
150
|
|
|
151
|
+
def log_debug_request(path, body)
|
|
152
|
+
return unless BrainzLab::Debug.enabled?
|
|
153
|
+
|
|
154
|
+
data = if body.is_a?(Hash) && body[:traces]
|
|
155
|
+
{ count: body[:traces].size }
|
|
156
|
+
elsif body.is_a?(Hash) && body[:name]
|
|
157
|
+
{ name: body[:name] }
|
|
158
|
+
else
|
|
159
|
+
{}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
BrainzLab::Debug.log_request(:pulse, 'POST', path, data: data)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def log_debug_response(status, duration_ms, error: nil)
|
|
166
|
+
return unless BrainzLab::Debug.enabled?
|
|
167
|
+
|
|
168
|
+
BrainzLab::Debug.log_response(:pulse, status, duration_ms, error: error)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def invoke_on_send(service, method, path, payload)
|
|
172
|
+
return unless @config.on_send
|
|
173
|
+
|
|
174
|
+
@config.on_send.call(service, method, path, payload)
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
# Don't let callback errors break the SDK
|
|
177
|
+
log_error("on_send callback error: #{e.message}")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def handle_error(error, context: {})
|
|
181
|
+
log_error("#{error.message}")
|
|
182
|
+
|
|
183
|
+
# Call on_error callback if configured
|
|
184
|
+
return unless @config.on_error
|
|
185
|
+
|
|
186
|
+
@config.on_error.call(error, context.merge(service: :pulse))
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
# Don't let callback errors break the SDK
|
|
189
|
+
log_error("on_error callback error: #{e.message}")
|
|
190
|
+
end
|
|
191
|
+
|
|
133
192
|
def log_error(message)
|
|
193
|
+
BrainzLab::Debug.log(message, level: :error) if BrainzLab::Debug.enabled?
|
|
194
|
+
|
|
134
195
|
return unless @config.logger
|
|
135
196
|
|
|
136
197
|
@config.logger.error("[BrainzLab::Pulse] #{message}")
|