Skip to content

Commit b0b4799

Browse files
committed
Fix persistent sessions raising StateError on cross-origin redirects
HTTP.persistent now returns an HTTP::Session that pools one HTTP::Client per origin instead of a single Client pinned to one host. When following redirects across domains, the session transparently looks up (or creates) a pooled client for the redirect target's origin. Key changes: * Session maintains a @Clients hash (origin => Client) for connection pooling when persistent? is true * Session#close shuts down all pooled clients * Session#perform_redirects selects the appropriate pooled client for each redirect hop via #redirect_client * Chainable#persistent returns Session (was Client), using branch instead of make_client * Cookie management is preserved across all cross-origin hops * Non-persistent sessions are unaffected (fresh client per request) Closes #557.
1 parent 371af15 commit b0b4799

File tree

8 files changed

+247
-32
lines changed

8 files changed

+247
-32
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3838
`read_nonblock` and `:wait_readable` from `write_nonblock` on SSL sockets
3939
during TLS renegotiation. Previously these symbols were returned as data
4040
instead of being waited on. ([#358])
41+
- Persistent sessions now follow cross-origin redirects instead of raising
42+
`StateError`. `HTTP.persistent` returns an `HTTP::Session` that pools one
43+
`HTTP::Client` per origin, so redirects to a different domain transparently
44+
open (and reuse) a separate persistent connection. Cookie management is
45+
preserved across all hops. ([#557])
4146

4247
### Changed
4348

49+
- **BREAKING** `HTTP.persistent` now returns an `HTTP::Session` instead of an
50+
`HTTP::Client`. The session pools persistent clients per origin and exposes
51+
the same chainable API (`get`, `post`, `headers`, `follow`, etc.) plus a
52+
`close` method that shuts down all pooled connections. Code that called
53+
`HTTP::Client`-only methods on the return value will need updating. ([#557])
4454
- **BREAKING** Convert options hash parameters to explicit keyword arguments
4555
across the public API. Methods like `HTTP.get(url, body: "data")` continue to
4656
work, but passing an explicit hash (e.g., `HTTP.get(url, {body: "data"})`) is
@@ -223,6 +233,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
223233
[#536]: https://github.com/httprb/http/issues/536
224234
[#537]: https://github.com/httprb/http/issues/537
225235
[#544]: https://github.com/httprb/http/issues/544
236+
[#557]: https://github.com/httprb/http/issues/557
226237
[#560]: https://github.com/httprb/http/pull/560
227238
[#565]: https://github.com/httprb/http/issues/565
228239
[#566]: https://github.com/httprb/http/issues/566

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,15 +165,25 @@ threads.each(&:join)
165165
Chainable configuration methods (`.headers`, `.timeout`, `.auth`, etc.) return
166166
an `HTTP::Session`, which creates a fresh `HTTP::Client` for every request.
167167

168-
Persistent connections (`HTTP.persistent`) return an `HTTP::Client`, which is
169-
**not** thread-safe. For thread-safe persistent connections, use the
168+
Persistent connections (`HTTP.persistent`) return an `HTTP::Session` that pools
169+
one `HTTP::Client` per origin. The session itself is **not** thread-safe. For
170+
thread-safe persistent connections, use the
170171
[connection_pool](https://rubygems.org/gems/connection_pool) gem:
171172

172173
```ruby
173174
pool = ConnectionPool.new(size: 5) { HTTP.persistent("https://example.com") }
174175
pool.with { |http| http.get("/path") }
175176
```
176177

178+
Cross-origin redirects are handled transparently — the session opens a separate
179+
persistent connection for each origin encountered during a redirect chain:
180+
181+
```ruby
182+
HTTP.persistent("https://example.com").follow do |http|
183+
http.get("/moved-to-other-domain") # follows redirect across origins
184+
end
185+
```
186+
177187
## Supported Ruby Versions
178188

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

lib/http/chainable.rb

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ def base_uri(uri)
9090

9191
# Open a persistent connection to a host
9292
#
93+
# Returns an {HTTP::Session} that pools persistent {HTTP::Client}
94+
# instances by origin. This allows connection reuse within the same
95+
# origin and transparent cross-origin redirect handling.
96+
#
9397
# When no host is given, the origin is derived from the configured base URI.
9498
#
9599
# @example
@@ -104,10 +108,10 @@ def base_uri(uri)
104108
# @option [Integer] timeout Keep alive timeout
105109
# @raise [ArgumentError] if host is nil and no base URI is set
106110
# @raise [Request::Error] if Host is invalid
107-
# @return [HTTP::Client] Persistent client
111+
# @return [HTTP::Session] Persistent session
108112
# @overload persistent(host = nil, timeout: 5, &block)
109-
# Executes given block with persistent client and automatically closes
110-
# connection at the end of execution.
113+
# Executes given block with persistent session and automatically closes
114+
# all connections at the end of execution.
111115
#
112116
# @example
113117
#
@@ -127,21 +131,21 @@ def base_uri(uri)
127131
# end
128132
#
129133
#
130-
# @yieldparam [HTTP::Client] client Persistent client
134+
# @yieldparam [HTTP::Session] session Persistent session
131135
# @return [Object] result of last expression in the block
132-
# @return [HTTP::Client, Object]
136+
# @return [HTTP::Session, Object]
133137
# @api public
134138
def persistent(host = nil, timeout: 5)
135139
host ||= default_options.base_uri&.origin
136140
raise ArgumentError, "host is required for persistent connections" unless host
137141

138142
options = default_options.merge(keep_alive_timeout: timeout).with_persistent(host)
139-
p_client = make_client(options)
140-
return p_client unless block_given?
143+
session = branch(options)
144+
return session unless block_given?
141145

142-
yield p_client
146+
yield session
143147
ensure
144-
p_client&.close
148+
session&.close if block_given?
145149
end
146150

147151
# Make a request through an HTTP proxy

lib/http/session.rb

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,23 @@ module HTTP
1515
# They hold an immutable {Options} object and create a new {Client}
1616
# for each request, making them safe to share across threads.
1717
#
18+
# When configured for persistent connections (via {Chainable#persistent}),
19+
# the session maintains a pool of {Client} instances keyed by origin,
20+
# enabling connection reuse within the same origin and transparent
21+
# cross-origin redirect handling.
22+
#
1823
# @example Reuse a configured session across threads
1924
# session = HTTP.headers("Accept" => "application/json").timeout(10)
2025
# threads = 5.times.map do
2126
# Thread.new { session.get("https://example.com") }
2227
# end
2328
# threads.each(&:join)
2429
#
30+
# @example Persistent session with cross-origin redirects
31+
# HTTP.persistent("https://example.com").follow do |http|
32+
# http.get("/redirect-to-other-domain") # follows cross-origin redirect
33+
# end
34+
#
2535
# @see Chainable
2636
# @see Client
2737
class Session
@@ -51,11 +61,32 @@ class Session
5161
# @api public
5262
def initialize(default_options = nil, **)
5363
@default_options = HTTP::Options.new(default_options, **)
64+
@clients = {}
65+
end
66+
67+
# Close all persistent connections held by this session
68+
#
69+
# When the session is persistent, this closes every pooled {Client}
70+
# and clears the pool. Safe to call on non-persistent sessions (no-op).
71+
#
72+
# @example
73+
# session = HTTP.persistent("https://example.com")
74+
# session.get("/")
75+
# session.close
76+
#
77+
# @return [void]
78+
# @api public
79+
def close
80+
@clients.each_value(&:close)
81+
@clients.clear
5482
end
5583

56-
# Make an HTTP request by creating a new {Client}
84+
# Make an HTTP request
85+
#
86+
# For non-persistent sessions a fresh {Client} is created for each
87+
# request, ensuring thread safety. For persistent sessions the pooled
88+
# {Client} for the request's origin is reused.
5789
#
58-
# A fresh {Client} is created for each request, ensuring thread safety.
5990
# Manages cookies across redirect hops when following redirects.
6091
#
6192
# @example Without a block
@@ -85,21 +116,23 @@ def request(verb, uri,
85116
timeout_class: timeout_class, timeout_options: timeout_options,
86117
keep_alive_timeout: keep_alive_timeout, base_uri: base_uri, persistent: persistent }.compact
87118
)
88-
client = make_client(default_options)
119+
client = persistent? ? nil : make_client(default_options)
89120
res = perform_request(client, verb, uri, merged)
90121

91122
return res unless block
92123

93124
yield res
94125
ensure
95-
client&.close if block
126+
if block
127+
persistent? ? close : client&.close
128+
end
96129
end
97130

98131
private
99132

100133
# Execute a request with cookie management
101134
#
102-
# @param client [HTTP::Client] the client to perform the request
135+
# @param client [HTTP::Client, nil] the client (nil when persistent; looked up from pool)
103136
# @param verb [Symbol] the HTTP method
104137
# @param uri [#to_s] the URI to request
105138
# @param merged [HTTP::Options] the merged options
@@ -109,6 +142,7 @@ def perform_request(client, verb, uri, merged)
109142
cookie_jar = CookieJar.new
110143
builder = Request::Builder.new(merged)
111144
req = builder.build(verb, uri)
145+
client ||= client_for_origin(req.uri.origin)
112146
load_cookies(cookie_jar, req)
113147
res = client.perform(req, merged)
114148
store_cookies(cookie_jar, res)
@@ -120,8 +154,13 @@ def perform_request(client, verb, uri, merged)
120154

121155
# Follow redirects with cookie management
122156
#
157+
# For persistent sessions, each redirect hop may target a different
158+
# origin. The session looks up (or creates) a pooled {Client} for
159+
# the redirect target's origin, allowing cross-origin redirects
160+
# without raising {StateError}.
161+
#
123162
# @param jar [HTTP::CookieJar] the cookie jar
124-
# @param client [HTTP::Client] the client to perform requests
163+
# @param client [HTTP::Client] the client for the initial request
125164
# @param req [HTTP::Request] the original request
126165
# @param res [HTTP::Response] the initial redirect response
127166
# @param opts [HTTP::Options] the merged options
@@ -134,12 +173,47 @@ def perform_redirects(jar, client, req, res, opts)
134173
wrapped = builder.wrap(redirect_req)
135174
apply_cookies(jar, wrapped)
136175
apply_cookies(jar, redirect_req)
137-
response = client.perform(wrapped, opts)
176+
response = redirect_client(client, wrapped).perform(wrapped, opts)
138177
store_cookies(jar, response)
139178
response
140179
end
141180
end
142181

182+
# Return the appropriate client for a redirect hop
183+
#
184+
# @param client [HTTP::Client] the client for the original request
185+
# @param request [HTTP::Request] the redirect request
186+
# @return [HTTP::Client] the client for the redirect target
187+
# @api private
188+
def redirect_client(client, request)
189+
persistent? ? client_for_origin(request.uri.origin) : client
190+
end
191+
192+
# Return a pooled persistent {Client} for the given origin
193+
#
194+
# Creates a new {Client} if one does not already exist for this origin.
195+
# For the session's primary persistent origin, the default options are
196+
# used directly. For other origins (e.g. redirect targets), the
197+
# persistent origin is overridden and base_uri is cleared.
198+
#
199+
# @param origin [String] the URI origin (scheme + host + port)
200+
# @return [HTTP::Client] a persistent client for the origin
201+
# @api private
202+
def client_for_origin(origin)
203+
@clients[origin] ||= make_client(options_for_origin(origin))
204+
end
205+
206+
# Build {Options} for a persistent client targeting the given origin
207+
#
208+
# @param origin [String] the URI origin
209+
# @return [HTTP::Options] options configured for this origin
210+
# @api private
211+
def options_for_origin(origin)
212+
return default_options if origin == default_options.persistent
213+
214+
default_options.merge(persistent: origin, base_uri: nil)
215+
end
216+
143217
# Load cookies from the request's Cookie header into the jar
144218
#
145219
# @param jar [HTTP::CookieJar] the cookie jar

sig/http.rbs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ module HTTP
9797
) { (Response) -> T } -> T
9898
def timeout: (Numeric | Hash[Symbol, Numeric] | :null options) -> Session
9999
def base_uri: (String | URI uri) -> Session
100-
def persistent: (?String? host, ?timeout: Integer) -> Client
101-
| (?String? host, ?timeout: Integer) { (Client) -> void } -> void
100+
def persistent: (?String? host, ?timeout: Integer) -> Session
101+
| [T] (?String? host, ?timeout: Integer) { (Session) -> T } -> T
102102
def via: (*(String | Integer | Hash[String, String]) proxy) -> Session
103103
alias through via
104104
def follow: (?strict: bool, ?max_hops: Integer, ?on_redirect: (^(Response, Request) -> void)?) -> Session
@@ -180,8 +180,10 @@ module HTTP
180180
include Chainable
181181

182182
@default_options: Options
183+
@clients: Hash[String, Client]
183184

184185
def initialize: (?Options? default_options, **untyped) -> void
186+
def close: () -> void
185187
def request: (
186188
verb verb,
187189
String | URI uri,
@@ -236,8 +238,11 @@ module HTTP
236238

237239
private
238240

239-
def perform_request: (Client client, verb verb, untyped uri, Options merged) -> Response
241+
def perform_request: (Client? client, verb verb, untyped uri, Options merged) -> Response
240242
def perform_redirects: (CookieJar jar, Client client, Request req, Response res, Options opts) -> Response
243+
def redirect_client: (Client client, Request request) -> Client
244+
def client_for_origin: (String origin) -> Client
245+
def options_for_origin: (String origin) -> Options
241246
def load_cookies: (CookieJar jar, Request request) -> void
242247
def store_cookies: (CookieJar jar, Response response) -> void
243248
def apply_cookies: (CookieJar jar, Request request) -> void

0 commit comments

Comments
 (0)