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
data/lib/butler/uri.rb ADDED
@@ -0,0 +1,77 @@
1
+ # Wraps ::URI to add the handful of things Butler needs repeatedly: joining
2
+ # a client's base_url with a per-request path, merging a params: hash into
3
+ # the query string, and the origin/cross-origin comparisons the connection
4
+ # pool and redirect/credential-stripping logic depend on.
5
+ class Butler::URI
6
+ attr_reader :uri
7
+
8
+ def self.build(base_url, path = nil, params = nil)
9
+ resolved = resolve(base_url, path)
10
+ new(::URI.parse(resolved)).with_params(params)
11
+ end
12
+
13
+ def self.resolve(base_url, path)
14
+ if path.nil? || path.to_s.empty?
15
+ base_url.to_s
16
+ elsif path.to_s =~ %r{\Ahttps?://}i
17
+ path.to_s
18
+ elsif base_url
19
+ ::URI.join(base_url.to_s, path.to_s).to_s
20
+ else
21
+ path.to_s
22
+ end
23
+ end
24
+ private_class_method :resolve
25
+
26
+ def initialize(uri)
27
+ @uri = uri
28
+ end
29
+
30
+ def with_params(params)
31
+ return self if params.nil? || params.empty?
32
+
33
+ existing = uri.query ? ::URI.decode_www_form(uri.query) : []
34
+ new_uri = uri.dup
35
+ new_uri.query = ::URI.encode_www_form(existing + params.to_a)
36
+ self.class.new(new_uri)
37
+ end
38
+
39
+ def scheme
40
+ uri.scheme
41
+ end
42
+
43
+ def host
44
+ uri.host
45
+ end
46
+
47
+ def port
48
+ uri.port
49
+ end
50
+
51
+ # "scheme://host:port" — the key the connection pool groups connections by.
52
+ def origin_key
53
+ "#{scheme}://#{host}:#{port}"
54
+ end
55
+
56
+ # The path+query Butler sends on the wire (never the full absolute URL).
57
+ def request_target
58
+ target = uri.path.to_s
59
+ target = "/" if target.empty?
60
+ target += "?#{uri.query}" if uri.query
61
+ target
62
+ end
63
+
64
+ def cross_origin?(other)
65
+ origin_key != other.origin_key
66
+ end
67
+
68
+ # Resolves a redirect's Location header (absolute or relative) against
69
+ # this URI.
70
+ def merge(location)
71
+ self.class.new(uri.merge(location.to_s))
72
+ end
73
+
74
+ def to_s
75
+ uri.to_s
76
+ end
77
+ end
@@ -0,0 +1,3 @@
1
+ class Butler
2
+ VERSION = "0.1.0"
3
+ end
data/lib/butler.rb ADDED
@@ -0,0 +1,176 @@
1
+ require "json"
2
+ require "uri"
3
+ require "openssl"
4
+ require "logger"
5
+ require "weakref"
6
+
7
+ require_relative "butler/version"
8
+ require_relative "butler/errors"
9
+ require_relative "butler/headers"
10
+ require_relative "butler/uri"
11
+ require_relative "butler/body"
12
+ require_relative "butler/stream"
13
+ require_relative "butler/configuration"
14
+ require_relative "butler/request"
15
+ require_relative "butler/response"
16
+
17
+ require_relative "butler/telemetry/instrumentation"
18
+ require_relative "butler/telemetry/open_telemetry_bridge"
19
+ require_relative "butler/telemetry/logger"
20
+
21
+ require_relative "butler/security/tls"
22
+ require_relative "butler/security/host_policy"
23
+ require_relative "butler/security/redirect_policy"
24
+ require_relative "butler/security/limits"
25
+
26
+ require_relative "butler/resilience/deadline"
27
+ require_relative "butler/resilience/timeout"
28
+ require_relative "butler/resilience/backoff"
29
+ require_relative "butler/resilience/retry_policy"
30
+ require_relative "butler/resilience/circuit_breaker"
31
+
32
+ require_relative "butler/testing/stub"
33
+ require_relative "butler/testing/stub_registry"
34
+ require_relative "butler/testing/fake_transport"
35
+ require_relative "butler/testing"
36
+
37
+ require_relative "butler/connection"
38
+ require_relative "butler/transport"
39
+ require_relative "butler/connection_pool"
40
+
41
+ # QUIC/HTTP3 (see docs/architecture.md and the "hand-rolled HTTP/3 (QUIC)
42
+ # support" plan): foundational packet-protection code only so far, not yet
43
+ # reachable from Client/Transport.current or any config option — nothing
44
+ # above this line depends on it, and requiring butler still works exactly
45
+ # as before if you never touch Butler::Quic directly.
46
+ require_relative "butler/quic/varint"
47
+ require_relative "butler/quic/crypto/hkdf"
48
+ require_relative "butler/quic/crypto/key_schedule"
49
+ require_relative "butler/quic/crypto/aead"
50
+ require_relative "butler/quic/crypto/header_protection"
51
+ require_relative "butler/quic/packet"
52
+
53
+ require_relative "butler/pipeline/context"
54
+ require_relative "butler/pipeline/middleware"
55
+ require_relative "butler/pipeline/chain"
56
+ require_relative "butler/pipeline/middlewares/security_middleware"
57
+ require_relative "butler/pipeline/middlewares/telemetry_middleware"
58
+ require_relative "butler/pipeline/middlewares/timeout_middleware"
59
+ require_relative "butler/pipeline/middlewares/circuit_breaker_middleware"
60
+ require_relative "butler/pipeline/middlewares/retry_middleware"
61
+
62
+ require_relative "butler/async"
63
+ require_relative "butler/client"
64
+
65
+ # Butler is a Fiber-native HTTP client: HTTP/1.1 and HTTP/2 (transparent ALPN
66
+ # negotiation, multiplexed connection reuse via the `async`/`async-http`
67
+ # gems), structured concurrency, built-in resilience (timeouts, deadlines,
68
+ # retries with backoff+jitter, a circuit breaker), security-conscious
69
+ # defaults, a small middleware pipeline, standalone instrumentation with
70
+ # optional OpenTelemetry/Rails bridges, and native request stubbing for
71
+ # tests — none of which ever exposes its underlying transport in the public
72
+ # API. See docs/architecture.md for the full request lifecycle.
73
+ #
74
+ # Module-level calls (Butler.get/post/...) are the quickest way to reach
75
+ # for Butler — they run against Butler.default_client, one lazily-built,
76
+ # connection-pooled Butler::Client shared for the life of the process, so
77
+ # repeated calls still get everything a real Client gives you (pooling,
78
+ # retries, a circuit breaker) rather than reconnecting from scratch each
79
+ # time:
80
+ #
81
+ # response = Butler.get("https://api.example.com/users")
82
+ # response = Butler.post("https://api.example.com/users", json: { name: "Ram" })
83
+ #
84
+ # Reach for a real Butler::Client — everything above is built on it — once
85
+ # you want per-instance configuration (a fixed base_url, custom retry
86
+ # policy, middleware) rather than the shared default:
87
+ #
88
+ # client = Butler::Client.new(base_url: "https://api.example.com")
89
+ # response = client.get("/users")
90
+ # response.status; response.headers; response.body; response.json
91
+ #
92
+ # client.async do |tasks|
93
+ # users = tasks.async { client.get("/users") }
94
+ # orders = tasks.async { client.get("/orders") }
95
+ # { users: users.wait, orders: orders.wait }
96
+ # end
97
+ class Butler
98
+ class << self
99
+ def configuration
100
+ @configuration ||= Butler::Configuration.new
101
+ end
102
+
103
+ def configure
104
+ yield configuration
105
+ end
106
+
107
+ # The Butler::Client every module-level call (Butler.get, Butler.post,
108
+ # ...) runs against — built lazily on first use and memoized for the
109
+ # life of the process, so it keeps its own connection pool/circuit
110
+ # breaker state across calls instead of reconnecting every time. It
111
+ # snapshots Butler.configuration as of whenever it's first built, the
112
+ # same way Butler::Client.new always has — call Butler.configure
113
+ # before your first module-level call (or Butler.reset_default_client!
114
+ # afterward) if you need later configuration changes to reach it.
115
+ def default_client
116
+ return @default_client if @default_client
117
+
118
+ @default_client_mutex ||= Mutex.new
119
+ @default_client_mutex.synchronize { @default_client ||= Butler::Client.new }
120
+ end
121
+
122
+ # Discards the memoized default client (its connections are closed
123
+ # first) so the next module-level call rebuilds one from whatever
124
+ # Butler.configuration looks like now. Mostly useful in tests, or after
125
+ # reconfiguring Butler well after the first module-level call already
126
+ # built one.
127
+ def reset_default_client!
128
+ return unless @default_client_mutex
129
+
130
+ @default_client_mutex.synchronize do
131
+ @default_client&.reset_connections!
132
+ @default_client = nil
133
+ end
134
+ end
135
+
136
+ Client::HTTP_METHODS.each do |http_method|
137
+ define_method(http_method) do |path = nil, **opts|
138
+ default_client.public_send(http_method, path, **opts)
139
+ end
140
+ end
141
+
142
+ def async(&block)
143
+ default_client.async(&block)
144
+ end
145
+
146
+ def register_client(client)
147
+ @clients_mutex ||= Mutex.new
148
+ @clients ||= []
149
+ @clients_mutex.synchronize { @clients << WeakRef.new(client) }
150
+ end
151
+
152
+ # Closes every tracked client's pooled connections and resets the
153
+ # shared background reactor (see Butler::Async.background). Called
154
+ # automatically post-fork by Butler::Rails::Railtie so a forked Puma
155
+ # worker never inherits a parent's live Async::HTTP::Client/reactor
156
+ # state — Ruby Thread objects don't survive fork(), only the forking
157
+ # thread does — safe to call manually otherwise (e.g. after a known
158
+ # upstream restart).
159
+ def reset_connections!
160
+ Butler::Async.background.reset!
161
+
162
+ return unless @clients
163
+
164
+ @clients_mutex.synchronize do
165
+ @clients.each do |ref|
166
+ ref.__getobj__.reset_connections! if ref.weakref_alive?
167
+ rescue WeakRef::RefError
168
+ nil
169
+ end
170
+ @clients.reject! { |ref| !ref.weakref_alive? }
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ require "butler/rails/railtie" if defined?(::Rails::Railtie)
@@ -0,0 +1,41 @@
1
+ class Butler
2
+ class Client
3
+ HTTP_METHODS: Array[Symbol]
4
+
5
+ @configuration: Butler::Configuration
6
+ @connection_pool: Butler::ConnectionPool
7
+ @circuit_breaker: Butler::Resilience::CircuitBreaker
8
+ @retry_policy: Butler::Resilience::RetryPolicy
9
+ @custom_middlewares: Array[untyped]
10
+ @pipeline: Butler::Pipeline::Chain
11
+
12
+ attr_reader configuration: Butler::Configuration
13
+
14
+ def initialize: (**untyped options) -> void
15
+
16
+ def get: (?String? path, **untyped opts) -> Butler::Response
17
+ def post: (?String? path, **untyped opts) -> Butler::Response
18
+ def put: (?String? path, **untyped opts) -> Butler::Response
19
+ def patch: (?String? path, **untyped opts) -> Butler::Response
20
+ def delete: (?String? path, **untyped opts) -> Butler::Response
21
+ def head: (?String? path, **untyped opts) -> Butler::Response
22
+ def options: (?String? path, **untyped opts) -> Butler::Response
23
+
24
+ def use: (untyped middleware) -> self
25
+
26
+ def request: (String | Symbol method, ?String? path, **untyped opts) -> Butler::Response
27
+
28
+ def async: () { (Butler::Async::TaskGroup) -> untyped } -> untyped
29
+
30
+ def reset_connections!: () -> void
31
+
32
+ private
33
+
34
+ def perform_request: (String | Symbol method, String? path, Hash[Symbol, untyped] opts) -> Butler::Response
35
+ def per_call_config: (Hash[Symbol, untyped] opts) -> Butler::Configuration
36
+ def maybe_raise: (Butler::Response response) -> Butler::Response
37
+ def strip_credential_headers: (untyped headers) -> Butler::Headers
38
+ def build_pipeline: () -> Butler::Pipeline::Chain
39
+ def attempt_timeout: (Butler::Configuration config, Butler::Resilience::Deadline deadline) -> Numeric
40
+ end
41
+ end
@@ -0,0 +1,63 @@
1
+ class Butler
2
+ class Configuration
3
+ class RetryOptions
4
+ attr_accessor max_attempts: Integer
5
+ attr_accessor retryable_status_codes: Array[Integer]
6
+ attr_accessor backoff: Symbol
7
+ attr_accessor base_delay: Float
8
+ attr_accessor max_delay: Float
9
+ attr_accessor jitter: bool
10
+ end
11
+
12
+ class CircuitBreakerOptions
13
+ attr_accessor enabled: bool
14
+ attr_accessor scope: Symbol
15
+ attr_accessor failure_threshold: Integer
16
+ attr_accessor recovery_timeout: Integer
17
+ attr_accessor max_tracked_hosts: Integer
18
+ end
19
+
20
+ class SecurityOptions
21
+ attr_accessor verify_tls: bool
22
+ attr_accessor allowed_hosts: Array[String]?
23
+ attr_accessor blocked_hosts: Array[String]
24
+ attr_accessor max_response_size: Integer
25
+ attr_accessor max_header_size: Integer
26
+ attr_accessor strip_credentials_on_redirect: bool
27
+ end
28
+
29
+ class PoolOptions
30
+ attr_accessor max_connections: Integer
31
+ attr_accessor idle_timeout: Integer
32
+ end
33
+
34
+ class TelemetryOptions
35
+ attr_accessor enabled: bool
36
+ attr_accessor opentelemetry: Symbol | bool
37
+ attr_accessor logger: untyped
38
+ end
39
+
40
+ attr_accessor base_url: String?
41
+ attr_accessor user_agent: String?
42
+ attr_accessor connect_timeout: Numeric
43
+ attr_accessor read_timeout: Numeric
44
+ attr_accessor write_timeout: Numeric
45
+ attr_accessor request_timeout: Numeric?
46
+ attr_accessor deadline: Numeric?
47
+ attr_accessor follow_redirects: bool
48
+ attr_accessor max_redirects: Integer
49
+ attr_accessor http_version: Symbol
50
+ attr_accessor proxy: String?
51
+ attr_accessor raise_on_error: bool
52
+
53
+ attr_reader default_headers: Butler::Headers
54
+ attr_reader retry: RetryOptions
55
+ attr_reader circuit_breaker: CircuitBreakerOptions
56
+ attr_reader security: SecurityOptions
57
+ attr_reader pool: PoolOptions
58
+ attr_reader telemetry: TelemetryOptions
59
+
60
+ def initialize: () -> void
61
+ def apply: (Hash[Symbol, untyped] options) -> self
62
+ end
63
+ end
@@ -0,0 +1,67 @@
1
+ class Butler
2
+ module Errors
3
+ class Error < StandardError
4
+ end
5
+
6
+ class ConfigurationError < Error
7
+ end
8
+
9
+ class RequestError < Error
10
+ end
11
+
12
+ class TooManyRedirectsError < RequestError
13
+ end
14
+
15
+ class HostNotAllowed < RequestError
16
+ end
17
+
18
+ class TransportError < Error
19
+ end
20
+
21
+ class ConnectionError < TransportError
22
+ end
23
+
24
+ class TimeoutError < TransportError
25
+ end
26
+
27
+ class TLSError < TransportError
28
+ end
29
+
30
+ class CertificateVerificationError < TLSError
31
+ end
32
+
33
+ class DNSFailure < TransportError
34
+ end
35
+
36
+ class ProtocolError < TransportError
37
+ end
38
+
39
+ class HTTPError < Error
40
+ attr_reader response: Butler::Response
41
+
42
+ def initialize: (Butler::Response response) -> void
43
+ end
44
+
45
+ class ClientError < HTTPError
46
+ end
47
+
48
+ class ServerError < HTTPError
49
+ end
50
+
51
+ class RetryExhausted < Error
52
+ attr_reader cause_response: Butler::Response?
53
+ attr_reader attempts: Integer?
54
+
55
+ def initialize: (String message, ?cause_response: Butler::Response?, ?attempts: Integer?) -> void
56
+ end
57
+
58
+ class CircuitOpen < Error
59
+ end
60
+
61
+ class Cancelled < Error
62
+ end
63
+
64
+ class LimitExceeded < Error
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,22 @@
1
+ class Butler
2
+ class Headers
3
+ include Enumerable[[String, String]]
4
+
5
+ def initialize: (?untyped pairs) -> void
6
+ def self.from_a: (Array[[String, String]] pairs) -> Butler::Headers
7
+ def self.coerce: (untyped headers) -> Butler::Headers
8
+
9
+ def []: (String | Symbol key) -> String?
10
+ def []=: (String | Symbol key, untyped value) -> untyped
11
+ def add: (String | Symbol key, untyped value) -> untyped
12
+ def key?: (String | Symbol key) -> bool
13
+ def delete: (String | Symbol key) -> String?
14
+ def values_at: (String | Symbol key) -> Array[String]
15
+ def each: () { (String, String) -> void } -> self
16
+ def to_a: () -> Array[[String, String]]
17
+ def to_h: () -> Hash[String, String]
18
+ def merge: (untyped other) -> Butler::Headers
19
+ def empty?: () -> bool
20
+ def size: () -> Integer
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ class Butler
2
+ class Request
3
+ attr_reader method: String
4
+ attr_reader uri: Butler::URI
5
+ attr_reader headers: Butler::Headers
6
+ attr_reader body: untyped
7
+ attr_reader idempotent: bool
8
+ attr_reader basic_auth: untyped
9
+
10
+ def self.build: (method: String | Symbol, uri: Butler::URI, options: Hash[Symbol, untyped], config: Butler::Configuration) -> Butler::Request
11
+
12
+ def initialize: (method: String, uri: Butler::URI, headers: Butler::Headers, ?body: untyped, ?idempotent: bool, ?basic_auth: untyped, ?stream: bool) -> void
13
+
14
+ def stream?: () -> bool
15
+ def origin_key: () -> String
16
+ def safe?: () -> bool
17
+ def retry_eligible?: () -> bool
18
+ def with_uri: (Butler::URI new_uri) -> Butler::Request
19
+ def without_credentials: () -> Butler::Request
20
+ end
21
+ end
@@ -0,0 +1,28 @@
1
+ class Butler
2
+ class Response
3
+ attr_reader uri: Butler::URI
4
+ attr_reader status: Integer
5
+ attr_reader headers: Butler::Headers
6
+
7
+ def initialize: (uri: Butler::URI, status: Integer, headers: Butler::Headers, body: untyped) -> void
8
+
9
+ def body: () -> String
10
+ def stream: () -> untyped
11
+ def code: () -> Integer
12
+
13
+ def informational?: () -> bool
14
+ def success?: () -> bool
15
+ def ok?: () -> bool
16
+ def redirect?: () -> bool
17
+ def client_error?: () -> bool
18
+ def server_error?: () -> bool
19
+ def error?: () -> bool
20
+
21
+ def content_type: () -> String
22
+ def json?: () -> bool
23
+ def json: () -> untyped
24
+ def parsed: () -> untyped
25
+
26
+ def to_s: () -> String
27
+ end
28
+ end
data/sig/butler.rbs ADDED
@@ -0,0 +1,22 @@
1
+ class Butler
2
+ VERSION: String
3
+
4
+ def self.configuration: () -> Butler::Configuration
5
+ def self.configure: () { (Butler::Configuration) -> void } -> void
6
+
7
+ def self.default_client: () -> Butler::Client
8
+ def self.reset_default_client!: () -> void
9
+
10
+ def self.get: (?String? path, **untyped opts) -> Butler::Response
11
+ def self.post: (?String? path, **untyped opts) -> Butler::Response
12
+ def self.put: (?String? path, **untyped opts) -> Butler::Response
13
+ def self.patch: (?String? path, **untyped opts) -> Butler::Response
14
+ def self.delete: (?String? path, **untyped opts) -> Butler::Response
15
+ def self.head: (?String? path, **untyped opts) -> Butler::Response
16
+ def self.options: (?String? path, **untyped opts) -> Butler::Response
17
+
18
+ def self.async: () { (Butler::Async::TaskGroup) -> untyped } -> untyped
19
+
20
+ def self.register_client: (Butler::Client client) -> void
21
+ def self.reset_connections!: () -> void
22
+ end