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
data/lib/butler/async.rb
ADDED
|
@@ -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
|
data/lib/butler/body.rb
ADDED
|
@@ -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
|