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,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