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
@@ -7,104 +7,217 @@ require "http/timeout/null"
7
7
 
8
8
  module HTTP
9
9
  module Timeout
10
+ # Timeout handler with a single global timeout for the entire request
10
11
  class Global < Null
11
- def initialize(*args)
12
+ # I/O wait result symbols returned by non-blocking operations
13
+ WAIT_RESULTS = %i[wait_readable wait_writable].freeze
14
+ # Initializes global timeout with options
15
+ #
16
+ # @example
17
+ # HTTP::Timeout::Global.new(global_timeout: 5)
18
+ #
19
+ # @param [Numeric] global_timeout Global timeout in seconds
20
+ # @param [Numeric, nil] read_timeout Read timeout in seconds
21
+ # @param [Numeric, nil] write_timeout Write timeout in seconds
22
+ # @param [Numeric, nil] connect_timeout Connect timeout in seconds
23
+ # @api public
24
+ # @return [HTTP::Timeout::Global]
25
+ def initialize(global_timeout:, read_timeout: nil, write_timeout: nil, connect_timeout: nil)
12
26
  super
13
27
 
14
- @timeout = @time_left = options.fetch(:global_timeout)
28
+ @timeout = @time_left = global_timeout
29
+ @read_timeout = read_timeout
30
+ @write_timeout = write_timeout
31
+ @connect_timeout = connect_timeout
15
32
  end
16
33
 
17
- # To future me: Don't remove this again, past you was smarter.
34
+ # Resets the time left counter to initial timeout
35
+ #
36
+ # @example
37
+ # timeout.reset_counter
38
+ #
39
+ # @api public
40
+ # @return [Numeric]
18
41
  def reset_counter
19
42
  @time_left = @timeout
20
43
  end
21
44
 
22
- def connect(socket_class, host, port, nodelay = false)
45
+ # Connects to a socket with global timeout
46
+ #
47
+ # @example
48
+ # timeout.connect(TCPSocket, "example.com", 80)
49
+ #
50
+ # @param [Class] socket_class
51
+ # @param [String] host
52
+ # @param [Integer] port
53
+ # @param [Boolean] nodelay
54
+ # @api public
55
+ # @return [void]
56
+ def connect(socket_class, host, port, nodelay: false)
23
57
  reset_timer
24
- ::Timeout.timeout(@time_left, ConnectTimeoutError) do
25
- @socket = socket_class.open(host, port)
26
- @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
27
- end
58
+ @socket = open_socket(socket_class, host, port, connect_timeout: effective_timeout(@connect_timeout))
59
+ @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
28
60
 
29
61
  log_time
30
62
  end
31
63
 
64
+ # Starts an SSL connection on a socket
65
+ #
66
+ # @example
67
+ # timeout.connect_ssl
68
+ #
69
+ # @api public
70
+ # @return [void]
32
71
  def connect_ssl
33
72
  reset_timer
34
73
 
35
74
  begin
36
75
  @socket.connect_nonblock
37
76
  rescue IO::WaitReadable
38
- wait_readable_or_timeout
77
+ wait_readable_or_timeout(@connect_timeout)
39
78
  retry
40
79
  rescue IO::WaitWritable
41
- wait_writable_or_timeout
80
+ wait_writable_or_timeout(@connect_timeout)
42
81
  retry
43
82
  end
44
83
  end
45
84
 
46
85
  # Read from the socket
86
+ #
87
+ # @example
88
+ # timeout.readpartial(1024)
89
+ #
90
+ # @param [Integer] size
91
+ # @param [String, nil] buffer
92
+ # @api public
93
+ # @return [String, :eof]
47
94
  def readpartial(size, buffer = nil)
48
- perform_io { read_nonblock(size, buffer) }
95
+ perform_io(@read_timeout) { read_nonblock(size, buffer) }
49
96
  end
50
97
 
51
98
  # Write to the socket
99
+ #
100
+ # @example
101
+ # timeout.write("GET / HTTP/1.1")
102
+ #
103
+ # @param [String] data
104
+ # @api public
105
+ # @return [Integer, :eof]
52
106
  def write(data)
53
- perform_io { write_nonblock(data) }
107
+ perform_io(@write_timeout) { write_nonblock(data) }
54
108
  end
55
109
 
56
110
  alias << write
57
111
 
58
112
  private
59
113
 
114
+ # Reads from socket in non-blocking mode
115
+ #
116
+ # @api private
117
+ # @return [String, Symbol]
60
118
  def read_nonblock(size, buffer = nil)
61
- @socket.read_nonblock(size, buffer, :exception => false)
119
+ @socket.read_nonblock(size, buffer, exception: false)
62
120
  end
63
121
 
122
+ # Writes to socket in non-blocking mode
123
+ #
124
+ # @api private
125
+ # @return [Integer, Symbol]
64
126
  def write_nonblock(data)
65
- @socket.write_nonblock(data, :exception => false)
127
+ @socket.write_nonblock(data, exception: false)
66
128
  end
67
129
 
68
- # Perform the given I/O operation with the given argument
69
- def perform_io
130
+ # Performs I/O operation with timeout tracking
131
+ #
132
+ # @param [Numeric, nil] per_op_timeout per-operation timeout limit
133
+ # @api private
134
+ # @return [Object]
135
+ def perform_io(per_op_timeout = nil)
70
136
  reset_timer
71
137
 
72
138
  loop do
73
139
  result = yield
140
+ return handle_io_result(result) unless WAIT_RESULTS.include?(result)
74
141
 
75
- case result
76
- when :wait_readable then wait_readable_or_timeout
77
- when :wait_writable then wait_writable_or_timeout
78
- when NilClass then return :eof
79
- else return result
80
- end
81
- rescue IO::WaitReadable
82
- wait_readable_or_timeout
83
- rescue IO::WaitWritable
84
- wait_writable_or_timeout
142
+ wait_for_io(result, per_op_timeout)
143
+ rescue IO::WaitReadable then wait_readable_or_timeout(per_op_timeout)
144
+ rescue IO::WaitWritable then wait_writable_or_timeout(per_op_timeout)
85
145
  end
86
146
  rescue EOFError
87
147
  :eof
88
148
  end
89
149
 
90
- # Wait for a socket to become readable
91
- def wait_readable_or_timeout
92
- @socket.to_io.wait_readable(@time_left)
150
+ # Handles the result of an I/O operation
151
+ #
152
+ # @api private
153
+ # @return [Object, Symbol]
154
+ def handle_io_result(result)
155
+ result.nil? ? :eof : result
156
+ end
157
+
158
+ # Waits for an I/O readiness based on the result type
159
+ #
160
+ # @param [Symbol] result the I/O wait type
161
+ # @param [Numeric, nil] per_op_timeout per-operation timeout limit
162
+ # @api private
163
+ # @return [void]
164
+ def wait_for_io(result, per_op_timeout = nil)
165
+ if result == :wait_readable
166
+ wait_readable_or_timeout(per_op_timeout)
167
+ else
168
+ wait_writable_or_timeout(per_op_timeout)
169
+ end
170
+ end
171
+
172
+ # Waits for a socket to become readable
173
+ #
174
+ # @param [Numeric, nil] per_op per-operation timeout limit
175
+ # @api private
176
+ # @return [void]
177
+ def wait_readable_or_timeout(per_op = nil)
178
+ timeout = effective_timeout(per_op)
179
+ result = @socket.to_io.wait_readable(timeout)
93
180
  log_time
181
+
182
+ raise TimeoutError, "Read timed out after #{per_op} seconds" if per_op && result.nil?
94
183
  end
95
184
 
96
- # Wait for a socket to become writable
97
- def wait_writable_or_timeout
98
- @socket.to_io.wait_writable(@time_left)
185
+ # Waits for a socket to become writable
186
+ #
187
+ # @param [Numeric, nil] per_op per-operation timeout limit
188
+ # @api private
189
+ # @return [void]
190
+ def wait_writable_or_timeout(per_op = nil)
191
+ timeout = effective_timeout(per_op)
192
+ result = @socket.to_io.wait_writable(timeout)
99
193
  log_time
194
+
195
+ raise TimeoutError, "Write timed out after #{per_op} seconds" if per_op && result.nil?
196
+ end
197
+
198
+ # Computes the effective timeout as the minimum of global and per-operation
199
+ #
200
+ # @param [Numeric, nil] per_op_timeout per-operation timeout limit
201
+ # @api private
202
+ # @return [Numeric]
203
+ def effective_timeout(per_op_timeout)
204
+ return @time_left unless per_op_timeout
205
+
206
+ [per_op_timeout, @time_left].min
100
207
  end
101
208
 
102
- # Due to the run/retry nature of nonblocking I/O, it's easier to keep track of time
103
- # via method calls instead of a block to monitor.
209
+ # Resets the I/O timer to current time
210
+ #
211
+ # @api private
212
+ # @return [Time]
104
213
  def reset_timer
105
214
  @started = Time.now
106
215
  end
107
216
 
217
+ # Logs elapsed time and checks for timeout
218
+ #
219
+ # @api private
220
+ # @return [void]
108
221
  def log_time
109
222
  @time_left -= (Time.now - @started)
110
223
  raise TimeoutError, "Timed out after using the allocated #{@timeout} seconds" if @time_left <= 0
@@ -1,36 +1,112 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "io/wait"
4
+ require "timeout"
4
5
 
5
6
  module HTTP
7
+ # Namespace for timeout handlers
6
8
  module Timeout
9
+ # Base timeout handler with no timeout enforcement
7
10
  class Null
8
- attr_reader :options, :socket
9
-
10
- def initialize(options = {})
11
- @options = options
11
+ # Whether TCPSocket natively supports connect_timeout and
12
+ # Happy Eyeballs (RFC 8305). Available in Ruby 3.4+.
13
+ #
14
+ # @api private
15
+ NATIVE_CONNECT_TIMEOUT = RUBY_VERSION >= "3.4"
16
+
17
+ # Timeout configuration options
18
+ #
19
+ # @example
20
+ # timeout.options # => {read_timeout: 5}
21
+ #
22
+ # @return [Hash] timeout options
23
+ # @api public
24
+ attr_reader :options
25
+
26
+ # The underlying socket
27
+ #
28
+ # @example
29
+ # timeout.socket
30
+ #
31
+ # @return [Object] the underlying socket
32
+ # @api public
33
+ attr_reader :socket
34
+
35
+ # Initializes the null timeout handler
36
+ #
37
+ # @example
38
+ # HTTP::Timeout::Null.new(read_timeout: 5)
39
+ #
40
+ # @param [Numeric, nil] read_timeout Read timeout in seconds
41
+ # @param [Numeric, nil] write_timeout Write timeout in seconds
42
+ # @param [Numeric, nil] connect_timeout Connect timeout in seconds
43
+ # @param [Numeric, nil] global_timeout Global timeout in seconds
44
+ # @api public
45
+ # @return [HTTP::Timeout::Null]
46
+ def initialize(read_timeout: nil, write_timeout: nil, connect_timeout: nil, global_timeout: nil)
47
+ @options = { read_timeout: read_timeout, write_timeout: write_timeout,
48
+ connect_timeout: connect_timeout, global_timeout: global_timeout }.compact
12
49
  end
13
50
 
14
51
  # Connects to a socket
15
- def connect(socket_class, host, port, nodelay = false)
16
- @socket = socket_class.open(host, port)
52
+ #
53
+ # @example
54
+ # timeout.connect(TCPSocket, "example.com", 80)
55
+ #
56
+ # @param [Class] socket_class
57
+ # @param [String] host
58
+ # @param [Integer] port
59
+ # @param [Boolean] nodelay
60
+ # @api public
61
+ # @return [void]
62
+ def connect(socket_class, host, port, nodelay: false)
63
+ @socket = open_socket(socket_class, host, port)
17
64
  @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
18
65
  end
19
66
 
20
67
  # Starts a SSL connection on a socket
68
+ #
69
+ # @example
70
+ # timeout.connect_ssl
71
+ #
72
+ # @api public
73
+ # @return [void]
21
74
  def connect_ssl
22
75
  @socket.connect
23
76
  end
24
77
 
78
+ # Closes the underlying socket
79
+ #
80
+ # @example
81
+ # timeout.close
82
+ #
83
+ # @api public
84
+ # @return [void]
25
85
  def close
26
86
  @socket&.close
27
87
  end
28
88
 
89
+ # Checks whether the socket is closed
90
+ #
91
+ # @example
92
+ # timeout.closed?
93
+ #
94
+ # @api public
95
+ # @return [Boolean]
29
96
  def closed?
30
97
  @socket&.closed?
31
98
  end
32
99
 
33
- # Configures the SSL connection and starts the connection
100
+ # Configures the SSL connection and starts it
101
+ #
102
+ # @example
103
+ # timeout.start_tls("example.com", ssl_class, ssl_ctx)
104
+ #
105
+ # @param [String] host
106
+ # @param [Class] ssl_socket_class
107
+ # @param [OpenSSL::SSL::SSLContext] ssl_context
108
+ # @api public
109
+ # @return [void]
34
110
  def start_tls(host, ssl_socket_class, ssl_context)
35
111
  @socket = ssl_socket_class.new(socket, ssl_context)
36
112
  @socket.hostname = host if @socket.respond_to? :hostname=
@@ -45,6 +121,14 @@ module HTTP
45
121
  end
46
122
 
47
123
  # Read from the socket
124
+ #
125
+ # @example
126
+ # timeout.readpartial(1024)
127
+ #
128
+ # @param [Integer] size
129
+ # @param [String, nil] buffer
130
+ # @api public
131
+ # @return [String, :eof]
48
132
  def readpartial(size, buffer = nil)
49
133
  @socket.readpartial(size, buffer)
50
134
  rescue EOFError
@@ -52,6 +136,13 @@ module HTTP
52
136
  end
53
137
 
54
138
  # Write to the socket
139
+ #
140
+ # @example
141
+ # timeout.write("GET / HTTP/1.1")
142
+ #
143
+ # @param [String] data
144
+ # @api public
145
+ # @return [Integer]
55
146
  def write(data)
56
147
  @socket.write(data)
57
148
  end
@@ -59,7 +150,10 @@ module HTTP
59
150
 
60
151
  private
61
152
 
62
- # Retry reading
153
+ # Retries reading on wait readable
154
+ #
155
+ # @api private
156
+ # @return [Object]
63
157
  def rescue_readable(timeout = read_timeout)
64
158
  yield
65
159
  rescue IO::WaitReadable
@@ -67,13 +161,65 @@ module HTTP
67
161
  raise TimeoutError, "Read timed out after #{timeout} seconds"
68
162
  end
69
163
 
70
- # Retry writing
164
+ # Retries writing on wait writable
165
+ #
166
+ # @api private
167
+ # @return [Object]
71
168
  def rescue_writable(timeout = write_timeout)
72
169
  yield
73
170
  rescue IO::WaitWritable
74
171
  retry if @socket.to_io.wait_writable(timeout)
75
172
  raise TimeoutError, "Write timed out after #{timeout} seconds"
76
173
  end
174
+
175
+ # Opens a TCP socket, using native connect_timeout when available
176
+ #
177
+ # On Ruby 3.4+ with TCPSocket, passes connect_timeout natively to
178
+ # enable proper Happy Eyeballs (RFC 8305) support. Falls back to
179
+ # Timeout.timeout on older Rubies or with custom socket classes.
180
+ #
181
+ # @param [Class] socket_class socket class to create
182
+ # @param [String] host remote hostname
183
+ # @param [Integer] port remote port
184
+ # @param [Numeric, nil] connect_timeout timeout in seconds
185
+ # @return [Object] the connected socket
186
+ # @api private
187
+ def open_socket(socket_class, host, port, connect_timeout: nil)
188
+ if connect_timeout
189
+ ::Timeout.timeout(connect_timeout, ConnectTimeoutError) do
190
+ open_with_timeout(socket_class, host, port, connect_timeout)
191
+ end
192
+ else
193
+ socket_class.open(host, port)
194
+ end
195
+ rescue IO::TimeoutError
196
+ raise ConnectTimeoutError, "Connect timed out after #{connect_timeout} seconds"
197
+ end
198
+
199
+ # Opens a socket, passing connect_timeout natively when supported
200
+ #
201
+ # @param [Class] socket_class socket class to create
202
+ # @param [String] host remote hostname
203
+ # @param [Integer] port remote port
204
+ # @param [Numeric] connect_timeout timeout in seconds
205
+ # @return [Object] the connected socket
206
+ # @api private
207
+ def open_with_timeout(socket_class, host, port, connect_timeout)
208
+ if native_timeout?(socket_class)
209
+ socket_class.open(host, port, connect_timeout: connect_timeout)
210
+ else
211
+ socket_class.open(host, port)
212
+ end
213
+ end
214
+
215
+ # Whether the socket class supports native connect_timeout
216
+ #
217
+ # @param [Class] socket_class socket class to check
218
+ # @return [Boolean]
219
+ # @api private
220
+ def native_timeout?(socket_class)
221
+ NATIVE_CONNECT_TIMEOUT && socket_class.is_a?(Class) && socket_class <= TCPSocket
222
+ end
77
223
  end
78
224
  end
79
225
  end
@@ -6,26 +6,113 @@ require "http/timeout/null"
6
6
 
7
7
  module HTTP
8
8
  module Timeout
9
+ # Timeout handler with separate timeouts for connect, read, and write
9
10
  class PerOperation < Null
10
- CONNECT_TIMEOUT = 0.25
11
- WRITE_TIMEOUT = 0.25
12
- READ_TIMEOUT = 0.25
11
+ # Mapping of shorthand option keys to their full forms
12
+ KEYS = %i[read write connect].to_h { |k| [k, :"#{k}_timeout"] }.freeze
13
13
 
14
- def initialize(*args)
14
+ # Normalize and validate timeout options
15
+ #
16
+ # @example
17
+ # PerOperation.normalize_options(read: 5, write: 3)
18
+ #
19
+ # @param [Hash] options timeout options with short or long keys
20
+ # @return [Hash] normalized options with long keys
21
+ # @raise [ArgumentError] if options are invalid
22
+ # @api public
23
+ def self.normalize_options(options)
24
+ remaining = options.dup
25
+ normalized = {} #: Hash[Symbol, Numeric]
26
+
27
+ KEYS.each do |short, long|
28
+ next if !remaining.key?(short) && !remaining.key?(long)
29
+
30
+ normalized[long] = resolve_timeout_value!(remaining, short, long)
31
+ end
32
+
33
+ raise ArgumentError, "unknown timeout options: #{remaining.keys.join(', ')}" unless remaining.empty?
34
+ raise ArgumentError, "no timeout options given" if normalized.empty?
35
+
36
+ normalized
37
+ end
38
+
39
+ # Extract and validate global timeout from options hash
40
+ #
41
+ # @example
42
+ # extract_global_timeout!({global: 60, read: 5})
43
+ #
44
+ # @param [Hash] options mutable options hash (global key is deleted if found)
45
+ # @return [Numeric, nil] the global timeout value, or nil if not present
46
+ # @raise [ArgumentError] if both forms given or value is not numeric
47
+ # @api private
48
+ private_class_method def self.extract_global_timeout!(options)
49
+ return unless options.key?(:global) || options.key?(:global_timeout)
50
+
51
+ resolve_timeout_value!(options, :global, :global_timeout)
52
+ end
53
+
54
+ # Resolve a single timeout value from the options hash
55
+ #
56
+ # @example
57
+ # resolve_timeout_value!({read: 5}, :read, :read_timeout)
58
+ #
59
+ # @param [Hash] options mutable options hash (keys are deleted as consumed)
60
+ # @param [Symbol] short short key name (e.g. :read)
61
+ # @param [Symbol] long long key name (e.g. :read_timeout)
62
+ # @return [Numeric] the timeout value
63
+ # @raise [ArgumentError] if both forms given or value is not numeric
64
+ # @api private
65
+ private_class_method def self.resolve_timeout_value!(options, short, long)
66
+ raise ArgumentError, "can't pass both #{short} and #{long}" if options.key?(short) && options.key?(long)
67
+
68
+ value = options.delete(options.key?(long) ? long : short)
69
+
70
+ raise ArgumentError, "#{long} must be numeric" unless value.is_a?(Numeric)
71
+
72
+ value
73
+ end
74
+
75
+ # Initializes per-operation timeout with options
76
+ #
77
+ # @example
78
+ # HTTP::Timeout::PerOperation.new(read_timeout: 5)
79
+ #
80
+ # @param [Numeric, nil] read_timeout Read timeout in seconds (nil for no timeout)
81
+ # @param [Numeric, nil] write_timeout Write timeout in seconds (nil for no timeout)
82
+ # @param [Numeric, nil] connect_timeout Connect timeout in seconds (nil for no timeout)
83
+ # @api public
84
+ # @return [HTTP::Timeout::PerOperation]
85
+ def initialize(read_timeout: nil, write_timeout: nil, connect_timeout: nil)
15
86
  super
16
87
 
17
- @read_timeout = options.fetch(:read_timeout, READ_TIMEOUT)
18
- @write_timeout = options.fetch(:write_timeout, WRITE_TIMEOUT)
19
- @connect_timeout = options.fetch(:connect_timeout, CONNECT_TIMEOUT)
88
+ @read_timeout = read_timeout
89
+ @write_timeout = write_timeout
90
+ @connect_timeout = connect_timeout
20
91
  end
21
92
 
22
- def connect(socket_class, host, port, nodelay = false)
23
- ::Timeout.timeout(@connect_timeout, ConnectTimeoutError) do
24
- @socket = socket_class.open(host, port)
25
- @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
26
- end
93
+ # Connects to a socket with connect timeout
94
+ #
95
+ # @example
96
+ # timeout.connect(TCPSocket, "example.com", 80)
97
+ #
98
+ # @param [Class] socket_class
99
+ # @param [String] host
100
+ # @param [Integer] port
101
+ # @param [Boolean] nodelay
102
+ # @api public
103
+ # @return [void]
104
+ def connect(socket_class, host, port, nodelay: false)
105
+ @socket = open_socket(socket_class, host, port, connect_timeout: @connect_timeout)
106
+ @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
27
107
  end
28
108
 
109
+ # Starts an SSL connection with connect timeout
110
+ #
111
+ # @example
112
+ # timeout.connect_ssl
113
+ #
114
+ # @api public
115
+ # @return [void]
29
116
  def connect_ssl
30
117
  rescue_readable(@connect_timeout) do
31
118
  rescue_writable(@connect_timeout) do
@@ -34,14 +121,25 @@ module HTTP
34
121
  end
35
122
  end
36
123
 
124
+ # I/O wait result symbols returned by non-blocking operations
125
+ WAIT_RESULTS = %i[wait_readable wait_writable].freeze
126
+
37
127
  # Read data from the socket
128
+ #
129
+ # @example
130
+ # timeout.readpartial(1024)
131
+ #
132
+ # @param [Integer] size
133
+ # @param [String, nil] buffer
134
+ # @api public
135
+ # @return [String, :eof]
38
136
  def readpartial(size, buffer = nil)
39
137
  timeout = false
40
138
  loop do
41
- result = @socket.read_nonblock(size, buffer, :exception => false)
139
+ result = @socket.read_nonblock(size, buffer, exception: false)
42
140
 
43
141
  return :eof if result.nil?
44
- return result if result != :wait_readable
142
+ return result unless WAIT_RESULTS.include?(result)
45
143
 
46
144
  raise TimeoutError, "Read timed out after #{@read_timeout} seconds" if timeout
47
145
 
@@ -55,20 +153,43 @@ module HTTP
55
153
  # timeout. Else, the first timeout was a proper timeout.
56
154
  # This hack has to be done because io/wait#wait_readable doesn't provide a value for when
57
155
  # the socket is closed by the server, and HTTP::Parser doesn't provide the limit for the chunks.
58
- timeout = true unless @socket.to_io.wait_readable(@read_timeout)
156
+ timeout = true unless wait_for_io(result, @read_timeout)
59
157
  end
60
158
  end
61
159
 
62
160
  # Write data to the socket
161
+ #
162
+ # @example
163
+ # timeout.write("GET / HTTP/1.1")
164
+ #
165
+ # @param [String] data
166
+ # @api public
167
+ # @return [Integer]
63
168
  def write(data)
64
169
  timeout = false
65
170
  loop do
66
- result = @socket.write_nonblock(data, :exception => false)
67
- return result unless result == :wait_writable
171
+ result = @socket.write_nonblock(data, exception: false)
172
+ return result unless WAIT_RESULTS.include?(result)
68
173
 
69
174
  raise TimeoutError, "Write timed out after #{@write_timeout} seconds" if timeout
70
175
 
71
- timeout = true unless @socket.to_io.wait_writable(@write_timeout)
176
+ timeout = true unless wait_for_io(result, @write_timeout)
177
+ end
178
+ end
179
+
180
+ private
181
+
182
+ # Waits for I/O readiness based on the result type
183
+ #
184
+ # @param [Symbol] result the I/O wait type (:wait_readable or :wait_writable)
185
+ # @param [Numeric, nil] timeout per-operation timeout limit
186
+ # @return [Object, nil] the socket if ready, nil on timeout
187
+ # @api private
188
+ def wait_for_io(result, timeout)
189
+ if result == :wait_readable
190
+ @socket.to_io.wait_readable(timeout)
191
+ else
192
+ @socket.to_io.wait_writable(timeout)
72
193
  end
73
194
  end
74
195
  end