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,113 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Quic
|
|
3
|
+
# Packet-level protection (RFC 9001 sections 5.3-5.4), scoped to the two
|
|
4
|
+
# operations Butler needs for one QUIC packet: turning an already-built
|
|
5
|
+
# plaintext header + payload into the bytes that go on the wire, and
|
|
6
|
+
# reversing that for a packet just read off the wire. Building the
|
|
7
|
+
# *plaintext* header itself (choosing dcid/scid/token, computing the
|
|
8
|
+
# Length field) for a packet Butler is about to send, and driving a
|
|
9
|
+
# whole connection's worth of these, is Stage 4/5 work — this module
|
|
10
|
+
# only protects/unprotects, it doesn't construct.
|
|
11
|
+
module Packet
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# +header+ is the complete unprotected header, ending in the true
|
|
15
|
+
# (unmasked) packet-number bytes — exactly RFC 9001 Appendix A's
|
|
16
|
+
# "*_PLAIN_HEADER" vectors. +pn_length+ is how many trailing bytes of
|
|
17
|
+
# +header+ are the packet number (1-4 bytes, RFC 9000 section 17.1).
|
|
18
|
+
def protect(header:, payload:, packet_number:, pn_length:, key:, iv:, hp_key:, cipher_suite: :aes_128_gcm)
|
|
19
|
+
nonce = Crypto::AEAD.nonce_for(iv, packet_number)
|
|
20
|
+
protected_payload = Crypto::AEAD.seal(cipher_suite, key, nonce, payload, header)
|
|
21
|
+
|
|
22
|
+
sample = protected_payload.byteslice(0, Crypto::HeaderProtection::SAMPLE_LENGTH)
|
|
23
|
+
mask = Crypto::HeaderProtection.mask_for(cipher_suite, hp_key, sample)
|
|
24
|
+
|
|
25
|
+
pn_offset = header.bytesize - pn_length
|
|
26
|
+
protected_header = header.dup
|
|
27
|
+
protected_header.setbyte(0, protected_header.getbyte(0) ^ (mask.getbyte(0) & first_byte_mask(header)))
|
|
28
|
+
pn_length.times do |i|
|
|
29
|
+
protected_header.setbyte(pn_offset + i, protected_header.getbyte(pn_offset + i) ^ mask.getbyte(1 + i))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
protected_header + protected_payload
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Parses a raw packet off the wire and returns
|
|
36
|
+
# [unprotected_header, plaintext_payload, packet_number]. +pn_offset+
|
|
37
|
+
# is the byte offset where the (still-protected) packet-number field
|
|
38
|
+
# begins — everything before it (version, connection IDs, token,
|
|
39
|
+
# Length) is never protected and so is readable without unmasking
|
|
40
|
+
# first. +expected_packet_number+ is the "largest packet number seen
|
|
41
|
+
# so far in this space, plus one" that RFC 9000 section 17.1's
|
|
42
|
+
# decoding algorithm needs to reconstruct the full packet number from
|
|
43
|
+
# its truncated wire encoding — for the very first packet in a space
|
|
44
|
+
# this is 0.
|
|
45
|
+
def unprotect(bytes, pn_offset:, expected_packet_number: 0, key:, iv:, hp_key:, cipher_suite: :aes_128_gcm)
|
|
46
|
+
# +bytes+ just came off a UDP socket — it's whatever an attacker (or
|
|
47
|
+
# a corrupted/truncated-in-transit datagram) sent, not something
|
|
48
|
+
# Butler built. Every offset below is a byteslice into attacker-
|
|
49
|
+
# controlled length, so this has to be checked *before* any of it
|
|
50
|
+
# reaches OpenSSL: an out-of-range start returns nil, not an empty
|
|
51
|
+
# string, and feeding nil/a short buffer to a cipher raises a raw
|
|
52
|
+
# TypeError or OpenSSL::Cipher::CipherError — neither is a
|
|
53
|
+
# Butler::Errors type, so nothing upstream could tell "malformed
|
|
54
|
+
# packet, drop it" apart from "the reactor task just crashed."
|
|
55
|
+
if pn_offset.negative? || bytes.bytesize < pn_offset + 4 + Crypto::HeaderProtection::SAMPLE_LENGTH
|
|
56
|
+
raise Butler::Errors::ProtocolError, "QUIC packet too short (#{bytes.bytesize} bytes) to unprotect at pn_offset #{pn_offset}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sample = bytes.byteslice(pn_offset + 4, Crypto::HeaderProtection::SAMPLE_LENGTH)
|
|
60
|
+
mask = Crypto::HeaderProtection.mask_for(cipher_suite, hp_key, sample)
|
|
61
|
+
|
|
62
|
+
header = bytes.byteslice(0, pn_offset).dup
|
|
63
|
+
first_byte = bytes.getbyte(0) ^ (mask.getbyte(0) & first_byte_mask(header))
|
|
64
|
+
header.setbyte(0, first_byte)
|
|
65
|
+
pn_length = (first_byte & 0x03) + 1
|
|
66
|
+
|
|
67
|
+
# No separate length check needed before slicing the packet number
|
|
68
|
+
# or the AEAD ciphertext+tag below: pn_length is at most 4, and the
|
|
69
|
+
# guard above already required bytes.bytesize >= pn_offset + 4 + 16
|
|
70
|
+
# — which is always >= pn_offset + pn_length + AEAD::TAG_LENGTH for
|
|
71
|
+
# any pn_length in 1..4, so both slices below are already in-bounds.
|
|
72
|
+
pn_bytes = (0...pn_length).map { |i| bytes.getbyte(pn_offset + i) ^ mask.getbyte(1 + i) }
|
|
73
|
+
header << pn_bytes.pack("C*")
|
|
74
|
+
truncated_pn = pn_bytes.reduce(0) { |acc, b| (acc << 8) | b }
|
|
75
|
+
packet_number = decode_packet_number(truncated_pn, pn_length * 8, expected_packet_number)
|
|
76
|
+
|
|
77
|
+
nonce = Crypto::AEAD.nonce_for(iv, packet_number)
|
|
78
|
+
ciphertext = bytes.byteslice(pn_offset + pn_length, bytes.bytesize - pn_offset - pn_length)
|
|
79
|
+
payload = Crypto::AEAD.open(cipher_suite, key, nonce, ciphertext, header)
|
|
80
|
+
|
|
81
|
+
[header, payload, packet_number]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# RFC 9000 Appendix A: reconstructs a full packet number from its
|
|
85
|
+
# truncated wire encoding using the connection's own view of what
|
|
86
|
+
# packet number it expected next — needed because the sender only
|
|
87
|
+
# sends as many low-order bytes as are provably unambiguous given
|
|
88
|
+
# what the receiver has already acknowledged.
|
|
89
|
+
def decode_packet_number(truncated, num_bits, expected)
|
|
90
|
+
window = 1 << num_bits
|
|
91
|
+
half_window = window / 2
|
|
92
|
+
candidate = (expected & ~(window - 1)) | truncated
|
|
93
|
+
|
|
94
|
+
if candidate <= expected - half_window && candidate < (1 << 62) - window
|
|
95
|
+
candidate + window
|
|
96
|
+
elsif candidate > expected + half_window && candidate >= window
|
|
97
|
+
candidate - window
|
|
98
|
+
else
|
|
99
|
+
candidate
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# RFC 9001 5.4.1: a long header's first byte only has its low 4 bits
|
|
104
|
+
# maskable (the header-form/fixed bits are excluded); a short
|
|
105
|
+
# header's has its low 5 bits maskable (also excluding the key-phase
|
|
106
|
+
# bit's neighbors). Bit 7 (0x80) distinguishes long from short.
|
|
107
|
+
def first_byte_mask(header)
|
|
108
|
+
header.getbyte(0).nobits?(0x80) ? 0x1f : 0x0f
|
|
109
|
+
end
|
|
110
|
+
private_class_method :first_byte_mask
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Quic
|
|
3
|
+
# QUIC's variable-length integer encoding (RFC 9000 section 16): the top
|
|
4
|
+
# two bits of the first byte select the encoded length (1, 2, 4, or 8
|
|
5
|
+
# bytes, holding 6/14/30/62 bits of value respectively), so small values
|
|
6
|
+
# — which dominate QUIC's wire format — cost far fewer bytes than a
|
|
7
|
+
# fixed-width encoding would.
|
|
8
|
+
module Varint
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
MAX_VALUE = (1 << 62) - 1
|
|
12
|
+
|
|
13
|
+
# Encodes +value+ using the smallest length that fits it, returning
|
|
14
|
+
# the raw bytes as a binary String.
|
|
15
|
+
def encode(value)
|
|
16
|
+
raise ArgumentError, "#{value} does not fit in a QUIC varint" if value.negative? || value > MAX_VALUE
|
|
17
|
+
|
|
18
|
+
if value <= 0x3f
|
|
19
|
+
[value].pack("C")
|
|
20
|
+
elsif value <= 0x3fff
|
|
21
|
+
[0x4000 | value].pack("n")
|
|
22
|
+
elsif value <= 0x3fffffff
|
|
23
|
+
[0x80000000 | value].pack("N")
|
|
24
|
+
else
|
|
25
|
+
[0xc000000000000000 | value].pack("Q>")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# How many bytes #encode(value) would produce, without building them.
|
|
30
|
+
def encoded_length(value)
|
|
31
|
+
raise ArgumentError, "#{value} does not fit in a QUIC varint" if value.negative? || value > MAX_VALUE
|
|
32
|
+
|
|
33
|
+
if value <= 0x3f then 1
|
|
34
|
+
elsif value <= 0x3fff then 2
|
|
35
|
+
elsif value <= 0x3fffffff then 4
|
|
36
|
+
else 8
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Decodes one varint starting at the beginning of +bytes+, returning
|
|
41
|
+
# [value, bytes_consumed] — the caller slices off bytes_consumed to
|
|
42
|
+
# continue parsing whatever follows.
|
|
43
|
+
def decode(bytes)
|
|
44
|
+
first = bytes.getbyte(0)
|
|
45
|
+
raise ArgumentError, "empty input" unless first
|
|
46
|
+
|
|
47
|
+
length = 1 << (first >> 6)
|
|
48
|
+
raise ArgumentError, "truncated varint" if bytes.bytesize < length
|
|
49
|
+
|
|
50
|
+
value = first & 0x3f
|
|
51
|
+
(1...length).each { |i| value = (value << 8) | bytes.getbyte(i) }
|
|
52
|
+
[value, length]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# The ActiveSupport::Notifications bridge itself is automatic (see
|
|
2
|
+
# Butler::Telemetry::Instrumentation, which checks
|
|
3
|
+
# `defined?(ActiveSupport::Notifications)` at instrument-time and needs no
|
|
4
|
+
# separate wiring). What's Rails-specific and worth this file: pointing
|
|
5
|
+
# Butler's structured request log line at Rails.logger instead of the bare
|
|
6
|
+
# Logger.new($stdout) default, without overriding anything an application
|
|
7
|
+
# already configured explicitly.
|
|
8
|
+
Butler.configure do |config|
|
|
9
|
+
config.telemetry.logger ||= ::Rails.logger
|
|
10
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Only ever required from the guarded line at the bottom of lib/butler.rb
|
|
2
|
+
# (`require "butler/rails/railtie" if defined?(::Rails::Railtie)`), so
|
|
3
|
+
# `require "butler"` keeps working standalone in a non-Rails process.
|
|
4
|
+
class Butler
|
|
5
|
+
module Rails
|
|
6
|
+
class Railtie < ::Rails::Railtie
|
|
7
|
+
initializer "butler.notifications" do
|
|
8
|
+
require "butler/rails/notifications"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
config.after_initialize do
|
|
12
|
+
# ActiveSupport::ForkTracker is the same mechanism Active Record
|
|
13
|
+
# itself uses to reset connections after Puma (or any preload_app!
|
|
14
|
+
# server) forks a worker — without this, a forked worker would
|
|
15
|
+
# inherit the parent's live Async::HTTP::Client/reactor state and
|
|
16
|
+
# corrupt it the moment two processes tried to use the same
|
|
17
|
+
# sockets.
|
|
18
|
+
if defined?(::ActiveSupport::ForkTracker)
|
|
19
|
+
::ActiveSupport::ForkTracker.after_fork { Butler.reset_connections! }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# An immutable-ish value object describing one HTTP call: method, resolved
|
|
2
|
+
# Butler::URI, Butler::Headers, and an optional Butler::Body. Built fresh for
|
|
3
|
+
# every attempt/redirect hop by Client#request; Transport is the only thing
|
|
4
|
+
# downstream that reads it.
|
|
5
|
+
class Butler::Request
|
|
6
|
+
attr_reader :method, :uri, :headers, :body, :idempotent, :basic_auth
|
|
7
|
+
|
|
8
|
+
def self.build(method:, uri:, options:, config:)
|
|
9
|
+
headers = config.default_headers.merge(Butler::Headers.coerce(options[:headers] || {}))
|
|
10
|
+
body = Butler::Body.wrap(options)
|
|
11
|
+
|
|
12
|
+
if body && !body.empty? && !headers.key?("content-type") && body.content_type
|
|
13
|
+
headers["Content-Type"] = body.content_type
|
|
14
|
+
end
|
|
15
|
+
headers["User-Agent"] = config.user_agent if config.user_agent && !headers.key?("user-agent")
|
|
16
|
+
|
|
17
|
+
new(
|
|
18
|
+
method: method.to_s.upcase,
|
|
19
|
+
uri: uri,
|
|
20
|
+
headers: headers,
|
|
21
|
+
body: body,
|
|
22
|
+
idempotent: options.fetch(:idempotent, false),
|
|
23
|
+
basic_auth: options[:basic_auth],
|
|
24
|
+
stream: options.fetch(:stream, false),
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(method:, uri:, headers:, body: nil, idempotent: false, basic_auth: nil, stream: false)
|
|
29
|
+
@method = method
|
|
30
|
+
@uri = uri
|
|
31
|
+
@headers = headers
|
|
32
|
+
@body = body
|
|
33
|
+
@idempotent = idempotent
|
|
34
|
+
@basic_auth = basic_auth
|
|
35
|
+
@stream = stream
|
|
36
|
+
apply_basic_auth!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def stream?
|
|
40
|
+
@stream
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def origin_key
|
|
44
|
+
uri.origin_key
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# GET/HEAD/OPTIONS are always safe to retry; anything else only becomes
|
|
48
|
+
# retry-eligible (on a retryable status code) when the caller explicitly
|
|
49
|
+
# opts in via idempotent: true.
|
|
50
|
+
def safe?
|
|
51
|
+
%w[GET HEAD OPTIONS].include?(method)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def retry_eligible?
|
|
55
|
+
safe? || idempotent
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def with_uri(new_uri)
|
|
59
|
+
copy = dup
|
|
60
|
+
copy.instance_variable_set(:@uri, new_uri)
|
|
61
|
+
copy
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Used on a cross-origin redirect: drops Authorization/Cookie so
|
|
65
|
+
# credentials for the original host aren't handed to a different one.
|
|
66
|
+
def without_credentials
|
|
67
|
+
stripped = headers.dup
|
|
68
|
+
stripped.delete("authorization")
|
|
69
|
+
stripped.delete("cookie")
|
|
70
|
+
copy = dup
|
|
71
|
+
copy.instance_variable_set(:@headers, stripped)
|
|
72
|
+
copy.instance_variable_set(:@basic_auth, nil)
|
|
73
|
+
copy
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def apply_basic_auth!
|
|
79
|
+
case basic_auth
|
|
80
|
+
in [user, password]
|
|
81
|
+
set_basic_auth(user, password)
|
|
82
|
+
in { username:, password: }
|
|
83
|
+
set_basic_auth(username, password)
|
|
84
|
+
else
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def set_basic_auth(user, password)
|
|
90
|
+
headers["Authorization"] = "Basic #{["#{user}:#{password}"].pack("m0")}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Resilience
|
|
3
|
+
# Exponential backoff with equal-jitter: the raw exponential delay is
|
|
4
|
+
# scaled by a random factor in [0.5, 1.0) rather than being used
|
|
5
|
+
# verbatim, so many clients failing at once don't all retry in lockstep
|
|
6
|
+
# against a recovering server.
|
|
7
|
+
module Backoff
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def delay_for(attempt, retry_options)
|
|
11
|
+
base = retry_options.base_delay * (2**(attempt - 1))
|
|
12
|
+
capped = [base, retry_options.max_delay].min
|
|
13
|
+
retry_options.jitter ? capped * (0.5 + (rand * 0.5)) : capped
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Resilience
|
|
3
|
+
# A CLOSED -> OPEN -> HALF_OPEN -> CLOSED/OPEN state machine, scoped
|
|
4
|
+
# either per-client (circuit_breaker.scope = :client, one shared state)
|
|
5
|
+
# or per-host (the default) so one struggling upstream doesn't trip the
|
|
6
|
+
# breaker for every other host the same client talks to.
|
|
7
|
+
#
|
|
8
|
+
# One Mutex guards every state transition. Ruby Fibers never run two at
|
|
9
|
+
# once on the same thread (they're cooperative, not preemptive), so this
|
|
10
|
+
# is really guarding against ordinary multi-threaded use (e.g. several
|
|
11
|
+
# Puma threads sharing one Butler::Client) — the critical sections are
|
|
12
|
+
# tiny bookkeeping only, never I/O, so contention is not a concern.
|
|
13
|
+
#
|
|
14
|
+
# State is capped at max_tracked_hosts (a Hash-based LRU: touching an
|
|
15
|
+
# entry re-inserts it at the end, and the oldest entry is evicted once
|
|
16
|
+
# the cap is exceeded) so a client that fans out to many different
|
|
17
|
+
# hosts (e.g. webhook delivery) can't grow this unboundedly.
|
|
18
|
+
class CircuitBreaker
|
|
19
|
+
HostState = Struct.new(:status, :failure_count, :opened_at, :probing)
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@states = {}
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def scope_key_for(request, config)
|
|
27
|
+
config.circuit_breaker.scope == :client ? :client : request.uri.host
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# +failure+ decides whether a *non-raising* result still counts as a
|
|
31
|
+
# circuit-breaker failure (e.g. a 5xx response returned normally
|
|
32
|
+
# rather than raised, since Butler only raises on error responses
|
|
33
|
+
# when raise_on_error is set) — an exception from the block always
|
|
34
|
+
# counts as a failure regardless of +failure+.
|
|
35
|
+
def call(scope_key, circuit_breaker_options, failure: ->(_result) { false })
|
|
36
|
+
state = fetch(scope_key, circuit_breaker_options)
|
|
37
|
+
guard!(state, circuit_breaker_options)
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
result = yield
|
|
41
|
+
if failure.call(result)
|
|
42
|
+
record_failure!(state, circuit_breaker_options)
|
|
43
|
+
else
|
|
44
|
+
record_success!(state)
|
|
45
|
+
end
|
|
46
|
+
result
|
|
47
|
+
rescue Butler::Errors::CircuitOpen
|
|
48
|
+
raise
|
|
49
|
+
rescue StandardError
|
|
50
|
+
record_failure!(state, circuit_breaker_options)
|
|
51
|
+
raise
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def fetch(scope_key, circuit_breaker_options)
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
state = @states.delete(scope_key) || HostState.new(:closed, 0, nil, false)
|
|
60
|
+
@states[scope_key] = state # re-insert at the end: cheap LRU touch
|
|
61
|
+
evict!(circuit_breaker_options)
|
|
62
|
+
state
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def guard!(state, circuit_breaker_options)
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
case state.status
|
|
69
|
+
when :open
|
|
70
|
+
if monotonic_now - state.opened_at >= circuit_breaker_options.recovery_timeout
|
|
71
|
+
raise Butler::Errors::CircuitOpen, "circuit half-open probe already in flight" if state.probing
|
|
72
|
+
|
|
73
|
+
state.status = :half_open
|
|
74
|
+
state.probing = true
|
|
75
|
+
else
|
|
76
|
+
raise Butler::Errors::CircuitOpen, "circuit is open"
|
|
77
|
+
end
|
|
78
|
+
when :half_open
|
|
79
|
+
raise Butler::Errors::CircuitOpen, "circuit half-open probe already in flight" if state.probing
|
|
80
|
+
|
|
81
|
+
state.probing = true
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def record_success!(state)
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
state.status = :closed
|
|
89
|
+
state.failure_count = 0
|
|
90
|
+
state.opened_at = nil
|
|
91
|
+
state.probing = false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def record_failure!(state, circuit_breaker_options)
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
state.probing = false
|
|
98
|
+
if state.status == :half_open
|
|
99
|
+
state.status = :open
|
|
100
|
+
state.opened_at = monotonic_now
|
|
101
|
+
else
|
|
102
|
+
state.failure_count += 1
|
|
103
|
+
if state.failure_count >= circuit_breaker_options.failure_threshold
|
|
104
|
+
state.status = :open
|
|
105
|
+
state.opened_at = monotonic_now
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def evict!(circuit_breaker_options)
|
|
112
|
+
@states.shift while @states.size > circuit_breaker_options.max_tracked_hosts
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def monotonic_now
|
|
116
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Resilience
|
|
3
|
+
# A total wall-clock budget for one Client#request call — DNS, connect,
|
|
4
|
+
# TLS, every retry attempt, every redirect hop, all count against the
|
|
5
|
+
# same Deadline. It is created once per call and threaded through
|
|
6
|
+
# Pipeline::Context; nothing downstream (RetryMiddleware included) is
|
|
7
|
+
# ever allowed to reset it, which is exactly the PRD's core rule:
|
|
8
|
+
# retries must never reset the total deadline.
|
|
9
|
+
class Deadline
|
|
10
|
+
def self.start(seconds)
|
|
11
|
+
seconds ? new(seconds) : Unbounded.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(seconds)
|
|
15
|
+
@end_at = monotonic_now + seconds
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def remaining
|
|
19
|
+
[@end_at - monotonic_now, 0].max
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def expired?
|
|
23
|
+
remaining <= 0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def exceeded!
|
|
27
|
+
raise Butler::Errors::TimeoutError, "deadline exceeded" if expired?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def monotonic_now
|
|
33
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class Deadline::Unbounded < Deadline
|
|
38
|
+
def initialize
|
|
39
|
+
# deliberately does not call super — there is no @end_at to compute
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def remaining
|
|
43
|
+
Float::INFINITY
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def expired?
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def exceeded!
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Resilience
|
|
3
|
+
# Decides whether a failed attempt should be retried, and how long to
|
|
4
|
+
# wait first. Two independent questions, deliberately kept separate:
|
|
5
|
+
#
|
|
6
|
+
# - #retry_on_exception? — the request never got a well-formed HTTP
|
|
7
|
+
# response at all (connection refused/reset, TLS failure, timeout).
|
|
8
|
+
# Retried for *any* HTTP method: nothing that reached the server can
|
|
9
|
+
# be confirmed either way, and refusing to retry a network-level
|
|
10
|
+
# failure wouldn't make a non-idempotent request any safer, just
|
|
11
|
+
# less reliable.
|
|
12
|
+
# - #retry_on_response? — a response *was* received. Only retried when
|
|
13
|
+
# its status is in retryable_status_codes *and* the method is
|
|
14
|
+
# GET/HEAD/OPTIONS or was explicitly marked idempotent: true. POST is
|
|
15
|
+
# never auto-retried on a 5xx.
|
|
16
|
+
#
|
|
17
|
+
# Both refuse to retry once the deadline has expired or max_attempts is
|
|
18
|
+
# reached, regardless of the above.
|
|
19
|
+
class RetryPolicy
|
|
20
|
+
RETRYABLE_EXCEPTIONS = [
|
|
21
|
+
Butler::Errors::ConnectionError,
|
|
22
|
+
Butler::Errors::TimeoutError,
|
|
23
|
+
Butler::Errors::TLSError,
|
|
24
|
+
Butler::Errors::DNSFailure,
|
|
25
|
+
Butler::Errors::ProtocolError,
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# Exceptions checked *before* RETRYABLE_EXCEPTIONS and, if matched,
|
|
29
|
+
# never retried even though they're a subclass of something in that
|
|
30
|
+
# list above (Errors::CertificateVerificationError < Errors::TLSError)
|
|
31
|
+
# — a bad certificate won't become valid on the next attempt, so
|
|
32
|
+
# retrying it only delays surfacing a real problem.
|
|
33
|
+
NON_RETRYABLE_EXCEPTIONS = [
|
|
34
|
+
Butler::Errors::CertificateVerificationError,
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
def retry_on_exception?(exception:, attempt:, deadline:, retry_options:)
|
|
38
|
+
return false if deadline.expired?
|
|
39
|
+
return false if attempt >= retry_options.max_attempts
|
|
40
|
+
return false if NON_RETRYABLE_EXCEPTIONS.any? { |klass| exception.is_a?(klass) }
|
|
41
|
+
|
|
42
|
+
RETRYABLE_EXCEPTIONS.any? { |klass| exception.is_a?(klass) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def retry_on_response?(request:, response:, attempt:, deadline:, retry_options:)
|
|
46
|
+
return false if deadline.expired?
|
|
47
|
+
return false if attempt >= retry_options.max_attempts
|
|
48
|
+
return false unless retry_options.retryable_status_codes.include?(response.status)
|
|
49
|
+
|
|
50
|
+
request.retry_eligible?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def delay_for(response: nil, attempt:, retry_options:, deadline:)
|
|
54
|
+
retry_after = response && parse_retry_after(response)
|
|
55
|
+
computed = retry_after || Backoff.delay_for(attempt, retry_options)
|
|
56
|
+
[computed, deadline.remaining].min
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def parse_retry_after(response)
|
|
62
|
+
value = response.headers["retry-after"]
|
|
63
|
+
return nil unless value
|
|
64
|
+
|
|
65
|
+
if value.match?(/\A\d+\z/)
|
|
66
|
+
value.to_i
|
|
67
|
+
else
|
|
68
|
+
delay = ::Time.httpdate(value) - ::Time.now
|
|
69
|
+
delay.positive? ? delay : 0
|
|
70
|
+
end
|
|
71
|
+
rescue ArgumentError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Resilience
|
|
3
|
+
# A single helper for wrapping a block with a per-attempt timeout via
|
|
4
|
+
# the async gem's Fiber-aware Task#with_timeout, translating its
|
|
5
|
+
# Async::TimeoutError into Butler's own Errors::TimeoutError so callers
|
|
6
|
+
# never need to rescue an async-gem class directly. Used by
|
|
7
|
+
# Transport::Async to enforce connect/read/write-ish budgets without
|
|
8
|
+
# duplicating this logic in every transport method.
|
|
9
|
+
module Timeout
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def enforce(seconds)
|
|
13
|
+
return yield if seconds.nil? || !seconds.finite?
|
|
14
|
+
raise Butler::Errors::TimeoutError, "deadline exceeded" if seconds <= 0
|
|
15
|
+
|
|
16
|
+
::Async::Task.current.with_timeout(seconds) { yield }
|
|
17
|
+
rescue ::Async::TimeoutError => e
|
|
18
|
+
raise Butler::Errors::TimeoutError, e.message
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# One consistent response shape regardless of protocol (HTTP/1.1 vs HTTP/2)
|
|
2
|
+
# or whether it came from the real transport or Butler::Testing's fake one.
|
|
3
|
+
# +body+ is either a String (the default, fully buffered) or a Butler::Stream
|
|
4
|
+
# (when the request was made with stream: true).
|
|
5
|
+
class Butler::Response
|
|
6
|
+
attr_reader :uri, :status, :headers
|
|
7
|
+
|
|
8
|
+
def initialize(uri:, status:, headers:, body:)
|
|
9
|
+
@uri = uri
|
|
10
|
+
@status = status
|
|
11
|
+
@headers = headers
|
|
12
|
+
@body = body
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def body
|
|
16
|
+
@body.is_a?(Butler::Stream) ? @body.read_all : @body
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The raw Butler::Stream, for callers who passed stream: true and want to
|
|
20
|
+
# consume it chunk-by-chunk instead of buffering it via #body.
|
|
21
|
+
def stream
|
|
22
|
+
@body
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
alias code status # familiar alias for anyone coming from Net::HTTP-style clients
|
|
26
|
+
|
|
27
|
+
def informational?
|
|
28
|
+
(100..199).cover?(status)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def success?
|
|
32
|
+
(200..299).cover?(status)
|
|
33
|
+
end
|
|
34
|
+
alias ok? success?
|
|
35
|
+
|
|
36
|
+
def redirect?
|
|
37
|
+
(300..399).cover?(status)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def client_error?
|
|
41
|
+
(400..499).cover?(status)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def server_error?
|
|
45
|
+
(500..599).cover?(status)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def error?
|
|
49
|
+
client_error? || server_error?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def content_type
|
|
53
|
+
headers["content-type"].to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def json?
|
|
57
|
+
content_type.include?("json")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Parses the body as JSON when the response looks like JSON, memoized
|
|
61
|
+
# since callers commonly read this more than once. Returns nil (rather
|
|
62
|
+
# than raising) when the body isn't valid JSON despite the header, since
|
|
63
|
+
# plenty of upstream services get their own Content-Type wrong.
|
|
64
|
+
def json
|
|
65
|
+
return @json if defined?(@json)
|
|
66
|
+
|
|
67
|
+
@json =
|
|
68
|
+
if json? && !body.to_s.empty?
|
|
69
|
+
begin
|
|
70
|
+
JSON.parse(body)
|
|
71
|
+
rescue JSON::ParserError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
alias parsed json
|
|
77
|
+
|
|
78
|
+
def to_s
|
|
79
|
+
body.to_s
|
|
80
|
+
end
|
|
81
|
+
end
|