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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/CHANGELOG.md +38 -0
- data/CLAUDE.md +9 -0
- data/README.md +45 -4
- data/docs/ACTIONS.md +329 -0
- data/docs/ADVANCED_USAGE.md +29 -45
- data/docs/API.md +19 -25
- data/docs/CONFIGURATION.md +281 -0
- data/docs/ERROR_HANDLING.md +94 -27
- data/docs/TESTING.md +21 -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 +59 -11
- data/lib/resteze/errors.rb +125 -4
- data/lib/resteze/instrumentation.rb +35 -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 +101 -11
- 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
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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.
|
|
232
|
-
response.
|
|
233
|
-
response.
|
|
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
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
data/docs/ERROR_HANDLING.md
CHANGED
|
@@ -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
|
|
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
|
-
└──
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
├── YourApi::
|
|
25
|
-
├── YourApi::ResourceNotFound
|
|
26
|
-
├── YourApi::
|
|
27
|
-
└── YourApi::
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.,
|
|
139
|
-
e.
|
|
140
|
-
|
|
141
|
-
#
|
|
142
|
-
e.
|
|
143
|
-
e.response
|
|
144
|
-
e.
|
|
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
|