resteze 0.3.0 → 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.
@@ -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
+ ```