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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/CHANGELOG.md +38 -0
- data/CLAUDE.md +83 -0
- data/README.md +75 -3
- data/docs/ACTIONS.md +329 -0
- data/docs/ADVANCED_USAGE.md +744 -0
- data/docs/API.md +404 -0
- data/docs/CONFIGURATION.md +962 -0
- data/docs/ERROR_HANDLING.md +676 -0
- data/docs/TESTING.md +789 -0
- data/lib/resteze/action.rb +99 -0
- data/lib/resteze/actions/validation.rb +56 -0
- data/lib/resteze/actions.rb +117 -0
- data/lib/resteze/api_module.rb +11 -5
- data/lib/resteze/api_resource.rb +28 -3
- data/lib/resteze/authenticating.rb +28 -0
- data/lib/resteze/authenticator/api_key.rb +45 -0
- data/lib/resteze/authenticator/basic.rb +40 -0
- data/lib/resteze/authenticator/bearer.rb +37 -0
- data/lib/resteze/authenticator/mtls.rb +73 -0
- data/lib/resteze/authenticator/strategy.rb +133 -0
- data/lib/resteze/authenticator.rb +187 -0
- data/lib/resteze/client.rb +60 -11
- data/lib/resteze/errors.rb +125 -4
- data/lib/resteze/instrumentation.rb +49 -0
- data/lib/resteze/list.rb +2 -2
- data/lib/resteze/middleware/raise_error.rb +38 -6
- data/lib/resteze/rate_limit.rb +44 -0
- data/lib/resteze/rate_limiter/fixed_window.rb +107 -0
- data/lib/resteze/rate_limiter/leaky_bucket.rb +40 -0
- data/lib/resteze/rate_limiter/sliding_window.rb +37 -0
- data/lib/resteze/rate_limiter/strategy.rb +58 -0
- data/lib/resteze/rate_limiter/token_bucket.rb +39 -0
- data/lib/resteze/rate_limiter.rb +229 -0
- data/lib/resteze/rate_limiting.rb +30 -0
- data/lib/resteze/response.rb +37 -1
- data/lib/resteze/result.rb +18 -0
- data/lib/resteze/save.rb +25 -3
- data/lib/resteze/synchronized_state.rb +29 -0
- data/lib/resteze/testing/minitest/configuration.rb +3 -1
- data/lib/resteze/testing/minitest/object.rb +30 -0
- data/lib/resteze/testing/rspec/configure.rb +3 -1
- data/lib/resteze/testing/rspec/object.rb +32 -2
- data/lib/resteze/version.rb +1 -1
- data/lib/resteze.rb +102 -10
- 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
|
+
```
|