butler-http 0.1.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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/LICENSE.txt +22 -0
  4. data/README.md +659 -0
  5. data/Rakefile +10 -0
  6. data/lib/butler/async.rb +130 -0
  7. data/lib/butler/body.rb +130 -0
  8. data/lib/butler/client.rb +206 -0
  9. data/lib/butler/configuration.rb +161 -0
  10. data/lib/butler/connection.rb +21 -0
  11. data/lib/butler/connection_pool.rb +64 -0
  12. data/lib/butler/errors.rb +78 -0
  13. data/lib/butler/headers.rb +106 -0
  14. data/lib/butler/pipeline/chain.rb +18 -0
  15. data/lib/butler/pipeline/context.rb +21 -0
  16. data/lib/butler/pipeline/middleware.rb +16 -0
  17. data/lib/butler/pipeline/middlewares/circuit_breaker_middleware.rb +21 -0
  18. data/lib/butler/pipeline/middlewares/retry_middleware.rb +67 -0
  19. data/lib/butler/pipeline/middlewares/security_middleware.rb +14 -0
  20. data/lib/butler/pipeline/middlewares/telemetry_middleware.rb +37 -0
  21. data/lib/butler/pipeline/middlewares/timeout_middleware.rb +17 -0
  22. data/lib/butler/quic/crypto/aead.rb +69 -0
  23. data/lib/butler/quic/crypto/header_protection.rb +51 -0
  24. data/lib/butler/quic/crypto/hkdf.rb +44 -0
  25. data/lib/butler/quic/crypto/key_schedule.rb +50 -0
  26. data/lib/butler/quic/packet.rb +113 -0
  27. data/lib/butler/quic/varint.rb +56 -0
  28. data/lib/butler/rails/notifications.rb +10 -0
  29. data/lib/butler/rails/railtie.rb +24 -0
  30. data/lib/butler/request.rb +92 -0
  31. data/lib/butler/resilience/backoff.rb +17 -0
  32. data/lib/butler/resilience/circuit_breaker.rb +120 -0
  33. data/lib/butler/resilience/deadline.rb +55 -0
  34. data/lib/butler/resilience/retry_policy.rb +76 -0
  35. data/lib/butler/resilience/timeout.rb +22 -0
  36. data/lib/butler/response.rb +81 -0
  37. data/lib/butler/security/host_policy.rb +29 -0
  38. data/lib/butler/security/limits.rb +39 -0
  39. data/lib/butler/security/redirect_policy.rb +28 -0
  40. data/lib/butler/security/tls.rb +51 -0
  41. data/lib/butler/stream.rb +44 -0
  42. data/lib/butler/telemetry/instrumentation.rb +59 -0
  43. data/lib/butler/telemetry/logger.rb +37 -0
  44. data/lib/butler/telemetry/open_telemetry_bridge.rb +46 -0
  45. data/lib/butler/testing/fake_transport.rb +29 -0
  46. data/lib/butler/testing/stub.rb +68 -0
  47. data/lib/butler/testing/stub_registry.rb +32 -0
  48. data/lib/butler/testing.rb +33 -0
  49. data/lib/butler/transport.rb +118 -0
  50. data/lib/butler/uri.rb +77 -0
  51. data/lib/butler/version.rb +3 -0
  52. data/lib/butler.rb +176 -0
  53. data/sig/butler/client.rbs +41 -0
  54. data/sig/butler/configuration.rbs +63 -0
  55. data/sig/butler/errors.rbs +67 -0
  56. data/sig/butler/headers.rbs +22 -0
  57. data/sig/butler/request.rbs +21 -0
  58. data/sig/butler/response.rbs +28 -0
  59. data/sig/butler.rbs +22 -0
  60. metadata +191 -0
@@ -0,0 +1,130 @@
1
+ require "async"
2
+ require "async/barrier"
3
+
4
+ # Thin aliases over the `async` gem's own structured-concurrency primitives
5
+ # rather than a reimplementation — Async::Task/Async::Barrier already give
6
+ # exactly the parent/child task lifecycle (exiting the parent
7
+ # resolves/cancels children, releases resources, propagates exceptions) the
8
+ # PRD describes. See Butler::Client#async for how these are actually used.
9
+ class Butler
10
+ module Async
11
+ Task = ::Async::Task
12
+ Barrier = ::Async::Barrier
13
+
14
+ # The object handed to a `client.async { |tasks| ... }` block, so
15
+ # callers never need to reach for ::Async::Barrier directly.
16
+ class TaskGroup
17
+ def initialize(barrier)
18
+ @barrier = barrier
19
+ end
20
+
21
+ # Spawns a child task; returns an ::Async::Task (#wait blocks for its
22
+ # result and re-raises any exception it raised).
23
+ def async(&block)
24
+ @barrier.async(&block)
25
+ end
26
+ end
27
+
28
+ class Cancellation
29
+ def initialize(task)
30
+ @task = task
31
+ end
32
+
33
+ def cancel!
34
+ @task.stop
35
+ end
36
+
37
+ def cancelled?
38
+ @task.stopping? || @task.stopped?
39
+ end
40
+ end
41
+
42
+ # Backs every *bare* `client.get`/`client.post`/etc call (one made from
43
+ # outside any existing reactor — the common synchronous case). Spinning
44
+ # up a full Async::Reactor (event loop, I/O selector, Fiber scheduler
45
+ # registration) and tearing it down again on every single such call is
46
+ # real, measurable overhead on top of the request itself — this instead
47
+ # starts exactly one reactor, lazily, on first use, and keeps it alive
48
+ # for the life of the process, dispatching each call into it as a
49
+ # cheap child task instead. The calling thread (an arbitrary OS thread
50
+ # — Puma workers, a plain script, anything) blocks on a plain
51
+ # Thread::Queue until that task's result (or exception) comes back,
52
+ # so this is invisible from the outside: still fully synchronous from
53
+ # the caller's point of view.
54
+ #
55
+ # Never touched when Client#request is called from *inside* an
56
+ # existing reactor (nested client.async, or a Fiber-scheduler-based
57
+ # app server like Falcon) — running inline on the current task there
58
+ # is strictly better than hopping through this at all.
59
+ class BackgroundReactor
60
+ Job = Struct.new(:block, :results)
61
+
62
+ def initialize
63
+ @jobs = ::Queue.new
64
+ @mutex = Mutex.new
65
+ @thread = nil
66
+ @pid = nil
67
+ end
68
+
69
+ def call(&block)
70
+ ensure_running!
71
+ results = ::Queue.new
72
+ @jobs << Job.new(block, results)
73
+ status, value = results.pop
74
+ status == :error ? (raise value) : value
75
+ end
76
+
77
+ # Called after a fork (see Butler.reset_connections!) so a forked
78
+ # worker doesn't try to keep using its parent's reactor thread —
79
+ # Ruby thread objects don't survive fork(), only the forking thread
80
+ # does. #call also checks the pid itself on every dispatch, so this
81
+ # is a defensive fast path more than a strict requirement.
82
+ def reset!
83
+ @mutex.synchronize do
84
+ @thread = nil
85
+ @pid = nil
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def ensure_running!
92
+ return if running?
93
+
94
+ @mutex.synchronize do
95
+ return if running?
96
+
97
+ @pid = ::Process.pid
98
+ @thread = ::Thread.new { run }
99
+ end
100
+ end
101
+
102
+ def running?
103
+ @thread&.alive? && @pid == ::Process.pid
104
+ end
105
+
106
+ def run
107
+ Async do |parent|
108
+ loop do
109
+ job = @jobs.pop
110
+ parent.async do
111
+ # Deliberately `rescue Exception`, not StandardError: whatever
112
+ # happens here, *something* must always be pushed to
113
+ # job.results, or the caller's #call blocks forever on a
114
+ # Queue nothing will ever write to again.
115
+ begin
116
+ job.results << [:ok, job.block.call]
117
+ rescue Exception => e # rubocop:disable Lint/RescueException
118
+ job.results << [:error, e]
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+
126
+ def self.background
127
+ @background ||= BackgroundReactor.new
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,130 @@
1
+ # Butler::Body::Base and its four concrete shapes are how request bodies are
2
+ # represented internally *before* they reach the transport layer — Transport
3
+ # is the only place that ever needs to know how to turn one into wire bytes,
4
+ # so nothing above it (Client, Request) needs to change if a fifth body kind
5
+ # is added later.
6
+ module Butler::Body
7
+ def self.wrap(options)
8
+ if options.key?(:json)
9
+ JSONBody.new(options[:json])
10
+ elsif options.key?(:form)
11
+ StringBody.new(::URI.encode_www_form(options[:form]), content_type: "application/x-www-form-urlencoded")
12
+ elsif options.key?(:stream)
13
+ StreamBody.new(options[:stream])
14
+ elsif options.key?(:io)
15
+ IOBody.new(options[:io])
16
+ elsif options.key?(:body)
17
+ wrap_raw(options[:body])
18
+ end
19
+ end
20
+
21
+ def self.wrap_raw(raw)
22
+ case raw
23
+ when IO, StringIO
24
+ IOBody.new(raw)
25
+ when Hash
26
+ StringBody.new(::URI.encode_www_form(raw), content_type: "application/x-www-form-urlencoded")
27
+ else
28
+ StringBody.new(raw.to_s)
29
+ end
30
+ end
31
+
32
+ class Base
33
+ def content_type
34
+ nil
35
+ end
36
+
37
+ def empty?
38
+ false
39
+ end
40
+
41
+ # Yields successive byte-string chunks. Subclasses must implement this;
42
+ # #to_s (used by anything that just wants the whole body as one string)
43
+ # is derived from it for free.
44
+ def each
45
+ raise NotImplementedError
46
+ end
47
+
48
+ def to_s
49
+ buffer = +""
50
+ each { |chunk| buffer << chunk }
51
+ buffer
52
+ end
53
+ end
54
+
55
+ class StringBody < Base
56
+ attr_reader :content_type
57
+
58
+ def initialize(string, content_type: nil)
59
+ @string = string.to_s
60
+ @content_type = content_type
61
+ end
62
+
63
+ def empty?
64
+ @string.empty?
65
+ end
66
+
67
+ def each
68
+ return enum_for(:each) unless block_given?
69
+
70
+ yield @string unless @string.empty?
71
+ end
72
+
73
+ def to_s
74
+ @string
75
+ end
76
+ end
77
+
78
+ class JSONBody < Base
79
+ def initialize(object)
80
+ @json = object.to_json
81
+ end
82
+
83
+ def content_type
84
+ "application/json"
85
+ end
86
+
87
+ def each
88
+ return enum_for(:each) unless block_given?
89
+
90
+ yield @json
91
+ end
92
+
93
+ def to_s
94
+ @json
95
+ end
96
+ end
97
+
98
+ # Wraps an already-open IO/StringIO (e.g. a file upload) and streams it in
99
+ # chunks rather than reading it all into memory up front.
100
+ class IOBody < Base
101
+ CHUNK_SIZE = 16 * 1024
102
+
103
+ def initialize(io)
104
+ @io = io
105
+ end
106
+
107
+ def each
108
+ return enum_for(:each) unless block_given?
109
+
110
+ @io.rewind if @io.respond_to?(:rewind)
111
+ while (chunk = @io.read(CHUNK_SIZE))
112
+ yield chunk
113
+ end
114
+ end
115
+ end
116
+
117
+ # Wraps a caller-supplied Enumerable of chunks for streaming uploads Butler
118
+ # itself has no opinion about the shape of (e.g. a lazily-generated body).
119
+ class StreamBody < Base
120
+ def initialize(enumerable)
121
+ @enumerable = enumerable
122
+ end
123
+
124
+ def each(&block)
125
+ return enum_for(:each) unless block
126
+
127
+ @enumerable.each(&block)
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,206 @@
1
+ # The public entry point. Builds one middleware pipeline at construction
2
+ # time (Security -> Telemetry -> [custom middlewares] -> Timeout ->
3
+ # CircuitBreaker -> Retry -> [terminal: ConnectionPool + Transport]) and
4
+ # reuses it for every request; redirects are handled by a loop in #request
5
+ # itself rather than as a pipeline step, since each hop needs a fresh
6
+ # HostPolicy check and independent security/telemetry/circuit-breaker
7
+ # accounting against the *new* host, while still sharing one Deadline
8
+ # across the whole call.
9
+ #
10
+ # client = Butler::Client.new(base_url: "https://api.example.com")
11
+ # response = client.get("/users")
12
+ #
13
+ # client.async do |tasks|
14
+ # users = tasks.async { client.get("/users") }
15
+ # orders = tasks.async { client.get("/orders") }
16
+ # { users: users.wait, orders: orders.wait }
17
+ # end
18
+ class Butler::Client
19
+ HTTP_METHODS = %i[get post put patch delete head options].freeze
20
+
21
+ attr_reader :configuration
22
+
23
+ def initialize(**options)
24
+ @configuration = Butler.configuration.dup.apply(options)
25
+ @connection_pool = Butler::ConnectionPool.new(@configuration)
26
+ @circuit_breaker = Butler::Resilience::CircuitBreaker.new
27
+ @retry_policy = Butler::Resilience::RetryPolicy.new
28
+ @custom_middlewares = []
29
+ @pipeline = build_pipeline
30
+ Butler.register_client(self)
31
+ end
32
+
33
+ HTTP_METHODS.each do |http_method|
34
+ define_method(http_method) do |path = nil, **opts|
35
+ request(http_method, path, **opts)
36
+ end
37
+ end
38
+
39
+ # Inserts a custom middleware between Telemetry and Timeout — third-party
40
+ # extensions can inspect/modify/short-circuit/observe/transform a request
41
+ # without core resilience/security features having to be expressed as
42
+ # middleware themselves.
43
+ def use(middleware)
44
+ @custom_middlewares << middleware
45
+ @pipeline = build_pipeline
46
+ self
47
+ end
48
+
49
+ # Whether this runs inline or gets dispatched depends on whether we're
50
+ # already inside a reactor:
51
+ #
52
+ # - Called from inside one (a nested client.async call, or a
53
+ # Fiber-scheduler-based app server like Falcon) — #perform_request just
54
+ # runs directly on the current task. No new task, no reactor, nothing
55
+ # to tear down.
56
+ # - Called from ordinary synchronous code (the common case — a bare
57
+ # `client.get("/users")` with no surrounding client.async block) — it's
58
+ # dispatched into Butler::Async.background, a single reactor shared by
59
+ # every Butler::Client in the process and lazily started once, rather
60
+ # than spinning up and tearing down a brand new one on *every* such
61
+ # call. That per-call setup/teardown is real, measurable overhead for
62
+ # back-to-back sequential calls; paying it once instead is what makes
63
+ # this fast without changing anything about how it looks to call.
64
+ #
65
+ # Either way this is fully synchronous from the caller's perspective, and
66
+ # neither path ever trips `async`'s own "Task may have ended with
67
+ # unhandled exception" diagnostic logging for a routinely-handled Butler
68
+ # error (a 4xx/5xx with raise_on_error, TooManyRedirectsError, a stubbed
69
+ # exception in tests): the inline path never opens a new task boundary
70
+ # for the exception to cross, and BackgroundReactor#call catches inside
71
+ # its own task and re-raises back on the calling thread afterward.
72
+ def request(method, path = nil, **opts)
73
+ perform = -> { perform_request(method, path, opts) }
74
+ ::Async::Task.current? ? perform.call : Butler::Async.background.call(&perform)
75
+ end
76
+
77
+ # Structured Fiber concurrency:
78
+ #
79
+ # client.async do |tasks|
80
+ # a = tasks.async { client.get("/a") }
81
+ # b = tasks.async { client.get("/b") }
82
+ # [a.wait, b.wait]
83
+ # end
84
+ #
85
+ # Same inline-vs-background-reactor dispatch as #request, for the same
86
+ # reason. The ensure block resolves any child the caller forgot to #wait
87
+ # and cancels anything still running after an exception, so exiting the
88
+ # block always leaves every child task/connection resolved or cancelled.
89
+ def async(&block)
90
+ raise ArgumentError, "block required" unless block
91
+
92
+ perform = lambda do
93
+ barrier = Butler::Async::Barrier.new
94
+ begin
95
+ block.call(Butler::Async::TaskGroup.new(barrier))
96
+ ensure
97
+ barrier.wait
98
+ barrier.stop
99
+ end
100
+ end
101
+
102
+ ::Async::Task.current? ? perform.call : Butler::Async.background.call(&perform)
103
+ end
104
+
105
+ # Closes every pooled connection this client is holding open. Called
106
+ # automatically post-fork by Butler::Rails::Railtie; safe to call
107
+ # manually otherwise (e.g. before a process exits, or to force
108
+ # reconnection after a known upstream restart).
109
+ def reset_connections!
110
+ @connection_pool.reset!
111
+ end
112
+
113
+ private
114
+
115
+ def perform_request(method, path, opts)
116
+ config = per_call_config(opts)
117
+ deadline = Butler::Resilience::Deadline.start(opts[:deadline] || configuration.deadline)
118
+ uri = Butler::URI.build(configuration.base_url, path, opts[:params])
119
+ current_method = method.to_s.upcase
120
+ current_opts = opts
121
+ redirects = 0
122
+
123
+ loop do
124
+ butler_request = Butler::Request.build(method: current_method, uri: uri, options: current_opts, config: config)
125
+ context = Butler::Pipeline::Context.new(client: self, request: butler_request, config: config, deadline: deadline)
126
+ response = @pipeline.call(context)
127
+
128
+ return maybe_raise(response) unless response.redirect? && configuration.follow_redirects
129
+
130
+ location = response.headers["location"]
131
+ return maybe_raise(response) unless location
132
+
133
+ if redirects >= configuration.max_redirects
134
+ raise Butler::Errors::TooManyRedirectsError, "exceeded #{configuration.max_redirects} redirects for #{uri}"
135
+ end
136
+
137
+ new_uri = uri.merge(location)
138
+ if Butler::Security::RedirectPolicy.strip_credentials?(configuration, uri, new_uri)
139
+ current_opts = current_opts.merge(headers: strip_credential_headers(current_opts[:headers]), basic_auth: nil)
140
+ end
141
+ current_method = Butler::Security::RedirectPolicy.method_for(response.status, current_method)
142
+ uri = new_uri
143
+ redirects += 1
144
+ end
145
+ end
146
+
147
+ # A per-call http_version: override needs its own Configuration — duped
148
+ # once per call, never mutating the shared client-level one — because
149
+ # it has to flow all the way through Request.build/Pipeline::Context
150
+ # into ConnectionPool's fingerprint and Security::TLS's ALPN selection,
151
+ # exactly like the client-level default already does (see
152
+ # Configuration#http_version). That's what lets a request pinned to
153
+ # :http1 get its own pooled connection rather than reusing one that
154
+ # negotiated :http2 for the same origin, and vice versa. No-op (returns
155
+ # the shared configuration as-is, no dup) when the call doesn't ask for
156
+ # an override.
157
+ def per_call_config(opts)
158
+ return configuration unless opts.key?(:http_version)
159
+
160
+ configuration.dup.tap { |c| c.http_version = opts[:http_version] }
161
+ end
162
+
163
+ def maybe_raise(response)
164
+ return response unless configuration.raise_on_error && response.error?
165
+
166
+ klass = response.client_error? ? Butler::Errors::ClientError : Butler::Errors::ServerError
167
+ raise klass, response
168
+ end
169
+
170
+ def strip_credential_headers(headers)
171
+ stripped = Butler::Headers.coerce(headers || {}).dup
172
+ stripped.delete("authorization")
173
+ stripped.delete("cookie")
174
+ stripped
175
+ end
176
+
177
+ def build_pipeline
178
+ middlewares = [
179
+ Butler::Pipeline::Middlewares::SecurityMiddleware.new,
180
+ Butler::Pipeline::Middlewares::TelemetryMiddleware.new,
181
+ *@custom_middlewares,
182
+ Butler::Pipeline::Middlewares::TimeoutMiddleware.new,
183
+ Butler::Pipeline::Middlewares::CircuitBreakerMiddleware.new(@circuit_breaker),
184
+ Butler::Pipeline::Middlewares::RetryMiddleware.new(@retry_policy),
185
+ ]
186
+
187
+ terminal = lambda do |context|
188
+ connection = @connection_pool.acquire(context.request.uri, context.config)
189
+ timeout = attempt_timeout(context.config, context.deadline)
190
+ Butler::Transport.current.call(connection, context.request, context.config, timeout: timeout)
191
+ end
192
+
193
+ Butler::Pipeline::Chain.new(middlewares, terminal: terminal)
194
+ end
195
+
196
+ # The per-attempt ceiling Transport enforces around one request/response
197
+ # round-trip: config.request_timeout if set, otherwise the larger of
198
+ # read_timeout/write_timeout (Async::HTTP::Client performs the whole
199
+ # round-trip as one call, so read and write can't be timed independently
200
+ # once a request reaches Transport::Async) — always additionally capped
201
+ # by however much of the total deadline remains.
202
+ def attempt_timeout(config, deadline)
203
+ per_attempt = config.request_timeout || [config.read_timeout, config.write_timeout].compact.max
204
+ [per_attempt, deadline.remaining].compact.min
205
+ end
206
+ end
@@ -0,0 +1,161 @@
1
+ # Every option Butler::Client understands, with production-safe defaults:
2
+ # TLS/hostname verification on, redirects followed but bounded, resilience
3
+ # (retry/circuit-breaker) on, no total deadline (unbounded) unless asked for.
4
+ #
5
+ # Butler.configuration is a process-wide template; Butler::Client.new(**opts)
6
+ # takes a private #dup of it (see #initialize_copy below — every nested
7
+ # options object is deep-copied too) and applies +opts+ on top, so
8
+ # per-client configuration never leaks back into the global default or
9
+ # across sibling clients.
10
+ class Butler::Configuration
11
+ class RetryOptions
12
+ attr_accessor :max_attempts, :retryable_status_codes, :backoff, :base_delay, :max_delay, :jitter
13
+
14
+ def initialize
15
+ @max_attempts = 2
16
+ @retryable_status_codes = [408, 425, 429, 500, 502, 503, 504].freeze
17
+ @backoff = :exponential
18
+ @base_delay = 0.1
19
+ @max_delay = 5.0
20
+ @jitter = true
21
+ end
22
+ end
23
+
24
+ class CircuitBreakerOptions
25
+ attr_accessor :enabled, :scope, :failure_threshold, :recovery_timeout, :max_tracked_hosts
26
+
27
+ def initialize
28
+ @enabled = true
29
+ @scope = :host # or :client, to share one breaker across every origin
30
+ @failure_threshold = 5
31
+ @recovery_timeout = 30
32
+ @max_tracked_hosts = 256
33
+ end
34
+ end
35
+
36
+ class SecurityOptions
37
+ attr_accessor :verify_tls, :allowed_hosts, :blocked_hosts,
38
+ :max_response_size, :max_header_size, :strip_credentials_on_redirect
39
+
40
+ def initialize
41
+ @verify_tls = true
42
+ @allowed_hosts = nil # nil = no allow-list restriction
43
+ @blocked_hosts = []
44
+ @max_response_size = 50 * 1024 * 1024
45
+ @max_header_size = 64 * 1024
46
+ @strip_credentials_on_redirect = true
47
+ end
48
+
49
+ def initialize_copy(source)
50
+ super
51
+ @blocked_hosts = source.blocked_hosts.dup
52
+ @allowed_hosts = source.allowed_hosts&.dup
53
+ end
54
+ end
55
+
56
+ class PoolOptions
57
+ attr_accessor :max_connections, :idle_timeout
58
+
59
+ def initialize
60
+ @max_connections = 100
61
+ @idle_timeout = 60
62
+ end
63
+ end
64
+
65
+ class TelemetryOptions
66
+ attr_accessor :enabled, :opentelemetry, :logger
67
+
68
+ def initialize
69
+ @enabled = true
70
+ @opentelemetry = :auto
71
+ @logger = nil
72
+ end
73
+ end
74
+
75
+ attr_accessor :base_url, :user_agent,
76
+ :connect_timeout, :read_timeout, :write_timeout, :request_timeout,
77
+ :deadline,
78
+ :follow_redirects, :max_redirects,
79
+ :http_version, :proxy, :raise_on_error
80
+ attr_reader :default_headers, :retry, :circuit_breaker, :security, :pool, :telemetry
81
+
82
+ def initialize
83
+ @base_url = nil
84
+ @default_headers = Butler::Headers.new
85
+ @user_agent = "Butler/#{Butler::VERSION}"
86
+
87
+ @connect_timeout = 5
88
+ @read_timeout = 10
89
+ @write_timeout = 10
90
+ # An explicit override for the per-attempt timeout Transport enforces
91
+ # around one request/response round-trip. Left unset, that budget is
92
+ # derived from read_timeout/write_timeout instead (see
93
+ # Client#attempt_timeout) — read and write aren't independently
94
+ # timed once the request reaches Transport::Async, since
95
+ # Async::HTTP::Client performs the whole round-trip as one call.
96
+ @request_timeout = nil
97
+
98
+ @deadline = nil
99
+
100
+ @follow_redirects = true
101
+ @max_redirects = 5
102
+
103
+ # :auto | :http1 | :http2 — :auto offers both via ALPN and lets the
104
+ # server pick (HTTP/2 whenever it supports it, HTTP/1.1 otherwise);
105
+ # :http1/:http2 force that choice. Overridable per call — see
106
+ # Client#request's http_version: option.
107
+ @http_version = :auto
108
+ @proxy = nil
109
+ @raise_on_error = false
110
+
111
+ @retry = RetryOptions.new
112
+ @circuit_breaker = CircuitBreakerOptions.new
113
+ @security = SecurityOptions.new
114
+ @pool = PoolOptions.new
115
+ @telemetry = TelemetryOptions.new
116
+ end
117
+
118
+ def initialize_copy(source)
119
+ super
120
+ @default_headers = source.default_headers.dup
121
+ @retry = source.retry.dup
122
+ @circuit_breaker = source.circuit_breaker.dup
123
+ @security = source.security.dup
124
+ @pool = source.pool.dup
125
+ @telemetry = source.telemetry.dup
126
+ end
127
+
128
+ # Applies a Client.new(**options) hash on top of this (already-duped)
129
+ # configuration. Nested option groups (retry:, circuit_breaker:, security:,
130
+ # pool:, telemetry:) merge into the existing sub-object rather than
131
+ # replacing it, so callers only need to specify the keys they want to
132
+ # override.
133
+ NESTED_OPTIONS = %i[retry circuit_breaker security pool telemetry].freeze
134
+
135
+ def apply(options)
136
+ options.each do |key, value|
137
+ if NESTED_OPTIONS.include?(key)
138
+ merge_nested(public_send(key), value)
139
+ elsif key == :headers
140
+ Butler::Headers.coerce(value || {}).each { |k, v| default_headers[k] = v }
141
+ elsif respond_to?("#{key}=")
142
+ public_send("#{key}=", value)
143
+ else
144
+ raise Butler::Errors::ConfigurationError, "unknown Butler::Client option #{key.inspect}"
145
+ end
146
+ end
147
+ self
148
+ end
149
+
150
+ private
151
+
152
+ def merge_nested(target, value)
153
+ return unless value
154
+
155
+ value.each do |k, v|
156
+ raise Butler::Errors::ConfigurationError, "unknown option #{k.inspect}" unless target.respond_to?("#{k}=")
157
+
158
+ target.public_send("#{k}=", v)
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,21 @@
1
+ # One origin's live connection state: the underlying async_client is an
2
+ # Async::HTTP::Client for the real transport (already doing its own HTTP/1.1
3
+ # vs HTTP/2 multiplexing and per-origin pooling internally) or nil for
4
+ # Butler::Testing::FakeTransport, which never touches it. Butler::Connection
5
+ # itself is the only thing ConnectionPool hands around — it never leaks the
6
+ # Async::HTTP::Client type further than butler/transport.rb.
7
+ class Butler::Connection
8
+ attr_reader :origin_key, :async_client, :http_version
9
+
10
+ def initialize(origin_key:, async_client:, http_version:)
11
+ @origin_key = origin_key
12
+ @async_client = async_client
13
+ @http_version = http_version
14
+ end
15
+
16
+ def close
17
+ @async_client&.close
18
+ rescue StandardError
19
+ nil
20
+ end
21
+ end