Skip to content

Commit 8e9538a

Browse files
committed
Introduce HTTP::Session for thread-safe request building
Chainable option methods (headers, timeout, cookies, etc.) now return an HTTP::Session instead of an HTTP::Client. Session is a lightweight, thread-safe builder that creates a fresh Client for each request, fixing the thread-safety issue where sharing a configured client across threads caused IOError due to shared mutable connection state. HTTP.retriable now returns an HTTP::Retriable::Session. HTTP.persistent still returns an HTTP::Client since persistent connections require mutable state. Closes #306.
1 parent b52733a commit 8e9538a

14 files changed

Lines changed: 615 additions & 172 deletions

File tree

.rubocop/style.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ Style/OptionHash:
3636
Enabled: true
3737
Exclude:
3838
- 'lib/http/chainable.rb'
39+
- 'lib/http/chainable/verbs.rb'
3940
- 'lib/http/client.rb'
4041
- 'lib/http/options.rb'
4142
- 'lib/http/redirector.rb'
43+
- 'lib/http/session.rb'
4244
- 'lib/http/timeout/null.rb'
4345
- 'test/support/dummy_server.rb'
4446

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131

3232
### Changed
3333

34+
- **BREAKING** Chainable option methods (`.headers`, `.timeout`, `.cookies`,
35+
`.auth`, `.follow`, `.via`, `.use`, `.encoding`, `.nodelay`, `.basic_auth`,
36+
`.accept`) now return a thread-safe `HTTP::Session` instead of `HTTP::Client`.
37+
`Session` creates a new `Client` for each request, making it safe to share a
38+
configured session across threads. `HTTP.persistent` still returns
39+
`HTTP::Client` since persistent connections require mutable state. Code that
40+
calls HTTP verb methods (`.get`, `.post`, etc.) or accesses `.default_options`
41+
is unaffected. Code that checks `is_a?(HTTP::Client)` on the return value of
42+
chainable methods will need to be updated to check for `HTTP::Session`
43+
- **BREAKING** `.retriable` now returns `HTTP::Retriable::Session` instead of
44+
`HTTP::Retriable::Client`
3445
- Improved error message when request body size cannot be determined to suggest
3546
setting `Content-Length` explicitly or using chunked `Transfer-Encoding` (#560)
3647
- **BREAKING** `Connection#readpartial` now raises `EOFError` instead of

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,34 @@ end
126126
Pattern matching is also supported on `HTTP::Response::Status`, `HTTP::Headers`,
127127
`HTTP::ContentType`, and `HTTP::URI`.
128128

129+
### Thread Safety
130+
131+
Configured sessions are safe to share across threads:
132+
133+
```ruby
134+
# Build a session once, use it from any thread
135+
session = HTTP.headers("Accept" => "application/json")
136+
.timeout(10)
137+
.auth("Bearer token")
138+
139+
threads = 10.times.map do
140+
Thread.new { session.get("https://example.com/api/data") }
141+
end
142+
threads.each(&:join)
143+
```
144+
145+
Chainable configuration methods (`.headers`, `.timeout`, `.auth`, etc.) return
146+
an `HTTP::Session`, which creates a fresh `HTTP::Client` for every request.
147+
148+
Persistent connections (`HTTP.persistent`) return an `HTTP::Client`, which is
149+
**not** thread-safe. For thread-safe persistent connections, use the
150+
[connection_pool](https://rubygems.org/gems/connection_pool) gem:
151+
152+
```ruby
153+
pool = ConnectionPool.new(size: 5) { HTTP.persistent("https://example.com") }
154+
pool.with { |http| http.get("/path") }
155+
```
156+
129157
## Supported Ruby Versions
130158

131159
This library aims to support and is [tested against][build-link]

lib/http.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
require "http/timeout/per_operation"
66
require "http/timeout/global"
77
require "http/chainable"
8+
require "http/session"
89
require "http/client"
910
require "http/retriable/client"
11+
require "http/retriable/session"
1012
require "http/connection"
1113
require "http/options"
1214
require "http/feature"
@@ -21,14 +23,14 @@ module HTTP
2123
extend Chainable
2224

2325
class << self
24-
# Set default headers and return a chainable client instance
26+
# Set default headers and return a chainable session
2527
#
2628
# @example
2729
# HTTP[:accept => "text/html"].get("https://example.com")
2830
#
2931
# @param headers [Hash] headers to set
3032
#
31-
# @return [HTTP::Client]
33+
# @return [HTTP::Session]
3234
#
3335
# @api public
3436
alias [] headers

lib/http/chainable.rb

Lines changed: 31 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -2,129 +2,14 @@
22

33
require "http/base64"
44
require "http/chainable/helpers"
5+
require "http/chainable/verbs"
56
require "http/headers"
67

78
module HTTP
89
# HTTP verb methods and client configuration DSL
910
module Chainable
1011
include HTTP::Base64
11-
12-
# Request a get sans response body
13-
#
14-
# @example
15-
# HTTP.head("http://example.com")
16-
#
17-
# @param [String, URI] uri URI to request
18-
# @param [Hash] options request options
19-
# @return [HTTP::Response]
20-
# @api public
21-
def head(uri, options = {})
22-
request :head, uri, options
23-
end
24-
25-
# Get a resource
26-
#
27-
# @example
28-
# HTTP.get("http://example.com")
29-
#
30-
# @param [String, URI] uri URI to request
31-
# @param [Hash] options request options
32-
# @return [HTTP::Response]
33-
# @api public
34-
def get(uri, options = {})
35-
request :get, uri, options
36-
end
37-
38-
# Post to a resource
39-
#
40-
# @example
41-
# HTTP.post("http://example.com", body: "data")
42-
#
43-
# @param [String, URI] uri URI to request
44-
# @param [Hash] options request options
45-
# @return [HTTP::Response]
46-
# @api public
47-
def post(uri, options = {})
48-
request :post, uri, options
49-
end
50-
51-
# Put to a resource
52-
#
53-
# @example
54-
# HTTP.put("http://example.com", body: "data")
55-
#
56-
# @param [String, URI] uri URI to request
57-
# @param [Hash] options request options
58-
# @return [HTTP::Response]
59-
# @api public
60-
def put(uri, options = {})
61-
request :put, uri, options
62-
end
63-
64-
# Delete a resource
65-
#
66-
# @example
67-
# HTTP.delete("http://example.com/resource")
68-
#
69-
# @param [String, URI] uri URI to request
70-
# @param [Hash] options request options
71-
# @return [HTTP::Response]
72-
# @api public
73-
def delete(uri, options = {})
74-
request :delete, uri, options
75-
end
76-
77-
# Echo the request back to the client
78-
#
79-
# @example
80-
# HTTP.trace("http://example.com")
81-
#
82-
# @param [String, URI] uri URI to request
83-
# @param [Hash] options request options
84-
# @return [HTTP::Response]
85-
# @api public
86-
def trace(uri, options = {})
87-
request :trace, uri, options
88-
end
89-
90-
# Return the methods supported on the given URI
91-
#
92-
# @example
93-
# HTTP.options("http://example.com")
94-
#
95-
# @param [String, URI] uri URI to request
96-
# @param [Hash] options request options
97-
# @return [HTTP::Response]
98-
# @api public
99-
def options(uri, options = {})
100-
request :options, uri, options
101-
end
102-
103-
# Convert to a transparent TCP/IP tunnel
104-
#
105-
# @example
106-
# HTTP.connect("http://example.com")
107-
#
108-
# @param [String, URI] uri URI to request
109-
# @param [Hash] options request options
110-
# @return [HTTP::Response]
111-
# @api public
112-
def connect(uri, options = {})
113-
request :connect, uri, options
114-
end
115-
116-
# Apply partial modifications to a resource
117-
#
118-
# @example
119-
# HTTP.patch("http://example.com/resource", body: "data")
120-
#
121-
# @param [String, URI] uri URI to request
122-
# @param [Hash] options request options
123-
# @return [HTTP::Response]
124-
# @api public
125-
def patch(uri, options = {})
126-
request :patch, uri, options
127-
end
12+
include Verbs
12813

12914
# Make an HTTP request with the given verb
13015
#
@@ -135,7 +20,7 @@ def patch(uri, options = {})
13520
# @return [HTTP::Response]
13621
# @api public
13722
def request(verb, uri, opts = {})
138-
branch(default_options).request(verb, uri, opts)
23+
make_client(default_options).request(verb, uri, opts)
13924
end
14025

14126
# Prepare an HTTP request with the given verb
@@ -147,7 +32,7 @@ def request(verb, uri, opts = {})
14732
# @return [HTTP::Request]
14833
# @api public
14934
def build_request(verb, uri, opts = {})
150-
branch(default_options).build_request(verb, uri, opts)
35+
make_client(default_options).build_request(verb, uri, opts)
15136
end
15237

15338
# Set timeout on the request
@@ -165,7 +50,7 @@ def build_request(verb, uri, opts = {})
16550
# @overload timeout(global_timeout)
16651
# Adds a global timeout to the full request
16752
# @param [Numeric] global_timeout
168-
# @return [HTTP::Client]
53+
# @return [HTTP::Session]
16954
# @api public
17055
def timeout(options)
17156
klass, options = case options
@@ -222,7 +107,8 @@ def timeout(options)
222107
# @return [HTTP::Client, Object]
223108
# @api public
224109
def persistent(host, timeout: 5)
225-
p_client = branch default_options.merge(keep_alive_timeout: timeout).with_persistent(host)
110+
options = default_options.merge(keep_alive_timeout: timeout).with_persistent(host)
111+
p_client = make_client(options)
226112
return p_client unless block_given?
227113

228114
yield p_client
@@ -237,7 +123,7 @@ def persistent(host, timeout: 5)
237123
#
238124
# @param [Array] proxy
239125
# @raise [Request::Error] if HTTP proxy is invalid
240-
# @return [HTTP::Client]
126+
# @return [HTTP::Session]
241127
# @api public
242128
def via(*proxy)
243129
proxy_hash = build_proxy_hash(proxy)
@@ -254,7 +140,7 @@ def via(*proxy)
254140
# HTTP.follow.get("http://example.com")
255141
#
256142
# @param [Hash] options redirect options
257-
# @return [HTTP::Client]
143+
# @return [HTTP::Session]
258144
# @see Redirector#initialize
259145
# @api public
260146
def follow(options = {})
@@ -267,7 +153,7 @@ def follow(options = {})
267153
# HTTP.headers("Accept" => "text/plain").get("http://example.com")
268154
#
269155
# @param [Hash] headers request headers
270-
# @return [HTTP::Client]
156+
# @return [HTTP::Session]
271157
# @api public
272158
def headers(headers)
273159
branch default_options.with_headers(headers)
@@ -279,7 +165,7 @@ def headers(headers)
279165
# HTTP.cookies(session: "abc123").get("http://example.com")
280166
#
281167
# @param [Hash] cookies cookies to set
282-
# @return [HTTP::Client]
168+
# @return [HTTP::Session]
283169
# @api public
284170
def cookies(cookies)
285171
branch default_options.with_cookies(cookies)
@@ -291,7 +177,7 @@ def cookies(cookies)
291177
# HTTP.encoding("UTF-8").get("http://example.com")
292178
#
293179
# @param [String, Encoding] encoding encoding to use
294-
# @return [HTTP::Client]
180+
# @return [HTTP::Session]
295181
# @api public
296182
def encoding(encoding)
297183
branch default_options.with_encoding(encoding)
@@ -303,7 +189,7 @@ def encoding(encoding)
303189
# HTTP.accept("application/json").get("http://example.com")
304190
#
305191
# @param [String, Symbol] type MIME type to accept
306-
# @return [HTTP::Client]
192+
# @return [HTTP::Session]
307193
# @api public
308194
def accept(type)
309195
headers Headers::ACCEPT => MimeType.normalize(type)
@@ -315,7 +201,7 @@ def accept(type)
315201
# HTTP.auth("Bearer token123").get("http://example.com")
316202
#
317203
# @param [#to_s] value Authorization header value
318-
# @return [HTTP::Client]
204+
# @return [HTTP::Session]
319205
# @api public
320206
def auth(value)
321207
headers Headers::AUTHORIZATION => value.to_s
@@ -330,7 +216,7 @@ def auth(value)
330216
# @param [#fetch] opts
331217
# @option opts [#to_s] :user
332218
# @option opts [#to_s] :pass
333-
# @return [HTTP::Client]
219+
# @return [HTTP::Session]
334220
# @api public
335221
def basic_auth(opts)
336222
user = opts.fetch(:user)
@@ -368,7 +254,7 @@ def default_options=(opts)
368254
# @example
369255
# HTTP.nodelay.get("http://example.com")
370256
#
371-
# @return [HTTP::Client]
257+
# @return [HTTP::Session]
372258
# @api public
373259
def nodelay
374260
branch default_options.with_nodelay(true)
@@ -380,13 +266,13 @@ def nodelay
380266
# HTTP.use(:auto_inflate).get("http://example.com")
381267
#
382268
# @param [Array<Symbol, Hash>] features features to enable
383-
# @return [HTTP::Client]
269+
# @return [HTTP::Session]
384270
# @api public
385271
def use(*features)
386272
branch default_options.with_features(features)
387273
end
388274

389-
# Return a retriable client that retries on failure
275+
# Return a retriable session that retries on failure
390276
#
391277
# @example Usage
392278
#
@@ -403,20 +289,29 @@ def use(*features)
403289
# HTTP.retriable(tries: 3, delay: proc { |i| 1 + i*i }).get(url)
404290
#
405291
# @param (see Performer#initialize)
406-
# @return [HTTP::Retriable::Client]
292+
# @return [HTTP::Retriable::Session]
407293
# @api public
408294
def retriable(**options)
409-
Retriable::Client.new(Retriable::Performer.new(options), default_options)
295+
Retriable::Session.new(Retriable::Performer.new(options), default_options)
410296
end
411297

412298
private
413299

414-
# Create a new client with the given options
300+
# Create a new session with the given options
301+
#
302+
# @param [HTTP::Options] options options for the session
303+
# @return [HTTP::Session]
304+
# @api private
305+
def branch(options)
306+
HTTP::Session.new(options)
307+
end
308+
309+
# Create a new client for executing a request
415310
#
416311
# @param [HTTP::Options] options options for the client
417312
# @return [HTTP::Client]
418313
# @api private
419-
def branch(options)
314+
def make_client(options)
420315
HTTP::Client.new(options)
421316
end
422317
end

0 commit comments

Comments
 (0)