resteze 0.4.0 → 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 +9 -0
  5. data/README.md +45 -4
  6. data/docs/ACTIONS.md +329 -0
  7. data/docs/ADVANCED_USAGE.md +29 -45
  8. data/docs/API.md +19 -25
  9. data/docs/CONFIGURATION.md +281 -0
  10. data/docs/ERROR_HANDLING.md +94 -27
  11. data/docs/TESTING.md +21 -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 +59 -11
  25. data/lib/resteze/errors.rb +125 -4
  26. data/lib/resteze/instrumentation.rb +35 -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 +101 -11
  47. metadata +23 -2
data/docs/API.md CHANGED
@@ -163,34 +163,23 @@ user.refresh # Re-fetches from API
163
163
 
164
164
  ### Custom Request Methods
165
165
 
166
+ Declare extra endpoints with the `action` DSL. See [ACTIONS.md](ACTIONS.md) for
167
+ the full option table, re-routing the built-ins, and API-wide defaults.
168
+
166
169
  ```ruby
167
170
  module MyApi
168
171
  class User < ApiResource
169
172
  property :id
170
173
  property :email
171
174
  property :status
172
-
173
- # Instance method for custom action
174
- def activate!
175
- response = request(
176
- :post,
177
- "#{resource_path}/activate",
178
- params: { send_email: true }
179
- )
180
- initialize_from(response.data)
181
- end
182
-
183
- # Class method for custom endpoint
184
- def self.search(query)
185
- response = request(
186
- :get,
187
- "#{resource_path}/search",
188
- params: { q: query }
189
- )
190
- response.data.map { |attrs| construct_from(attrs) }
191
- end
192
-
193
- # Custom headers
175
+
176
+ # Member action: user.activate(send_email: true) => POST /users/:id/activate
177
+ action :activate, method: :post, on: :member, returns: :self
178
+
179
+ # Collection action: User.search(q: "jane") => GET /users/search?q=jane
180
+ action :search, method: :get, returns: :self
181
+
182
+ # Anything the DSL does not express is still one request call away
194
183
  def update_with_version(version, attributes)
195
184
  request(
196
185
  :patch,
@@ -228,12 +217,17 @@ response = MyApi::User.request(:get, '/users')
228
217
 
229
218
  # Access response data
230
219
  response.data # Parsed response body
231
- response.status # HTTP status code
232
- response.headers # Response headers
233
- response.body # Raw response body
220
+ response.http_status # HTTP status code
221
+ response.http_headers # Response headers
222
+ response.http_body # Raw response body
234
223
  response.request_id # Request ID from headers (if present)
224
+ response.success? # true for any 2xx status
225
+ response.ok? # true only for 200
226
+ response.rate_limit # MyApi.rate_limiter.rate_limit(response): the strategy's view, or nil
235
227
  ```
236
228
 
229
+ For APIs that report failures inside a successful response, override `MyApi::Response.body_error` to return an error to raise; see [Errors Reported in a 2xx Body](ERROR_HANDLING.md#errors-reported-in-a-2xx-body).
230
+
237
231
  ### Working with Objects
238
232
 
239
233
  ```ruby
@@ -2,11 +2,13 @@
2
2
 
3
3
  ## Table of Contents
4
4
  - [Basic Configuration](#basic-configuration)
5
+ - [Authentication](#authentication)
5
6
  - [Environment-based Configuration](#environment-based-configuration)
6
7
  - [Dynamic Configuration](#dynamic-configuration)
7
8
  - [Connection Configuration](#connection-configuration)
8
9
  - [Logging Configuration](#logging-configuration)
9
10
  - [Proxy Configuration](#proxy-configuration)
11
+ - [Rate Limits](#rate-limits)
10
12
  - [Advanced Configuration Patterns](#advanced-configuration-patterns)
11
13
 
12
14
  ## Basic Configuration
@@ -81,6 +83,90 @@ module MyApi
81
83
  end
82
84
  ```
83
85
 
86
+ ## Authentication
87
+
88
+ Each module gets a `MyApi::Authenticator`, a subclass of `Resteze::Authenticator` that does nothing until you choose a scheme with `authenticate_with` on the module. A scheme is a `Resteze::Authenticator::Strategy` subclass; it expects its credential in a conventional module setting, declares that setting on the module when chosen, and reads it on every request, so an initializer fills it through `configure` like any other setting and a rotated value takes effect without a restart. A setting that is still `nil` when a request goes out raises an `ArgumentError` naming it rather than sending an empty credential; `MyApi.authenticator.missing_settings` lists them for a boot-time check.
89
+
90
+ ```ruby
91
+ module MyApi
92
+ include Resteze
93
+
94
+ authenticate_with :api_key, header: 'X-Api-Key' # expects MyApi.api_key
95
+ end
96
+
97
+ # config/initializers/my_api.rb
98
+ MyApi.configure do |config|
99
+ config.api_key = ENV['MY_API_KEY']
100
+ end
101
+ ```
102
+
103
+ The convention is a default. Any credential option also takes a Symbol naming a different setting (`value: :partner_key`), a callable (`value: -> { TokenStore.current }`), or a literal. Schemes combine; call `authenticate_with` once per scheme. (`strategy` inside `class Authenticator` is the same thing, for when you are reopening the class anyway to override its lifecycle.)
104
+
105
+ ### Built-in schemes
106
+
107
+ | Scheme | Expects | Options | Sends |
108
+ |---|---|---|---|
109
+ | `:api_key` | `api_key` | `value:`, and `header:` (default `X-Api-Key`) or `query:` | the key in that header, or in the query string on every request whatever the method |
110
+ | `:basic` | `username`, `password` | `username:`, `password:` | `Authorization: Basic …` |
111
+ | `:bearer` | `api_key` | `token:`, `scheme:` (default `Bearer`) | `Authorization: Bearer …`; `scheme: 'Token'` for another prefix |
112
+ | `:mtls` | `client_cert`, `client_key`; optionally `client_key_passphrase`, `ca_file` | `cert:`, `key:`, `passphrase:`, `ca_file:`, `verify:` | a client certificate on the TLS connection; nothing on the requests |
113
+
114
+ `cert:` and `key:` for `:mtls` are PEM strings or paths to PEM files, from the settings, a callable, or a literal. A path is read as given when the connection is first built, so make it absolute (`Rails.root.join(...)`); a relative path resolves against whatever the process's working directory happens to be at that moment. The certificate is applied when `Client.default_connection` builds the connection, so an override of that method must pass `connection_options` to Faraday itself. `missing_settings` leaves a scheme's optional settings alone, so an unencrypted key with no passphrase is not reported as missing. `verify: false` logs a warning through the module's logger every time a connection is built with it.
115
+
116
+ ### The lifecycle
117
+
118
+ The client calls the authenticator at three points: `connection_options` when the per-thread connection is built (an override of `Client.default_connection` must pass them to Faraday itself), `apply(request)` before every request (with `headers`, `params` and `query` to mutate; `query` always goes in the query string whatever the method), and `refresh?(error, attempt)` after a `MyApi::AuthenticationError`, where returning `true` sends the request once more with a renewed credential.
119
+
120
+ ### Credentials and the log
121
+
122
+ At `Logger::DEBUG` the client logs the whole request, headers and query string included. Each strategy names where it put the credential through `sensitive_keys` (`{ headers: [...], query: [...], params: [...] }`), and the client replaces those values with `[REDACTED]` in the logged copy only; the request goes out intact. The built-ins all declare theirs. `Authorization` and `Proxy-Authorization` are masked whatever the strategies say, which also covers a credential added through an overridden `request_headers`.
123
+
124
+ ### Writing a scheme
125
+
126
+ Subclass `Resteze::Authenticator::Strategy` and implement `apply(request, state)`; add `connection_options` or `refresh?(error, attempt, state)` when needed. A `DEFAULTS` constant whose Symbol values name module settings makes `authenticate_with` declare those settings; list in `OPTIONAL` any the scheme works without, and `missing_settings` will not report them. `resolve` reads an option whether it is a Symbol (from the module), a callable, or a literal; `resolve!` does the same and raises a clear `ArgumentError` on `nil`; `required!(name, value)` in a constructor rejects `nil` for an option that must be given. Implement `sensitive_keys` to name the header, query or body keys the credential lands under, or it will appear in the `DEBUG` log. `state` is a hash shared by every thread and every strategy on the authenticator, already under its lock, so a cached credential needs no synchronisation of its own; prefix your keys with the scheme's name. Options after the class go to your constructor.
127
+
128
+ ```ruby
129
+ module MyApi
130
+ class SessionToken < Resteze::Authenticator::Strategy
131
+ DEFAULTS = { username: :username, password: :password }.freeze # the settings this scheme expects
132
+
133
+ def initialize(login_url:, username: :username, password: :password)
134
+ super()
135
+ @login_url, @username, @password = login_url, username, password
136
+ end
137
+
138
+ def apply(request, state)
139
+ state[:session_token] ||= login
140
+ request.headers['X-Session-Token'] = state[:session_token]
141
+ end
142
+
143
+ def refresh?(_error, attempt, state)
144
+ return false if attempt > 1
145
+
146
+ state[:session_token] = login
147
+ true
148
+ end
149
+
150
+ def sensitive_keys
151
+ { headers: ['X-Session-Token'] } # masked in the DEBUG log
152
+ end
153
+
154
+ private
155
+
156
+ def login
157
+ body = { username: resolve!(:username, @username), password: resolve!(:password, @password) }.to_json
158
+ JSON.parse(Faraday.post(@login_url, body, 'Content-Type' => 'application/json').body).fetch('token')
159
+ end
160
+ end
161
+
162
+ authenticate_with SessionToken, login_url: 'https://api.example.com/login' # expects MyApi.username and .password
163
+ end
164
+ ```
165
+
166
+ `Resteze::Authenticator::Strategy.register(:session_token, SessionToken)` gives a scheme a name, so `authenticate_with :session_token` works like a built-in; a gem that ships a scheme registers it this way.
167
+
168
+ `MyApi.authenticator` is the instance the client uses; assign another to replace it (tests do this to swap credentials) or `nil` to restore the default. `Client#request_headers` remains overridable for headers that are not a credential.
169
+
84
170
  ## Environment-based Configuration
85
171
 
86
172
  ### Using Rails Environments
@@ -535,6 +621,201 @@ module MyApi
535
621
  end
536
622
  ```
537
623
 
624
+ ## Rate Limits
625
+
626
+ ### The RateLimiter
627
+
628
+ APIs disagree about rate limits: which headers carry the quota, whether the reset is an epoch or a countdown, whether a 429 is the only signal, whether the client should pace itself or just react. Resteze assumes none of it. `include Resteze` gives your module a `MyApi::RateLimiter`, a subclass of `Resteze::RateLimiter` whose every method is a no-op, and the client calls one instance of it, shared by every thread, at fixed points in each request:
629
+
630
+ | Called | When | Default |
631
+ |---|---|---|
632
+ | `before_request(method, path, params:, headers:)` | before sending | waits for the strategy, if one is configured |
633
+ | `track(response_or_error)` | after every `Response` and every `MyApi::Error` | lets the strategy learn from the API |
634
+ | `retry?(error, attempt)` | after a `MyApi::RateLimitError`; `attempt` starts at 1 | `false` |
635
+ | `retry_after(error)` | before a retry | the error's `Retry-After` |
636
+ | `rate_limit(response_or_error)` | when `rate_limit` is read on a response or error | the strategy's view, else `nil` |
637
+
638
+ The instance is `MyApi.rate_limiter`; assign one to replace it. Each attempt of a retried request is logged and instrumented on its own.
639
+
640
+ ### Choosing a strategy
641
+
642
+ The four common algorithms are built in. Pick one with `strategy` in your module's `RateLimiter`:
643
+
644
+ ```ruby
645
+ module Braze
646
+ class RateLimiter
647
+ strategy :fixed_window, limit: 250_000, window: 3600, headers: true
648
+ end
649
+ end
650
+ ```
651
+
652
+ | Strategy | Options | Behaviour |
653
+ |---|---|---|
654
+ | `:fixed_window` | `limit:`, `window:` seconds, `headers:`, `reset:` | At most `limit` requests per window, windows aligned to the first request. With `headers: true` the budget and window follow the API's `X-RateLimit-Limit`, `-Remaining` and `-Reset` headers, which is how Braze and JD Power report theirs; pass a Hash to name other headers, and `reset: :seconds` when the reset header is a countdown rather than a time. `limit:` may be omitted when the API sends it, or sends only the remaining count. Syncing is conservative: within a window the lower of the local and reported remaining counts wins, since requests admitted after the server answered are not in its number yet; a reset beyond the local window starts a fresh one; non-finite or far-future resets are not trusted |
655
+ | `:sliding_window` | `limit:`, `window:` seconds | At most `limit` requests in any trailing window. Exact; memory bounded by `limit` |
656
+ | `:token_bucket` | `capacity:`, `refill_rate:` per second | Bursts up to `capacity`, then paced at the refill rate |
657
+ | `:leaky_bucket` | `capacity:`, `leak_rate:` per second | Smooths bursts into a steady rate; a full bucket waits for room |
658
+
659
+ With a strategy, `before_request` waits until the request fits, outside the lock so one thread's wait never blocks another's bookkeeping, and `rate_limit` on any response or error returns a `Resteze::RateLimit` (`limit`, `remaining`, `reset`, `exhausted?`) of the strategy's current view. For your own algorithm, subclass `Resteze::RateLimiter::Strategy` and pass the class to `strategy`; options after it go to your constructor. `acquire(state, now)` is required: record one request in `state` and return `0`, or record nothing and return the seconds until one could be admitted. `observe(state, response, now)` and `describe(state, now)` are optional. `state` is the limiter's hash, already under its lock, and the strategy itself must stay stateless. A daily quota aligned to midnight UTC:
660
+
661
+ ```ruby
662
+ module MyApi
663
+ class DailyQuota < Resteze::RateLimiter::Strategy
664
+ DAY = 86_400
665
+
666
+ def initialize(limit:)
667
+ super()
668
+ @limit = limit
669
+ end
670
+
671
+ def acquire(state, now)
672
+ roll(state, now)
673
+ return (state[:day_started] + DAY) - now if state[:count] >= @limit
674
+
675
+ state[:count] += 1
676
+ 0
677
+ end
678
+
679
+ def observe(state, response, now)
680
+ data = response.respond_to?(:data) ? response.data : response.json_body
681
+ return unless data.is_a?(Hash) && data[:calls_remaining]
682
+
683
+ roll(state, now)
684
+ state[:count] = @limit - data[:calls_remaining].to_i
685
+ end
686
+
687
+ def describe(state, now)
688
+ roll(state, now)
689
+ Resteze::RateLimit.new(limit: @limit, remaining: @limit - state[:count], reset: state[:day_started] + DAY)
690
+ end
691
+
692
+ private
693
+
694
+ def roll(state, now)
695
+ today = now - (now % DAY)
696
+ return if state[:day_started] == today
697
+
698
+ state[:day_started] = today
699
+ state[:count] = 0
700
+ end
701
+ end
702
+
703
+ class RateLimiter
704
+ strategy DailyQuota, limit: 10_000
705
+ end
706
+ end
707
+ ```
708
+
709
+ ### Reacting to a 429
710
+
711
+ A strategy keeps you under the limit; `retry?` and `retry_after` say what to do when the API disagrees. Override them alongside, or instead of, a strategy:
712
+
713
+ ```ruby
714
+ module Braze
715
+ class RateLimiter
716
+ strategy :fixed_window, limit: 250_000, window: 3600, headers: true
717
+
718
+ def retry?(_error, attempt)
719
+ attempt <= 3
720
+ end
721
+ end
722
+ end
723
+ ```
724
+
725
+ `retry_after` reads the HTTP `Retry-After` header, accepting delay-seconds or an HTTP-date. For an API that signals the wait another way, override it; `header` does a case-insensitive lookup on the error:
726
+
727
+ ```ruby
728
+ def retry_after(error)
729
+ error.header('X-Retry-In')&.to_i || super
730
+ end
731
+ ```
732
+
733
+ ### Waiting without blocking
734
+
735
+ With a strategy, `before_request` waits in the calling thread by default (`on_wait: :sleep`). A background job would often rather give the thread back and reschedule itself. `on_wait: :raise` makes the limiter raise `MyApi::RateLimitError` instead of waiting, with the computed wait in `retry_after` and no `http_status`, since nothing was sent. An error raised this way never goes through `retry?`; it is a refusal, not a failed attempt.
736
+
737
+ ```ruby
738
+ module Braze
739
+ class RateLimiter
740
+ strategy :fixed_window, limit: 250_000, window: 3600, headers: true, on_wait: :raise
741
+ end
742
+ end
743
+
744
+ class TrackEventsJob
745
+ include Sidekiq::Job
746
+
747
+ sidekiq_retry_in do |_count, exception|
748
+ exception.respond_to?(:retry_after) ? (exception.retry_after || 60) : :default
749
+ end
750
+
751
+ def perform(events)
752
+ Braze::User.track(events:) # a spent budget, or a 429, raises; Sidekiq reschedules
753
+ end
754
+ end
755
+ ```
756
+
757
+ The same option is accepted by the constructor, `MyApi::RateLimiter.new(on_wait: :raise)`, and `RateLimitError.new(message, retry_after: seconds)` builds such an error by hand. Under `on_wait: :raise` a retry decided by `retry?` is not slept either: the 429 itself is raised, with its `retry_after`, so both cases reach the job the same way.
758
+
759
+ Every wait, whether from a strategy or before a retry, is clamped to `max_wait` seconds, one hour by default. A `Retry-After` or reset header that is absurd, non-finite or malicious therefore costs at most one `max_wait`, never a thread parked for a year or a crash on `sleep(Infinity)`. Set it alongside `on_wait:` in `strategy`, or in the constructor:
760
+
761
+ ```ruby
762
+ strategy :fixed_window, limit: 1000, window: 60, max_wait: 120
763
+ ```
764
+
765
+ ### Overriding the lifecycle
766
+
767
+ Any method can be overridden; call `super` to keep the strategy's behaviour. State belongs in the limiter's mutex-guarded hash, never in instance variables, so a subclass stays thread-safe without doing anything: `state` returns a snapshot, `update_state` merges values, and `synchronize { |state| ... }` yields the live hash for read-modify-write sequences.
768
+
769
+ ```ruby
770
+ module Braze
771
+ class RateLimiter
772
+ strategy :fixed_window, limit: 250_000, window: 3600, headers: true
773
+
774
+ def before_request(method, path, **)
775
+ raise RateLimitError, "budget reserved for exports" if path.start_with?("/users/export") && state[:remaining].to_i < 1000
776
+
777
+ super
778
+ end
779
+
780
+ def track(response)
781
+ super
782
+ update_state(remaining: response.rate_limit&.remaining)
783
+ end
784
+ end
785
+ end
786
+ ```
787
+
788
+ `Resteze::RateLimit.from_headers(headers, names = {})` remains available as a helper for reading the three-header shape by hand.
789
+
790
+ ### Retrying after a 429
791
+
792
+ Resteze retries a 429 only when your `RateLimiter`'s `retry?` says so. For retries handled below the limiter, with backoff and per-status rules, add the [`faraday-retry`](https://github.com/lostisland/faraday-retry) gem (it is not a Resteze dependency) and place it **after** `RaiseError` in the stack. That puts it closer to the adapter, so it sees the raw 429 and retries before `RaiseError` turns the final answer into an error. `retry_statuses: [429]` makes it honor `Retry-After` automatically.
793
+
794
+ ```ruby
795
+ require 'faraday/retry'
796
+
797
+ module MyApi
798
+ class Client < Resteze::Client
799
+ def self.default_connection
800
+ Thread.current["#{client_name}_default_connection"] ||= Faraday.new do |conn|
801
+ conn.use Faraday::Request::UrlEncoded
802
+ conn.use Middleware::RaiseError
803
+ conn.request :retry,
804
+ max: 3,
805
+ interval: 0.5,
806
+ backoff_factor: 2,
807
+ retry_statuses: [429],
808
+ methods: %i[get post] # POST is not retried by default; add it only
809
+ # where the API treats a 429 as "not processed"
810
+ conn.adapter Faraday.default_adapter
811
+ end
812
+ end
813
+ end
814
+ end
815
+ ```
816
+
817
+ For an API that signals the wait through a non-standard header, pass `rate_limit_retry_header:` and `rate_limit_reset_header:`, with a `header_parser_block:` to convert the value into seconds. See the faraday-retry README.
818
+
538
819
  ## Advanced Configuration Patterns
539
820
 
540
821
  ### Configuration with Validation
@@ -5,32 +5,38 @@
5
5
  - [Built-in Error Types](#built-in-error-types)
6
6
  - [Error Attributes](#error-attributes)
7
7
  - [Handling Errors](#handling-errors)
8
+ - [Errors Reported in a 2xx Body](#errors-reported-in-a-2xx-body)
8
9
  - [Custom Error Classes](#custom-error-classes)
9
10
  - [Middleware Error Handling](#middleware-error-handling)
10
11
  - [Best Practices](#best-practices)
11
12
 
12
13
  ## Error Hierarchy
13
14
 
14
- Resteze provides a comprehensive error hierarchy that gets dynamically created for each API module:
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.
15
16
 
16
17
  ```
17
18
  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)
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
28
32
  ```
29
33
 
34
+ The `RaiseError` middleware maps every Faraday error onto this hierarchy. The original Faraday error is available as `e.cause`.
35
+
30
36
  ## Built-in Error Types
31
37
 
32
38
  ### ApiError
33
- Server-side errors (5xx status codes):
39
+ Server-side errors (5xx), plus any 4xx that has no more specific class (for example 405 or 410):
34
40
 
35
41
  ```ruby
36
42
  begin
@@ -38,7 +44,8 @@ begin
38
44
  rescue MyApi::ApiError => e
39
45
  puts "Server error: #{e.message}"
40
46
  puts "HTTP Status: #{e.http_status}"
41
- puts "Response body: #{e.response.body}"
47
+ puts "Response body: #{e.http_body}"
48
+ puts "Parsed body: #{e.json_body.inspect}" # nil unless the body parsed as JSON or XML
42
49
 
43
50
  # Retry logic for server errors
44
51
  retry if e.http_status >= 500
@@ -67,7 +74,7 @@ end
67
74
  ```
68
75
 
69
76
  ### InvalidRequestError
70
- Client-side errors (4xx status codes):
77
+ A `400 Bad Request`, or a request the client could not build (for example `refresh` on an object with no id):
71
78
 
72
79
  ```ruby
73
80
  begin
@@ -75,13 +82,37 @@ begin
75
82
  rescue MyApi::InvalidRequestError => e
76
83
  puts "Invalid request: #{e.message}"
77
84
  puts "Invalid parameter: #{e.param}" if e.param
78
- puts "HTTP Status: #{e.http_status}"
85
+ puts "HTTP Status: #{e.http_status}" # nil when the request was never sent
79
86
 
80
87
  # Don't retry - fix the request
81
88
  log_validation_error(e)
82
89
  end
83
90
  ```
84
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
+
85
116
  ### ResourceNotFound
86
117
  404 Not Found errors:
87
118
 
@@ -105,7 +136,7 @@ rescue MyApi::UnprocessableEntityError => e
105
136
  puts "Validation failed: #{e.message}"
106
137
 
107
138
  # Parse validation errors from response
108
- errors = JSON.parse(e.response.body)['errors']
139
+ errors = JSON.parse(e.response[:body])['errors']
109
140
  errors.each do |field, messages|
110
141
  puts "#{field}: #{messages.join(', ')}"
111
142
  end
@@ -127,27 +158,32 @@ end
127
158
 
128
159
  ## Error Attributes
129
160
 
130
- All error classes include these attributes:
161
+ Every subclass of `MyApi::Error` carries the HTTP response that produced it:
131
162
 
132
163
  ```ruby
133
164
  begin
134
165
  MyApi::User.retrieve('123')
135
166
  rescue MyApi::Error => e
136
- # Basic attributes
137
167
  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
-
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
+
146
177
  # For InvalidRequestError
147
- e.param # The parameter that caused the error
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
148
182
  end
149
183
  ```
150
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
+
151
187
  ## Handling Errors
152
188
 
153
189
  ### Basic Error Handling
@@ -276,6 +312,37 @@ end
276
312
  user = RetryableRequest.execute { MyApi::User.retrieve('123') }
277
313
  ```
278
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
+
279
346
  ## Custom Error Classes
280
347
 
281
348
  ### Defining Custom Errors
data/docs/TESTING.md CHANGED
@@ -161,6 +161,27 @@ class UserTest < ApiTestCase
161
161
  end
162
162
  ```
163
163
 
164
+ ### Testing Actions
165
+
166
+ `has_action` (from `Resteze::Testing::Minitest::Object` or
167
+ `Resteze::Testing::RSpec::Object`) asserts an action is declared and checks any
168
+ options you pass. `path:` is the full routed path, with the id `"ID"` for member
169
+ actions.
170
+
171
+ ```ruby
172
+ class CampaignTest < Minitest::Spec
173
+ include Resteze::Testing::Minitest::Object
174
+
175
+ subject { TestApi::Campaign }
176
+
177
+ it "routes its actions" do
178
+ has_action :data_series, method: :get, on: :collection, path: "/campaigns/data_series"
179
+ has_action :duplicate, method: :post, on: :member, id: :param, path: "/campaigns/duplicate"
180
+ has_action :retrieve, path: "/campaigns/details"
181
+ end
182
+ end
183
+ ```
184
+
164
185
  ### Testing Lists
165
186
 
166
187
  ```ruby