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,962 @@
1
+ # Configuration Guide
2
+
3
+ ## Table of Contents
4
+ - [Basic Configuration](#basic-configuration)
5
+ - [Authentication](#authentication)
6
+ - [Environment-based Configuration](#environment-based-configuration)
7
+ - [Dynamic Configuration](#dynamic-configuration)
8
+ - [Connection Configuration](#connection-configuration)
9
+ - [Logging Configuration](#logging-configuration)
10
+ - [Proxy Configuration](#proxy-configuration)
11
+ - [Rate Limits](#rate-limits)
12
+ - [Advanced Configuration Patterns](#advanced-configuration-patterns)
13
+
14
+ ## Basic Configuration
15
+
16
+ ### Minimal Setup
17
+
18
+ ```ruby
19
+ require 'resteze'
20
+
21
+ module MyApi
22
+ include Resteze
23
+
24
+ configure do |config|
25
+ config.api_base = 'https://api.example.com/'
26
+ end
27
+ end
28
+ ```
29
+
30
+ ### Standard Configuration
31
+
32
+ ```ruby
33
+ module MyApi
34
+ include Resteze
35
+
36
+ configure do |config|
37
+ # API endpoint
38
+ config.api_base = 'https://api.example.com/'
39
+
40
+ # Timeouts (in seconds)
41
+ config.open_timeout = 30 # Connection timeout
42
+ config.read_timeout = 60 # Read timeout
43
+
44
+ # Logging
45
+ config.logger = Logger.new($stdout)
46
+
47
+ # Proxy (optional)
48
+ config.proxy = ENV['HTTP_PROXY']
49
+ end
50
+ end
51
+ ```
52
+
53
+ ### Custom Configuration Properties
54
+
55
+ ```ruby
56
+ module MyApi
57
+ include Resteze
58
+
59
+ # Define custom properties
60
+ class << self
61
+ attr_accessor :api_key,
62
+ :api_secret,
63
+ :environment,
64
+ :rate_limit,
65
+ :retry_count,
66
+ :cache_ttl
67
+ end
68
+
69
+ configure do |config|
70
+ # Standard Resteze config
71
+ config.api_base = 'https://api.example.com/'
72
+ config.open_timeout = 30
73
+ config.read_timeout = 60
74
+
75
+ # Custom config
76
+ config.api_key = ENV['API_KEY']
77
+ config.api_secret = ENV['API_SECRET']
78
+ config.environment = ENV['API_ENV'] || 'production'
79
+ config.rate_limit = 100 # requests per minute
80
+ config.retry_count = 3
81
+ config.cache_ttl = 300 # 5 minutes
82
+ end
83
+ end
84
+ ```
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
+
170
+ ## Environment-based Configuration
171
+
172
+ ### Using Rails Environments
173
+
174
+ ```ruby
175
+ module MyApi
176
+ include Resteze
177
+
178
+ configure do |config|
179
+ case Rails.env
180
+ when 'production'
181
+ config.api_base = 'https://api.example.com/'
182
+ config.logger = Rails.logger
183
+ config.open_timeout = 30
184
+ config.read_timeout = 60
185
+ when 'staging'
186
+ config.api_base = 'https://staging-api.example.com/'
187
+ config.logger = Rails.logger
188
+ config.open_timeout = 45
189
+ config.read_timeout = 90
190
+ when 'development'
191
+ config.api_base = 'http://localhost:3000/'
192
+ config.logger = Logger.new($stdout)
193
+ config.logger.level = Logger::DEBUG
194
+ config.open_timeout = 60
195
+ config.read_timeout = 120
196
+ when 'test'
197
+ config.api_base = 'http://test.local/'
198
+ config.logger = Logger.new(nil) # Disable logging in tests
199
+ config.open_timeout = 5
200
+ config.read_timeout = 5
201
+ end
202
+ end
203
+ end
204
+ ```
205
+
206
+ ### Using Environment Variables
207
+
208
+ ```ruby
209
+ module MyApi
210
+ include Resteze
211
+
212
+ configure do |config|
213
+ # Required environment variables
214
+ config.api_base = ENV.fetch('MY_API_URL') do
215
+ raise "MY_API_URL environment variable is required"
216
+ end
217
+
218
+ # Optional with defaults
219
+ config.open_timeout = ENV.fetch('MY_API_OPEN_TIMEOUT', 30).to_i
220
+ config.read_timeout = ENV.fetch('MY_API_READ_TIMEOUT', 60).to_i
221
+
222
+ # Conditional configuration
223
+ if ENV['MY_API_DEBUG'] == 'true'
224
+ config.logger = Logger.new($stdout)
225
+ config.logger.level = Logger::DEBUG
226
+ else
227
+ config.logger = Logger.new('log/my_api.log')
228
+ config.logger.level = Logger::INFO
229
+ end
230
+
231
+ # Proxy configuration
232
+ config.proxy = ENV['HTTP_PROXY'] if ENV['HTTP_PROXY']
233
+ end
234
+ end
235
+ ```
236
+
237
+ ### Using Configuration Files
238
+
239
+ ```ruby
240
+ # config/my_api.yml
241
+ production:
242
+ api_base: "https://api.example.com/"
243
+ api_key: <%= ENV['MY_API_KEY'] %>
244
+ open_timeout: 30
245
+ read_timeout: 60
246
+
247
+ development:
248
+ api_base: "http://localhost:3000/"
249
+ api_key: "development-key"
250
+ open_timeout: 60
251
+ read_timeout: 120
252
+
253
+ test:
254
+ api_base: "http://test.local/"
255
+ api_key: "test-key"
256
+ open_timeout: 5
257
+ read_timeout: 5
258
+ ```
259
+
260
+ ```ruby
261
+ # Load configuration from YAML
262
+ require 'yaml'
263
+ require 'erb'
264
+
265
+ module MyApi
266
+ include Resteze
267
+
268
+ # Load config file
269
+ config_file = File.join(Rails.root, 'config', 'my_api.yml')
270
+ config_data = YAML.safe_load(ERB.new(File.read(config_file)).result)
271
+ settings = config_data[Rails.env]
272
+
273
+ configure do |config|
274
+ settings.each do |key, value|
275
+ config.send("#{key}=", value)
276
+ end
277
+ end
278
+ end
279
+ ```
280
+
281
+ ## Dynamic Configuration
282
+
283
+ ### Runtime Configuration Changes
284
+
285
+ ```ruby
286
+ module MyApi
287
+ include Resteze
288
+
289
+ class << self
290
+ def reconfigure
291
+ yield self if block_given?
292
+ end
293
+
294
+ def reset_configuration!
295
+ configure do |config|
296
+ config.api_base = default_api_base
297
+ config.open_timeout = 30
298
+ config.read_timeout = 60
299
+ config.logger = Logger.new($stdout)
300
+ end
301
+ end
302
+
303
+ private
304
+
305
+ def default_api_base
306
+ 'https://api.example.com/'
307
+ end
308
+ end
309
+ end
310
+
311
+ # Usage
312
+ MyApi.reconfigure do |config|
313
+ config.api_base = 'https://new-api.example.com/'
314
+ config.api_key = 'new-key'
315
+ end
316
+
317
+ # Reset to defaults
318
+ MyApi.reset_configuration!
319
+ ```
320
+
321
+ ### Per-Request Configuration
322
+
323
+ ```ruby
324
+ module MyApi
325
+ class Client < Resteze::Client
326
+ def with_timeout(open: nil, read: nil)
327
+ old_open = api_module.open_timeout
328
+ old_read = api_module.read_timeout
329
+
330
+ api_module.open_timeout = open if open
331
+ api_module.read_timeout = read if read
332
+
333
+ yield
334
+ ensure
335
+ api_module.open_timeout = old_open
336
+ api_module.read_timeout = old_read
337
+ end
338
+ end
339
+
340
+ class User < ApiResource
341
+ # Use custom timeout for slow endpoints
342
+ def self.export_all
343
+ Client.active_client.with_timeout(read: 300) do
344
+ request(:get, "#{resource_path}/export")
345
+ end
346
+ end
347
+ end
348
+ end
349
+ ```
350
+
351
+ ### Multi-tenant Configuration
352
+
353
+ ```ruby
354
+ module MyApi
355
+ include Resteze
356
+
357
+ class << self
358
+ def for_tenant(tenant_id)
359
+ Thread.current[:my_api_tenant] = tenant_id
360
+ yield
361
+ ensure
362
+ Thread.current[:my_api_tenant] = nil
363
+ end
364
+
365
+ def current_tenant
366
+ Thread.current[:my_api_tenant]
367
+ end
368
+ end
369
+
370
+ class Client < Resteze::Client
371
+ def request_headers
372
+ headers = super
373
+ if api_module.current_tenant
374
+ headers['X-Tenant-ID'] = api_module.current_tenant
375
+ end
376
+ headers
377
+ end
378
+
379
+ def self.api_url(path = "")
380
+ base = if api_module.current_tenant
381
+ "https://#{api_module.current_tenant}.api.example.com/"
382
+ else
383
+ api_module.api_base
384
+ end
385
+ [base.chomp("/"), path].join
386
+ end
387
+ end
388
+ end
389
+
390
+ # Usage
391
+ MyApi.for_tenant('acme') do
392
+ user = MyApi::User.retrieve('123') # Uses acme.api.example.com
393
+ end
394
+ ```
395
+
396
+ ## Connection Configuration
397
+
398
+ ### Faraday Connection Setup
399
+
400
+ ```ruby
401
+ module MyApi
402
+ class Client < Resteze::Client
403
+ def self.default_connection
404
+ @default_connection ||= Faraday.new do |conn|
405
+ # Request middleware (order matters!)
406
+ conn.request :json # Encode request bodies as JSON
407
+ conn.request :retry, max: 3, interval: 0.5 # Retry failed requests
408
+ conn.request :authorization, 'Bearer', -> { api_module.api_key }
409
+
410
+ # Response middleware
411
+ conn.response :json, content_type: /\bjson$/ # Parse JSON responses
412
+ conn.response :logger, api_module.logger, bodies: true # Log requests
413
+
414
+ # Error handling
415
+ conn.use Middleware::RaiseError
416
+
417
+ # Adapter (must be last)
418
+ conn.adapter :net_http_persistent # Use persistent connections
419
+ end
420
+ end
421
+ end
422
+ end
423
+ ```
424
+
425
+ ### SSL/TLS Configuration
426
+
427
+ ```ruby
428
+ module MyApi
429
+ class Client < Resteze::Client
430
+ def self.default_connection
431
+ @default_connection ||= Faraday.new(ssl: ssl_options) do |conn|
432
+ conn.use Middleware::RaiseError
433
+ conn.adapter Faraday.default_adapter
434
+ end
435
+ end
436
+
437
+ private
438
+
439
+ def self.ssl_options
440
+ {
441
+ # Certificate verification
442
+ verify: true, # Verify SSL certificates
443
+
444
+ # Client certificates
445
+ client_cert: OpenSSL::X509::Certificate.new(File.read('client.crt')),
446
+ client_key: OpenSSL::PKey::RSA.new(File.read('client.key')),
447
+
448
+ # CA bundle
449
+ ca_file: '/path/to/ca_bundle.pem',
450
+
451
+ # Minimum TLS version
452
+ version: :TLSv1_2,
453
+
454
+ # Cipher suites
455
+ ciphers: 'HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA'
456
+ }
457
+ rescue => e
458
+ Rails.logger.error "Failed to load SSL certificates: #{e.message}"
459
+ { verify: true } # Fall back to default verification
460
+ end
461
+ end
462
+ end
463
+ ```
464
+
465
+ ### Custom Adapters
466
+
467
+ ```ruby
468
+ module MyApi
469
+ class Client < Resteze::Client
470
+ def self.default_connection
471
+ @default_connection ||= Faraday.new do |conn|
472
+ conn.use Middleware::RaiseError
473
+
474
+ # Choose adapter based on environment
475
+ case Rails.env
476
+ when 'production'
477
+ # Use Typhoeus for better performance
478
+ conn.adapter :typhoeus
479
+ when 'test'
480
+ # Use test adapter for testing
481
+ conn.adapter :test do |stub|
482
+ stub.get('/users/123') { [200, {}, { id: 123 }.to_json] }
483
+ end
484
+ else
485
+ # Default adapter
486
+ conn.adapter Faraday.default_adapter
487
+ end
488
+ end
489
+ end
490
+ end
491
+ end
492
+ ```
493
+
494
+ ## Logging Configuration
495
+
496
+ ### Custom Logger
497
+
498
+ ```ruby
499
+ require 'logger'
500
+
501
+ module MyApi
502
+ include Resteze
503
+
504
+ class ApiLogger < Logger
505
+ def format_message(severity, timestamp, progname, msg)
506
+ {
507
+ severity: severity,
508
+ timestamp: timestamp.iso8601,
509
+ service: 'my_api',
510
+ message: msg
511
+ }.to_json + "\n"
512
+ end
513
+ end
514
+
515
+ configure do |config|
516
+ config.logger = ApiLogger.new('log/my_api.log', 'daily')
517
+ config.logger.level = Rails.env.production? ? Logger::INFO : Logger::DEBUG
518
+ end
519
+ end
520
+ ```
521
+
522
+ ### Structured Logging
523
+
524
+ ```ruby
525
+ module MyApi
526
+ class Client < Resteze::Client
527
+ private
528
+
529
+ def log_request(context)
530
+ logger.info({
531
+ event: 'api_request',
532
+ method: context.method,
533
+ path: context.path,
534
+ headers: sanitize_headers(context.headers),
535
+ params: sanitize_params(context.query_params)
536
+ }.to_json)
537
+ end
538
+
539
+ def log_response(context, request_start, response)
540
+ logger.info({
541
+ event: 'api_response',
542
+ method: context.method,
543
+ path: context.path,
544
+ status: response.status,
545
+ duration_ms: ((Time.now - request_start) * 1000).round,
546
+ request_id: response.headers['x-request-id']
547
+ }.to_json)
548
+ end
549
+
550
+ def sanitize_headers(headers)
551
+ headers.transform_values do |value|
552
+ value.to_s.include?('Bearer') ? '[REDACTED]' : value
553
+ end
554
+ end
555
+
556
+ def sanitize_params(params)
557
+ params.transform_values do |value|
558
+ sensitive_param?(value) ? '[REDACTED]' : value
559
+ end
560
+ end
561
+
562
+ def sensitive_param?(value)
563
+ value.to_s.match?(/password|token|secret|key/i)
564
+ end
565
+ end
566
+ end
567
+ ```
568
+
569
+ ## Proxy Configuration
570
+
571
+ ### Basic Proxy
572
+
573
+ ```ruby
574
+ module MyApi
575
+ include Resteze
576
+
577
+ configure do |config|
578
+ config.proxy = 'http://proxy.example.com:8080'
579
+ end
580
+ end
581
+ ```
582
+
583
+ ### Authenticated Proxy
584
+
585
+ ```ruby
586
+ module MyApi
587
+ include Resteze
588
+
589
+ configure do |config|
590
+ config.proxy = {
591
+ uri: 'http://proxy.example.com:8080',
592
+ user: ENV['PROXY_USER'],
593
+ password: ENV['PROXY_PASSWORD']
594
+ }
595
+ end
596
+ end
597
+ ```
598
+
599
+ ### Conditional Proxy
600
+
601
+ ```ruby
602
+ module MyApi
603
+ include Resteze
604
+
605
+ configure do |config|
606
+ # Only use proxy in production
607
+ if Rails.env.production?
608
+ config.proxy = ENV['HTTP_PROXY']
609
+ end
610
+
611
+ # Or use different proxies per environment
612
+ config.proxy = case Rails.env
613
+ when 'production'
614
+ 'http://prod-proxy.example.com:8080'
615
+ when 'staging'
616
+ 'http://staging-proxy.example.com:8080'
617
+ else
618
+ nil # No proxy for development/test
619
+ end
620
+ end
621
+ end
622
+ ```
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
+
819
+ ## Advanced Configuration Patterns
820
+
821
+ ### Configuration with Validation
822
+
823
+ ```ruby
824
+ module MyApi
825
+ include Resteze
826
+
827
+ class Configuration
828
+ REQUIRED_SETTINGS = %i[api_base api_key].freeze
829
+ VALID_ENVIRONMENTS = %w[production staging development test].freeze
830
+
831
+ def self.validate!(config)
832
+ REQUIRED_SETTINGS.each do |setting|
833
+ value = config.send(setting)
834
+ if value.nil? || value.to_s.empty?
835
+ raise "Configuration error: #{setting} is required"
836
+ end
837
+ end
838
+
839
+ # Validate API base URL
840
+ unless config.api_base =~ URI::DEFAULT_PARSER.make_regexp
841
+ raise "Configuration error: api_base must be a valid URL"
842
+ end
843
+
844
+ # Validate environment
845
+ if config.respond_to?(:environment)
846
+ unless VALID_ENVIRONMENTS.include?(config.environment)
847
+ raise "Configuration error: invalid environment '#{config.environment}'"
848
+ end
849
+ end
850
+
851
+ # Validate timeouts
852
+ if config.open_timeout <= 0 || config.read_timeout <= 0
853
+ raise "Configuration error: timeouts must be positive"
854
+ end
855
+ end
856
+ end
857
+
858
+ configure do |config|
859
+ config.api_base = ENV['MY_API_URL']
860
+ config.api_key = ENV['MY_API_KEY']
861
+ config.open_timeout = 30
862
+ config.read_timeout = 60
863
+
864
+ # Validate configuration
865
+ Configuration.validate!(config)
866
+ end
867
+ end
868
+ ```
869
+
870
+ ### Feature Flags Configuration
871
+
872
+ ```ruby
873
+ module MyApi
874
+ include Resteze
875
+
876
+ class << self
877
+ attr_accessor :features
878
+
879
+ def feature_enabled?(feature)
880
+ features && features[feature] == true
881
+ end
882
+ end
883
+
884
+ configure do |config|
885
+ config.api_base = 'https://api.example.com/'
886
+
887
+ # Feature flags
888
+ config.features = {
889
+ caching: Rails.env.production?,
890
+ retry: true,
891
+ circuit_breaker: Rails.env.production?,
892
+ detailed_logging: Rails.env.development?,
893
+ batch_requests: true
894
+ }
895
+ end
896
+
897
+ class Client < Resteze::Client
898
+ def execute_request(method, path, **options)
899
+ # Use feature flags
900
+ if api_module.feature_enabled?(:caching) && method == :get
901
+ cached_request(method, path, **options)
902
+ else
903
+ super
904
+ end
905
+ end
906
+
907
+ private
908
+
909
+ def cached_request(method, path, **options)
910
+ cache_key = "#{path}:#{options.hash}"
911
+ Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
912
+ super(method, path, **options)
913
+ end
914
+ end
915
+ end
916
+ end
917
+ ```
918
+
919
+ ### Configuration Registry
920
+
921
+ ```ruby
922
+ module MyApi
923
+ include Resteze
924
+
925
+ class ConfigurationRegistry
926
+ def self.configurations
927
+ @configurations ||= {}
928
+ end
929
+
930
+ def self.register(name, &block)
931
+ configurations[name] = block
932
+ end
933
+
934
+ def self.apply(name)
935
+ config_block = configurations[name]
936
+ raise "Unknown configuration: #{name}" unless config_block
937
+
938
+ MyApi.configure(&config_block)
939
+ end
940
+ end
941
+
942
+ # Register configurations
943
+ ConfigurationRegistry.register(:production) do |config|
944
+ config.api_base = 'https://api.example.com/'
945
+ config.api_key = ENV['PROD_API_KEY']
946
+ config.open_timeout = 30
947
+ config.read_timeout = 60
948
+ end
949
+
950
+ ConfigurationRegistry.register(:development) do |config|
951
+ config.api_base = 'http://localhost:3000/'
952
+ config.api_key = 'dev-key'
953
+ config.open_timeout = 60
954
+ config.read_timeout = 120
955
+ config.logger = Logger.new($stdout)
956
+ config.logger.level = Logger::DEBUG
957
+ end
958
+
959
+ # Apply configuration
960
+ ConfigurationRegistry.apply(Rails.env.to_sym)
961
+ end
962
+ ```