@@ -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
0 commit comments