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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +241 -41
- data/LICENSE.txt +1 -1
- data/README.md +110 -13
- data/UPGRADING.md +491 -0
- data/http.gemspec +32 -29
- data/lib/http/base64.rb +11 -1
- data/lib/http/chainable/helpers.rb +62 -0
- data/lib/http/chainable/verbs.rb +136 -0
- data/lib/http/chainable.rb +232 -136
- data/lib/http/client.rb +158 -127
- data/lib/http/connection/internals.rb +141 -0
- data/lib/http/connection.rb +126 -97
- data/lib/http/content_type.rb +61 -6
- data/lib/http/errors.rb +25 -1
- data/lib/http/feature.rb +65 -5
- data/lib/http/features/auto_deflate.rb +124 -17
- data/lib/http/features/auto_inflate.rb +38 -15
- data/lib/http/features/caching/entry.rb +178 -0
- data/lib/http/features/caching/in_memory_store.rb +63 -0
- data/lib/http/features/caching.rb +216 -0
- data/lib/http/features/digest_auth.rb +234 -0
- data/lib/http/features/instrumentation.rb +97 -17
- data/lib/http/features/logging.rb +183 -5
- data/lib/http/features/normalize_uri.rb +17 -0
- data/lib/http/features/raise_error.rb +18 -3
- data/lib/http/form_data/composite_io.rb +106 -0
- data/lib/http/form_data/file.rb +95 -0
- data/lib/http/form_data/multipart/param.rb +62 -0
- data/lib/http/form_data/multipart.rb +106 -0
- data/lib/http/form_data/part.rb +52 -0
- data/lib/http/form_data/readable.rb +58 -0
- data/lib/http/form_data/urlencoded.rb +175 -0
- data/lib/http/form_data/version.rb +8 -0
- data/lib/http/form_data.rb +102 -0
- data/lib/http/headers/known.rb +3 -0
- data/lib/http/headers/normalizer.rb +17 -36
- data/lib/http/headers.rb +172 -65
- data/lib/http/mime_type/adapter.rb +24 -9
- data/lib/http/mime_type/json.rb +19 -4
- data/lib/http/mime_type.rb +21 -3
- data/lib/http/options/definitions.rb +189 -0
- data/lib/http/options.rb +172 -125
- data/lib/http/redirector.rb +80 -75
- data/lib/http/request/body.rb +87 -6
- data/lib/http/request/builder.rb +184 -0
- data/lib/http/request/proxy.rb +83 -0
- data/lib/http/request/writer.rb +76 -16
- data/lib/http/request.rb +214 -98
- data/lib/http/response/body.rb +103 -18
- data/lib/http/response/inflater.rb +35 -7
- data/lib/http/response/parser.rb +98 -4
- data/lib/http/response/status/reasons.rb +2 -4
- data/lib/http/response/status.rb +141 -31
- data/lib/http/response.rb +219 -61
- data/lib/http/retriable/delay_calculator.rb +38 -11
- data/lib/http/retriable/errors.rb +21 -0
- data/lib/http/retriable/performer.rb +82 -38
- data/lib/http/session.rb +280 -0
- data/lib/http/timeout/global.rb +147 -34
- data/lib/http/timeout/null.rb +155 -9
- data/lib/http/timeout/per_operation.rb +139 -18
- data/lib/http/uri/normalizer.rb +82 -0
- data/lib/http/uri/parsing.rb +182 -0
- data/lib/http/uri.rb +289 -124
- data/lib/http/version.rb +2 -1
- data/lib/http.rb +11 -2
- data/sig/deps.rbs +122 -0
- data/sig/http.rbs +1619 -0
- data/test/http/base64_test.rb +28 -0
- data/test/http/client_test.rb +739 -0
- data/test/http/connection_test.rb +1533 -0
- data/test/http/content_type_test.rb +190 -0
- data/test/http/errors_test.rb +28 -0
- data/test/http/feature_test.rb +49 -0
- data/test/http/features/auto_deflate_test.rb +317 -0
- data/test/http/features/auto_inflate_test.rb +213 -0
- data/test/http/features/caching_test.rb +942 -0
- data/test/http/features/digest_auth_test.rb +996 -0
- data/test/http/features/instrumentation_test.rb +246 -0
- data/test/http/features/logging_test.rb +654 -0
- data/test/http/features/normalize_uri_test.rb +41 -0
- data/test/http/features/raise_error_test.rb +77 -0
- data/test/http/form_data/composite_io_test.rb +215 -0
- data/test/http/form_data/file_test.rb +255 -0
- data/test/http/form_data/fixtures/the-http-gem.info +1 -0
- data/test/http/form_data/multipart_test.rb +303 -0
- data/test/http/form_data/part_test.rb +90 -0
- data/test/http/form_data/urlencoded_test.rb +164 -0
- data/test/http/form_data_test.rb +232 -0
- data/test/http/headers/normalizer_test.rb +93 -0
- data/test/http/headers_test.rb +888 -0
- data/test/http/mime_type/json_test.rb +39 -0
- data/test/http/mime_type_test.rb +150 -0
- data/test/http/options/base_uri_test.rb +148 -0
- data/test/http/options/body_test.rb +21 -0
- data/test/http/options/features_test.rb +38 -0
- data/test/http/options/form_test.rb +21 -0
- data/test/http/options/headers_test.rb +32 -0
- data/test/http/options/json_test.rb +21 -0
- data/test/http/options/merge_test.rb +78 -0
- data/test/http/options/new_test.rb +37 -0
- data/test/http/options/proxy_test.rb +32 -0
- data/test/http/options_test.rb +575 -0
- data/test/http/redirector_test.rb +639 -0
- data/test/http/request/body_test.rb +318 -0
- data/test/http/request/builder_test.rb +623 -0
- data/test/http/request/writer_test.rb +391 -0
- data/test/http/request_test.rb +1733 -0
- data/test/http/response/body_test.rb +292 -0
- data/test/http/response/parser_test.rb +105 -0
- data/test/http/response/status_test.rb +322 -0
- data/test/http/response_test.rb +502 -0
- data/test/http/retriable/delay_calculator_test.rb +194 -0
- data/test/http/retriable/errors_test.rb +71 -0
- data/test/http/retriable/performer_test.rb +551 -0
- data/test/http/session_test.rb +424 -0
- data/test/http/timeout/global_test.rb +239 -0
- data/test/http/timeout/null_test.rb +218 -0
- data/test/http/timeout/per_operation_test.rb +220 -0
- data/test/http/uri/normalizer_test.rb +89 -0
- data/test/http/uri_test.rb +1140 -0
- data/test/http/version_test.rb +15 -0
- data/test/http_test.rb +818 -0
- data/test/regression_tests.rb +27 -0
- data/test/support/dummy_server/encoding_routes.rb +47 -0
- data/test/support/dummy_server/routes.rb +201 -0
- data/test/support/dummy_server/servlet.rb +81 -0
- data/test/support/dummy_server.rb +200 -0
- data/{spec → test}/support/fakeio.rb +2 -2
- data/test/support/http_handling_shared/connection_reuse_tests.rb +97 -0
- data/test/support/http_handling_shared/timeout_tests.rb +134 -0
- data/test/support/http_handling_shared.rb +11 -0
- data/test/support/proxy_server.rb +207 -0
- data/test/support/servers/runner.rb +67 -0
- data/{spec → test}/support/simplecov.rb +11 -2
- data/test/support/ssl_helper.rb +108 -0
- data/test/test_helper.rb +38 -0
- metadata +108 -168
- data/.github/workflows/ci.yml +0 -67
- data/.gitignore +0 -15
- data/.rspec +0 -1
- data/.rubocop/layout.yml +0 -8
- data/.rubocop/metrics.yml +0 -4
- data/.rubocop/rspec.yml +0 -9
- data/.rubocop/style.yml +0 -32
- data/.rubocop.yml +0 -11
- data/.rubocop_todo.yml +0 -219
- data/.yardopts +0 -2
- data/CHANGES_OLD.md +0 -1002
- data/Gemfile +0 -51
- data/Guardfile +0 -18
- data/Rakefile +0 -64
- data/lib/http/headers/mixin.rb +0 -34
- data/lib/http/retriable/client.rb +0 -37
- data/logo.png +0 -0
- data/spec/lib/http/client_spec.rb +0 -556
- data/spec/lib/http/connection_spec.rb +0 -88
- data/spec/lib/http/content_type_spec.rb +0 -47
- data/spec/lib/http/features/auto_deflate_spec.rb +0 -77
- data/spec/lib/http/features/auto_inflate_spec.rb +0 -86
- data/spec/lib/http/features/instrumentation_spec.rb +0 -81
- data/spec/lib/http/features/logging_spec.rb +0 -65
- data/spec/lib/http/features/raise_error_spec.rb +0 -62
- data/spec/lib/http/headers/mixin_spec.rb +0 -36
- data/spec/lib/http/headers/normalizer_spec.rb +0 -52
- data/spec/lib/http/headers_spec.rb +0 -527
- data/spec/lib/http/options/body_spec.rb +0 -15
- data/spec/lib/http/options/features_spec.rb +0 -33
- data/spec/lib/http/options/form_spec.rb +0 -15
- data/spec/lib/http/options/headers_spec.rb +0 -24
- data/spec/lib/http/options/json_spec.rb +0 -15
- data/spec/lib/http/options/merge_spec.rb +0 -68
- data/spec/lib/http/options/new_spec.rb +0 -30
- data/spec/lib/http/options/proxy_spec.rb +0 -20
- data/spec/lib/http/options_spec.rb +0 -13
- data/spec/lib/http/redirector_spec.rb +0 -530
- data/spec/lib/http/request/body_spec.rb +0 -211
- data/spec/lib/http/request/writer_spec.rb +0 -121
- data/spec/lib/http/request_spec.rb +0 -234
- data/spec/lib/http/response/body_spec.rb +0 -85
- data/spec/lib/http/response/parser_spec.rb +0 -74
- data/spec/lib/http/response/status_spec.rb +0 -253
- data/spec/lib/http/response_spec.rb +0 -262
- data/spec/lib/http/retriable/delay_calculator_spec.rb +0 -69
- data/spec/lib/http/retriable/performer_spec.rb +0 -302
- data/spec/lib/http/uri/normalizer_spec.rb +0 -95
- data/spec/lib/http/uri_spec.rb +0 -71
- data/spec/lib/http_spec.rb +0 -535
- data/spec/regression_specs.rb +0 -24
- data/spec/spec_helper.rb +0 -89
- data/spec/support/black_hole.rb +0 -13
- data/spec/support/dummy_server/servlet.rb +0 -203
- data/spec/support/dummy_server.rb +0 -44
- data/spec/support/fuubar.rb +0 -21
- data/spec/support/http_handling_shared.rb +0 -190
- data/spec/support/proxy_server.rb +0 -39
- data/spec/support/servers/config.rb +0 -11
- data/spec/support/servers/runner.rb +0 -19
- data/spec/support/ssl_helper.rb +0 -104
- /data/{spec → test}/support/capture_warning.rb +0 -0
data/lib/http/timeout/global.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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, :
|
|
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, :
|
|
127
|
+
@socket.write_nonblock(data, exception: false)
|
|
66
128
|
end
|
|
67
129
|
|
|
68
|
-
#
|
|
69
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
#
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
#
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
#
|
|
103
|
-
#
|
|
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
|
data/lib/http/timeout/null.rb
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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 =
|
|
18
|
-
@write_timeout =
|
|
19
|
-
@connect_timeout =
|
|
88
|
+
@read_timeout = read_timeout
|
|
89
|
+
@write_timeout = write_timeout
|
|
90
|
+
@connect_timeout = connect_timeout
|
|
20
91
|
end
|
|
21
92
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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, :
|
|
139
|
+
result = @socket.read_nonblock(size, buffer, exception: false)
|
|
42
140
|
|
|
43
141
|
return :eof if result.nil?
|
|
44
|
-
return result
|
|
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
|
|
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, :
|
|
67
|
-
return result unless result
|
|
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
|
|
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
|