http 5.2.0 → 6.0.2

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/README.md +110 -13
  4. data/http.gemspec +38 -35
  5. data/lib/http/base64.rb +22 -0
  6. data/lib/http/chainable/helpers.rb +62 -0
  7. data/lib/http/chainable/verbs.rb +136 -0
  8. data/lib/http/chainable.rb +249 -129
  9. data/lib/http/client.rb +158 -127
  10. data/lib/http/connection/internals.rb +141 -0
  11. data/lib/http/connection.rb +128 -97
  12. data/lib/http/content_type.rb +61 -6
  13. data/lib/http/errors.rb +41 -1
  14. data/lib/http/feature.rb +67 -6
  15. data/lib/http/features/auto_deflate.rb +124 -17
  16. data/lib/http/features/auto_inflate.rb +38 -15
  17. data/lib/http/features/caching/entry.rb +178 -0
  18. data/lib/http/features/caching/in_memory_store.rb +63 -0
  19. data/lib/http/features/caching.rb +216 -0
  20. data/lib/http/features/digest_auth.rb +234 -0
  21. data/lib/http/features/instrumentation.rb +97 -17
  22. data/lib/http/features/logging.rb +183 -5
  23. data/lib/http/features/normalize_uri.rb +17 -0
  24. data/lib/http/features/raise_error.rb +37 -0
  25. data/lib/http/form_data/composite_io.rb +106 -0
  26. data/lib/http/form_data/file.rb +95 -0
  27. data/lib/http/form_data/multipart/param.rb +62 -0
  28. data/lib/http/form_data/multipart.rb +106 -0
  29. data/lib/http/form_data/part.rb +52 -0
  30. data/lib/http/form_data/readable.rb +58 -0
  31. data/lib/http/form_data/urlencoded.rb +175 -0
  32. data/lib/http/form_data/version.rb +8 -0
  33. data/lib/http/form_data.rb +102 -0
  34. data/lib/http/headers/known.rb +3 -0
  35. data/lib/http/headers/normalizer.rb +50 -0
  36. data/lib/http/headers.rb +185 -92
  37. data/lib/http/mime_type/adapter.rb +24 -9
  38. data/lib/http/mime_type/json.rb +19 -4
  39. data/lib/http/mime_type.rb +21 -3
  40. data/lib/http/options/definitions.rb +189 -0
  41. data/lib/http/options.rb +172 -125
  42. data/lib/http/redirector.rb +80 -75
  43. data/lib/http/request/body.rb +87 -6
  44. data/lib/http/request/builder.rb +184 -0
  45. data/lib/http/request/proxy.rb +83 -0
  46. data/lib/http/request/writer.rb +78 -17
  47. data/lib/http/request.rb +216 -99
  48. data/lib/http/response/body.rb +103 -18
  49. data/lib/http/response/inflater.rb +35 -7
  50. data/lib/http/response/parser.rb +98 -4
  51. data/lib/http/response/status/reasons.rb +2 -4
  52. data/lib/http/response/status.rb +141 -31
  53. data/lib/http/response.rb +219 -61
  54. data/lib/http/retriable/delay_calculator.rb +91 -0
  55. data/lib/http/retriable/errors.rb +35 -0
  56. data/lib/http/retriable/performer.rb +197 -0
  57. data/lib/http/session.rb +280 -0
  58. data/lib/http/timeout/global.rb +147 -34
  59. data/lib/http/timeout/null.rb +155 -9
  60. data/lib/http/timeout/per_operation.rb +139 -18
  61. data/lib/http/uri/normalizer.rb +82 -0
  62. data/lib/http/uri/parsing.rb +182 -0
  63. data/lib/http/uri.rb +289 -124
  64. data/lib/http/version.rb +2 -1
  65. data/lib/http.rb +11 -1
  66. data/sig/http.rbs +1619 -0
  67. metadata +42 -175
  68. data/.github/workflows/ci.yml +0 -67
  69. data/.gitignore +0 -15
  70. data/.rspec +0 -1
  71. data/.rubocop/layout.yml +0 -8
  72. data/.rubocop/metrics.yml +0 -4
  73. data/.rubocop/style.yml +0 -32
  74. data/.rubocop.yml +0 -11
  75. data/.rubocop_todo.yml +0 -206
  76. data/.yardopts +0 -2
  77. data/CHANGELOG.md +0 -41
  78. data/CHANGES_OLD.md +0 -1002
  79. data/CONTRIBUTING.md +0 -26
  80. data/Gemfile +0 -50
  81. data/Guardfile +0 -18
  82. data/Rakefile +0 -64
  83. data/SECURITY.md +0 -17
  84. data/lib/http/headers/mixin.rb +0 -34
  85. data/logo.png +0 -0
  86. data/spec/lib/http/client_spec.rb +0 -556
  87. data/spec/lib/http/connection_spec.rb +0 -88
  88. data/spec/lib/http/content_type_spec.rb +0 -47
  89. data/spec/lib/http/features/auto_deflate_spec.rb +0 -77
  90. data/spec/lib/http/features/auto_inflate_spec.rb +0 -86
  91. data/spec/lib/http/features/instrumentation_spec.rb +0 -81
  92. data/spec/lib/http/features/logging_spec.rb +0 -65
  93. data/spec/lib/http/headers/mixin_spec.rb +0 -36
  94. data/spec/lib/http/headers_spec.rb +0 -527
  95. data/spec/lib/http/options/body_spec.rb +0 -15
  96. data/spec/lib/http/options/features_spec.rb +0 -33
  97. data/spec/lib/http/options/form_spec.rb +0 -15
  98. data/spec/lib/http/options/headers_spec.rb +0 -24
  99. data/spec/lib/http/options/json_spec.rb +0 -15
  100. data/spec/lib/http/options/merge_spec.rb +0 -68
  101. data/spec/lib/http/options/new_spec.rb +0 -30
  102. data/spec/lib/http/options/proxy_spec.rb +0 -20
  103. data/spec/lib/http/options_spec.rb +0 -13
  104. data/spec/lib/http/redirector_spec.rb +0 -529
  105. data/spec/lib/http/request/body_spec.rb +0 -211
  106. data/spec/lib/http/request/writer_spec.rb +0 -121
  107. data/spec/lib/http/request_spec.rb +0 -234
  108. data/spec/lib/http/response/body_spec.rb +0 -85
  109. data/spec/lib/http/response/parser_spec.rb +0 -74
  110. data/spec/lib/http/response/status_spec.rb +0 -253
  111. data/spec/lib/http/response_spec.rb +0 -262
  112. data/spec/lib/http/uri/normalizer_spec.rb +0 -95
  113. data/spec/lib/http/uri_spec.rb +0 -71
  114. data/spec/lib/http_spec.rb +0 -506
  115. data/spec/regression_specs.rb +0 -24
  116. data/spec/spec_helper.rb +0 -88
  117. data/spec/support/black_hole.rb +0 -13
  118. data/spec/support/capture_warning.rb +0 -10
  119. data/spec/support/dummy_server/servlet.rb +0 -190
  120. data/spec/support/dummy_server.rb +0 -43
  121. data/spec/support/fakeio.rb +0 -21
  122. data/spec/support/fuubar.rb +0 -21
  123. data/spec/support/http_handling_shared.rb +0 -190
  124. data/spec/support/proxy_server.rb +0 -39
  125. data/spec/support/servers/config.rb +0 -11
  126. data/spec/support/servers/runner.rb +0 -19
  127. data/spec/support/simplecov.rb +0 -19
  128. data/spec/support/ssl_helper.rb +0 -104
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP
4
+ module Retriable
5
+ # Calculates retry delays with support for Retry-After headers
6
+ # @api private
7
+ class DelayCalculator
8
+ # Initializes the delay calculator
9
+ #
10
+ # @param [#call, Numeric, nil] delay delay value or proc
11
+ # @param [#to_f] max_delay maximum delay cap
12
+ # @api private
13
+ # @return [HTTP::Retriable::DelayCalculator]
14
+ def initialize(delay: nil, max_delay: Float::MAX)
15
+ @max_delay = Float(max_delay)
16
+ if delay.respond_to?(:call)
17
+ @delay_proc = delay
18
+ else
19
+ @delay = delay
20
+ end
21
+ end
22
+
23
+ # Calculates delay for the given iteration
24
+ #
25
+ # @param [Integer] iteration
26
+ # @param [HTTP::Response, nil] response
27
+ # @api private
28
+ # @return [Numeric]
29
+ def call(iteration, response)
30
+ delay = if response && (retry_header = response.headers["Retry-After"])
31
+ delay_from_retry_header(retry_header)
32
+ else
33
+ calculate_delay_from_iteration(iteration)
34
+ end
35
+
36
+ ensure_delay_in_bounds(delay)
37
+ end
38
+
39
+ # Pattern matching RFC 2822 formatted dates in Retry-After headers
40
+ RFC2822_DATE_REGEX = /^
41
+ (?:Sun|Mon|Tue|Wed|Thu|Fri|Sat),\s+
42
+ (?:0[1-9]|[1-2]?[0-9]|3[01])\s+
43
+ (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+
44
+ (?:19[0-9]{2}|[2-9][0-9]{3})\s+
45
+ (?:2[0-3]|[0-1][0-9]):(?:[0-5][0-9]):(?:60|[0-5][0-9])\s+
46
+ GMT
47
+ $/x
48
+
49
+ # Parses delay from Retry-After header value
50
+ #
51
+ # @param [String] value
52
+ # @api private
53
+ # @return [Numeric]
54
+ def delay_from_retry_header(value)
55
+ value = String(value).strip
56
+
57
+ case value
58
+ when RFC2822_DATE_REGEX then DateTime.rfc2822(value).to_time - Time.now.utc
59
+ when /\A\d+$/ then value.to_i
60
+ else 0
61
+ end
62
+ end
63
+
64
+ # Calculates delay based on iteration number
65
+ #
66
+ # @param [Integer] iteration
67
+ # @api private
68
+ # @return [Numeric]
69
+ def calculate_delay_from_iteration(iteration)
70
+ if @delay_proc
71
+ @delay_proc.call(iteration)
72
+ elsif @delay
73
+ @delay
74
+ else
75
+ delay = (2**(iteration - 1)) - 1
76
+ delay_noise = rand
77
+ delay + delay_noise
78
+ end
79
+ end
80
+
81
+ # Clamps delay to configured bounds
82
+ #
83
+ # @param [Numeric] delay
84
+ # @api private
85
+ # @return [Numeric]
86
+ def ensure_delay_in_bounds(delay)
87
+ Float(delay.clamp(0, @max_delay))
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP
4
+ # Retriable performance ran out of attempts
5
+ class OutOfRetriesError < Error
6
+ # The last response received before failure
7
+ #
8
+ # @example
9
+ # error.response
10
+ #
11
+ # @return [HTTP::Response, nil] the last response received
12
+ # @api public
13
+ attr_accessor :response
14
+
15
+ # Set the underlying exception
16
+ #
17
+ # @example
18
+ # error.cause = original_error
19
+ #
20
+ # @return [Exception, nil]
21
+ # @api public
22
+ attr_writer :cause
23
+
24
+ # Returns the cause of the error
25
+ #
26
+ # @example
27
+ # error.cause
28
+ #
29
+ # @api public
30
+ # @return [Exception, nil]
31
+ def cause
32
+ @cause || super
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "http/retriable/errors"
5
+ require "http/retriable/delay_calculator"
6
+ require "openssl"
7
+
8
+ module HTTP
9
+ # Retry logic for failed HTTP requests
10
+ module Retriable
11
+ # Request performing watchdog.
12
+ # @api private
13
+ class Performer
14
+ # Exceptions we should retry
15
+ RETRIABLE_ERRORS = [
16
+ HTTP::TimeoutError,
17
+ HTTP::ConnectionError,
18
+ IO::EAGAINWaitReadable,
19
+ Errno::ECONNRESET,
20
+ Errno::ECONNREFUSED,
21
+ Errno::EHOSTUNREACH,
22
+ OpenSSL::SSL::SSLError,
23
+ EOFError,
24
+ IOError
25
+ ].freeze
26
+
27
+ # Create a new retry performer
28
+ #
29
+ # @param [#to_i] tries maximum number of attempts
30
+ # @param [#call, #to_i, nil] delay delay between retries
31
+ # @param [Array<Exception>] exceptions exception classes to retry
32
+ # @param [Array<#to_i>, nil] retry_statuses status codes to retry
33
+ # @param [#call] on_retry callback invoked on each retry
34
+ # @param [#to_f] max_delay maximum delay between retries
35
+ # @param [#call, nil] should_retry custom retry predicate
36
+ # @api private
37
+ # @return [HTTP::Retriable::Performer]
38
+ def initialize(tries: 5, delay: nil, exceptions: RETRIABLE_ERRORS, retry_statuses: nil,
39
+ on_retry: ->(*_args) {}, max_delay: Float::MAX, should_retry: nil)
40
+ @exception_classes = exceptions
41
+ @retry_statuses = retry_statuses
42
+ @tries = tries.to_i
43
+ @on_retry = on_retry
44
+ @should_retry_proc = should_retry
45
+ @delay_calculator = DelayCalculator.new(delay: delay, max_delay: max_delay)
46
+ end
47
+
48
+ # Execute request with retry logic
49
+ #
50
+ # @see #initialize
51
+ # @return [HTTP::Response]
52
+ # @api private
53
+ def perform(client, req, &block)
54
+ 1.upto(Float::INFINITY) do |attempt| # infinite loop with index
55
+ err, res = try_request(&block)
56
+
57
+ if retry_request?(req, err, res, attempt)
58
+ retry_attempt(client, req, err, res, attempt)
59
+ elsif err
60
+ finish_attempt(client, err)
61
+ elsif res
62
+ return res
63
+ end
64
+ end
65
+ end
66
+
67
+ # Calculates delay between retries
68
+ #
69
+ # @param [Integer] iteration
70
+ # @param [HTTP::Response, nil] response
71
+ # @api private
72
+ # @return [Numeric]
73
+ def calculate_delay(iteration, response)
74
+ @delay_calculator.call(iteration, response)
75
+ end
76
+
77
+ private
78
+
79
+ # Executes a single retry attempt
80
+ #
81
+ # @api private
82
+ # @return [void]
83
+ def retry_attempt(client, req, err, res, attempt)
84
+ # Some servers support Keep-Alive on any response. Thus we should
85
+ # flush response before retry, to avoid state error (when socket
86
+ # has pending response data and we try to write new request).
87
+ # Alternatively, as we don't need response body here at all, we
88
+ # are going to close client, effectively closing underlying socket
89
+ # and resetting client's state.
90
+ wait_for_retry_or_raise(req, err, res, attempt)
91
+ ensure
92
+ client.close
93
+ end
94
+
95
+ # Closes client and raises the error
96
+ #
97
+ # @api private
98
+ # @return [void]
99
+ def finish_attempt(client, err)
100
+ client.close
101
+ raise err
102
+ end
103
+
104
+ # Attempts to execute the request block
105
+ #
106
+ # @api private
107
+ # @return [Array]
108
+ # rubocop:disable Lint/RescueException
109
+ def try_request
110
+ err, res = nil
111
+
112
+ begin
113
+ res = yield
114
+ rescue Exception => e
115
+ err = e
116
+ end
117
+
118
+ [err, res]
119
+ end
120
+ # rubocop:enable Lint/RescueException
121
+
122
+ # Checks whether the request should be retried
123
+ #
124
+ # @api private
125
+ # @return [Boolean]
126
+ def retry_request?(req, err, res, attempt)
127
+ if @should_retry_proc
128
+ @should_retry_proc.call(req, err, res, attempt)
129
+ elsif err
130
+ retry_exception?(err)
131
+ else
132
+ retry_response?(res)
133
+ end
134
+ end
135
+
136
+ # Checks whether the exception is retriable
137
+ #
138
+ # @api private
139
+ # @return [Boolean]
140
+ def retry_exception?(err)
141
+ @exception_classes.any? { |e| err.is_a?(e) }
142
+ end
143
+
144
+ # Checks whether the response status warrants retry
145
+ #
146
+ # @api private
147
+ # @return [Boolean]
148
+ def retry_response?(res)
149
+ return false unless @retry_statuses
150
+
151
+ response_status = Integer(res.status)
152
+ retry_matchers = [@retry_statuses].flatten
153
+
154
+ retry_matchers.any? do |matcher|
155
+ case matcher
156
+ when Range then matcher.cover?(response_status)
157
+ when Numeric then matcher == response_status
158
+ else matcher.call(response_status)
159
+ end
160
+ end
161
+ end
162
+
163
+ # Waits for retry delay or raises if out of attempts
164
+ #
165
+ # @api private
166
+ # @return [void]
167
+ def wait_for_retry_or_raise(req, err, res, attempt)
168
+ if attempt < @tries
169
+ @on_retry.call(req, err, res)
170
+ sleep(calculate_delay(attempt, res))
171
+ else
172
+ res&.flush
173
+ raise out_of_retries_error(req, res, err)
174
+ end
175
+ end
176
+
177
+ # Builds OutOfRetriesError
178
+ #
179
+ # @param request [HTTP::Request]
180
+ # @param response [HTTP::Response, nil]
181
+ # @param exception [Exception, nil]
182
+ # @api private
183
+ # @return [HTTP::OutOfRetriesError]
184
+ def out_of_retries_error(request, response, exception)
185
+ message = format("%s <%s> failed", String(request.verb).upcase, request.uri)
186
+
187
+ message += " with #{response.status}" if response
188
+ message += ":#{exception}" if exception
189
+
190
+ OutOfRetriesError.new(message).tap do |ex|
191
+ ex.cause = exception
192
+ ex.response = response
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ require "http/cookie_jar"
6
+ require "http/headers"
7
+ require "http/redirector"
8
+ require "http/request/builder"
9
+
10
+ module HTTP
11
+ # Thread-safe options builder for configuring HTTP requests.
12
+ #
13
+ # Session objects are returned by all chainable configuration methods
14
+ # (e.g., {Chainable#headers}, {Chainable#timeout}, {Chainable#cookies}).
15
+ # They hold an immutable {Options} object and create a new {Client}
16
+ # for each request, making them safe to share across threads.
17
+ #
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
+ #
23
+ # @example Reuse a configured session across threads
24
+ # session = HTTP.headers("Accept" => "application/json").timeout(10)
25
+ # threads = 5.times.map do
26
+ # Thread.new { session.get("https://example.com") }
27
+ # end
28
+ # threads.each(&:join)
29
+ #
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
+ #
35
+ # @see Chainable
36
+ # @see Client
37
+ class Session
38
+ extend Forwardable
39
+ include Chainable
40
+
41
+ # @!method persistent?
42
+ # Indicate whether the session has persistent connection options
43
+ #
44
+ # @example
45
+ # session = HTTP::Session.new(persistent: "http://example.com")
46
+ # session.persistent?
47
+ #
48
+ # @see Options#persistent?
49
+ # @return [Boolean]
50
+ # @api public
51
+ def_delegator :default_options, :persistent?
52
+
53
+ # Initialize a new Session
54
+ #
55
+ # @example
56
+ # session = HTTP::Session.new(headers: {"Accept" => "application/json"})
57
+ #
58
+ # @param default_options [HTTP::Options, nil] existing options instance
59
+ # @param clients [Hash, nil] shared connection pool (internal use)
60
+ # @param options [Hash] keyword options (see HTTP::Options#initialize)
61
+ # @return [HTTP::Session] a new session instance
62
+ # @api public
63
+ def initialize(default_options = nil, clients: nil, **)
64
+ @default_options = HTTP::Options.new(default_options, **)
65
+ @clients = clients || {}
66
+ end
67
+
68
+ # Close all persistent connections held by this session
69
+ #
70
+ # When the session is persistent, this closes every pooled {Client}
71
+ # and clears the pool. Safe to call on non-persistent sessions (no-op).
72
+ #
73
+ # @example
74
+ # session = HTTP.persistent("https://example.com")
75
+ # session.get("/")
76
+ # session.close
77
+ #
78
+ # @return [void]
79
+ # @api public
80
+ def close
81
+ @clients.each_value(&:close)
82
+ @clients.clear
83
+ end
84
+
85
+ # Make an HTTP request
86
+ #
87
+ # For non-persistent sessions a fresh {Client} is created for each
88
+ # request, ensuring thread safety. For persistent sessions the pooled
89
+ # {Client} for the request's origin is reused.
90
+ #
91
+ # Manages cookies across redirect hops when following redirects.
92
+ #
93
+ # @example Without a block
94
+ # session = HTTP::Session.new
95
+ # session.request(:get, "https://example.com")
96
+ #
97
+ # @example With a block (auto-closes connection)
98
+ # session = HTTP::Session.new
99
+ # session.request(:get, "https://example.com") { |res| res.status }
100
+ #
101
+ # @param verb [Symbol] the HTTP method
102
+ # @param uri [#to_s] the URI to request
103
+ # @yieldparam response [HTTP::Response] the response
104
+ # @return [HTTP::Response, Object] the response, or block return value
105
+ # @api public
106
+ def request(verb, uri,
107
+ headers: nil, params: nil, form: nil, json: nil, body: nil,
108
+ response: nil, encoding: nil, follow: nil, ssl: nil, ssl_context: nil,
109
+ proxy: nil, nodelay: nil, features: nil, retriable: nil,
110
+ socket_class: nil, ssl_socket_class: nil, timeout_class: nil,
111
+ timeout_options: nil, keep_alive_timeout: nil, base_uri: nil, persistent: nil, &block)
112
+ merged = default_options.merge(
113
+ { headers: headers, params: params, form: form, json: json, body: body,
114
+ response: response, encoding: encoding, follow: follow, ssl: ssl,
115
+ ssl_context: ssl_context, proxy: proxy, nodelay: nodelay, features: features,
116
+ retriable: retriable, socket_class: socket_class, ssl_socket_class: ssl_socket_class,
117
+ timeout_class: timeout_class, timeout_options: timeout_options,
118
+ keep_alive_timeout: keep_alive_timeout, base_uri: base_uri, persistent: persistent }.compact
119
+ )
120
+ client = persistent? ? nil : make_client(default_options)
121
+ res = perform_request(client, verb, uri, merged)
122
+
123
+ return res unless block
124
+
125
+ yield res
126
+ ensure
127
+ if block
128
+ persistent? ? close : client&.close
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ # Create a new session with the given options
135
+ #
136
+ # When the current session is persistent, the child session shares the
137
+ # same connection pool so that chaining methods like {Chainable#headers}
138
+ # or {Chainable#auth} do not break connection reuse.
139
+ #
140
+ # @param options [HTTP::Options] options for the new session
141
+ # @return [HTTP::Session]
142
+ # @api private
143
+ def branch(options)
144
+ if persistent?
145
+ self.class.new(options, clients: @clients)
146
+ else
147
+ self.class.new(options)
148
+ end
149
+ end
150
+
151
+ # Execute a request with cookie management
152
+ #
153
+ # @param client [HTTP::Client, nil] the client (nil when persistent; looked up from pool)
154
+ # @param verb [Symbol] the HTTP method
155
+ # @param uri [#to_s] the URI to request
156
+ # @param merged [HTTP::Options] the merged options
157
+ # @return [HTTP::Response] the response
158
+ # @api private
159
+ def perform_request(client, verb, uri, merged)
160
+ cookie_jar = CookieJar.new
161
+ builder = Request::Builder.new(merged)
162
+ req = builder.build(verb, uri)
163
+ client ||= client_for_origin(req.uri.origin)
164
+ load_cookies(cookie_jar, req)
165
+ res = client.perform(req, merged)
166
+ store_cookies(cookie_jar, res)
167
+
168
+ return res unless merged.follow
169
+
170
+ perform_redirects(cookie_jar, client, req, res, merged)
171
+ end
172
+
173
+ # Follow redirects with cookie management
174
+ #
175
+ # For persistent sessions, each redirect hop may target a different
176
+ # origin. The session looks up (or creates) a pooled {Client} for
177
+ # the redirect target's origin, allowing cross-origin redirects
178
+ # without raising {StateError}.
179
+ #
180
+ # @param jar [HTTP::CookieJar] the cookie jar
181
+ # @param client [HTTP::Client] the client for the initial request
182
+ # @param req [HTTP::Request] the original request
183
+ # @param res [HTTP::Response] the initial redirect response
184
+ # @param opts [HTTP::Options] the merged options
185
+ # @return [HTTP::Response] the final non-redirect response
186
+ # @api private
187
+ def perform_redirects(jar, client, req, res, opts)
188
+ builder = Request::Builder.new(opts)
189
+ follow = opts.follow || {} #: Hash[untyped, untyped]
190
+ Redirector.new(**follow).perform(req, res) do |redirect_req|
191
+ wrapped = builder.wrap(redirect_req)
192
+ apply_cookies(jar, wrapped)
193
+ apply_cookies(jar, redirect_req)
194
+ response = redirect_client(client, wrapped).perform(wrapped, opts)
195
+ store_cookies(jar, response)
196
+ response
197
+ end
198
+ end
199
+
200
+ # Return the appropriate client for a redirect hop
201
+ #
202
+ # @param client [HTTP::Client] the client for the original request
203
+ # @param request [HTTP::Request] the redirect request
204
+ # @return [HTTP::Client] the client for the redirect target
205
+ # @api private
206
+ def redirect_client(client, request)
207
+ persistent? ? client_for_origin(request.uri.origin) : client
208
+ end
209
+
210
+ # Return a pooled persistent {Client} for the given origin
211
+ #
212
+ # Creates a new {Client} if one does not already exist for this origin.
213
+ # For the session's primary persistent origin, the default options are
214
+ # used directly. For other origins (e.g. redirect targets), the
215
+ # persistent origin is overridden and base_uri is cleared.
216
+ #
217
+ # @param origin [String] the URI origin (scheme + host + port)
218
+ # @return [HTTP::Client] a persistent client for the origin
219
+ # @api private
220
+ def client_for_origin(origin)
221
+ @clients[origin] ||= make_client(options_for_origin(origin))
222
+ end
223
+
224
+ # Build {Options} for a persistent client targeting the given origin
225
+ #
226
+ # @param origin [String] the URI origin
227
+ # @return [HTTP::Options] options configured for this origin
228
+ # @api private
229
+ def options_for_origin(origin)
230
+ return default_options if origin == default_options.persistent
231
+
232
+ default_options.merge(persistent: origin, base_uri: nil)
233
+ end
234
+
235
+ # Load cookies from the request's Cookie header into the jar
236
+ #
237
+ # @param jar [HTTP::CookieJar] the cookie jar
238
+ # @param request [HTTP::Request] the request
239
+ # @return [void]
240
+ # @api private
241
+ def load_cookies(jar, request)
242
+ header = request.headers[Headers::COOKIE]
243
+ cookies = HTTP::Cookie.cookie_value_to_hash(header.to_s)
244
+
245
+ cookies.each do |name, value|
246
+ jar.add(HTTP::Cookie.new(name, value, path: request.uri.path, domain: request.host))
247
+ end
248
+ end
249
+
250
+ # Store cookies from the response's Set-Cookie headers into the jar
251
+ #
252
+ # @param jar [HTTP::CookieJar] the cookie jar
253
+ # @param response [HTTP::Response] the response
254
+ # @return [void]
255
+ # @api private
256
+ def store_cookies(jar, response)
257
+ response.cookies.each do |cookie|
258
+ if cookie.value == ""
259
+ jar.delete(cookie)
260
+ else
261
+ jar.add(cookie)
262
+ end
263
+ end
264
+ end
265
+
266
+ # Apply cookies from the jar to the request's Cookie header
267
+ #
268
+ # @param jar [HTTP::CookieJar] the cookie jar
269
+ # @param request [HTTP::Request] the request
270
+ # @return [void]
271
+ # @api private
272
+ def apply_cookies(jar, request)
273
+ if jar.empty?
274
+ request.headers.delete(Headers::COOKIE)
275
+ else
276
+ request.headers.set(Headers::COOKIE, jar.map { |c| "#{c.name}=#{c.value}" }.join("; "))
277
+ end
278
+ end
279
+ end
280
+ end