resteze 0.3.1 → 1.0.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/CHANGELOG.md +38 -0
  4. data/CLAUDE.md +83 -0
  5. data/README.md +75 -3
  6. data/docs/ACTIONS.md +329 -0
  7. data/docs/ADVANCED_USAGE.md +744 -0
  8. data/docs/API.md +404 -0
  9. data/docs/CONFIGURATION.md +962 -0
  10. data/docs/ERROR_HANDLING.md +676 -0
  11. data/docs/TESTING.md +789 -0
  12. data/lib/resteze/action.rb +99 -0
  13. data/lib/resteze/actions/validation.rb +56 -0
  14. data/lib/resteze/actions.rb +117 -0
  15. data/lib/resteze/api_module.rb +11 -5
  16. data/lib/resteze/api_resource.rb +28 -3
  17. data/lib/resteze/authenticating.rb +28 -0
  18. data/lib/resteze/authenticator/api_key.rb +45 -0
  19. data/lib/resteze/authenticator/basic.rb +40 -0
  20. data/lib/resteze/authenticator/bearer.rb +37 -0
  21. data/lib/resteze/authenticator/mtls.rb +73 -0
  22. data/lib/resteze/authenticator/strategy.rb +133 -0
  23. data/lib/resteze/authenticator.rb +187 -0
  24. data/lib/resteze/client.rb +60 -11
  25. data/lib/resteze/errors.rb +125 -4
  26. data/lib/resteze/instrumentation.rb +49 -0
  27. data/lib/resteze/list.rb +2 -2
  28. data/lib/resteze/middleware/raise_error.rb +38 -6
  29. data/lib/resteze/rate_limit.rb +44 -0
  30. data/lib/resteze/rate_limiter/fixed_window.rb +107 -0
  31. data/lib/resteze/rate_limiter/leaky_bucket.rb +40 -0
  32. data/lib/resteze/rate_limiter/sliding_window.rb +37 -0
  33. data/lib/resteze/rate_limiter/strategy.rb +58 -0
  34. data/lib/resteze/rate_limiter/token_bucket.rb +39 -0
  35. data/lib/resteze/rate_limiter.rb +229 -0
  36. data/lib/resteze/rate_limiting.rb +30 -0
  37. data/lib/resteze/response.rb +37 -1
  38. data/lib/resteze/result.rb +18 -0
  39. data/lib/resteze/save.rb +25 -3
  40. data/lib/resteze/synchronized_state.rb +29 -0
  41. data/lib/resteze/testing/minitest/configuration.rb +3 -1
  42. data/lib/resteze/testing/minitest/object.rb +30 -0
  43. data/lib/resteze/testing/rspec/configure.rb +3 -1
  44. data/lib/resteze/testing/rspec/object.rb +32 -2
  45. data/lib/resteze/version.rb +1 -1
  46. data/lib/resteze.rb +102 -10
  47. metadata +30 -2
@@ -0,0 +1,676 @@
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
+ - [Errors Reported in a 2xx Body](#errors-reported-in-a-2xx-body)
9
+ - [Custom Error Classes](#custom-error-classes)
10
+ - [Middleware Error Handling](#middleware-error-handling)
11
+ - [Best Practices](#best-practices)
12
+
13
+ ## Error Hierarchy
14
+
15
+ Resteze creates a full error hierarchy inside each API module. Every class below is a subclass of that module's own `Error`, so `rescue YourApi::Error` catches everything the client raises, and an error from one API is never caught by a rescue clause written for another.
16
+
17
+ ```
18
+ StandardError
19
+ └── Resteze::Error
20
+ └── YourApi::Error (base for all of this API's errors)
21
+ ├── YourApi::ApiError (5xx, and 4xx with no more specific class)
22
+ ├── YourApi::ApiConnectionError (connection refused, timeout, SSL)
23
+ ├── YourApi::AuthenticationError (401, 403)
24
+ ├── YourApi::InvalidRequestError (400, and requests the client cannot build)
25
+ ├── YourApi::RateLimitError (429, with #retry_after)
26
+ ├── YourApi::ResourceNotFound (404)
27
+ ├── YourApi::ConflictError (409)
28
+ └── YourApi::UnprocessableEntityError (422)
29
+
30
+ Also namespaced:
31
+ └── YourApi::NotImplementedError
32
+ ```
33
+
34
+ The `RaiseError` middleware maps every Faraday error onto this hierarchy. The original Faraday error is available as `e.cause`.
35
+
36
+ ## Built-in Error Types
37
+
38
+ ### ApiError
39
+ Server-side errors (5xx), plus any 4xx that has no more specific class (for example 405 or 410):
40
+
41
+ ```ruby
42
+ begin
43
+ user = MyApi::User.retrieve('123')
44
+ rescue MyApi::ApiError => e
45
+ puts "Server error: #{e.message}"
46
+ puts "HTTP Status: #{e.http_status}"
47
+ puts "Response body: #{e.http_body}"
48
+ puts "Parsed body: #{e.json_body.inspect}" # nil unless the body parsed as JSON or XML
49
+
50
+ # Retry logic for server errors
51
+ retry if e.http_status >= 500
52
+ end
53
+ ```
54
+
55
+ ### ApiConnectionError
56
+ Network and connection failures:
57
+
58
+ ```ruby
59
+ begin
60
+ user = MyApi::User.retrieve('123')
61
+ rescue MyApi::ApiConnectionError => e
62
+ puts "Connection failed: #{e.message}"
63
+
64
+ # Common causes:
65
+ # - Network timeout
66
+ # - DNS resolution failure
67
+ # - Connection refused
68
+ # - SSL/TLS errors
69
+
70
+ # Implement retry with backoff
71
+ sleep(2)
72
+ retry
73
+ end
74
+ ```
75
+
76
+ ### InvalidRequestError
77
+ A `400 Bad Request`, or a request the client could not build (for example `refresh` on an object with no id):
78
+
79
+ ```ruby
80
+ begin
81
+ user = MyApi::User.create(email: 'invalid')
82
+ rescue MyApi::InvalidRequestError => e
83
+ puts "Invalid request: #{e.message}"
84
+ puts "Invalid parameter: #{e.param}" if e.param
85
+ puts "HTTP Status: #{e.http_status}" # nil when the request was never sent
86
+
87
+ # Don't retry - fix the request
88
+ log_validation_error(e)
89
+ end
90
+ ```
91
+
92
+ ### AuthenticationError
93
+ `401 Unauthorized` and `403 Forbidden`:
94
+
95
+ ```ruby
96
+ begin
97
+ user = MyApi::User.retrieve('123')
98
+ rescue MyApi::AuthenticationError => e
99
+ puts "Check the API key: #{e.message}"
100
+ end
101
+ ```
102
+
103
+ ### RateLimitError
104
+ `429 Too Many Requests`. `retry_after` reads the `Retry-After` header as seconds, accepting either delay-seconds or an HTTP-date, and is `nil` when the header is absent. `rate_limit` exposes your module's `RateLimiter` view of the limit, and that limiter decides whether the client retries (see [Rate Limits](CONFIGURATION.md#rate-limits)):
105
+
106
+ ```ruby
107
+ begin
108
+ MyApi::Event.track(events: batch)
109
+ rescue MyApi::RateLimitError => e
110
+ e.rate_limit&.remaining # 0
111
+ sleep(e.retry_after || 1)
112
+ retry
113
+ end
114
+ ```
115
+
116
+ ### ResourceNotFound
117
+ 404 Not Found errors:
118
+
119
+ ```ruby
120
+ begin
121
+ user = MyApi::User.retrieve('non-existent-id')
122
+ rescue MyApi::ResourceNotFound => e
123
+ puts "User not found"
124
+ # Handle missing resource
125
+ create_default_user()
126
+ end
127
+ ```
128
+
129
+ ### UnprocessableEntityError
130
+ 422 Unprocessable Entity (validation errors):
131
+
132
+ ```ruby
133
+ begin
134
+ user = MyApi::User.create(email: 'not-an-email')
135
+ rescue MyApi::UnprocessableEntityError => e
136
+ puts "Validation failed: #{e.message}"
137
+
138
+ # Parse validation errors from response
139
+ errors = JSON.parse(e.response[:body])['errors']
140
+ errors.each do |field, messages|
141
+ puts "#{field}: #{messages.join(', ')}"
142
+ end
143
+ end
144
+ ```
145
+
146
+ ### ConflictError
147
+ 409 Conflict errors:
148
+
149
+ ```ruby
150
+ begin
151
+ user = MyApi::User.create(email: 'existing@example.com')
152
+ rescue MyApi::ConflictError => e
153
+ puts "Conflict: #{e.message}"
154
+ # Resource already exists
155
+ user = MyApi::User.find_by_email('existing@example.com')
156
+ end
157
+ ```
158
+
159
+ ## Error Attributes
160
+
161
+ Every subclass of `MyApi::Error` carries the HTTP response that produced it:
162
+
163
+ ```ruby
164
+ begin
165
+ MyApi::User.retrieve('123')
166
+ rescue MyApi::Error => e
167
+ e.message # Error message
168
+ e.http_status # HTTP status code (e.g., 401, 500); nil for connection failures
169
+ e.http_body # Raw response body
170
+ e.http_headers # Response headers
171
+ e.header("Retry-After") # Case-insensitive header lookup
172
+ e.json_body # Body parsed as JSON or XML, or nil
173
+ e.response # Faraday's response hash: { status:, headers:, body:, request: }
174
+ e.cause # The original Faraday error
175
+ e.rate_limit # MyApi.rate_limiter.rate_limit(e): the strategy's view, or nil
176
+
177
+ # For InvalidRequestError
178
+ e.param # The parameter that caused the error, when known
179
+
180
+ # For RateLimitError
181
+ e.retry_after # Seconds to wait, from Retry-After, or nil
182
+ end
183
+ ```
184
+
185
+ Every error also answers Faraday's reader names, `response_status`, `response_headers` and `response_body`, as aliases of the `http_` readers. Code written against the earlier Faraday-derived `ResourceNotFound`, `UnprocessableEntityError` and `ConflictError` keeps working.
186
+
187
+ ## Handling Errors
188
+
189
+ ### Basic Error Handling
190
+
191
+ ```ruby
192
+ def fetch_user(id)
193
+ MyApi::User.retrieve(id)
194
+ rescue MyApi::ResourceNotFound
195
+ nil
196
+ rescue MyApi::ApiConnectionError => e
197
+ Rails.logger.error "API connection failed: #{e.message}"
198
+ raise ServiceUnavailableError, "Unable to fetch user"
199
+ rescue MyApi::Error => e
200
+ Rails.logger.error "API error: #{e.message}"
201
+ Bugsnag.notify(e)
202
+ raise
203
+ end
204
+ ```
205
+
206
+ ### Comprehensive Error Handling
207
+
208
+ ```ruby
209
+ class ApiErrorHandler
210
+ def self.handle
211
+ yield
212
+ rescue MyApi::ResourceNotFound => e
213
+ handle_not_found(e)
214
+ rescue MyApi::UnprocessableEntityError => e
215
+ handle_validation_error(e)
216
+ rescue MyApi::ConflictError => e
217
+ handle_conflict(e)
218
+ rescue MyApi::InvalidRequestError => e
219
+ handle_bad_request(e)
220
+ rescue MyApi::ApiConnectionError => e
221
+ handle_connection_error(e)
222
+ rescue MyApi::ApiError => e
223
+ handle_server_error(e)
224
+ end
225
+
226
+ private
227
+
228
+ def self.handle_not_found(error)
229
+ { error: 'Resource not found', status: 404 }
230
+ end
231
+
232
+ def self.handle_validation_error(error)
233
+ errors = parse_validation_errors(error.response.body)
234
+ { errors: errors, status: 422 }
235
+ end
236
+
237
+ def self.handle_conflict(error)
238
+ { error: 'Resource already exists', status: 409 }
239
+ end
240
+
241
+ def self.handle_bad_request(error)
242
+ { error: error.message, param: error.param, status: 400 }
243
+ end
244
+
245
+ def self.handle_connection_error(error)
246
+ notify_monitoring_service(error)
247
+ { error: 'Service temporarily unavailable', status: 503 }
248
+ end
249
+
250
+ def self.handle_server_error(error)
251
+ notify_monitoring_service(error)
252
+ { error: 'Internal server error', status: 500 }
253
+ end
254
+
255
+ def self.parse_validation_errors(body)
256
+ JSON.parse(body)['errors'] rescue {}
257
+ end
258
+
259
+ def self.notify_monitoring_service(error)
260
+ # Send to error tracking service
261
+ Sentry.capture_exception(error)
262
+ end
263
+ end
264
+
265
+ # Usage
266
+ result = ApiErrorHandler.handle { MyApi::User.create(params) }
267
+ ```
268
+
269
+ ### Retry Logic with Error Handling
270
+
271
+ ```ruby
272
+ class RetryableRequest
273
+ MAX_RETRIES = 3
274
+ RETRY_DELAY = 1 # seconds
275
+
276
+ def self.execute(&block)
277
+ retries = 0
278
+
279
+ begin
280
+ yield
281
+ rescue MyApi::ApiConnectionError, MyApi::ApiError => e
282
+ if should_retry?(e, retries)
283
+ retries += 1
284
+ delay = RETRY_DELAY * (2 ** (retries - 1)) # Exponential backoff
285
+
286
+ Rails.logger.warn "Request failed, retrying in #{delay}s (attempt #{retries}/#{MAX_RETRIES})"
287
+ sleep(delay)
288
+ retry
289
+ else
290
+ raise
291
+ end
292
+ end
293
+ end
294
+
295
+ private
296
+
297
+ def self.should_retry?(error, retries)
298
+ return false if retries >= MAX_RETRIES
299
+
300
+ # Retry on connection errors
301
+ return true if error.is_a?(MyApi::ApiConnectionError)
302
+
303
+ # Retry on specific server errors
304
+ return true if error.is_a?(MyApi::ApiError) && [502, 503, 504].include?(error.http_status)
305
+
306
+ # Don't retry on client errors
307
+ false
308
+ end
309
+ end
310
+
311
+ # Usage
312
+ user = RetryableRequest.execute { MyApi::User.retrieve('123') }
313
+ ```
314
+
315
+ ## Errors Reported in a 2xx Body
316
+
317
+ Some APIs answer with a successful HTTP status and put the failure in the body. Braze, for example, returns `201 Created` with `{"message": "success", "errors": [...]}` when some records in a batch were rejected, and `200 OK` with `{"message": "Invalid API key"}` for others. The status-based middleware cannot see these.
318
+
319
+ Override `body_error` on your module's `Response`. It receives the parsed `Response` after the middleware has accepted the status. Return an exception to raise it, or `nil` to accept the response. `Error.from_response` builds a namespaced error that carries the status, headers, body and parsed `json_body`, with the `Response` itself as `e.response`:
320
+
321
+ ```ruby
322
+ module MyApi
323
+ class Response
324
+ SUCCESS = %w[success queued].freeze
325
+
326
+ def self.body_error(response)
327
+ data = response.data
328
+ return unless data.is_a?(Hash)
329
+ return if SUCCESS.include?(data[:message]) && data[:errors].blank?
330
+
331
+ ApiError.from_response(response, data[:message] || "errors in response body")
332
+ end
333
+ end
334
+ end
335
+
336
+ begin
337
+ MyApi::User.track(events: batch)
338
+ rescue MyApi::ApiError => e
339
+ e.http_status # 201
340
+ e.json_body[:errors] # the per-record errors
341
+ end
342
+ ```
343
+
344
+ The hook runs inside `execute_request`, so the error is instrumented with its status like any other, and `rescue MyApi::Error` catches it. Return a subclass of `ApiError` if callers need to tell partial success apart from a hard failure.
345
+
346
+ ## Custom Error Classes
347
+
348
+ ### Defining Custom Errors
349
+
350
+ ```ruby
351
+ module MyApi
352
+ # Custom error classes
353
+ class RateLimitError < Error
354
+ attr_reader :retry_after
355
+
356
+ def initialize(message, retry_after: nil, **options)
357
+ super(message, **options)
358
+ @retry_after = retry_after
359
+ end
360
+ end
361
+
362
+ class AuthenticationError < Error
363
+ attr_reader :auth_type
364
+
365
+ def initialize(message, auth_type: nil, **options)
366
+ super(message, **options)
367
+ @auth_type = auth_type
368
+ end
369
+ end
370
+
371
+ class ValidationError < InvalidRequestError
372
+ attr_reader :errors
373
+
374
+ def initialize(message, errors: {}, **options)
375
+ super(message, **options)
376
+ @errors = errors
377
+ end
378
+ end
379
+ end
380
+ ```
381
+
382
+ ### Using Custom Errors
383
+
384
+ ```ruby
385
+ module MyApi
386
+ class Client < Resteze::Client
387
+ def execute_request(method, path, **options)
388
+ response = super
389
+ check_rate_limit(response)
390
+ response
391
+ rescue Faraday::ClientError => e
392
+ handle_client_error(e)
393
+ end
394
+
395
+ private
396
+
397
+ def check_rate_limit(response)
398
+ remaining = response.headers['x-rate-limit-remaining'].to_i
399
+ if remaining == 0
400
+ reset_time = response.headers['x-rate-limit-reset'].to_i
401
+ retry_after = reset_time - Time.now.to_i
402
+
403
+ raise RateLimitError.new(
404
+ "Rate limit exceeded",
405
+ retry_after: retry_after,
406
+ http_status: 429,
407
+ response: response
408
+ )
409
+ end
410
+ end
411
+
412
+ def handle_client_error(error)
413
+ case error.response[:status]
414
+ when 401
415
+ raise AuthenticationError.new(
416
+ "Authentication failed",
417
+ auth_type: error.response[:headers]['www-authenticate'],
418
+ http_status: 401,
419
+ response: error.response
420
+ )
421
+ when 422
422
+ errors = JSON.parse(error.response[:body])['errors'] rescue {}
423
+ raise ValidationError.new(
424
+ "Validation failed",
425
+ errors: errors,
426
+ http_status: 422,
427
+ response: error.response
428
+ )
429
+ else
430
+ raise
431
+ end
432
+ end
433
+ end
434
+ end
435
+ ```
436
+
437
+ ## Middleware Error Handling
438
+
439
+ ### Custom Error Middleware
440
+
441
+ ```ruby
442
+ module MyApi
443
+ module Middleware
444
+ class ErrorHandler < Faraday::Middleware
445
+ def on_complete(env)
446
+ case env[:status]
447
+ when 400
448
+ raise_invalid_request(env)
449
+ when 401
450
+ raise_authentication_error(env)
451
+ when 403
452
+ raise_authorization_error(env)
453
+ when 404
454
+ raise_not_found(env)
455
+ when 409
456
+ raise_conflict(env)
457
+ when 422
458
+ raise_validation_error(env)
459
+ when 429
460
+ raise_rate_limit(env)
461
+ when 500..599
462
+ raise_server_error(env)
463
+ end
464
+ end
465
+
466
+ private
467
+
468
+ def raise_invalid_request(env)
469
+ body = parse_body(env)
470
+ raise InvalidRequestError.new(
471
+ body['error'] || 'Invalid request',
472
+ param: body['param'],
473
+ http_status: 400,
474
+ response: env
475
+ )
476
+ end
477
+
478
+ def raise_authentication_error(env)
479
+ raise AuthenticationError.new(
480
+ 'Authentication required',
481
+ http_status: 401,
482
+ response: env
483
+ )
484
+ end
485
+
486
+ def raise_rate_limit(env)
487
+ retry_after = env[:response_headers]['retry-after']
488
+ raise RateLimitError.new(
489
+ 'Rate limit exceeded',
490
+ retry_after: retry_after,
491
+ http_status: 429,
492
+ response: env
493
+ )
494
+ end
495
+
496
+ def raise_server_error(env)
497
+ raise ApiError.new(
498
+ "Server error: #{env[:status]}",
499
+ http_status: env[:status],
500
+ response: env
501
+ )
502
+ end
503
+
504
+ def parse_body(env)
505
+ JSON.parse(env[:body]) rescue {}
506
+ end
507
+ end
508
+ end
509
+
510
+ class Client < Resteze::Client
511
+ def self.default_connection
512
+ @default_connection ||= Faraday.new do |conn|
513
+ conn.use Middleware::ErrorHandler
514
+ conn.use Middleware::RaiseError
515
+ conn.adapter Faraday.default_adapter
516
+ end
517
+ end
518
+ end
519
+ end
520
+ ```
521
+
522
+ ### Circuit Breaker Pattern
523
+
524
+ ```ruby
525
+ require 'circuit_breaker'
526
+
527
+ module MyApi
528
+ class CircuitBreakerMiddleware < Faraday::Middleware
529
+ def initialize(app, options = {})
530
+ super(app)
531
+ @circuit = CircuitBreaker.new(
532
+ failure_threshold: options[:failure_threshold] || 5,
533
+ recovery_timeout: options[:recovery_timeout] || 60,
534
+ expected_exception: ApiError
535
+ )
536
+ end
537
+
538
+ def call(env)
539
+ @circuit.call do
540
+ @app.call(env)
541
+ end
542
+ rescue CircuitBreaker::OpenCircuitError
543
+ raise ApiConnectionError.new(
544
+ "Service circuit breaker is open",
545
+ http_status: 503
546
+ )
547
+ end
548
+ end
549
+
550
+ class Client < Resteze::Client
551
+ def self.default_connection
552
+ @default_connection ||= Faraday.new do |conn|
553
+ conn.use CircuitBreakerMiddleware,
554
+ failure_threshold: 3,
555
+ recovery_timeout: 30
556
+ conn.use Middleware::RaiseError
557
+ conn.adapter Faraday.default_adapter
558
+ end
559
+ end
560
+ end
561
+ end
562
+ ```
563
+
564
+ ## Best Practices
565
+
566
+ ### 1. Always Rescue Specific Errors
567
+
568
+ ```ruby
569
+ # Good
570
+ begin
571
+ user = MyApi::User.retrieve(id)
572
+ rescue MyApi::ResourceNotFound
573
+ return nil
574
+ rescue MyApi::ApiConnectionError => e
575
+ log_error(e)
576
+ retry
577
+ end
578
+
579
+ # Bad
580
+ begin
581
+ user = MyApi::User.retrieve(id)
582
+ rescue => e
583
+ # Too broad, catches unexpected errors
584
+ end
585
+ ```
586
+
587
+ ### 2. Log Errors with Context
588
+
589
+ ```ruby
590
+ def fetch_user_with_logging(id)
591
+ MyApi::User.retrieve(id)
592
+ rescue MyApi::Error => e
593
+ Rails.logger.error(
594
+ message: "Failed to fetch user",
595
+ user_id: id,
596
+ error_class: e.class.name,
597
+ error_message: e.message,
598
+ http_status: e.http_status,
599
+ response_body: e.response&.body
600
+ )
601
+ raise
602
+ end
603
+ ```
604
+
605
+ ### 3. Implement Graceful Degradation
606
+
607
+ ```ruby
608
+ class UserService
609
+ def get_user_with_fallback(id)
610
+ # Try primary API
611
+ fetch_from_api(id)
612
+ rescue MyApi::ApiConnectionError, MyApi::ApiError
613
+ # Fall back to cache
614
+ fetch_from_cache(id)
615
+ rescue MyApi::ResourceNotFound
616
+ # Fall back to default
617
+ User.new(id: id, name: 'Unknown User')
618
+ end
619
+
620
+ private
621
+
622
+ def fetch_from_api(id)
623
+ MyApi::User.retrieve(id)
624
+ end
625
+
626
+ def fetch_from_cache(id)
627
+ Rails.cache.read("user:#{id}")
628
+ end
629
+ end
630
+ ```
631
+
632
+ ### 4. Use Error Monitoring
633
+
634
+ ```ruby
635
+ class ErrorNotifier
636
+ def self.configure
637
+ # Configure error monitoring service
638
+ Sentry.init do |config|
639
+ config.before_send = lambda do |event, hint|
640
+ if hint[:exception].is_a?(MyApi::Error)
641
+ event.extra[:api_response] = hint[:exception].response&.body
642
+ event.extra[:http_status] = hint[:exception].http_status
643
+ end
644
+ event
645
+ end
646
+ end
647
+ end
648
+
649
+ def self.notify(error, context = {})
650
+ Sentry.capture_exception(error, extra: context)
651
+ end
652
+ end
653
+ ```
654
+
655
+ ### 5. Provide User-Friendly Messages
656
+
657
+ ```ruby
658
+ class ApiErrorPresenter
659
+ def self.message_for(error)
660
+ case error
661
+ when MyApi::ResourceNotFound
662
+ "The requested item could not be found."
663
+ when MyApi::UnprocessableEntityError
664
+ "Please check your input and try again."
665
+ when MyApi::ConflictError
666
+ "This item already exists."
667
+ when MyApi::ApiConnectionError
668
+ "We're having trouble connecting to our servers. Please try again later."
669
+ when MyApi::ApiError
670
+ "Something went wrong on our end. We've been notified and are working on it."
671
+ else
672
+ "An unexpected error occurred. Please try again."
673
+ end
674
+ end
675
+ end
676
+ ```