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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE.txt +22 -0
- data/README.md +659 -0
- data/Rakefile +10 -0
- data/lib/butler/async.rb +130 -0
- data/lib/butler/body.rb +130 -0
- data/lib/butler/client.rb +206 -0
- data/lib/butler/configuration.rb +161 -0
- data/lib/butler/connection.rb +21 -0
- data/lib/butler/connection_pool.rb +64 -0
- data/lib/butler/errors.rb +78 -0
- data/lib/butler/headers.rb +106 -0
- data/lib/butler/pipeline/chain.rb +18 -0
- data/lib/butler/pipeline/context.rb +21 -0
- data/lib/butler/pipeline/middleware.rb +16 -0
- data/lib/butler/pipeline/middlewares/circuit_breaker_middleware.rb +21 -0
- data/lib/butler/pipeline/middlewares/retry_middleware.rb +67 -0
- data/lib/butler/pipeline/middlewares/security_middleware.rb +14 -0
- data/lib/butler/pipeline/middlewares/telemetry_middleware.rb +37 -0
- data/lib/butler/pipeline/middlewares/timeout_middleware.rb +17 -0
- data/lib/butler/quic/crypto/aead.rb +69 -0
- data/lib/butler/quic/crypto/header_protection.rb +51 -0
- data/lib/butler/quic/crypto/hkdf.rb +44 -0
- data/lib/butler/quic/crypto/key_schedule.rb +50 -0
- data/lib/butler/quic/packet.rb +113 -0
- data/lib/butler/quic/varint.rb +56 -0
- data/lib/butler/rails/notifications.rb +10 -0
- data/lib/butler/rails/railtie.rb +24 -0
- data/lib/butler/request.rb +92 -0
- data/lib/butler/resilience/backoff.rb +17 -0
- data/lib/butler/resilience/circuit_breaker.rb +120 -0
- data/lib/butler/resilience/deadline.rb +55 -0
- data/lib/butler/resilience/retry_policy.rb +76 -0
- data/lib/butler/resilience/timeout.rb +22 -0
- data/lib/butler/response.rb +81 -0
- data/lib/butler/security/host_policy.rb +29 -0
- data/lib/butler/security/limits.rb +39 -0
- data/lib/butler/security/redirect_policy.rb +28 -0
- data/lib/butler/security/tls.rb +51 -0
- data/lib/butler/stream.rb +44 -0
- data/lib/butler/telemetry/instrumentation.rb +59 -0
- data/lib/butler/telemetry/logger.rb +37 -0
- data/lib/butler/telemetry/open_telemetry_bridge.rb +46 -0
- data/lib/butler/testing/fake_transport.rb +29 -0
- data/lib/butler/testing/stub.rb +68 -0
- data/lib/butler/testing/stub_registry.rb +32 -0
- data/lib/butler/testing.rb +33 -0
- data/lib/butler/transport.rb +118 -0
- data/lib/butler/uri.rb +77 -0
- data/lib/butler/version.rb +3 -0
- data/lib/butler.rb +176 -0
- data/sig/butler/client.rbs +41 -0
- data/sig/butler/configuration.rbs +63 -0
- data/sig/butler/errors.rbs +67 -0
- data/sig/butler/headers.rbs +22 -0
- data/sig/butler/request.rbs +21 -0
- data/sig/butler/response.rbs +28 -0
- data/sig/butler.rbs +22 -0
- 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
|