http 5.3.1 → 6.0.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 (201) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +241 -41
  3. data/LICENSE.txt +1 -1
  4. data/README.md +110 -13
  5. data/UPGRADING.md +491 -0
  6. data/http.gemspec +32 -29
  7. data/lib/http/base64.rb +11 -1
  8. data/lib/http/chainable/helpers.rb +62 -0
  9. data/lib/http/chainable/verbs.rb +136 -0
  10. data/lib/http/chainable.rb +232 -136
  11. data/lib/http/client.rb +158 -127
  12. data/lib/http/connection/internals.rb +141 -0
  13. data/lib/http/connection.rb +126 -97
  14. data/lib/http/content_type.rb +61 -6
  15. data/lib/http/errors.rb +25 -1
  16. data/lib/http/feature.rb +65 -5
  17. data/lib/http/features/auto_deflate.rb +124 -17
  18. data/lib/http/features/auto_inflate.rb +38 -15
  19. data/lib/http/features/caching/entry.rb +178 -0
  20. data/lib/http/features/caching/in_memory_store.rb +63 -0
  21. data/lib/http/features/caching.rb +216 -0
  22. data/lib/http/features/digest_auth.rb +234 -0
  23. data/lib/http/features/instrumentation.rb +97 -17
  24. data/lib/http/features/logging.rb +183 -5
  25. data/lib/http/features/normalize_uri.rb +17 -0
  26. data/lib/http/features/raise_error.rb +18 -3
  27. data/lib/http/form_data/composite_io.rb +106 -0
  28. data/lib/http/form_data/file.rb +95 -0
  29. data/lib/http/form_data/multipart/param.rb +62 -0
  30. data/lib/http/form_data/multipart.rb +106 -0
  31. data/lib/http/form_data/part.rb +52 -0
  32. data/lib/http/form_data/readable.rb +58 -0
  33. data/lib/http/form_data/urlencoded.rb +175 -0
  34. data/lib/http/form_data/version.rb +8 -0
  35. data/lib/http/form_data.rb +102 -0
  36. data/lib/http/headers/known.rb +3 -0
  37. data/lib/http/headers/normalizer.rb +17 -36
  38. data/lib/http/headers.rb +172 -65
  39. data/lib/http/mime_type/adapter.rb +24 -9
  40. data/lib/http/mime_type/json.rb +19 -4
  41. data/lib/http/mime_type.rb +21 -3
  42. data/lib/http/options/definitions.rb +189 -0
  43. data/lib/http/options.rb +172 -125
  44. data/lib/http/redirector.rb +80 -75
  45. data/lib/http/request/body.rb +87 -6
  46. data/lib/http/request/builder.rb +184 -0
  47. data/lib/http/request/proxy.rb +83 -0
  48. data/lib/http/request/writer.rb +76 -16
  49. data/lib/http/request.rb +214 -98
  50. data/lib/http/response/body.rb +103 -18
  51. data/lib/http/response/inflater.rb +35 -7
  52. data/lib/http/response/parser.rb +98 -4
  53. data/lib/http/response/status/reasons.rb +2 -4
  54. data/lib/http/response/status.rb +141 -31
  55. data/lib/http/response.rb +219 -61
  56. data/lib/http/retriable/delay_calculator.rb +38 -11
  57. data/lib/http/retriable/errors.rb +21 -0
  58. data/lib/http/retriable/performer.rb +82 -38
  59. data/lib/http/session.rb +280 -0
  60. data/lib/http/timeout/global.rb +147 -34
  61. data/lib/http/timeout/null.rb +155 -9
  62. data/lib/http/timeout/per_operation.rb +139 -18
  63. data/lib/http/uri/normalizer.rb +82 -0
  64. data/lib/http/uri/parsing.rb +182 -0
  65. data/lib/http/uri.rb +289 -124
  66. data/lib/http/version.rb +2 -1
  67. data/lib/http.rb +11 -2
  68. data/sig/deps.rbs +122 -0
  69. data/sig/http.rbs +1619 -0
  70. data/test/http/base64_test.rb +28 -0
  71. data/test/http/client_test.rb +739 -0
  72. data/test/http/connection_test.rb +1533 -0
  73. data/test/http/content_type_test.rb +190 -0
  74. data/test/http/errors_test.rb +28 -0
  75. data/test/http/feature_test.rb +49 -0
  76. data/test/http/features/auto_deflate_test.rb +317 -0
  77. data/test/http/features/auto_inflate_test.rb +213 -0
  78. data/test/http/features/caching_test.rb +942 -0
  79. data/test/http/features/digest_auth_test.rb +996 -0
  80. data/test/http/features/instrumentation_test.rb +246 -0
  81. data/test/http/features/logging_test.rb +654 -0
  82. data/test/http/features/normalize_uri_test.rb +41 -0
  83. data/test/http/features/raise_error_test.rb +77 -0
  84. data/test/http/form_data/composite_io_test.rb +215 -0
  85. data/test/http/form_data/file_test.rb +255 -0
  86. data/test/http/form_data/fixtures/the-http-gem.info +1 -0
  87. data/test/http/form_data/multipart_test.rb +303 -0
  88. data/test/http/form_data/part_test.rb +90 -0
  89. data/test/http/form_data/urlencoded_test.rb +164 -0
  90. data/test/http/form_data_test.rb +232 -0
  91. data/test/http/headers/normalizer_test.rb +93 -0
  92. data/test/http/headers_test.rb +888 -0
  93. data/test/http/mime_type/json_test.rb +39 -0
  94. data/test/http/mime_type_test.rb +150 -0
  95. data/test/http/options/base_uri_test.rb +148 -0
  96. data/test/http/options/body_test.rb +21 -0
  97. data/test/http/options/features_test.rb +38 -0
  98. data/test/http/options/form_test.rb +21 -0
  99. data/test/http/options/headers_test.rb +32 -0
  100. data/test/http/options/json_test.rb +21 -0
  101. data/test/http/options/merge_test.rb +78 -0
  102. data/test/http/options/new_test.rb +37 -0
  103. data/test/http/options/proxy_test.rb +32 -0
  104. data/test/http/options_test.rb +575 -0
  105. data/test/http/redirector_test.rb +639 -0
  106. data/test/http/request/body_test.rb +318 -0
  107. data/test/http/request/builder_test.rb +623 -0
  108. data/test/http/request/writer_test.rb +391 -0
  109. data/test/http/request_test.rb +1733 -0
  110. data/test/http/response/body_test.rb +292 -0
  111. data/test/http/response/parser_test.rb +105 -0
  112. data/test/http/response/status_test.rb +322 -0
  113. data/test/http/response_test.rb +502 -0
  114. data/test/http/retriable/delay_calculator_test.rb +194 -0
  115. data/test/http/retriable/errors_test.rb +71 -0
  116. data/test/http/retriable/performer_test.rb +551 -0
  117. data/test/http/session_test.rb +424 -0
  118. data/test/http/timeout/global_test.rb +239 -0
  119. data/test/http/timeout/null_test.rb +218 -0
  120. data/test/http/timeout/per_operation_test.rb +220 -0
  121. data/test/http/uri/normalizer_test.rb +89 -0
  122. data/test/http/uri_test.rb +1140 -0
  123. data/test/http/version_test.rb +15 -0
  124. data/test/http_test.rb +818 -0
  125. data/test/regression_tests.rb +27 -0
  126. data/test/support/dummy_server/encoding_routes.rb +47 -0
  127. data/test/support/dummy_server/routes.rb +201 -0
  128. data/test/support/dummy_server/servlet.rb +81 -0
  129. data/test/support/dummy_server.rb +200 -0
  130. data/{spec → test}/support/fakeio.rb +2 -2
  131. data/test/support/http_handling_shared/connection_reuse_tests.rb +97 -0
  132. data/test/support/http_handling_shared/timeout_tests.rb +134 -0
  133. data/test/support/http_handling_shared.rb +11 -0
  134. data/test/support/proxy_server.rb +207 -0
  135. data/test/support/servers/runner.rb +67 -0
  136. data/{spec → test}/support/simplecov.rb +11 -2
  137. data/test/support/ssl_helper.rb +108 -0
  138. data/test/test_helper.rb +38 -0
  139. metadata +108 -168
  140. data/.github/workflows/ci.yml +0 -67
  141. data/.gitignore +0 -15
  142. data/.rspec +0 -1
  143. data/.rubocop/layout.yml +0 -8
  144. data/.rubocop/metrics.yml +0 -4
  145. data/.rubocop/rspec.yml +0 -9
  146. data/.rubocop/style.yml +0 -32
  147. data/.rubocop.yml +0 -11
  148. data/.rubocop_todo.yml +0 -219
  149. data/.yardopts +0 -2
  150. data/CHANGES_OLD.md +0 -1002
  151. data/Gemfile +0 -51
  152. data/Guardfile +0 -18
  153. data/Rakefile +0 -64
  154. data/lib/http/headers/mixin.rb +0 -34
  155. data/lib/http/retriable/client.rb +0 -37
  156. data/logo.png +0 -0
  157. data/spec/lib/http/client_spec.rb +0 -556
  158. data/spec/lib/http/connection_spec.rb +0 -88
  159. data/spec/lib/http/content_type_spec.rb +0 -47
  160. data/spec/lib/http/features/auto_deflate_spec.rb +0 -77
  161. data/spec/lib/http/features/auto_inflate_spec.rb +0 -86
  162. data/spec/lib/http/features/instrumentation_spec.rb +0 -81
  163. data/spec/lib/http/features/logging_spec.rb +0 -65
  164. data/spec/lib/http/features/raise_error_spec.rb +0 -62
  165. data/spec/lib/http/headers/mixin_spec.rb +0 -36
  166. data/spec/lib/http/headers/normalizer_spec.rb +0 -52
  167. data/spec/lib/http/headers_spec.rb +0 -527
  168. data/spec/lib/http/options/body_spec.rb +0 -15
  169. data/spec/lib/http/options/features_spec.rb +0 -33
  170. data/spec/lib/http/options/form_spec.rb +0 -15
  171. data/spec/lib/http/options/headers_spec.rb +0 -24
  172. data/spec/lib/http/options/json_spec.rb +0 -15
  173. data/spec/lib/http/options/merge_spec.rb +0 -68
  174. data/spec/lib/http/options/new_spec.rb +0 -30
  175. data/spec/lib/http/options/proxy_spec.rb +0 -20
  176. data/spec/lib/http/options_spec.rb +0 -13
  177. data/spec/lib/http/redirector_spec.rb +0 -530
  178. data/spec/lib/http/request/body_spec.rb +0 -211
  179. data/spec/lib/http/request/writer_spec.rb +0 -121
  180. data/spec/lib/http/request_spec.rb +0 -234
  181. data/spec/lib/http/response/body_spec.rb +0 -85
  182. data/spec/lib/http/response/parser_spec.rb +0 -74
  183. data/spec/lib/http/response/status_spec.rb +0 -253
  184. data/spec/lib/http/response_spec.rb +0 -262
  185. data/spec/lib/http/retriable/delay_calculator_spec.rb +0 -69
  186. data/spec/lib/http/retriable/performer_spec.rb +0 -302
  187. data/spec/lib/http/uri/normalizer_spec.rb +0 -95
  188. data/spec/lib/http/uri_spec.rb +0 -71
  189. data/spec/lib/http_spec.rb +0 -535
  190. data/spec/regression_specs.rb +0 -24
  191. data/spec/spec_helper.rb +0 -89
  192. data/spec/support/black_hole.rb +0 -13
  193. data/spec/support/dummy_server/servlet.rb +0 -203
  194. data/spec/support/dummy_server.rb +0 -44
  195. data/spec/support/fuubar.rb +0 -21
  196. data/spec/support/http_handling_shared.rb +0 -190
  197. data/spec/support/proxy_server.rb +0 -39
  198. data/spec/support/servers/config.rb +0 -11
  199. data/spec/support/servers/runner.rb +0 -19
  200. data/spec/support/ssl_helper.rb +0 -104
  201. /data/{spec → test}/support/capture_warning.rb +0 -0
@@ -2,17 +2,30 @@
2
2
 
3
3
  module HTTP
4
4
  module Retriable
5
+ # Calculates retry delays with support for Retry-After headers
5
6
  # @api private
6
7
  class DelayCalculator
7
- def initialize(opts)
8
- @max_delay = opts.fetch(:max_delay, Float::MAX).to_f
9
- if (delay = opts[:delay]).respond_to?(:call)
10
- @delay_proc = opts.fetch(:delay)
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
11
18
  else
12
19
  @delay = delay
13
20
  end
14
21
  end
15
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]
16
29
  def call(iteration, response)
17
30
  delay = if response && (retry_header = response.headers["Retry-After"])
18
31
  delay_from_retry_header(retry_header)
@@ -20,9 +33,10 @@ module HTTP
20
33
  calculate_delay_from_iteration(iteration)
21
34
  end
22
35
 
23
- ensure_dealy_in_bounds(delay)
36
+ ensure_delay_in_bounds(delay)
24
37
  end
25
38
 
39
+ # Pattern matching RFC 2822 formatted dates in Retry-After headers
26
40
  RFC2822_DATE_REGEX = /^
27
41
  (?:Sun|Mon|Tue|Wed|Thu|Fri|Sat),\s+
28
42
  (?:0[1-9]|[1-2]?[0-9]|3[01])\s+
@@ -32,18 +46,26 @@ module HTTP
32
46
  GMT
33
47
  $/x
34
48
 
35
- # Spec for Retry-After header
36
- # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
49
+ # Parses delay from Retry-After header value
50
+ #
51
+ # @param [String] value
52
+ # @api private
53
+ # @return [Numeric]
37
54
  def delay_from_retry_header(value)
38
- value = value.to_s.strip
55
+ value = String(value).strip
39
56
 
40
57
  case value
41
58
  when RFC2822_DATE_REGEX then DateTime.rfc2822(value).to_time - Time.now.utc
42
- when /^\d+$/ then value.to_i
59
+ when /\A\d+$/ then value.to_i
43
60
  else 0
44
61
  end
45
62
  end
46
63
 
64
+ # Calculates delay based on iteration number
65
+ #
66
+ # @param [Integer] iteration
67
+ # @api private
68
+ # @return [Numeric]
47
69
  def calculate_delay_from_iteration(iteration)
48
70
  if @delay_proc
49
71
  @delay_proc.call(iteration)
@@ -56,8 +78,13 @@ module HTTP
56
78
  end
57
79
  end
58
80
 
59
- def ensure_dealy_in_bounds(delay)
60
- delay.clamp(0, @max_delay)
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))
61
88
  end
62
89
  end
63
90
  end
@@ -3,10 +3,31 @@
3
3
  module HTTP
4
4
  # Retriable performance ran out of attempts
5
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
6
13
  attr_accessor :response
7
14
 
15
+ # Set the underlying exception
16
+ #
17
+ # @example
18
+ # error.cause = original_error
19
+ #
20
+ # @return [Exception, nil]
21
+ # @api public
8
22
  attr_writer :cause
9
23
 
24
+ # Returns the cause of the error
25
+ #
26
+ # @example
27
+ # error.cause
28
+ #
29
+ # @api public
30
+ # @return [Exception, nil]
10
31
  def cause
11
32
  @cause || super
12
33
  end
@@ -6,6 +6,7 @@ require "http/retriable/delay_calculator"
6
6
  require "openssl"
7
7
 
8
8
  module HTTP
9
+ # Retry logic for failed HTTP requests
9
10
  module Retriable
10
11
  # Request performing watchdog.
11
12
  # @api private
@@ -23,62 +24,87 @@ module HTTP
23
24
  IOError
24
25
  ].freeze
25
26
 
26
- # @param [Hash] opts
27
- # @option opts [#to_i] :tries (5)
28
- # @option opts [#call, #to_i] :delay (DELAY_PROC)
29
- # @option opts [Array(Exception)] :exceptions (RETRIABLE_ERRORS)
30
- # @option opts [Array(#to_i)] :retry_statuses
31
- # @option opts [#call] :on_retry
32
- # @option opts [#to_f] :max_delay (Float::MAX)
33
- # @option opts [#call] :should_retry
34
- def initialize(opts)
35
- @exception_classes = opts.fetch(:exceptions, RETRIABLE_ERRORS)
36
- @retry_statuses = opts[:retry_statuses]
37
- @tries = opts.fetch(:tries, 5).to_i
38
- @on_retry = opts.fetch(:on_retry, ->(*) {})
39
- @should_retry_proc = opts[:should_retry]
40
- @delay_calculator = DelayCalculator.new(opts)
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)
41
46
  end
42
47
 
43
- # Watches request/response execution.
44
- #
45
- # If any of {RETRIABLE_ERRORS} occur or response status is `5xx`, retries
46
- # up to `:tries` amount of times. Sleeps for amount of seconds calculated
47
- # with `:delay` proc before each retry.
48
+ # Execute request with retry logic
48
49
  #
49
50
  # @see #initialize
51
+ # @return [HTTP::Response]
50
52
  # @api private
51
53
  def perform(client, req, &block)
52
54
  1.upto(Float::INFINITY) do |attempt| # infinite loop with index
53
55
  err, res = try_request(&block)
54
56
 
55
57
  if retry_request?(req, err, res, attempt)
56
- begin
57
- wait_for_retry_or_raise(req, err, res, attempt)
58
- ensure
59
- # Some servers support Keep-Alive on any response. Thus we should
60
- # flush response before retry, to avoid state error (when socket
61
- # has pending response data and we try to write new request).
62
- # Alternatively, as we don't need response body here at all, we
63
- # are going to close client, effectivle closing underlying socket
64
- # and resetting client's state.
65
- client.close
66
- end
58
+ retry_attempt(client, req, err, res, attempt)
67
59
  elsif err
68
- client.close
69
- raise err
60
+ finish_attempt(client, err)
70
61
  elsif res
71
62
  return res
72
63
  end
73
64
  end
74
65
  end
75
66
 
67
+ # Calculates delay between retries
68
+ #
69
+ # @param [Integer] iteration
70
+ # @param [HTTP::Response, nil] response
71
+ # @api private
72
+ # @return [Numeric]
76
73
  def calculate_delay(iteration, response)
77
74
  @delay_calculator.call(iteration, response)
78
75
  end
79
76
 
80
77
  private
81
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]
82
108
  # rubocop:disable Lint/RescueException
83
109
  def try_request
84
110
  err, res = nil
@@ -93,6 +119,10 @@ module HTTP
93
119
  end
94
120
  # rubocop:enable Lint/RescueException
95
121
 
122
+ # Checks whether the request should be retried
123
+ #
124
+ # @api private
125
+ # @return [Boolean]
96
126
  def retry_request?(req, err, res, attempt)
97
127
  if @should_retry_proc
98
128
  @should_retry_proc.call(req, err, res, attempt)
@@ -103,14 +133,22 @@ module HTTP
103
133
  end
104
134
  end
105
135
 
136
+ # Checks whether the exception is retriable
137
+ #
138
+ # @api private
139
+ # @return [Boolean]
106
140
  def retry_exception?(err)
107
141
  @exception_classes.any? { |e| err.is_a?(e) }
108
142
  end
109
143
 
144
+ # Checks whether the response status warrants retry
145
+ #
146
+ # @api private
147
+ # @return [Boolean]
110
148
  def retry_response?(res)
111
149
  return false unless @retry_statuses
112
150
 
113
- response_status = res.status.to_i
151
+ response_status = Integer(res.status)
114
152
  retry_matchers = [@retry_statuses].flatten
115
153
 
116
154
  retry_matchers.any? do |matcher|
@@ -122,10 +160,14 @@ module HTTP
122
160
  end
123
161
  end
124
162
 
163
+ # Waits for retry delay or raises if out of attempts
164
+ #
165
+ # @api private
166
+ # @return [void]
125
167
  def wait_for_retry_or_raise(req, err, res, attempt)
126
168
  if attempt < @tries
127
169
  @on_retry.call(req, err, res)
128
- sleep calculate_delay(attempt, res)
170
+ sleep(calculate_delay(attempt, res))
129
171
  else
130
172
  res&.flush
131
173
  raise out_of_retries_error(req, res, err)
@@ -135,15 +177,17 @@ module HTTP
135
177
  # Builds OutOfRetriesError
136
178
  #
137
179
  # @param request [HTTP::Request]
138
- # @param status [HTTP::Response, nil]
180
+ # @param response [HTTP::Response, nil]
139
181
  # @param exception [Exception, nil]
182
+ # @api private
183
+ # @return [HTTP::OutOfRetriesError]
140
184
  def out_of_retries_error(request, response, exception)
141
- message = "#{request.verb.to_s.upcase} <#{request.uri}> failed"
185
+ message = format("%s <%s> failed", String(request.verb).upcase, request.uri)
142
186
 
143
187
  message += " with #{response.status}" if response
144
188
  message += ":#{exception}" if exception
145
189
 
146
- HTTP::OutOfRetriesError.new(message).tap do |ex|
190
+ OutOfRetriesError.new(message).tap do |ex|
147
191
  ex.cause = exception
148
192
  ex.response = response
149
193
  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