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,29 @@
1
+ class Butler
2
+ module Security
3
+ # An allow/block-list check against the request's host. This is
4
+ # deliberately simple (glob matching, no DNS-rebinding protection, no
5
+ # IP-literal/private-range awareness) — it's SSRF-*lite*, a cheap first
6
+ # line of defense, not a substitute for application-level SSRF
7
+ # protection, exactly as the PRD calls out.
8
+ module HostPolicy
9
+ module_function
10
+
11
+ def check!(uri, config)
12
+ security = config.security
13
+ host = uri.host.to_s
14
+
15
+ if security.allowed_hosts && security.allowed_hosts.none? { |pattern| match?(pattern, host) }
16
+ raise Butler::Errors::HostNotAllowed, "#{host} is not in security.allowed_hosts"
17
+ end
18
+
19
+ if security.blocked_hosts.any? { |pattern| match?(pattern, host) }
20
+ raise Butler::Errors::HostNotAllowed, "#{host} is in security.blocked_hosts"
21
+ end
22
+ end
23
+
24
+ def match?(pattern, host)
25
+ ::File.fnmatch?(pattern.to_s, host, ::File::FNM_CASEFOLD)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,39 @@
1
+ class Butler
2
+ module Security
3
+ # Enforces response header/body size caps. Header size is checked in one
4
+ # shot once the response headers are in hand; body size is checked
5
+ # incrementally as Transport reads chunks off the wire, so an
6
+ # oversized/slow-drip response is aborted rather than fully buffered
7
+ # into memory first.
8
+ module Limits
9
+ module_function
10
+
11
+ def check_headers!(headers, config)
12
+ max = config.security.max_header_size
13
+ return unless max
14
+
15
+ total = headers.to_a.sum { |k, v| k.bytesize + v.to_s.bytesize }
16
+ return unless total > max
17
+
18
+ raise Butler::Errors::LimitExceeded, "response headers exceeded security.max_header_size (#{max} bytes)"
19
+ end
20
+
21
+ # Reads +raw_body+ (anything responding to #each, yielding byte-string
22
+ # chunks) into a single buffered String, raising once it would exceed
23
+ # security.max_response_size. Returns "" if raw_body is nil.
24
+ def read_body(raw_body, config)
25
+ buffer = +""
26
+ return buffer unless raw_body
27
+
28
+ max = config.security.max_response_size
29
+ raw_body.each do |chunk|
30
+ buffer << chunk
31
+ next unless max && buffer.bytesize > max
32
+
33
+ raise Butler::Errors::LimitExceeded, "response body exceeded security.max_response_size (#{max} bytes)"
34
+ end
35
+ buffer
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,28 @@
1
+ class Butler
2
+ module Security
3
+ # Pure decision logic for following a redirect; the actual hop-by-hop
4
+ # loop lives in Client#request (see butler/client.rb) since each hop
5
+ # needs a fresh HostPolicy check and independent
6
+ # security/telemetry/circuit-breaker accounting against the *new* host
7
+ # while still sharing one Deadline across the whole call.
8
+ module RedirectPolicy
9
+ module_function
10
+
11
+ # 303 always downgrades to GET. 301/302 downgrade to GET too, unless
12
+ # the original request was HEAD — this matches how browsers (and most
13
+ # HTTP client libraries) behave in practice, rather than the stricter
14
+ # original RFC 7231 reading that would replay POST on 301/302.
15
+ def method_for(status, current_method)
16
+ case status
17
+ when 303 then "GET"
18
+ when 301, 302 then (current_method == "HEAD" ? "HEAD" : "GET")
19
+ else current_method
20
+ end
21
+ end
22
+
23
+ def strip_credentials?(config, from_uri, to_uri)
24
+ config.security.strip_credentials_on_redirect && from_uri.cross_origin?(to_uri)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,51 @@
1
+ class Butler
2
+ module Security
3
+ # TLS + hostname verification are on by default and only turned off by
4
+ # an explicit security.verify_tls = false — and doing so logs one loud
5
+ # warning per process so it's never silently insecure.
6
+ module TLS
7
+ module_function
8
+
9
+ def context_for(config)
10
+ context = ::OpenSSL::SSL::SSLContext.new
11
+ if config.security.verify_tls
12
+ # #set_params (not a bare verify_mode= assignment) is what loads
13
+ # the system's trusted CA store — a plain SSLContext.new has none
14
+ # at all, so every certificate would fail verification regardless
15
+ # of how legitimate it is.
16
+ context.set_params(verify_mode: ::OpenSSL::SSL::VERIFY_PEER)
17
+ else
18
+ warn_once!
19
+ context.set_params(verify_mode: ::OpenSSL::SSL::VERIFY_NONE)
20
+ end
21
+
22
+ # Async::HTTP::Endpoint's own `alpn_protocols:` option only takes
23
+ # effect on a context *it* builds internally — handing it a custom
24
+ # ssl_context (as Butler always does, for verify_mode/CA control)
25
+ # bypasses that, silently falling back to HTTP/1.1 negotiation.
26
+ # Setting alpn_protocols directly on this context is what actually
27
+ # makes ALPN negotiate h2 when both ends support it.
28
+ context.alpn_protocols = alpn_protocols_for(config)
29
+ context
30
+ end
31
+
32
+ def alpn_protocols_for(config)
33
+ case config.http_version
34
+ when :http1 then ["http/1.1"]
35
+ when :http2 then ["h2"]
36
+ else defined?(::Async::HTTP::Protocol::HTTPS) ? ::Async::HTTP::Protocol::HTTPS.names : ["h2", "http/1.1"]
37
+ end
38
+ end
39
+
40
+ def warn_once!
41
+ return if @warned
42
+
43
+ @warned = true
44
+ Butler::Telemetry::Logger.warn(
45
+ "Butler: TLS certificate verification is DISABLED (security.verify_tls = false). " \
46
+ "Do not use this outside of local development/testing.",
47
+ )
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,44 @@
1
+ # Wraps a streaming response body for callers who pass stream: true, without
2
+ # ever exposing the underlying Protocol::HTTP::Body::Readable that produced
3
+ # it — Transport::Async is the only place that constructs one.
4
+ class Butler::Stream
5
+ def initialize(&reader)
6
+ @reader = reader
7
+ @on_close = nil
8
+ @closed = false
9
+ end
10
+
11
+ def on_close(&block)
12
+ @on_close = block
13
+ self
14
+ end
15
+
16
+ # Yields each chunk in turn, closing the underlying connection once the
17
+ # caller has consumed (or abandoned) the stream.
18
+ def each_chunk
19
+ return enum_for(:each_chunk) unless block_given?
20
+
21
+ @reader.call { |chunk| yield chunk }
22
+ ensure
23
+ close
24
+ end
25
+
26
+ def read_all
27
+ buffer = +""
28
+ each_chunk { |chunk| buffer << chunk }
29
+ buffer
30
+ end
31
+ alias read read_all
32
+ alias to_s read_all
33
+
34
+ def close
35
+ return if @closed
36
+
37
+ @closed = true
38
+ @on_close&.call
39
+ end
40
+
41
+ def closed?
42
+ @closed
43
+ end
44
+ end
@@ -0,0 +1,59 @@
1
+ class Butler
2
+ module Telemetry
3
+ # A standalone publish/subscribe mechanism — no ActiveSupport required.
4
+ # #instrument measures wall-clock duration around the block, notifies
5
+ # every plain subscriber, and (only if ActiveSupport happens to already
6
+ # be loaded) also fires the equivalent ActiveSupport::Notifications
7
+ # event, so Rails-side tooling (e.g. the Rails log subscriber, or a
8
+ # custom one an app registers) sees "butler.request" for free.
9
+ #
10
+ # +payload+ is a plain Hash built from an explicit allow-list of fields
11
+ # by TelemetryMiddleware (method/host/port/status/duration/attempt/
12
+ # error) — this file never reads request/response bodies or
13
+ # Authorization/Cookie/Set-Cookie headers, so there's no redaction step
14
+ # that could be forgotten.
15
+ module Instrumentation
16
+ @subscribers = Hash.new { |hash, key| hash[key] = [] }
17
+ @mutex = Mutex.new
18
+
19
+ class << self
20
+ def subscribe(event, &block)
21
+ @mutex.synchronize { @subscribers[event.to_sym] << block }
22
+ block
23
+ end
24
+
25
+ def instrument(event, payload = {})
26
+ start = monotonic_now
27
+ result = with_active_support(event, payload) { yield payload }
28
+ payload[:duration] = monotonic_now - start
29
+ notify(event, payload)
30
+ result
31
+ rescue StandardError => e
32
+ payload[:duration] ||= monotonic_now - start
33
+ payload[:error] ||= e.class.name
34
+ notify(event, payload)
35
+ raise
36
+ end
37
+
38
+ private
39
+
40
+ def notify(event, payload)
41
+ subscribers = @mutex.synchronize { @subscribers[event.to_sym].dup }
42
+ subscribers.each { |subscriber| subscriber.call(payload.dup) }
43
+ end
44
+
45
+ def with_active_support(event, payload, &block)
46
+ if defined?(::ActiveSupport::Notifications)
47
+ ::ActiveSupport::Notifications.instrument("butler.#{event}", payload, &block)
48
+ else
49
+ block.call
50
+ end
51
+ end
52
+
53
+ def monotonic_now
54
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,37 @@
1
+ class Butler
2
+ module Telemetry
3
+ # One structured log line per completed request by default (subscribed
4
+ # below), plus a couple of standalone warnings (e.g. TLS verification
5
+ # disabled) other parts of Butler call directly. Uses
6
+ # Butler.configuration.telemetry.logger when set (Butler::Rails::Railtie
7
+ # points this at Rails.logger) and otherwise a plain
8
+ # Logger.new($stdout).
9
+ module Logger
10
+ module_function
11
+
12
+ def info(payload)
13
+ logger.info(format_payload(payload))
14
+ end
15
+
16
+ def warn(message)
17
+ logger.warn(message)
18
+ end
19
+
20
+ def logger
21
+ Butler.configuration.telemetry.logger || default_logger
22
+ end
23
+
24
+ def default_logger
25
+ @default_logger ||= ::Logger.new($stdout)
26
+ end
27
+
28
+ def format_payload(payload)
29
+ payload.map { |key, value| "#{key}=#{value.inspect}" }.join(" ")
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ Butler::Telemetry::Instrumentation.subscribe(:request) do |payload|
36
+ Butler::Telemetry::Logger.info(payload) if Butler.configuration.telemetry.enabled
37
+ end
@@ -0,0 +1,46 @@
1
+ class Butler
2
+ module Telemetry
3
+ # Only ever touched when the host application has already loaded
4
+ # opentelemetry-api itself — Butler never requires it. Wraps the *real*
5
+ # request execution in a proper span (rather than recording one after
6
+ # the fact), so span timing and any downstream context propagation
7
+ # (e.g. into the outgoing request headers, if the app wires that up via
8
+ # its own OTel HTTP instrumentation) behaves like any other traced call.
9
+ #
10
+ # NOTE: the attribute names below (http.request.method, server.address,
11
+ # server.port, http.response.status_code, network.protocol.name/
12
+ # version, error.type) reflect OpenTelemetry's HTTP semantic conventions
13
+ # as of this writing. Semantic conventions have changed before and will
14
+ # again — re-verify against the installed opentelemetry-semantic-
15
+ # conventions gem rather than trusting this comment indefinitely.
16
+ module OpenTelemetryBridge
17
+ module_function
18
+
19
+ def available?(config)
20
+ return false unless config.telemetry.opentelemetry
21
+ return false if config.telemetry.opentelemetry == false
22
+
23
+ defined?(::OpenTelemetry) && ::OpenTelemetry.respond_to?(:tracer_provider)
24
+ end
25
+
26
+ def in_span(payload)
27
+ tracer.in_span("HTTP #{payload[:method]}", kind: :client) do |span|
28
+ result = yield
29
+ span.set_attribute("http.request.method", payload[:method].to_s)
30
+ span.set_attribute("server.address", payload[:host].to_s) if payload[:host]
31
+ span.set_attribute("server.port", payload[:port]) if payload[:port]
32
+ span.set_attribute("http.response.status_code", payload[:status]) if payload[:status]
33
+ span.set_attribute("network.protocol.name", "http")
34
+ result
35
+ rescue StandardError => e
36
+ span.set_attribute("error.type", e.class.name)
37
+ raise
38
+ end
39
+ end
40
+
41
+ def tracer
42
+ ::OpenTelemetry.tracer_provider.tracer("butler-http", Butler::VERSION)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,29 @@
1
+ class Butler
2
+ module Testing
3
+ # Implements the same contract as Butler::Transport::Async (see
4
+ # butler/transport.rb) but never opens a real socket — it just matches
5
+ # the request against Butler::Testing::StubRegistry. Because it's
6
+ # selected by Butler::Transport.current at call time and plugged into
7
+ # the *same* pipeline, a stubbed response/error still flows through
8
+ # RetryMiddleware/CircuitBreakerMiddleware/TelemetryMiddleware exactly
9
+ # like a real one would.
10
+ module FakeTransport
11
+ module_function
12
+
13
+ def build_connection(uri, _config)
14
+ Butler::Connection.new(origin_key: uri.origin_key, async_client: nil, http_version: :fake)
15
+ end
16
+
17
+ def call(_connection, request, _config, timeout: nil)
18
+ stub = Butler::Testing::StubRegistry.match(request)
19
+ unless stub
20
+ raise Butler::Errors::RequestError,
21
+ "no stub registered for #{request.method} #{request.uri} " \
22
+ "(call Butler::Testing.stub_request(#{request.method.downcase.inspect}, \"#{request.uri.uri.path}\") first)"
23
+ end
24
+
25
+ stub.response_for(request)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,68 @@
1
+ class Butler
2
+ module Testing
3
+ # One registered stub: a method+URL/path matcher plus the canned
4
+ # response (or error) to hand back. Deliberately smaller than WebMock's
5
+ # API — one matcher shape (method + URL-or-path, optional header match),
6
+ # one response builder.
7
+ class Stub
8
+ def initialize(method, matcher)
9
+ @method = method.to_s.upcase
10
+ @matcher = matcher.to_s
11
+ @with_headers = nil
12
+ @response_status = 200
13
+ @response_headers = Butler::Headers.new
14
+ @response_body = ""
15
+ @raise_error = nil
16
+ end
17
+
18
+ def with(headers: nil)
19
+ @with_headers = headers
20
+ self
21
+ end
22
+
23
+ def to_return(status: 200, json: nil, body: nil, headers: {})
24
+ @response_status = status
25
+ @response_headers = Butler::Headers.coerce(headers)
26
+ @response_body =
27
+ if json
28
+ @response_headers["Content-Type"] = "application/json" unless @response_headers.key?("content-type")
29
+ json.to_json
30
+ else
31
+ body.to_s
32
+ end
33
+ self
34
+ end
35
+
36
+ def to_raise(error)
37
+ @raise_error = error
38
+ self
39
+ end
40
+
41
+ def match?(request)
42
+ request.method == @method && target_matches?(request) && headers_match?(request)
43
+ end
44
+
45
+ def response_for(request)
46
+ raise(@raise_error.is_a?(Class) ? @raise_error.new : @raise_error) if @raise_error
47
+
48
+ Butler::Response.new(uri: request.uri, status: @response_status, headers: @response_headers, body: @response_body)
49
+ end
50
+
51
+ private
52
+
53
+ def target_matches?(request)
54
+ if @matcher =~ %r{\Ahttps?://}i
55
+ request.uri.to_s.split("?").first == @matcher.split("?").first
56
+ else
57
+ request.uri.uri.path == @matcher
58
+ end
59
+ end
60
+
61
+ def headers_match?(request)
62
+ return true unless @with_headers
63
+
64
+ @with_headers.all? { |k, v| request.headers[k].to_s == v.to_s }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,32 @@
1
+ class Butler
2
+ module Testing
3
+ # Process-wide, thread-safe registry of active stubs. Its #active? is
4
+ # checked at the *start of every request* (see Butler::Transport.current)
5
+ # rather than once at Client construction time, so stubbing/unstubbing
6
+ # around a Butler::Client instance that already exists (the normal case
7
+ # in a test suite's setup/teardown) works correctly.
8
+ module StubRegistry
9
+ @stubs = []
10
+ @mutex = Mutex.new
11
+
12
+ class << self
13
+ def register(stub)
14
+ @mutex.synchronize { @stubs << stub }
15
+ stub
16
+ end
17
+
18
+ def match(request)
19
+ @mutex.synchronize { @stubs.reverse_each.find { |stub| stub.match?(request) } }
20
+ end
21
+
22
+ def active?
23
+ @mutex.synchronize { !@stubs.empty? }
24
+ end
25
+
26
+ def reset!
27
+ @mutex.synchronize { @stubs.clear }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,33 @@
1
+ class Butler
2
+ # Native request stubbing — no WebMock/VCR dependency needed. Stubs are
3
+ # matched at the transport boundary, so everything above it (retry,
4
+ # circuit breaker, telemetry) behaves exactly as it would against a real
5
+ # server.
6
+ #
7
+ # Butler::Testing.stub("GET", "https://api.example.com/users/1", status: 200, json: { id: 1 })
8
+ #
9
+ # stub = Butler::Testing.stub_request(:get, "/users/1")
10
+ # stub.to_return(status: 200, json: { id: 1 })
11
+ #
12
+ # Remember to call Butler::Testing.reset! between tests (e.g. in an
13
+ # after(:each) hook) — stubs are process-wide, not scoped to a Client.
14
+ module Testing
15
+ module_function
16
+
17
+ def stub(method, url, status: 200, json: nil, body: nil, headers: {})
18
+ stub_request(method, url).to_return(status: status, json: json, body: body, headers: headers)
19
+ end
20
+
21
+ def stub_request(method, url_or_path)
22
+ StubRegistry.register(Stub.new(method, url_or_path))
23
+ end
24
+
25
+ def reset!
26
+ StubRegistry.reset!
27
+ end
28
+
29
+ def active?
30
+ StubRegistry.active?
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,118 @@
1
+ require "async"
2
+ require "async/http/client"
3
+ require "async/http/endpoint"
4
+ require "protocol/http/body/buffered"
5
+
6
+ class Butler
7
+ module Transport
8
+ # The contract any transport implements — the real Async-backed one
9
+ # below, or Butler::Testing::FakeTransport. Not enforced through
10
+ # inheritance (Ruby doesn't need that to get the benefit); this exists
11
+ # purely as the documented interface a future real HTTP/3 transport
12
+ # would also implement.
13
+ module Base
14
+ # @return [Butler::Connection]
15
+ def self.build_connection(uri, config)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ # @return [Butler::Response]
20
+ def self.call(connection, request, config, timeout:)
21
+ raise NotImplementedError
22
+ end
23
+ end
24
+
25
+ # Which transport module is live right now. A single switch point used
26
+ # by both ConnectionPool (to build the right kind of Connection) and
27
+ # Client's pipeline terminal (to dispatch the actual call), so stubbing
28
+ # activated via Butler::Testing after a Client already exists still
29
+ # takes effect on its very next request.
30
+ def self.current
31
+ Butler::Testing::StubRegistry.active? ? Butler::Testing::FakeTransport : Async
32
+ end
33
+
34
+ # Wraps Async::HTTP::Client to give Butler real HTTP/1.1 *and* HTTP/2
35
+ # (transparent ALPN negotiation over TLS), Fiber-native I/O, and
36
+ # per-origin connection reuse/multiplexing — all implemented by the
37
+ # `async`/`async-http` gems rather than hand-written here. This is the
38
+ # only file in the gem that touches Async::HTTP/Protocol::HTTP types;
39
+ # everything above it deals exclusively in Butler::Request/Response.
40
+ #
41
+ # Async::HTTP::Client's own built-in retry is explicitly disabled
42
+ # (retries: 0 below) so Butler::Resilience::RetryPolicy is the single
43
+ # source of truth for retry decisions — otherwise a request could be
44
+ # silently retried twice (once inside async-http, once inside Butler's
45
+ # pipeline) and the Deadline accounting would no longer be accurate.
46
+ module Async
47
+ module_function
48
+
49
+ def build_connection(uri, config)
50
+ endpoint = build_endpoint(uri, config)
51
+ async_client = ::Async::HTTP::Client.new(endpoint, retries: 0)
52
+ Butler::Connection.new(origin_key: uri.origin_key, async_client: async_client, http_version: config.http_version)
53
+ end
54
+
55
+ def call(connection, request, config, timeout:)
56
+ headers = request.headers.to_a
57
+ body = build_request_body(request.body)
58
+
59
+ raw_response = Butler::Resilience::Timeout.enforce(timeout) do
60
+ connection.async_client.public_send(http_method(request.method), request.uri.request_target, headers, body)
61
+ end
62
+
63
+ build_response(request, config, raw_response)
64
+ rescue ::Errno::ECONNREFUSED, ::Errno::ECONNRESET, ::Errno::EPIPE, ::EOFError, ::SocketError => e
65
+ raise Butler::Errors::ConnectionError, "#{e.class}: #{e.message} requesting #{request.uri}"
66
+ rescue ::OpenSSL::SSL::SSLError => e
67
+ raise tls_error_class(e), "#{e.class}: #{e.message} requesting #{request.uri}"
68
+ end
69
+
70
+ # OpenSSL's own verification-failure text is a stable, unified
71
+ # "certificate verify failed (<reason>)" across self-signed/expired/
72
+ # hostname-mismatch/untrusted-root — confirmed live against
73
+ # badssl.com's test certificates for all four. Anything else raised
74
+ # as OpenSSL::SSL::SSLError (a handshake timeout, a reset
75
+ # mid-handshake) stays a plain TLSError, since those genuinely can
76
+ # be worth retrying — unlike a bad certificate, which won't become
77
+ # valid on the next attempt (see RetryPolicy::NON_RETRYABLE_EXCEPTIONS).
78
+ def tls_error_class(ssl_error)
79
+ ssl_error.message.include?("certificate verify failed") ? Butler::Errors::CertificateVerificationError : Butler::Errors::TLSError
80
+ end
81
+
82
+ def build_endpoint(uri, config)
83
+ options = { timeout: config.connect_timeout }
84
+ # ALPN protocol selection lives entirely in Security::TLS.context_for
85
+ # (it has to be set on the ssl_context object itself, not passed as
86
+ # a separate Endpoint.parse option — see that method for why).
87
+ options[:ssl_context] = Butler::Security::TLS.context_for(config) if uri.scheme == "https"
88
+ ::Async::HTTP::Endpoint.parse(uri.origin_key, **options)
89
+ end
90
+
91
+ def http_method(method)
92
+ method.to_s.downcase.to_sym
93
+ end
94
+
95
+ def build_request_body(body)
96
+ return nil if body.nil? || body.empty?
97
+
98
+ ::Protocol::HTTP::Body::Buffered.wrap(body.to_s)
99
+ end
100
+
101
+ def build_response(request, config, raw_response)
102
+ headers = Butler::Headers.from_a(raw_response.headers.to_a)
103
+ Butler::Security::Limits.check_headers!(headers, config)
104
+
105
+ body =
106
+ if request.stream?
107
+ Butler::Stream.new { |&block| raw_response.body&.each(&block) }.on_close { raw_response.close }
108
+ else
109
+ buffered = Butler::Security::Limits.read_body(raw_response.body, config)
110
+ raw_response.close
111
+ buffered
112
+ end
113
+
114
+ Butler::Response.new(uri: request.uri, status: raw_response.status, headers: headers, body: body)
115
+ end
116
+ end
117
+ end
118
+ end