x-core 1.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 +7 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +353 -0
- data/LICENSE.txt +21 -0
- data/README.md +54 -0
- data/lib/x/core/app_only_authenticator.rb +198 -0
- data/lib/x/core/authenticator.rb +74 -0
- data/lib/x/core/authenticator_request.rb +56 -0
- data/lib/x/core/bearer_token_authenticator.rb +46 -0
- data/lib/x/core/built_response.rb +100 -0
- data/lib/x/core/client.rb +728 -0
- data/lib/x/core/client_app_only.rb +115 -0
- data/lib/x/core/client_credentials.rb +193 -0
- data/lib/x/core/client_internals.rb +315 -0
- data/lib/x/core/client_memo.rb +58 -0
- data/lib/x/core/client_settings.rb +166 -0
- data/lib/x/core/client_token_refresh.rb +281 -0
- data/lib/x/core/connection.rb +328 -0
- data/lib/x/core/connection_pool.rb +123 -0
- data/lib/x/core/connection_proxy.rb +151 -0
- data/lib/x/core/connection_request.rb +90 -0
- data/lib/x/core/credential_holder.rb +78 -0
- data/lib/x/core/credential_validator.rb +286 -0
- data/lib/x/core/errors/authorization_denied.rb +55 -0
- data/lib/x/core/errors/authorization_error.rb +41 -0
- data/lib/x/core/errors/bad_gateway.rb +9 -0
- data/lib/x/core/errors/bad_request.rb +10 -0
- data/lib/x/core/errors/callback_error.rb +88 -0
- data/lib/x/core/errors/client_error.rb +17 -0
- data/lib/x/core/errors/conflict.rb +9 -0
- data/lib/x/core/errors/error.rb +66 -0
- data/lib/x/core/errors/forbidden.rb +10 -0
- data/lib/x/core/errors/gateway_timeout.rb +9 -0
- data/lib/x/core/errors/gone.rb +9 -0
- data/lib/x/core/errors/http_error.rb +333 -0
- data/lib/x/core/errors/internal_server_error.rb +9 -0
- data/lib/x/core/errors/invalid_response.rb +96 -0
- data/lib/x/core/errors/method_not_allowed.rb +10 -0
- data/lib/x/core/errors/network_error.rb +59 -0
- data/lib/x/core/errors/not_acceptable.rb +10 -0
- data/lib/x/core/errors/not_found.rb +15 -0
- data/lib/x/core/errors/payload_too_large.rb +10 -0
- data/lib/x/core/errors/payment_required.rb +10 -0
- data/lib/x/core/errors/request_timeout.rb +9 -0
- data/lib/x/core/errors/server_error.rb +15 -0
- data/lib/x/core/errors/service_unavailable.rb +9 -0
- data/lib/x/core/errors/token_report_failed.rb +59 -0
- data/lib/x/core/errors/too_many_redirects.rb +53 -0
- data/lib/x/core/errors/too_many_requests.rb +80 -0
- data/lib/x/core/errors/unauthorized.rb +15 -0
- data/lib/x/core/errors/unavailable_for_legal_reasons.rb +10 -0
- data/lib/x/core/errors/unprocessable_entity.rb +10 -0
- data/lib/x/core/errors/unsupported_format.rb +22 -0
- data/lib/x/core/errors/unsupported_media_type.rb +10 -0
- data/lib/x/core/errors/unsupported_operation.rb +18 -0
- data/lib/x/core/oauth1_authenticator.rb +142 -0
- data/lib/x/core/oauth2_authenticator.rb +296 -0
- data/lib/x/core/oauth2_authorization.rb +405 -0
- data/lib/x/core/oauth2_refresh.rb +272 -0
- data/lib/x/core/oauth2_tokens.rb +277 -0
- data/lib/x/core/origin.rb +95 -0
- data/lib/x/core/problem.rb +302 -0
- data/lib/x/core/proxy_setting.rb +22 -0
- data/lib/x/core/rate_limit.rb +137 -0
- data/lib/x/core/rate_limit_handler.rb +166 -0
- data/lib/x/core/redirect_handler.rb +208 -0
- data/lib/x/core/refresh_reporter.rb +92 -0
- data/lib/x/core/request_builder.rb +155 -0
- data/lib/x/core/request_context.rb +85 -0
- data/lib/x/core/request_encoding.rb +135 -0
- data/lib/x/core/response.rb +196 -0
- data/lib/x/core/response_headers.rb +31 -0
- data/lib/x/core/response_parser.rb +155 -0
- data/lib/x/core/retry_handler.rb +149 -0
- data/lib/x/core/setting_validator.rb +399 -0
- data/lib/x/core/stream_body.rb +77 -0
- data/lib/x/core/stream_response.rb +267 -0
- data/lib/x/core/token_endpoint.rb +238 -0
- data/lib/x/core/version.rb +25 -0
- data/lib/x/core.rb +6 -0
- data/sig/manifest.yaml +8 -0
- data/sig/x-core.rbs +396 -0
- metadata +162 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "zlib"
|
|
7
|
+
require_relative "connection_pool"
|
|
8
|
+
require_relative "connection_proxy"
|
|
9
|
+
require_relative "connection_request"
|
|
10
|
+
require_relative "proxy_setting"
|
|
11
|
+
require_relative "request_context"
|
|
12
|
+
require_relative "setting_validator"
|
|
13
|
+
require_relative "errors/network_error"
|
|
14
|
+
require_relative "errors/callback_error"
|
|
15
|
+
|
|
16
|
+
module X
|
|
17
|
+
module Core
|
|
18
|
+
# Manages HTTP connections to the X API
|
|
19
|
+
#
|
|
20
|
+
# Internal to x-core: Client, and the authenticators and authorization that fetch tokens send
|
|
21
|
+
# their requests with it, so that it can change within 1.x. Configure it through the settings of Client, such as
|
|
22
|
+
# proxy_url and the timeouts.
|
|
23
|
+
#
|
|
24
|
+
# Requests keep their connections open for the next request to the same host, which saves opening a TCP and
|
|
25
|
+
# TLS connection each time. A stream opens a connection of its own, which it holds for as long as it reads.
|
|
26
|
+
#
|
|
27
|
+
# A connection keeps the settings it was built with for as long as it lives, as {Client} does, so a request never
|
|
28
|
+
# opens a connection under a setting another thread is halfway through changing. Build another connection to
|
|
29
|
+
# reach the API differently.
|
|
30
|
+
#
|
|
31
|
+
# @api private
|
|
32
|
+
class Connection
|
|
33
|
+
include ConnectionProxy
|
|
34
|
+
include ConnectionRequest
|
|
35
|
+
include ProxySetting
|
|
36
|
+
|
|
37
|
+
# Default timeout for opening connections in seconds; opening a connection is a TCP handshake and a TLS one,
|
|
38
|
+
# which a reachable host finishes in well under a second, so a host that takes longer is one a request waits
|
|
39
|
+
# on rather than reaches, and it is given less time than reading a response, which an endpoint may be slow to
|
|
40
|
+
# send
|
|
41
|
+
DEFAULT_OPEN_TIMEOUT = 10 # seconds
|
|
42
|
+
# Default timeout for reading responses in seconds
|
|
43
|
+
DEFAULT_READ_TIMEOUT = 60 # seconds
|
|
44
|
+
# Default timeout for writing requests in seconds
|
|
45
|
+
DEFAULT_WRITE_TIMEOUT = 60 # seconds
|
|
46
|
+
# Default time to keep a connection open for the next request to the same host, in seconds; X holds an idle
|
|
47
|
+
# connection open for more than five minutes, so a connection closed within this time was closed by a proxy
|
|
48
|
+
DEFAULT_KEEP_ALIVE_TIMEOUT = 30 # seconds
|
|
49
|
+
# The settings a connection is opened under that are values, which a connection that shares the pool of another
|
|
50
|
+
# holds equal ones of; the debug output is an object that is written to, so it is the same one, rather than an
|
|
51
|
+
# equal one, as an empty Array is to another
|
|
52
|
+
SETTINGS = %i[open_timeout read_timeout write_timeout keep_alive_timeout proxy_url].freeze
|
|
53
|
+
private_constant :SETTINGS
|
|
54
|
+
# The timeout for opening connections in seconds
|
|
55
|
+
# @api private
|
|
56
|
+
# @return [Integer, Float, nil] the timeout for opening connections in seconds, or nil for none
|
|
57
|
+
# @example Get the open timeout
|
|
58
|
+
# connection.open_timeout # => 10
|
|
59
|
+
attr_reader :open_timeout
|
|
60
|
+
|
|
61
|
+
# The timeout for reading responses in seconds
|
|
62
|
+
# @api private
|
|
63
|
+
# @return [Integer, Float, nil] the timeout for reading responses in seconds, or nil for none
|
|
64
|
+
# @example Get the read timeout
|
|
65
|
+
# connection.read_timeout # => 60
|
|
66
|
+
attr_reader :read_timeout
|
|
67
|
+
|
|
68
|
+
# The timeout for writing requests in seconds
|
|
69
|
+
# @api private
|
|
70
|
+
# @return [Integer, Float, nil] the timeout for writing requests in seconds, or nil for none
|
|
71
|
+
# @example Get the write timeout
|
|
72
|
+
# connection.write_timeout # => 60
|
|
73
|
+
attr_reader :write_timeout
|
|
74
|
+
|
|
75
|
+
# The IO object for debug output
|
|
76
|
+
# @api private
|
|
77
|
+
# @return [IO, #<<, nil] the IO object for debug output, or anything else that takes a String with <<, such as
|
|
78
|
+
# a Logger, or nil for none
|
|
79
|
+
# @example Get the debug output
|
|
80
|
+
# connection.debug_output
|
|
81
|
+
attr_reader :debug_output
|
|
82
|
+
|
|
83
|
+
# The time to keep a connection open for the next request, in seconds
|
|
84
|
+
# @api private
|
|
85
|
+
# @return [Integer, Float] the time in seconds
|
|
86
|
+
# @example Get the keep-alive timeout
|
|
87
|
+
# connection.keep_alive_timeout # => 30
|
|
88
|
+
attr_reader :keep_alive_timeout
|
|
89
|
+
|
|
90
|
+
# Initialize a new connection
|
|
91
|
+
#
|
|
92
|
+
# @api private
|
|
93
|
+
# @param open_timeout [Integer, Float, nil] the timeout for opening connections in seconds, or nil for none
|
|
94
|
+
# @param read_timeout [Integer, Float, nil] the timeout for reading responses in seconds, or nil for none
|
|
95
|
+
# @param write_timeout [Integer, Float, nil] the timeout for writing requests in seconds, or nil for none
|
|
96
|
+
# @param keep_alive_timeout [Integer, Float] the time to keep a connection open for the next request to the same
|
|
97
|
+
# host, in seconds, which a proxy that closes idle connections sooner than X does may need lowered
|
|
98
|
+
# @param debug_output [IO, #<<, nil] the IO object for debug output, or anything else that takes a String with <<,
|
|
99
|
+
# such as a StringIO or a Logger
|
|
100
|
+
# @param proxy_url [String, URI::Generic, nil] the proxy URL for requests
|
|
101
|
+
# @return [Connection] a new connection instance
|
|
102
|
+
# @raise [ArgumentError] if a timeout is neither a finite number of seconds of at least 0 nor, for any but
|
|
103
|
+
# keep_alive_timeout, nil
|
|
104
|
+
# @raise [ArgumentError] if debug_output is a String, or is neither nil nor takes a String with <<
|
|
105
|
+
# @example Create a connection with default settings
|
|
106
|
+
# connection = X::Core::Connection.new
|
|
107
|
+
# @example Create a connection with custom timeouts
|
|
108
|
+
# connection = X::Core::Connection.new(open_timeout: 30, read_timeout: 30)
|
|
109
|
+
def initialize(open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT,
|
|
110
|
+
write_timeout: DEFAULT_WRITE_TIMEOUT, keep_alive_timeout: DEFAULT_KEEP_ALIVE_TIMEOUT, debug_output: nil, proxy_url: nil)
|
|
111
|
+
@open_timeout = SettingValidator.timeout!(:open_timeout, open_timeout)
|
|
112
|
+
@read_timeout = SettingValidator.timeout!(:read_timeout, read_timeout)
|
|
113
|
+
@write_timeout = SettingValidator.timeout!(:write_timeout, write_timeout)
|
|
114
|
+
@keep_alive_timeout = SettingValidator.finite_seconds!(:keep_alive_timeout, keep_alive_timeout)
|
|
115
|
+
@debug_output = SettingValidator.debug_output!(debug_output)
|
|
116
|
+
@pool = ConnectionPool.new
|
|
117
|
+
initialize_proxy(proxy_url)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Summarize the connection for the console without revealing proxy credentials
|
|
121
|
+
#
|
|
122
|
+
# @api private
|
|
123
|
+
# @return [String] the class name, proxy URL, and timeouts
|
|
124
|
+
# @example Inspect a connection
|
|
125
|
+
# connection.inspect # => #<X::Core::Connection proxy_url="http://proxy.example.com:8080" open_timeout=10 ...>
|
|
126
|
+
def inspect
|
|
127
|
+
"#<#{self.class} proxy_url=#{redacted_proxy_url.inspect} open_timeout=#{open_timeout} " \
|
|
128
|
+
"read_timeout=#{read_timeout} write_timeout=#{write_timeout}>"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Perform an HTTP request
|
|
132
|
+
#
|
|
133
|
+
# Internal to x-core: Client, its redirects, and token requests send their requests with it, so that it can change
|
|
134
|
+
# within 1.x, as the Net::HTTP requests it takes may.
|
|
135
|
+
#
|
|
136
|
+
# The body of the response is tagged UTF-8, the encoding of the JSON the API sends, rather than the binary that
|
|
137
|
+
# Net::HTTP reads it as, so that it can be searched and joined with other Strings. A body that is not valid
|
|
138
|
+
# UTF-8, such as the page of a proxy in another encoding, keeps its bytes, and valid_encoding? tells it apart.
|
|
139
|
+
#
|
|
140
|
+
# @api private
|
|
141
|
+
# @param request [Net::HTTPRequest] the HTTP request to perform
|
|
142
|
+
# @return [Net::HTTPResponse] the HTTP response, whose body is tagged UTF-8
|
|
143
|
+
# @raise [NetworkError] if a network error occurs, or Net::HTTP cannot read the response, as one whose header
|
|
144
|
+
# holds a bare CR, for which it raises ArgumentError
|
|
145
|
+
# @raise [ArgumentError] if the connection was given no proxy and the proxy the environment names for the request
|
|
146
|
+
# cannot be parsed, or is not an HTTP or HTTPS URL that names a host, with a message that names the variables
|
|
147
|
+
# read for its scheme and leaves out the value, and with no cause
|
|
148
|
+
# @example Perform a request
|
|
149
|
+
# response = connection.perform(request: request)
|
|
150
|
+
def perform(request:)
|
|
151
|
+
uri = request.uri
|
|
152
|
+
hostname, port = host_and_port(uri)
|
|
153
|
+
use_ssl = uri.scheme.eql?("https")
|
|
154
|
+
send_request(request, [use_ssl, hostname, port], -> { open_http_client(uri, use_ssl) })
|
|
155
|
+
rescue InvalidEnvironmentProxy
|
|
156
|
+
raise environment_proxy_error(request.uri), cause: nil
|
|
157
|
+
rescue *NETWORK_ERRORS, ArgumentError => e
|
|
158
|
+
raise NetworkError.new("Network error: #{e}", **RequestContext.of(request))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Perform a streaming HTTP request
|
|
162
|
+
#
|
|
163
|
+
# Internal to x-core: Client#get_stream opens its requests with it.
|
|
164
|
+
#
|
|
165
|
+
# The connection is opened for this request and closed once the block returns, rather than taken from the
|
|
166
|
+
# connections kept open and given back, since a stream holds its connection for as long as it reads. Once the
|
|
167
|
+
# block returns, the connection is closed with what is left of the body unread, rather than read to its end, as
|
|
168
|
+
# Net::HTTP would otherwise read it, which a stream never reaches.
|
|
169
|
+
#
|
|
170
|
+
# An error the block raises, which the client tags as a CallbackError, is raised tagged, rather than reported as
|
|
171
|
+
# a network error: the block of a stream runs inside the request that reads it. The NetworkError the read_body
|
|
172
|
+
# of a StreamResponse raises for an error of the socket, which the client leaves untagged, is raised as it is,
|
|
173
|
+
# rather than wrapped in another.
|
|
174
|
+
#
|
|
175
|
+
# The body is not tagged UTF-8 here, as the body of {#perform} is: Net::HTTP tags a body it reads whole, and
|
|
176
|
+
# raises for one it passes to a block a chunk at a time, as a stream is read.
|
|
177
|
+
#
|
|
178
|
+
# @api private
|
|
179
|
+
# @param request [Net::HTTPRequest] the HTTP request to perform
|
|
180
|
+
# @yield [Net::HTTPResponse] the HTTP response for streaming
|
|
181
|
+
# @return [Object] what the block returns
|
|
182
|
+
# @raise [NetworkError] if a network error occurs, or Net::HTTP cannot read the response, as one whose header
|
|
183
|
+
# holds a bare CR, for which it raises ArgumentError
|
|
184
|
+
# @raise [ArgumentError] if the connection was given no proxy and the proxy the environment names for the request
|
|
185
|
+
# cannot be parsed, or is not an HTTP or HTTPS URL that names a host, with a message that names the variables
|
|
186
|
+
# read for its scheme and leaves out the value, and with no cause
|
|
187
|
+
# @raise [CallbackError] if the block raises an error the client tagged
|
|
188
|
+
# @example Perform a streaming request
|
|
189
|
+
# connection.perform_stream(request: request) { |response| response.read_body { |chunk| } }
|
|
190
|
+
def perform_stream(request:)
|
|
191
|
+
http_client = build_http_client(request.uri)
|
|
192
|
+
http_client.use_ssl = request.uri.scheme.eql?("https")
|
|
193
|
+
catch { |done| http_client.request(request) { |response| throw done, yield(response) } }
|
|
194
|
+
rescue InvalidEnvironmentProxy
|
|
195
|
+
raise environment_proxy_error(request.uri), cause: nil
|
|
196
|
+
rescue *NETWORK_ERRORS, ArgumentError => e
|
|
197
|
+
raise NetworkError.new("Network error: #{e}", **RequestContext.of(request))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Check whether an error is one a request raises as a NetworkError
|
|
201
|
+
#
|
|
202
|
+
# @api private
|
|
203
|
+
# @param error [Exception] the error
|
|
204
|
+
# @return [Boolean] true if the error is one of a socket
|
|
205
|
+
# @example Check an error a stream raised
|
|
206
|
+
# X::Core::Connection.network_error?(IOError.new) # => true
|
|
207
|
+
def self.network_error?(error) = NETWORK_ERRORS.any? { |network_error| error.is_a?(network_error) }
|
|
208
|
+
|
|
209
|
+
# Close the connections kept open between requests
|
|
210
|
+
#
|
|
211
|
+
# A later request opens a connection again. Nothing else closes them: the sockets of a connection that is
|
|
212
|
+
# dropped rather than closed are shut as the garbage collector reclaims them, at a time the process does not
|
|
213
|
+
# choose and without the shutdown this performs.
|
|
214
|
+
#
|
|
215
|
+
# @api private
|
|
216
|
+
# @return [void]
|
|
217
|
+
# @example Close the connections before a long pause
|
|
218
|
+
# connection.close
|
|
219
|
+
def close
|
|
220
|
+
@pool.clear
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Take connections from the pool of another connection that opens them alike
|
|
224
|
+
#
|
|
225
|
+
# Internal to x-core: Client#with gives a copy the connections of the client it was copied from with it. The
|
|
226
|
+
# two then take their connections from one pool, and close them together, so a copy made for each request opens
|
|
227
|
+
# none of its own. A connection whose timeouts, keep-alive timeout, debug output, or proxy differ keeps a pool
|
|
228
|
+
# of its own, since a connection is opened under those. The debug output differs unless it is the same object,
|
|
229
|
+
# since a connection writes to the one it was opened under: an Array is equal to another that holds the same,
|
|
230
|
+
# and a request written to the wrong one is lost to the one it was meant for.
|
|
231
|
+
#
|
|
232
|
+
# @api private
|
|
233
|
+
# @param other [Connection] the connection whose pool to share
|
|
234
|
+
# @return [void]
|
|
235
|
+
# @example Share the connections of another connection
|
|
236
|
+
# connection.share_pool_of(other)
|
|
237
|
+
def share_pool_of(other)
|
|
238
|
+
output = debug_output #: untyped
|
|
239
|
+
@pool = other.__send__(:pool) if output.equal?(other.debug_output) && SETTINGS.all? { |name| __send__(name) == other.__send__(name) }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
# The connections kept open between requests
|
|
245
|
+
# @api private
|
|
246
|
+
# @return [ConnectionPool] the pool
|
|
247
|
+
attr_reader :pool
|
|
248
|
+
|
|
249
|
+
# The host and port to connect to for a URI
|
|
250
|
+
#
|
|
251
|
+
# The URI of a request is an HTTP or HTTPS URL, since Net::HTTP builds a request from no other, so it names both.
|
|
252
|
+
#
|
|
253
|
+
# @api private
|
|
254
|
+
# @param uri [URI::Generic] the URI of the request
|
|
255
|
+
# @return [Array(String, Integer)] the host and the port to connect to
|
|
256
|
+
def host_and_port(uri)
|
|
257
|
+
hostname = uri.hostname #: String
|
|
258
|
+
port = uri.port #: Integer
|
|
259
|
+
[hostname, port]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Open an HTTP client for requests read whole, which tags each body UTF-8
|
|
263
|
+
#
|
|
264
|
+
# @api private
|
|
265
|
+
# @param uri [URI::Generic] the URI of the request
|
|
266
|
+
# @param use_ssl [Boolean] whether to connect over TLS
|
|
267
|
+
# @return [Net::HTTP] the HTTP client
|
|
268
|
+
def open_http_client(uri, use_ssl)
|
|
269
|
+
build_http_client(uri).tap do |http_client|
|
|
270
|
+
http_client.use_ssl = use_ssl
|
|
271
|
+
http_client.response_body_encoding = Encoding::UTF_8
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Build an HTTP client for the host of a URI
|
|
276
|
+
#
|
|
277
|
+
# The client connects to an HTTPS proxy over TLS. A client that reaches its host directly is given no proxy to
|
|
278
|
+
# resolve, rather than the :ENV of Net::HTTP, which reads http_proxy for a request of any scheme: the proxy of a
|
|
279
|
+
# request is resolved from the scheme of its own URI, by ConnectionProxy.
|
|
280
|
+
#
|
|
281
|
+
# @api private
|
|
282
|
+
# @param uri [URI::Generic] the URI of the request
|
|
283
|
+
# @return [Net::HTTP] the HTTP client
|
|
284
|
+
def build_http_client(uri)
|
|
285
|
+
host, port = host_and_port(uri)
|
|
286
|
+
proxy = proxy_for(uri)
|
|
287
|
+
http_client = if proxy
|
|
288
|
+
Net::HTTP.new(host, port, proxy.hostname, proxy.port, decode(proxy.user), decode(proxy.password), nil, proxy.instance_of?(URI::HTTPS))
|
|
289
|
+
else
|
|
290
|
+
Net::HTTP.new(host, port, nil)
|
|
291
|
+
end
|
|
292
|
+
configure_http_client(http_client)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Configure a new HTTP client with the timeouts and debug output of the connection
|
|
296
|
+
#
|
|
297
|
+
# The settings of a connection never change, so they are applied as each client is opened, rather than before
|
|
298
|
+
# each request one makes.
|
|
299
|
+
#
|
|
300
|
+
# Net::HTTP sends a GET, PUT, or DELETE request again by itself after a timeout or a dropped connection, with the
|
|
301
|
+
# same OAuth 1.0a nonce and signature, which the API may bill twice, so its retries are turned off: a request
|
|
302
|
+
# that fails raises NetworkError, and the caller decides whether to send it again.
|
|
303
|
+
#
|
|
304
|
+
# Net::HTTP keeps a connection for two seconds by default, which reuses it within a burst of requests alone, so
|
|
305
|
+
# it keeps one for keep_alive_timeout instead.
|
|
306
|
+
#
|
|
307
|
+
# Net::HTTP returns a body that ends before the length its Content-Length gives as though it were whole, which
|
|
308
|
+
# would be parsed as JSON cut short, so ignore_eof is turned off: a connection that closes before the body is
|
|
309
|
+
# read raises EOFError, which a request raises as a NetworkError.
|
|
310
|
+
#
|
|
311
|
+
# @api private
|
|
312
|
+
# @param http_client [Net::HTTP] the HTTP client to configure
|
|
313
|
+
# @return [Net::HTTP] the configured HTTP client
|
|
314
|
+
def configure_http_client(http_client)
|
|
315
|
+
http_client.tap do |c|
|
|
316
|
+
c.open_timeout = open_timeout
|
|
317
|
+
c.read_timeout = read_timeout
|
|
318
|
+
c.write_timeout = write_timeout
|
|
319
|
+
c.max_retries = 0
|
|
320
|
+
c.ignore_eof = false
|
|
321
|
+
c.keep_alive_timeout = keep_alive_timeout
|
|
322
|
+
c.set_debug_output(debug_output)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
private_constant :Connection
|
|
327
|
+
end
|
|
328
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
module Core
|
|
7
|
+
# The open HTTP connections of a Connection, kept to be used again by the next request to the same host
|
|
8
|
+
#
|
|
9
|
+
# A connection is used by one request at a time: a request takes an idle connection, or opens one, and gives
|
|
10
|
+
# it back once it has read the response. A request that fails closes its connection instead, and so does one
|
|
11
|
+
# that finishes after clear. A forked process opens
|
|
12
|
+
# connections of its own rather than share its parent's.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
class ConnectionPool
|
|
16
|
+
# Most idle connections kept open to each host, as many as the chunks x-uploads sends at once at most, so that
|
|
17
|
+
# each sender of an upload keeps its connection from one chunk to the next
|
|
18
|
+
MAX_IDLE = 16
|
|
19
|
+
|
|
20
|
+
# Initialize an empty pool
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
# @return [ConnectionPool] a new pool
|
|
24
|
+
def initialize
|
|
25
|
+
@lock = Mutex.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Run a block with an open connection, keeping it for later if the block returns
|
|
29
|
+
#
|
|
30
|
+
# The block is told whether the connection was one the pool had kept open, since a connection the peer closed
|
|
31
|
+
# while it was idle fails the request that takes it, where one opened for the request did not go stale.
|
|
32
|
+
#
|
|
33
|
+
# @api private
|
|
34
|
+
# @param key [Array] the scheme, host, and port the connection is to
|
|
35
|
+
# @param open [Proc] builds a connection to the host, when none is idle
|
|
36
|
+
# @param fresh [Boolean] whether to open a connection even when one is idle
|
|
37
|
+
# @yield [Net::HTTP, Boolean] the started connection, and whether it came from the pool
|
|
38
|
+
# @return [Object] what the block returns
|
|
39
|
+
def with(key, open, fresh: false)
|
|
40
|
+
pool, http_client, pooled = checkout(key, open, fresh)
|
|
41
|
+
kept = nil
|
|
42
|
+
begin
|
|
43
|
+
result = yield http_client, pooled
|
|
44
|
+
kept = checkin(key, http_client, pool)
|
|
45
|
+
result
|
|
46
|
+
ensure
|
|
47
|
+
close(http_client) unless kept
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Close every idle connection, and each one in use once its request finishes
|
|
52
|
+
#
|
|
53
|
+
# A forked process leaves the connections its parent opened alone, since closing one would end the parent's
|
|
54
|
+
# TLS session over the socket they share.
|
|
55
|
+
#
|
|
56
|
+
# @api private
|
|
57
|
+
# @return [void]
|
|
58
|
+
def clear
|
|
59
|
+
idle = @lock.synchronize do
|
|
60
|
+
forget_after_fork
|
|
61
|
+
@idle.values.flatten.tap { @idle = {} }
|
|
62
|
+
end
|
|
63
|
+
idle.each { |http_client| close(http_client) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Take an idle connection to a host, or open one
|
|
69
|
+
# @api private
|
|
70
|
+
# @param key [Array] the scheme, host, and port
|
|
71
|
+
# @param open [Proc] builds a connection to the host
|
|
72
|
+
# @param fresh [Boolean] whether to open a connection even when one is idle
|
|
73
|
+
# @return [Array(Hash, Net::HTTP, bool)] the idle connections the connection returns to, the started
|
|
74
|
+
# connection, and whether it was idle rather than opened for this request
|
|
75
|
+
def checkout(key, open, fresh)
|
|
76
|
+
pool, idle = @lock.synchronize do
|
|
77
|
+
forget_after_fork
|
|
78
|
+
[@idle, (@idle[key]&.pop unless fresh)]
|
|
79
|
+
end
|
|
80
|
+
http_client = idle || open.call
|
|
81
|
+
http_client.start unless http_client.started?
|
|
82
|
+
[pool, http_client, !idle.nil?]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Keep a connection for later, unless the pool was cleared or is full
|
|
86
|
+
# @api private
|
|
87
|
+
# @param key [Array] the scheme, host, and port
|
|
88
|
+
# @param http_client [Net::HTTP] the connection
|
|
89
|
+
# @param pool [Hash] the idle connections when the connection was taken, which clear replaces
|
|
90
|
+
# @return [Array<Net::HTTP>, nil] the idle connections to the host, or nil if the connection was not kept
|
|
91
|
+
def checkin(key, http_client, pool)
|
|
92
|
+
@lock.synchronize do
|
|
93
|
+
idle = @idle[key] ||= []
|
|
94
|
+
idle.push(http_client) if pool.equal?(@idle) && idle.size < MAX_IDLE
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Drop the connections a parent process opened, which a fork must not share
|
|
99
|
+
#
|
|
100
|
+
# The first call in a process, which is the first in a new pool, starts the pool with no connections.
|
|
101
|
+
#
|
|
102
|
+
# @api private
|
|
103
|
+
# @return [void]
|
|
104
|
+
def forget_after_fork
|
|
105
|
+
return if @pid.eql?(Process.pid)
|
|
106
|
+
|
|
107
|
+
@idle = {}
|
|
108
|
+
@pid = Process.pid
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Close a connection, unless it is closed already
|
|
112
|
+
# @api private
|
|
113
|
+
# @param http_client [Net::HTTP] the connection
|
|
114
|
+
# @return [void]
|
|
115
|
+
def close(http_client)
|
|
116
|
+
http_client.finish
|
|
117
|
+
rescue IOError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
private_constant :ConnectionPool
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
module Core
|
|
7
|
+
# The proxy of a connection, parsed from its URL, included into Connection
|
|
8
|
+
#
|
|
9
|
+
# A proxy URL can hold the user and password of the proxy, so a connection reveals neither its URL nor anything
|
|
10
|
+
# parsed from it, and its inspect leaves out the user and password; see {ProxySetting}.
|
|
11
|
+
#
|
|
12
|
+
# A connection given no proxy URL takes the proxy the environment names for each request, and sends a request to
|
|
13
|
+
# a host that no_proxy names without a proxy. Set no_proxy to reach every host directly from a process whose
|
|
14
|
+
# environment names a proxy. A proxy of the environment that cannot be parsed, or that is not an HTTP or HTTPS
|
|
15
|
+
# URL that names a host, such as proxy.example.com:8080, which has no scheme, is refused as a proxy URL given is,
|
|
16
|
+
# with an ArgumentError that names the variables it is read from and leaves out its value, which can hold a
|
|
17
|
+
# password, though by the request that reads it, since the environment is read for each request that opens a
|
|
18
|
+
# connection, rather than where the client is built. A variable that is empty names no proxy, as it names none
|
|
19
|
+
# to Ruby.
|
|
20
|
+
#
|
|
21
|
+
# Most HTTP clients take the proxy the environment names, and Net::HTTP reads http_proxy alone, whatever the scheme of the request, which would leave the HTTPS requests
|
|
22
|
+
# these gems make unproxied for anyone who sets https_proxy, and proxied through whatever http_proxy names for
|
|
23
|
+
# anyone who sets that instead. So the proxy of a request is resolved here, from the scheme of its own URI, and
|
|
24
|
+
# passed to Net::HTTP, which is given no proxy of its own to resolve.
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
module ConnectionProxy
|
|
28
|
+
# The message of the error an HTTPS request raises for a proxy of the environment that is no proxy URL, which
|
|
29
|
+
# names the variables URI::Generic#find_proxy reads for one, in the order it reads them, and leaves out the value
|
|
30
|
+
INVALID_HTTPS_PROXY = "Invalid proxy URL in the environment, in https_proxy or HTTPS_PROXY"
|
|
31
|
+
# The message of the error an HTTP request raises for a proxy of the environment that is no proxy URL, which
|
|
32
|
+
# names the variables URI::Generic#find_proxy reads for one, and leaves out the value: http_proxy, and else
|
|
33
|
+
# HTTP_PROXY, or, in a CGI process, which REQUEST_METHOD is set in, CGI_HTTP_PROXY in place of HTTP_PROXY
|
|
34
|
+
INVALID_HTTP_PROXY = "Invalid proxy URL in the environment, in http_proxy, HTTP_PROXY, or CGI_HTTP_PROXY"
|
|
35
|
+
private_constant :INVALID_HTTPS_PROXY, :INVALID_HTTP_PROXY
|
|
36
|
+
|
|
37
|
+
# Raised for a proxy of the environment that cannot be parsed, or is not an HTTP or HTTPS URL that names a host,
|
|
38
|
+
# inside a request, which raises an ArgumentError for it in turn, rather than the NetworkError it raises for an
|
|
39
|
+
# ArgumentError of Net::HTTP
|
|
40
|
+
#
|
|
41
|
+
# @api private
|
|
42
|
+
class InvalidEnvironmentProxy < StandardError; end
|
|
43
|
+
private_constant :InvalidEnvironmentProxy
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# The parsed proxy URI
|
|
48
|
+
# @api private
|
|
49
|
+
# @return [URI::Generic, nil] the parsed proxy URI, or nil to take the proxy the environment names
|
|
50
|
+
attr_reader :proxy_uri
|
|
51
|
+
|
|
52
|
+
# Read the proxy URL a connection is built with
|
|
53
|
+
#
|
|
54
|
+
# A connection keeps it for as long as it lives, as it keeps every setting. The message of an invalid URL
|
|
55
|
+
# leaves out its user and password.
|
|
56
|
+
#
|
|
57
|
+
# @api private
|
|
58
|
+
# @param proxy_url [String, URI::Generic, nil] the proxy URL, or nil to take the proxy from the environment
|
|
59
|
+
# @return [void]
|
|
60
|
+
# @raise [ArgumentError] if the proxy URL is invalid
|
|
61
|
+
def initialize_proxy(proxy_url)
|
|
62
|
+
@proxy_uri = proxy_url&.then { |url| parse_proxy_url(url) }
|
|
63
|
+
@proxy_url = @proxy_uri&.then { |uri| String(uri) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The proxy of a request, the one given or the one the environment names
|
|
67
|
+
#
|
|
68
|
+
# The proxy the connection was given stands for every request. Otherwise the URI of the request is asked for
|
|
69
|
+
# the proxy of its own scheme, which is held to what a proxy URL given is: URI::Generic#find_proxy parses a
|
|
70
|
+
# value without a scheme, such as proxy.example.com:8080, into a URI that names no host, which Net::HTTP would
|
|
71
|
+
# take for no proxy at all, and send the request around the proxy its environment names. A variable that is
|
|
72
|
+
# empty, and a host that no_proxy names, have no proxy to check. The error of a proxy of the environment that
|
|
73
|
+
# cannot be parsed has no cause, since the message of the URI::InvalidURIError holds the value of the variable
|
|
74
|
+
# whole, with the password a proxy URL can hold.
|
|
75
|
+
#
|
|
76
|
+
# @api private
|
|
77
|
+
# @param uri [URI::Generic] the URI of the request
|
|
78
|
+
# @return [URI::Generic, nil] the proxy of the request, or nil to reach the host directly
|
|
79
|
+
# @raise [InvalidEnvironmentProxy] if the proxy the environment names cannot be parsed, or is not an HTTP or
|
|
80
|
+
# HTTPS URL that names a host
|
|
81
|
+
def proxy_for(uri)
|
|
82
|
+
proxy_uri || uri.find_proxy&.tap { |proxy| raise InvalidEnvironmentProxy unless proxy_server?(proxy) }
|
|
83
|
+
rescue URI::InvalidURIError
|
|
84
|
+
raise InvalidEnvironmentProxy, cause: nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The error a request raises for a proxy of the environment that is no proxy URL
|
|
88
|
+
#
|
|
89
|
+
# The variable the proxy was read from is not looked up again, which would repeat the lookup of
|
|
90
|
+
# URI::Generic#find_proxy, so the message names each variable that is read for the scheme of the request, in
|
|
91
|
+
# either case, and is true whichever of them was set.
|
|
92
|
+
#
|
|
93
|
+
# @api private
|
|
94
|
+
# @param uri [URI::Generic] the URI of the request, whose scheme names the variables the proxy was read from
|
|
95
|
+
# @return [ArgumentError] the error, whose message names the variables and leaves out the value
|
|
96
|
+
def environment_proxy_error(uri) = ArgumentError.new(uri.scheme.eql?("https") ? INVALID_HTTPS_PROXY : INVALID_HTTP_PROXY)
|
|
97
|
+
|
|
98
|
+
# A percent-encoded component of a proxy URL, decoded
|
|
99
|
+
#
|
|
100
|
+
# @api private
|
|
101
|
+
# @param component [String, nil] the component, or nil for none
|
|
102
|
+
# @return [String, nil] the decoded component, or nil for none
|
|
103
|
+
def decode(component) = component&.then { |value| URI.decode_uri_component(value) }
|
|
104
|
+
|
|
105
|
+
# The proxy URL without its user and password
|
|
106
|
+
# @api private
|
|
107
|
+
# @return [String, nil] the proxy URL, without its user and password
|
|
108
|
+
def redacted_proxy_url = proxy_url&.then { |url| redact(url) }
|
|
109
|
+
|
|
110
|
+
# Parse a proxy URL, which must be an HTTP or HTTPS URL that names a host
|
|
111
|
+
#
|
|
112
|
+
# A URL that names no host, such as http:proxy:8080, is refused, since Net::HTTP would take its missing host for
|
|
113
|
+
# no proxy at all and send each request around the proxy. The URL is parsed anew, so that a URI the caller
|
|
114
|
+
# changes later does not change the proxy.
|
|
115
|
+
#
|
|
116
|
+
# @api private
|
|
117
|
+
# @param proxy_url [String, URI::Generic] the proxy URL
|
|
118
|
+
# @return [URI::HTTP] the parsed proxy URL
|
|
119
|
+
# @raise [ArgumentError] if the proxy URL is invalid
|
|
120
|
+
def parse_proxy_url(proxy_url)
|
|
121
|
+
proxy_uri = URI(String(proxy_url))
|
|
122
|
+
raise ArgumentError, "Invalid proxy URL: #{redact(proxy_url)}" unless proxy_server?(proxy_uri)
|
|
123
|
+
|
|
124
|
+
proxy_uri
|
|
125
|
+
rescue URI::InvalidURIError
|
|
126
|
+
raise ArgumentError, "Invalid proxy URL: #{redact(proxy_url)}", cause: nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Check whether a URI names a proxy, as an HTTP or HTTPS URL with a host does
|
|
130
|
+
#
|
|
131
|
+
# A proxy URL given and the proxy of the environment are held to it alike. A user, a password, a port, and a
|
|
132
|
+
# path are each allowed, and the path is not read.
|
|
133
|
+
#
|
|
134
|
+
# @api private
|
|
135
|
+
# @param uri [URI::Generic] the parsed proxy URL
|
|
136
|
+
# @return [Boolean] true if the URI is an HTTP or HTTPS URL that names a host
|
|
137
|
+
def proxy_server?(uri) = uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
138
|
+
|
|
139
|
+
# A proxy URL without its user and password
|
|
140
|
+
#
|
|
141
|
+
# A password written into a URL unescaped can hold any character, an @ or a / among them, so everything
|
|
142
|
+
# between the scheme and the last @ is left out, and everything before the last @ of a URL without a scheme.
|
|
143
|
+
#
|
|
144
|
+
# @api private
|
|
145
|
+
# @param proxy_url [String, URI::Generic] the proxy URL
|
|
146
|
+
# @return [String] the URL, without the user and password
|
|
147
|
+
def redact(proxy_url) = String(proxy_url).sub(%r{([^/]*//)?.*@}m, "\\1")
|
|
148
|
+
end
|
|
149
|
+
private_constant :ConnectionProxy
|
|
150
|
+
end
|
|
151
|
+
end
|