Skip to content

Commit 1786285

Browse files
committed
Rework HTTP::Client logic
Implement force resolve via IO constructor Use `DB::Pool#retry` in connection pool `HTTP::Clients` created through its IO constructor cannot reconnect to closed connections, and instead will result in an error on usage. The `DB::Pool#retry` allows us to cycle until we find a client that that still has an open connection, or to create a new one if necessary. The poisoned clients are then removed once identified by `#retry`'s internal logic The fact that clients with closed connections will now be slowly removed also means that Invidious will no longer use the same pattern of companion connections within the pool; distributing routes more evenly.
1 parent ccbbc45 commit 1786285

File tree

3 files changed

+140
-91
lines changed

3 files changed

+140
-91
lines changed

src/invidious/connection/client.cr

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,62 @@
1+
module Invidious
2+
class IVTCPSocket < TCPSocket
3+
def initialize(host : String, port, dns_timeout = nil, connect_timeout = nil, blocking = false, family = Socket::Family::UNSPEC)
4+
Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo|
5+
super(addrinfo.family, addrinfo.type, addrinfo.protocol, blocking)
6+
connect(addrinfo, timeout: connect_timeout) do |error|
7+
close
8+
error
9+
end
10+
end
11+
end
12+
end
13+
14+
class HTTPClient < HTTP::Client
15+
def initialize(uri : URI, tls : TLSContext = nil, force_resolve : Socket::Family = Socket::Family::UNSPEC)
16+
tls = HTTP::Client.tls_flag(uri, tls)
17+
18+
{% if flag?(:without_openssl) %}
19+
if tls
20+
raise "HTTP::Client TLS is disabled because `-D without_openssl` was passed at compile time"
21+
end
22+
@tls = nil
23+
{% else %}
24+
@tls = case tls
25+
when true
26+
OpenSSL::SSL::Context::Client.new
27+
when OpenSSL::SSL::Context::Client
28+
tls
29+
when false, nil
30+
nil
31+
end
32+
{% end %}
33+
34+
@host = HTTP::Client.validate_host(uri)
35+
@port = (uri.port || (@tls ? 443 : 80)).to_i
36+
37+
tcp_socket = IVTCPSocket.new(
38+
host: @host,
39+
port: @port,
40+
family: force_resolve,
41+
)
42+
43+
if tls = @tls
44+
begin
45+
@io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host.rchop('.'))
46+
rescue ex
47+
# Don't leak the TCP socket when the SSL connection failed
48+
tcp_socket.close
49+
raise ex
50+
end
51+
else
52+
@io = tcp_socket
53+
end
54+
55+
@reconnect = false
56+
end
57+
end
58+
end
59+
160
def add_yt_headers(request)
261
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
362
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
@@ -13,14 +72,14 @@ def add_yt_headers(request)
1372
end
1473
end
1574

16-
def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false, use_http_proxy : Bool = true)
17-
client = HTTP::Client.new(url)
18-
client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy && use_http_proxy
19-
20-
# Force the usage of a specific configured IP Family
21-
if force_resolve
22-
client.family = CONFIG.force_resolve
23-
client.family = Socket::Family::INET if client.family == Socket::Family::UNSPEC
75+
def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = true, use_http_proxy : Bool = true)
76+
if CONFIG.http_proxy && use_http_proxy
77+
client = Invidious::HTTPClient.new(url)
78+
client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy && use_http_proxy
79+
elsif force_resolve
80+
client = Invidious::HTTPClient.new(url, force_resolve: CONFIG.force_resolve)
81+
else
82+
client = Invidious::HTTPClient.new(url)
2483
end
2584

2685
client.before_request { |r| add_yt_headers(r) } if url.host.try &.ends_with?("youtube.com") || force_youtube_headers

src/invidious/connection/pool.cr

Lines changed: 73 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,9 @@ module Invidious::ConnectionPool
2525
# Streaming API for {{method.id.upcase}} request.
2626
# The response will have its body as an `IO` accessed via `HTTP::Client::Response#body_io`.
2727
def {{method.id}}(*args, **kwargs, &)
28-
self.checkout do | client |
28+
self.checkout_with_retry do | client |
2929
client.{{method.id}}(*args, **kwargs) do | response |
30-
result = yield response
31-
return result
30+
return yield response
3231
ensure
3332
response.body_io?.try &.skip_to_end
3433
end
@@ -38,45 +37,82 @@ module Invidious::ConnectionPool
3837
# Executes a {{method.id.upcase}} request.
3938
# The response will have its body as a `String`, accessed via `HTTP::Client::Response#body`.
4039
def {{method.id}}(*args, **kwargs)
41-
self.checkout do | client |
40+
self.checkout_with_retry do | client |
4241
return client.{{method.id}}(*args, **kwargs)
4342
end
4443
end
4544
{% end %}
4645

4746
# Checks out a client in the pool
47+
#
48+
# This method will NOT delete a client that has errored from the pool.
49+
# Use `#checkout_with_retry` to ensure that the pool does not get poisoned.
4850
def checkout(&)
49-
# If a client has been deleted from the pool
50-
# we won't try to release it
51-
client_exists_in_pool = true
52-
53-
http_client = pool.checkout
54-
55-
# When the HTTP::Client connection is closed, the automatic reconnection
56-
# feature will create a new IO to connect to the server with
57-
#
58-
# This new TCP IO will be a direct connection to the server and will not go
59-
# through the proxy. As such we'll need to reinitialize the proxy connection
60-
61-
http_client.proxy = make_configured_http_proxy_client() if @reinitialize_proxy && CONFIG.http_proxy
62-
63-
response = yield http_client
64-
rescue ex : DB::PoolTimeout
65-
# Failed to checkout a client
66-
raise ConnectionPool::PoolCheckoutError.new(ex.message)
67-
rescue ex
68-
# An error occurred with the client itself.
69-
# Delete the client from the pool and close the connection
70-
if http_client
71-
client_exists_in_pool = false
72-
@pool.delete(http_client)
73-
http_client.close
51+
pool.checkout do |client|
52+
# When the HTTP::Client connection is closed, the automatic reconnection
53+
# feature will create a new IO to connect to the server with
54+
#
55+
# This new TCP IO will be a direct connection to the server and will not go
56+
# through the proxy. As such we'll need to reinitialize the proxy connection
57+
client.proxy = make_configured_http_proxy_client() if @reinitialize_proxy && CONFIG.http_proxy
58+
59+
response = yield client
60+
61+
return response
62+
rescue ex : DB::PoolTimeout
63+
# Failed to checkout a client
64+
raise ConnectionPool::PoolCheckoutError.new(ex.message)
7465
end
66+
end
7567

76-
# Raise exception for outer methods to handle
77-
raise ConnectionPool::Error.new(ex.message, cause: ex)
78-
ensure
79-
pool.release(http_client) if http_client && client_exists_in_pool
68+
# Checks out a client from the pool; retries only if a connection is lost or refused
69+
#
70+
# Will cycle through all of the existing connections at no delay, but any new connections
71+
# that is created will be subject to a delay.
72+
#
73+
# The first attempt to make a new connection will not have the delay, but all subsequent
74+
# attempts will.
75+
#
76+
# To `DB::Pool#retry`:
77+
# - `DB::PoolResourceLost` means that the connection has been lost
78+
# and should be deleted from the pool.
79+
#
80+
# - `DB::PoolResourceRefused` means a new connection was intended to be created but failed
81+
# but the client can be safely released back into the pool to try again later with
82+
#
83+
# See the following code of `crystal-db` for more information
84+
#
85+
# https://github.com/crystal-lang/crystal-db/blob/023dc5de90c11927656fc747601c5f08ea3c906f/src/db/pool.cr#L191
86+
# https://github.com/crystal-lang/crystal-db/blob/023dc5de90c11927656fc747601c5f08ea3c906f/src/db/pool_statement.cr#L41
87+
# https://github.com/crystal-lang/crystal-db/blob/023dc5de90c11927656fc747601c5f08ea3c906f/src/db/pool_prepared_statement.cr#L13
88+
#
89+
def checkout_with_retry(&)
90+
@pool.retry do
91+
self.checkout do |client|
92+
begin
93+
return yield client
94+
rescue ex : IO::TimeoutError
95+
LOGGER.trace("Client: #{client} has failed to complete the request. Retrying with a new client")
96+
raise DB::PoolResourceRefused.new
97+
rescue ex : InfoException
98+
raise ex
99+
rescue ex : Exception
100+
# Any other errors should cause the client to be deleted from the pool
101+
102+
# This means that the client is closed and needs to be deleted from the pool
103+
# due its inability to reconnect
104+
if ex.message == "This HTTP::Client cannot be reconnected"
105+
LOGGER.trace("Checked out client is closed and cannot be reconnected. Trying the next retry attempt...")
106+
else
107+
LOGGER.error("Client: #{client} has encountered an error: #{ex} #{ex.message} and will be removed from the pool")
108+
end
109+
110+
raise DB::PoolResourceLost(HTTP::Client).new(client)
111+
end
112+
end
113+
rescue ex : DB::PoolRetryAttemptsExceeded
114+
raise PoolRetryAttemptsExceeded.new
115+
end
80116
end
81117
end
82118

@@ -87,6 +123,10 @@ module Invidious::ConnectionPool
87123
class PoolCheckoutError < Error
88124
end
89125

126+
# Raised when too many retries
127+
class PoolRetryAttemptsExceeded < Error
128+
end
129+
90130
# Mapping of subdomain => Invidious::ConnectionPool::Pool
91131
# This is needed as we may need to access arbitrary subdomains of ytimg
92132
private YTIMG_POOLS = {} of String => ConnectionPool::Pool

src/invidious/helpers/crystal_class_overrides.cr

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,3 @@
1-
# Override of the TCPSocket and HTTP::Client classes in order to allow an
2-
# IP family to be selected for domains that resolve to both IPv4 and
3-
# IPv6 addresses.
4-
#
5-
class TCPSocket
6-
def initialize(host, port, dns_timeout = nil, connect_timeout = nil, blocking = false, family = Socket::Family::UNSPEC)
7-
Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo|
8-
super(addrinfo.family, addrinfo.type, addrinfo.protocol, blocking)
9-
connect(addrinfo, timeout: connect_timeout) do |error|
10-
close
11-
error
12-
end
13-
end
14-
end
15-
end
16-
17-
# :ditto:
18-
class HTTP::Client
19-
property family : Socket::Family = Socket::Family::UNSPEC
20-
21-
private def io
22-
io = @io
23-
return io if io
24-
unless @reconnect
25-
raise "This HTTP::Client cannot be reconnected"
26-
end
27-
28-
hostname = @host.starts_with?('[') && @host.ends_with?(']') ? @host[1..-2] : @host
29-
io = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, family: @family
30-
io.read_timeout = @read_timeout if @read_timeout
31-
io.write_timeout = @write_timeout if @write_timeout
32-
io.sync = false
33-
34-
{% if !flag?(:without_openssl) %}
35-
if tls = @tls
36-
tcp_socket = io
37-
begin
38-
io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host.rchop('.'))
39-
rescue exc
40-
# don't leak the TCP socket when the SSL connection failed
41-
tcp_socket.close
42-
raise exc
43-
end
44-
end
45-
{% end %}
46-
47-
@io = io
48-
end
49-
end
50-
511
# Mute the ClientError exception raised when a connection is flushed.
522
# This happends when the connection is unexpectedly closed by the client.
533
#

0 commit comments

Comments
 (0)