resteze 0.3.1 → 0.4.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/CLAUDE.md +74 -0
- data/README.md +31 -0
- data/docs/ADVANCED_USAGE.md +760 -0
- data/docs/API.md +410 -0
- data/docs/CONFIGURATION.md +681 -0
- data/docs/ERROR_HANDLING.md +609 -0
- data/docs/TESTING.md +768 -0
- data/lib/resteze/client.rb +1 -0
- data/lib/resteze/instrumentation.rb +14 -0
- data/lib/resteze/version.rb +1 -1
- data/lib/resteze.rb +2 -0
- metadata +9 -2
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
# Error Handling Guide
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
- [Error Hierarchy](#error-hierarchy)
|
|
5
|
+
- [Built-in Error Types](#built-in-error-types)
|
|
6
|
+
- [Error Attributes](#error-attributes)
|
|
7
|
+
- [Handling Errors](#handling-errors)
|
|
8
|
+
- [Custom Error Classes](#custom-error-classes)
|
|
9
|
+
- [Middleware Error Handling](#middleware-error-handling)
|
|
10
|
+
- [Best Practices](#best-practices)
|
|
11
|
+
|
|
12
|
+
## Error Hierarchy
|
|
13
|
+
|
|
14
|
+
Resteze provides a comprehensive error hierarchy that gets dynamically created for each API module:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
StandardError
|
|
18
|
+
└── YourApi::Error (base for all API errors)
|
|
19
|
+
├── YourApi::ApiError (server-side errors)
|
|
20
|
+
├── YourApi::ApiConnectionError (network/connection errors)
|
|
21
|
+
└── YourApi::InvalidRequestError (client-side errors)
|
|
22
|
+
|
|
23
|
+
Additional wrapped errors:
|
|
24
|
+
├── YourApi::NotImplementedError
|
|
25
|
+
├── YourApi::ResourceNotFound (404)
|
|
26
|
+
├── YourApi::UnprocessableEntityError (422)
|
|
27
|
+
└── YourApi::ConflictError (409)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Built-in Error Types
|
|
31
|
+
|
|
32
|
+
### ApiError
|
|
33
|
+
Server-side errors (5xx status codes):
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
begin
|
|
37
|
+
user = MyApi::User.retrieve('123')
|
|
38
|
+
rescue MyApi::ApiError => e
|
|
39
|
+
puts "Server error: #{e.message}"
|
|
40
|
+
puts "HTTP Status: #{e.http_status}"
|
|
41
|
+
puts "Response body: #{e.response.body}"
|
|
42
|
+
|
|
43
|
+
# Retry logic for server errors
|
|
44
|
+
retry if e.http_status >= 500
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### ApiConnectionError
|
|
49
|
+
Network and connection failures:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
begin
|
|
53
|
+
user = MyApi::User.retrieve('123')
|
|
54
|
+
rescue MyApi::ApiConnectionError => e
|
|
55
|
+
puts "Connection failed: #{e.message}"
|
|
56
|
+
|
|
57
|
+
# Common causes:
|
|
58
|
+
# - Network timeout
|
|
59
|
+
# - DNS resolution failure
|
|
60
|
+
# - Connection refused
|
|
61
|
+
# - SSL/TLS errors
|
|
62
|
+
|
|
63
|
+
# Implement retry with backoff
|
|
64
|
+
sleep(2)
|
|
65
|
+
retry
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### InvalidRequestError
|
|
70
|
+
Client-side errors (4xx status codes):
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
begin
|
|
74
|
+
user = MyApi::User.create(email: 'invalid')
|
|
75
|
+
rescue MyApi::InvalidRequestError => e
|
|
76
|
+
puts "Invalid request: #{e.message}"
|
|
77
|
+
puts "Invalid parameter: #{e.param}" if e.param
|
|
78
|
+
puts "HTTP Status: #{e.http_status}"
|
|
79
|
+
|
|
80
|
+
# Don't retry - fix the request
|
|
81
|
+
log_validation_error(e)
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### ResourceNotFound
|
|
86
|
+
404 Not Found errors:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
begin
|
|
90
|
+
user = MyApi::User.retrieve('non-existent-id')
|
|
91
|
+
rescue MyApi::ResourceNotFound => e
|
|
92
|
+
puts "User not found"
|
|
93
|
+
# Handle missing resource
|
|
94
|
+
create_default_user()
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### UnprocessableEntityError
|
|
99
|
+
422 Unprocessable Entity (validation errors):
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
begin
|
|
103
|
+
user = MyApi::User.create(email: 'not-an-email')
|
|
104
|
+
rescue MyApi::UnprocessableEntityError => e
|
|
105
|
+
puts "Validation failed: #{e.message}"
|
|
106
|
+
|
|
107
|
+
# Parse validation errors from response
|
|
108
|
+
errors = JSON.parse(e.response.body)['errors']
|
|
109
|
+
errors.each do |field, messages|
|
|
110
|
+
puts "#{field}: #{messages.join(', ')}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### ConflictError
|
|
116
|
+
409 Conflict errors:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
begin
|
|
120
|
+
user = MyApi::User.create(email: 'existing@example.com')
|
|
121
|
+
rescue MyApi::ConflictError => e
|
|
122
|
+
puts "Conflict: #{e.message}"
|
|
123
|
+
# Resource already exists
|
|
124
|
+
user = MyApi::User.find_by_email('existing@example.com')
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Error Attributes
|
|
129
|
+
|
|
130
|
+
All error classes include these attributes:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
begin
|
|
134
|
+
MyApi::User.retrieve('123')
|
|
135
|
+
rescue MyApi::Error => e
|
|
136
|
+
# Basic attributes
|
|
137
|
+
e.message # Error message
|
|
138
|
+
e.http_status # HTTP status code (e.g., 404, 500)
|
|
139
|
+
e.response # Faraday response object
|
|
140
|
+
|
|
141
|
+
# Response details
|
|
142
|
+
e.response.body # Raw response body
|
|
143
|
+
e.response.headers # Response headers
|
|
144
|
+
e.response.status # Status code
|
|
145
|
+
|
|
146
|
+
# For InvalidRequestError
|
|
147
|
+
e.param # The parameter that caused the error
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Handling Errors
|
|
152
|
+
|
|
153
|
+
### Basic Error Handling
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
def fetch_user(id)
|
|
157
|
+
MyApi::User.retrieve(id)
|
|
158
|
+
rescue MyApi::ResourceNotFound
|
|
159
|
+
nil
|
|
160
|
+
rescue MyApi::ApiConnectionError => e
|
|
161
|
+
Rails.logger.error "API connection failed: #{e.message}"
|
|
162
|
+
raise ServiceUnavailableError, "Unable to fetch user"
|
|
163
|
+
rescue MyApi::Error => e
|
|
164
|
+
Rails.logger.error "API error: #{e.message}"
|
|
165
|
+
Bugsnag.notify(e)
|
|
166
|
+
raise
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Comprehensive Error Handling
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
class ApiErrorHandler
|
|
174
|
+
def self.handle
|
|
175
|
+
yield
|
|
176
|
+
rescue MyApi::ResourceNotFound => e
|
|
177
|
+
handle_not_found(e)
|
|
178
|
+
rescue MyApi::UnprocessableEntityError => e
|
|
179
|
+
handle_validation_error(e)
|
|
180
|
+
rescue MyApi::ConflictError => e
|
|
181
|
+
handle_conflict(e)
|
|
182
|
+
rescue MyApi::InvalidRequestError => e
|
|
183
|
+
handle_bad_request(e)
|
|
184
|
+
rescue MyApi::ApiConnectionError => e
|
|
185
|
+
handle_connection_error(e)
|
|
186
|
+
rescue MyApi::ApiError => e
|
|
187
|
+
handle_server_error(e)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def self.handle_not_found(error)
|
|
193
|
+
{ error: 'Resource not found', status: 404 }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.handle_validation_error(error)
|
|
197
|
+
errors = parse_validation_errors(error.response.body)
|
|
198
|
+
{ errors: errors, status: 422 }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def self.handle_conflict(error)
|
|
202
|
+
{ error: 'Resource already exists', status: 409 }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def self.handle_bad_request(error)
|
|
206
|
+
{ error: error.message, param: error.param, status: 400 }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.handle_connection_error(error)
|
|
210
|
+
notify_monitoring_service(error)
|
|
211
|
+
{ error: 'Service temporarily unavailable', status: 503 }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def self.handle_server_error(error)
|
|
215
|
+
notify_monitoring_service(error)
|
|
216
|
+
{ error: 'Internal server error', status: 500 }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def self.parse_validation_errors(body)
|
|
220
|
+
JSON.parse(body)['errors'] rescue {}
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def self.notify_monitoring_service(error)
|
|
224
|
+
# Send to error tracking service
|
|
225
|
+
Sentry.capture_exception(error)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Usage
|
|
230
|
+
result = ApiErrorHandler.handle { MyApi::User.create(params) }
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Retry Logic with Error Handling
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
class RetryableRequest
|
|
237
|
+
MAX_RETRIES = 3
|
|
238
|
+
RETRY_DELAY = 1 # seconds
|
|
239
|
+
|
|
240
|
+
def self.execute(&block)
|
|
241
|
+
retries = 0
|
|
242
|
+
|
|
243
|
+
begin
|
|
244
|
+
yield
|
|
245
|
+
rescue MyApi::ApiConnectionError, MyApi::ApiError => e
|
|
246
|
+
if should_retry?(e, retries)
|
|
247
|
+
retries += 1
|
|
248
|
+
delay = RETRY_DELAY * (2 ** (retries - 1)) # Exponential backoff
|
|
249
|
+
|
|
250
|
+
Rails.logger.warn "Request failed, retrying in #{delay}s (attempt #{retries}/#{MAX_RETRIES})"
|
|
251
|
+
sleep(delay)
|
|
252
|
+
retry
|
|
253
|
+
else
|
|
254
|
+
raise
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
private
|
|
260
|
+
|
|
261
|
+
def self.should_retry?(error, retries)
|
|
262
|
+
return false if retries >= MAX_RETRIES
|
|
263
|
+
|
|
264
|
+
# Retry on connection errors
|
|
265
|
+
return true if error.is_a?(MyApi::ApiConnectionError)
|
|
266
|
+
|
|
267
|
+
# Retry on specific server errors
|
|
268
|
+
return true if error.is_a?(MyApi::ApiError) && [502, 503, 504].include?(error.http_status)
|
|
269
|
+
|
|
270
|
+
# Don't retry on client errors
|
|
271
|
+
false
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Usage
|
|
276
|
+
user = RetryableRequest.execute { MyApi::User.retrieve('123') }
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Custom Error Classes
|
|
280
|
+
|
|
281
|
+
### Defining Custom Errors
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
module MyApi
|
|
285
|
+
# Custom error classes
|
|
286
|
+
class RateLimitError < Error
|
|
287
|
+
attr_reader :retry_after
|
|
288
|
+
|
|
289
|
+
def initialize(message, retry_after: nil, **options)
|
|
290
|
+
super(message, **options)
|
|
291
|
+
@retry_after = retry_after
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
class AuthenticationError < Error
|
|
296
|
+
attr_reader :auth_type
|
|
297
|
+
|
|
298
|
+
def initialize(message, auth_type: nil, **options)
|
|
299
|
+
super(message, **options)
|
|
300
|
+
@auth_type = auth_type
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
class ValidationError < InvalidRequestError
|
|
305
|
+
attr_reader :errors
|
|
306
|
+
|
|
307
|
+
def initialize(message, errors: {}, **options)
|
|
308
|
+
super(message, **options)
|
|
309
|
+
@errors = errors
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Using Custom Errors
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
module MyApi
|
|
319
|
+
class Client < Resteze::Client
|
|
320
|
+
def execute_request(method, path, **options)
|
|
321
|
+
response = super
|
|
322
|
+
check_rate_limit(response)
|
|
323
|
+
response
|
|
324
|
+
rescue Faraday::ClientError => e
|
|
325
|
+
handle_client_error(e)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
private
|
|
329
|
+
|
|
330
|
+
def check_rate_limit(response)
|
|
331
|
+
remaining = response.headers['x-rate-limit-remaining'].to_i
|
|
332
|
+
if remaining == 0
|
|
333
|
+
reset_time = response.headers['x-rate-limit-reset'].to_i
|
|
334
|
+
retry_after = reset_time - Time.now.to_i
|
|
335
|
+
|
|
336
|
+
raise RateLimitError.new(
|
|
337
|
+
"Rate limit exceeded",
|
|
338
|
+
retry_after: retry_after,
|
|
339
|
+
http_status: 429,
|
|
340
|
+
response: response
|
|
341
|
+
)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def handle_client_error(error)
|
|
346
|
+
case error.response[:status]
|
|
347
|
+
when 401
|
|
348
|
+
raise AuthenticationError.new(
|
|
349
|
+
"Authentication failed",
|
|
350
|
+
auth_type: error.response[:headers]['www-authenticate'],
|
|
351
|
+
http_status: 401,
|
|
352
|
+
response: error.response
|
|
353
|
+
)
|
|
354
|
+
when 422
|
|
355
|
+
errors = JSON.parse(error.response[:body])['errors'] rescue {}
|
|
356
|
+
raise ValidationError.new(
|
|
357
|
+
"Validation failed",
|
|
358
|
+
errors: errors,
|
|
359
|
+
http_status: 422,
|
|
360
|
+
response: error.response
|
|
361
|
+
)
|
|
362
|
+
else
|
|
363
|
+
raise
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Middleware Error Handling
|
|
371
|
+
|
|
372
|
+
### Custom Error Middleware
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
module MyApi
|
|
376
|
+
module Middleware
|
|
377
|
+
class ErrorHandler < Faraday::Middleware
|
|
378
|
+
def on_complete(env)
|
|
379
|
+
case env[:status]
|
|
380
|
+
when 400
|
|
381
|
+
raise_invalid_request(env)
|
|
382
|
+
when 401
|
|
383
|
+
raise_authentication_error(env)
|
|
384
|
+
when 403
|
|
385
|
+
raise_authorization_error(env)
|
|
386
|
+
when 404
|
|
387
|
+
raise_not_found(env)
|
|
388
|
+
when 409
|
|
389
|
+
raise_conflict(env)
|
|
390
|
+
when 422
|
|
391
|
+
raise_validation_error(env)
|
|
392
|
+
when 429
|
|
393
|
+
raise_rate_limit(env)
|
|
394
|
+
when 500..599
|
|
395
|
+
raise_server_error(env)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
private
|
|
400
|
+
|
|
401
|
+
def raise_invalid_request(env)
|
|
402
|
+
body = parse_body(env)
|
|
403
|
+
raise InvalidRequestError.new(
|
|
404
|
+
body['error'] || 'Invalid request',
|
|
405
|
+
param: body['param'],
|
|
406
|
+
http_status: 400,
|
|
407
|
+
response: env
|
|
408
|
+
)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def raise_authentication_error(env)
|
|
412
|
+
raise AuthenticationError.new(
|
|
413
|
+
'Authentication required',
|
|
414
|
+
http_status: 401,
|
|
415
|
+
response: env
|
|
416
|
+
)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def raise_rate_limit(env)
|
|
420
|
+
retry_after = env[:response_headers]['retry-after']
|
|
421
|
+
raise RateLimitError.new(
|
|
422
|
+
'Rate limit exceeded',
|
|
423
|
+
retry_after: retry_after,
|
|
424
|
+
http_status: 429,
|
|
425
|
+
response: env
|
|
426
|
+
)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def raise_server_error(env)
|
|
430
|
+
raise ApiError.new(
|
|
431
|
+
"Server error: #{env[:status]}",
|
|
432
|
+
http_status: env[:status],
|
|
433
|
+
response: env
|
|
434
|
+
)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def parse_body(env)
|
|
438
|
+
JSON.parse(env[:body]) rescue {}
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
class Client < Resteze::Client
|
|
444
|
+
def self.default_connection
|
|
445
|
+
@default_connection ||= Faraday.new do |conn|
|
|
446
|
+
conn.use Middleware::ErrorHandler
|
|
447
|
+
conn.use Middleware::RaiseError
|
|
448
|
+
conn.adapter Faraday.default_adapter
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### Circuit Breaker Pattern
|
|
456
|
+
|
|
457
|
+
```ruby
|
|
458
|
+
require 'circuit_breaker'
|
|
459
|
+
|
|
460
|
+
module MyApi
|
|
461
|
+
class CircuitBreakerMiddleware < Faraday::Middleware
|
|
462
|
+
def initialize(app, options = {})
|
|
463
|
+
super(app)
|
|
464
|
+
@circuit = CircuitBreaker.new(
|
|
465
|
+
failure_threshold: options[:failure_threshold] || 5,
|
|
466
|
+
recovery_timeout: options[:recovery_timeout] || 60,
|
|
467
|
+
expected_exception: ApiError
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def call(env)
|
|
472
|
+
@circuit.call do
|
|
473
|
+
@app.call(env)
|
|
474
|
+
end
|
|
475
|
+
rescue CircuitBreaker::OpenCircuitError
|
|
476
|
+
raise ApiConnectionError.new(
|
|
477
|
+
"Service circuit breaker is open",
|
|
478
|
+
http_status: 503
|
|
479
|
+
)
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
class Client < Resteze::Client
|
|
484
|
+
def self.default_connection
|
|
485
|
+
@default_connection ||= Faraday.new do |conn|
|
|
486
|
+
conn.use CircuitBreakerMiddleware,
|
|
487
|
+
failure_threshold: 3,
|
|
488
|
+
recovery_timeout: 30
|
|
489
|
+
conn.use Middleware::RaiseError
|
|
490
|
+
conn.adapter Faraday.default_adapter
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
## Best Practices
|
|
498
|
+
|
|
499
|
+
### 1. Always Rescue Specific Errors
|
|
500
|
+
|
|
501
|
+
```ruby
|
|
502
|
+
# Good
|
|
503
|
+
begin
|
|
504
|
+
user = MyApi::User.retrieve(id)
|
|
505
|
+
rescue MyApi::ResourceNotFound
|
|
506
|
+
return nil
|
|
507
|
+
rescue MyApi::ApiConnectionError => e
|
|
508
|
+
log_error(e)
|
|
509
|
+
retry
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Bad
|
|
513
|
+
begin
|
|
514
|
+
user = MyApi::User.retrieve(id)
|
|
515
|
+
rescue => e
|
|
516
|
+
# Too broad, catches unexpected errors
|
|
517
|
+
end
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
### 2. Log Errors with Context
|
|
521
|
+
|
|
522
|
+
```ruby
|
|
523
|
+
def fetch_user_with_logging(id)
|
|
524
|
+
MyApi::User.retrieve(id)
|
|
525
|
+
rescue MyApi::Error => e
|
|
526
|
+
Rails.logger.error(
|
|
527
|
+
message: "Failed to fetch user",
|
|
528
|
+
user_id: id,
|
|
529
|
+
error_class: e.class.name,
|
|
530
|
+
error_message: e.message,
|
|
531
|
+
http_status: e.http_status,
|
|
532
|
+
response_body: e.response&.body
|
|
533
|
+
)
|
|
534
|
+
raise
|
|
535
|
+
end
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
### 3. Implement Graceful Degradation
|
|
539
|
+
|
|
540
|
+
```ruby
|
|
541
|
+
class UserService
|
|
542
|
+
def get_user_with_fallback(id)
|
|
543
|
+
# Try primary API
|
|
544
|
+
fetch_from_api(id)
|
|
545
|
+
rescue MyApi::ApiConnectionError, MyApi::ApiError
|
|
546
|
+
# Fall back to cache
|
|
547
|
+
fetch_from_cache(id)
|
|
548
|
+
rescue MyApi::ResourceNotFound
|
|
549
|
+
# Fall back to default
|
|
550
|
+
User.new(id: id, name: 'Unknown User')
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
private
|
|
554
|
+
|
|
555
|
+
def fetch_from_api(id)
|
|
556
|
+
MyApi::User.retrieve(id)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def fetch_from_cache(id)
|
|
560
|
+
Rails.cache.read("user:#{id}")
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
### 4. Use Error Monitoring
|
|
566
|
+
|
|
567
|
+
```ruby
|
|
568
|
+
class ErrorNotifier
|
|
569
|
+
def self.configure
|
|
570
|
+
# Configure error monitoring service
|
|
571
|
+
Sentry.init do |config|
|
|
572
|
+
config.before_send = lambda do |event, hint|
|
|
573
|
+
if hint[:exception].is_a?(MyApi::Error)
|
|
574
|
+
event.extra[:api_response] = hint[:exception].response&.body
|
|
575
|
+
event.extra[:http_status] = hint[:exception].http_status
|
|
576
|
+
end
|
|
577
|
+
event
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def self.notify(error, context = {})
|
|
583
|
+
Sentry.capture_exception(error, extra: context)
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
### 5. Provide User-Friendly Messages
|
|
589
|
+
|
|
590
|
+
```ruby
|
|
591
|
+
class ApiErrorPresenter
|
|
592
|
+
def self.message_for(error)
|
|
593
|
+
case error
|
|
594
|
+
when MyApi::ResourceNotFound
|
|
595
|
+
"The requested item could not be found."
|
|
596
|
+
when MyApi::UnprocessableEntityError
|
|
597
|
+
"Please check your input and try again."
|
|
598
|
+
when MyApi::ConflictError
|
|
599
|
+
"This item already exists."
|
|
600
|
+
when MyApi::ApiConnectionError
|
|
601
|
+
"We're having trouble connecting to our servers. Please try again later."
|
|
602
|
+
when MyApi::ApiError
|
|
603
|
+
"Something went wrong on our end. We've been notified and are working on it."
|
|
604
|
+
else
|
|
605
|
+
"An unexpected error occurred. Please try again."
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
```
|