Compare commits

...

36 Commits

Author SHA1 Message Date
HoneyryderChuck
0261449b39 fixed sig for callbacks_for 2025-08-08 17:06:03 +01:00
HoneyryderChuck
84c8126cd9 callback_for: check for ivar existence first
Closes #353
2025-08-08 16:30:17 +01:00
HoneyryderChuck
ff3f1f726f fix warning about argument potentially being ignored 2025-08-07 12:34:59 +01:00
HoneyryderChuck
b8b710470c fix sentry deprecation 2025-08-07 12:30:31 +01:00
HoneyryderChuck
0f3e3ab068 remove trailing :: from IO module usage, as there's no more internal module 2025-08-07 12:30:21 +01:00
HoneyryderChuck
095fbb3463 using local aws for the max requests tests
reduce exposure to httpbin.org even more
2025-08-07 12:12:50 +01:00
HoneyryderChuck
7790589c1f linting issue 2025-08-07 11:28:18 +01:00
HoneyryderChuck
dd8608ec3b small improv in max requests tests to make it tolerant to multi-homed networks 2025-08-07 11:22:29 +01:00
HoneyryderChuck
8205b351aa removing usage of httpbin.org peer in tests wherever possible
it has been quite unstable, 503'ing often
2025-08-07 11:21:59 +01:00
HoneyryderChuck
5992628926 update nghttp2 used in CI tests 2025-08-07 11:21:02 +01:00
HoneyryderChuck
39370b5883 Merge branch 'issue-337' into 'master'
fix for issues blocking reconnection in proxy mode

Closes #337

See merge request os85/httpx!397
2025-07-30 09:49:51 +00:00
HoneyryderChuck
1801a7815c http2 parser: fix calculation when connection closes and there's no termination handshake 2025-07-18 17:48:23 +01:00
HoneyryderChuck
0953e4f91a fix for #receive_requests bailout routing when out of selectables
the routine was using #fetch_response, which may return nil, and wasn't handling it, making it potentially return a nil instead of a response/errorresponse object. since, depending on the plugins, #fetch_response may reroute requests, one allows to keep in the loop in case there are selectables again to process as a result of it
2025-07-18 17:48:23 +01:00
HoneyryderChuck
a78a3f0b7c proxy fixes: allow proxy connection errors to be retriable
when coupled with the retries plugin, the exception is raised inside send_request, which breaks the integration; in order to protect from it, the proxy plugin will protect from proxy connection errors (socket/timeout errors happening until tunnel established) and allow them to be retried, while ignoring other proxy errors; meanwhile, the naming of errors was simplified, and now there's an HTTPX::ProxyError replacing HTTPX::HTTPProxyError (which is a breaking change).
2025-07-18 17:48:23 +01:00
HoneyryderChuck
aeb8fe5382 fix proxy ssl reconnection
when a proxied ssl connection would be lost, standard reconnection wouldn't work, as it would not pick the information from the internal tcp socket. in order to fix this, the connection retrieves the proxied io on reset/purge, which makes the establish a new proxyssl connection on reconnect
2025-07-18 17:48:23 +01:00
HoneyryderChuck
03170b6c89 promote certain transition logs to regular code (under level 3)
not really useful as telemetry metered, but would have been useful for other bugs
2025-07-18 17:48:23 +01:00
HoneyryderChuck
814d607a45 Revert "options: initialize all possible options to improve object shape"
This reverts commit f64c3ab5990b68f850d0d190535a45162929f0af.
2025-07-18 17:47:08 +01:00
HoneyryderChuck
5502332e7e logging when connections are deregistered from the selector/pool
also, logging when a response is fetched in the session
2025-07-18 17:46:43 +01:00
HoneyryderChuck
f3b68950d6 adding current fiber id to log message tags 2025-07-18 17:45:21 +01:00
HoneyryderChuck
2c4638784f Merge branch 'fix-shape' into 'master'
object shape improvements

See merge request os85/httpx!396
2025-07-14 15:38:19 +00:00
HoneyryderChuck
b0016525e3 recover from network unreachable errors when using cached IPs
while this type of error is avoided when doing HEv2, the IPs remain
in the cache; this means that, one the same host is reached, the
IPs are loaded onto the same socket, and if the issue is IPv6
connectivity, it'll break outside of the HEv2 flow.

this error is now protected inside the connect block, so that other
IPs in the list can be tried after; the IP is then evicted from the
cachee.

HEv2 related regression test is disabled in CI, as it's currently
reliable in Gitlab CI, which allows to resolve the IPv6 address,
but does not allow connecting to it
2025-07-14 15:44:47 +01:00
HoneyryderChuck
49555694fe remove check for non unique local ipv6 which is disabling HEv2
not sure anymore under which condition this was done...
2025-07-14 11:57:02 +01:00
HoneyryderChuck
93e5efa32e http2 stream header logs: initial newline to align values and make debug logs clearer 2025-07-14 11:50:22 +01:00
HoneyryderChuck
8b3c1da507 removed ivar left behind and used nowhere 2025-07-14 11:50:22 +01:00
HoneyryderChuck
d64f247e11 fix for Connection too many object shapes
some more ivars which were not initialized in the first place were leading to the warning in CI mode
2025-07-14 11:50:22 +01:00
HoneyryderChuck
f64c3ab599 options: initialize all possible options to improve object shape
Options#merge works by duping-then-filling ivars, but due to not all of them being initialized on object creation, each merge had the potential of adding more object shapes for the same class, which breaks one of the most recent ruby optimizations

this was fixed by caching all possible options names at the class level, and using that as reference in the initalize method to nilify all unreferenced options
2025-07-14 11:50:22 +01:00
HoneyryderChuck
af03ddba3b options: inlining logic from do_initialize in constructor 2025-07-14 09:10:52 +01:00
HoneyryderChuck
7012ca1f27 fixed previous commit, as the tag is not available before 1.15 2025-07-03 16:39:54 +01:00
HoneyryderChuck
d405f8905f fixed ddtrace compatibility for versions under 1.13.0 2025-07-03 16:23:27 +01:00
HoneyryderChuck
3ff10f142a replace h2 upgrade peer with a custom implementation
the remote one has been failing for some time
2025-06-09 22:56:30 +01:00
HoneyryderChuck
51ce9d10a4 bump version to 1.5.1 2025-06-09 09:04:05 +01:00
HoneyryderChuck
6bde11b09c Merge branch 'gh-92' into 'master'
don't bookkeep retry attempts when errors happen on just-checked-out open connections

See merge request os85/httpx!394
2025-05-28 17:54:03 +00:00
HoneyryderChuck
0c2808fa25 prevent needless closing loop when process is interrupted during DNS request
the native resolver needs to be unselected. it was already, but it was taken into account still for bookkeeping. this removes it from the list by eliminating closed selectables from the list (which were probably already removed from the list via callback)

Closes https://github.com/HoneyryderChuck/httpx/issues/91
2025-05-28 15:26:11 +01:00
HoneyryderChuck
cb78091e03 don't bookkeep retry attempts when errors happen on just-checked-out open connections
in case of multiple connections to the same server, where the server may have closed all of them at the same time, a request will fail after checkout multiple times, before starting a new one where the request may succeed. this patch allows the prior attempts not to exhaust the number of possible retries on the request

it does so by marking the request as ping when the connection it's being sent to is marked as inactive; this leverages the logic of gating retries bookkeeping in such a case

Closes https://github.com/HoneyryderChuck/httpx/issues/92
2025-05-28 15:25:50 +01:00
HoneyryderChuck
6fa69ba475 Merge branch 'duplicate-method-def' into 'master'
Fix duplicate `option_pool_options` method

See merge request os85/httpx!393
2025-05-21 15:30:34 +00:00
Earlopain
4a78e78d32
Fix duplicate option_pool_options method
> /usr/local/bundle/bundler/gems/httpx-0e393987d027/lib/httpx/options.rb:237: warning: method redefined; discarding old option_pool_options (StandardError)
> /usr/local/bundle/bundler/gems/httpx-0e393987d027/lib/httpx/options.rb:221: warning: previous definition of option_pool_options was here
2025-05-21 12:49:54 +02:00
68 changed files with 413 additions and 192 deletions

View File

@ -0,0 +1,6 @@
# 1.5.1
## Bugfixes
* connection errors on persistent connections which have just been checked out from the pool no longer account for retries bookkeeping; the assumption should be that, if a connection has been checked into the pool in an open state, chances are, when it eventually gets checked out, it may be corrupt. This issue was more exacerbated in `:persistent` plugin connections, which by design have a retry of 1, thus failing often immediately after check out without a legitimate request try.
* native resolver: fix issue with process interrupts during DNS request, which caused a busy loop when closing the selector.

View File

@ -9,7 +9,7 @@ services:
- doh
doh:
image: registry.gitlab.com/os85/httpx/nghttp2:1
image: registry.gitlab.com/os85/httpx/nghttp2:3
depends_on:
- doh-proxy
entrypoint:

View File

@ -9,7 +9,7 @@ services:
- doh
doh:
image: registry.gitlab.com/os85/httpx/nghttp2:1
image: registry.gitlab.com/os85/httpx/nghttp2:3
depends_on:
- doh-proxy
entrypoint:

View File

@ -9,7 +9,7 @@ services:
- doh
doh:
image: registry.gitlab.com/os85/httpx/nghttp2:1
image: registry.gitlab.com/os85/httpx/nghttp2:3
depends_on:
- doh-proxy
entrypoint:

View File

@ -9,7 +9,7 @@ services:
- doh
doh:
image: registry.gitlab.com/os85/httpx/nghttp2:1
image: registry.gitlab.com/os85/httpx/nghttp2:3
depends_on:
- doh-proxy
entrypoint:

View File

@ -9,7 +9,7 @@ services:
- doh
doh:
image: registry.gitlab.com/os85/httpx/nghttp2:1
image: registry.gitlab.com/os85/httpx/nghttp2:3
depends_on:
- doh-proxy
entrypoint:

View File

@ -9,7 +9,7 @@ services:
- doh
doh:
image: registry.gitlab.com/os85/httpx/nghttp2:1
image: registry.gitlab.com/os85/httpx/nghttp2:3
depends_on:
- doh-proxy
entrypoint: /usr/local/bin/nghttpx

View File

@ -69,7 +69,7 @@ services:
command: -d 3
http2proxy:
image: registry.gitlab.com/os85/httpx/nghttp2:1
image: registry.gitlab.com/os85/httpx/nghttp2:3
ports:
- 3300:80
depends_on:
@ -78,7 +78,7 @@ services:
command: --no-ocsp --frontend '*,80;no-tls' --backend 'httpproxy,3128' --http2-proxy
nghttp2:
image: registry.gitlab.com/os85/httpx/nghttp2:1
image: registry.gitlab.com/os85/httpx/nghttp2:3
ports:
- 80:80
- 443:443
@ -94,7 +94,7 @@ services:
- another
altsvc-nghttp2:
image: registry.gitlab.com/os85/httpx/nghttp2:1
image: registry.gitlab.com/os85/httpx/nghttp2:3
ports:
- 81:80
- 444:443

View File

@ -133,7 +133,7 @@ class SentryTest < Minitest::Test
Sentry.init do |config|
config.traces_sample_rate = 1.0
config.logger = mock_logger
config.sdk_logger = mock_logger
config.dsn = DUMMY_DSN
config.transport.transport_class = Sentry::DummyTransport
config.background_worker_threads = 0

View File

@ -13,7 +13,11 @@ module Datadog::Tracing
TYPE_OUTBOUND = Datadog::Tracing::Metadata::Ext::HTTP::TYPE_OUTBOUND
TAG_BASE_SERVICE = Datadog::Tracing::Contrib::Ext::Metadata::TAG_BASE_SERVICE
TAG_BASE_SERVICE = if Gem::Version.new(DATADOG_VERSION::STRING) < Gem::Version.new("1.15.0")
"_dd.base_service"
else
Datadog::Tracing::Contrib::Ext::Metadata::TAG_BASE_SERVICE
end
TAG_PEER_HOSTNAME = Datadog::Tracing::Metadata::Ext::TAG_PEER_HOSTNAME
TAG_KIND = Datadog::Tracing::Metadata::Ext::TAG_KIND
@ -89,7 +93,7 @@ module Datadog::Tracing
span.set_tags(
Datadog.configuration.tracing.header_tags.response_tags(response.headers.to_h)
)
) if Datadog.configuration.tracing.respond_to?(:header_tags)
end
span.finish
@ -139,7 +143,7 @@ module Datadog::Tracing
span.set_tags(
Datadog.configuration.tracing.header_tags.request_tags(request.headers.to_h)
)
) if Datadog.configuration.tracing.respond_to?(:header_tags)
span
rescue StandardError => e

View File

@ -20,7 +20,7 @@ module HTTPX
end
def callbacks_for?(type)
@callbacks.key?(type) && @callbacks[type].any?
@callbacks && @callbacks.key?(type) && @callbacks[type].any?
end
protected

View File

@ -50,7 +50,11 @@ module HTTPX
protected :sibling
def initialize(uri, options)
@current_session = @current_selector = @sibling = @coalesced_connection = nil
@current_session = @current_selector =
@parser = @sibling = @coalesced_connection =
@io = @ssl_session = @timeout =
@connected_at = @response_received_at = nil
@exhausted = @cloned = @main_sibling = false
@options = Options.new(options)
@ -61,6 +65,8 @@ module HTTPX
@read_buffer = Buffer.new(@options.buffer_size)
@write_buffer = Buffer.new(@options.buffer_size)
@pending = []
@inflight = 0
@keep_alive_timeout = @options.timeout[:keep_alive_timeout]
on(:error, &method(:on_error))
if @options.io
@ -98,9 +104,6 @@ module HTTPX
build_altsvc_connection(alt_origin, origin, alt_params)
end
@inflight = 0
@keep_alive_timeout = @options.timeout[:keep_alive_timeout]
self.addresses = @options.addresses if @options.addresses
end
@ -369,8 +372,6 @@ module HTTPX
end
def handle_connect_error(error)
@connect_error = error
return handle_error(error) unless @sibling && @sibling.connecting?
@sibling.merge(self)
@ -553,6 +554,10 @@ module HTTPX
return unless @state == :inactive
transition(:active)
# mark request as ping, as this inactive connection may have been
# closed by the server, and we don't want that to influence retry
# bookkeeping.
request.ping!
end
def build_parser(protocol = @io.protocol)
@ -744,6 +749,7 @@ module HTTPX
# activate
@current_session.select_connection(self, @current_selector)
end
log(level: 3) { "#{@state} -> #{nextstate}" }
@state = nextstate
end

View File

@ -58,6 +58,8 @@ module HTTPX
if @connection.state == :closed
return unless @handshake_completed
return if @buffer.empty?
return :w
end
@ -229,7 +231,7 @@ module HTTPX
end
log(level: 1, color: :yellow) do
request.headers.merge(extra_headers).each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{log_redact(v)}" }.join("\n")
"\n#{request.headers.merge(extra_headers).each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{log_redact(v)}" }.join("\n")}"
end
stream.headers(request.headers.each(extra_headers), end_stream: request.body.empty?)
end

View File

@ -75,9 +75,18 @@ module HTTPX
@io = build_socket
end
try_connect
rescue Errno::EHOSTUNREACH,
Errno::ENETUNREACH => e
raise e if @ip_index <= 0
log { "failed connecting to #{@ip} (#{e.message}), evict from cache and trying next..." }
Resolver.cached_lookup_evict(@hostname, @ip)
@ip_index -= 1
@io = build_socket
retry
rescue Errno::ECONNREFUSED,
Errno::EADDRNOTAVAIL,
Errno::EHOSTUNREACH,
SocketError,
IOError => e
raise e if @ip_index <= 0

View File

@ -48,7 +48,7 @@ module HTTPX
transition(:connected)
rescue Errno::EINPROGRESS,
Errno::EALREADY,
::IO::WaitReadable
IO::WaitReadable
end
def expired?

View File

@ -34,7 +34,10 @@ module HTTPX
klass = klass.superclass
end
message = +"(pid:#{Process.pid} tid:#{Thread.current.object_id}, self:#{class_name}##{object_id}) "
message = +"(pid:#{Process.pid}, " \
"tid:#{Thread.current.object_id}, " \
"fid:#{Fiber.current.object_id}, " \
"self:#{class_name}##{object_id}) "
message << msg.call << "\n"
message = "\e[#{COLORS[color]}m#{message}\e[0m" if color && debug_stream.respond_to?(:isatty) && debug_stream.isatty
debug_stream << message

View File

@ -18,7 +18,7 @@ module HTTPX
# https://github.com/ruby/resolv/blob/095f1c003f6073730500f02acbdbc55f83d70987/lib/resolv.rb#L408
ip_address_families = begin
list = Socket.ip_address_list
if list.any? { |a| a.ipv6? && !a.ipv6_loopback? && !a.ipv6_linklocal? && !a.ipv6_unique_local? }
if list.any? { |a| a.ipv6? && !a.ipv6_loopback? && !a.ipv6_linklocal? }
[Socket::AF_INET6, Socket::AF_INET]
else
[Socket::AF_INET]
@ -103,7 +103,7 @@ module HTTPX
# :debug :: an object which log messages are written to (must respond to <tt><<</tt>)
# :debug_level :: the log level of messages (can be 1, 2, or 3).
# :debug_redact :: whether header/body payload should be redacted (defaults to <tt>false</tt>).
# :ssl :: a hash of options which can be set as params of OpenSSL::SSL::SSLContext (see HTTPX::IO::SSL)
# :ssl :: a hash of options which can be set as params of OpenSSL::SSL::SSLContext (see HTTPX::SSL)
# :http2_settings :: a hash of options to be passed to a HTTP2::Connection (ex: <tt>{ max_concurrent_streams: 2 }</tt>)
# :fallback_protocol :: version of HTTP protocol to use by default in the absence of protocol negotiation
# like ALPN (defaults to <tt>"http/1.1"</tt>)
@ -144,7 +144,16 @@ module HTTPX
#
# This list of options are enhanced with each loaded plugin, see the plugin docs for details.
def initialize(options = {})
do_initialize(options)
defaults = DEFAULT_OPTIONS.merge(options)
defaults.each do |k, v|
next if v.nil?
option_method_name = :"option_#{k}"
raise Error, "unknown option: #{k}" unless respond_to?(option_method_name)
value = __send__(option_method_name, v)
instance_variable_set(:"@#{k}", value)
end
freeze
end
@ -227,7 +236,7 @@ module HTTPX
%i[
request_class response_class headers_class request_body_class
response_body_class connection_class options_class
pool_class pool_options
pool_class
io fallback_protocol debug debug_redact resolver_class
compress_request_body decompress_response_body
persistent close_on_fork
@ -350,19 +359,6 @@ module HTTPX
private
def do_initialize(options = {})
defaults = DEFAULT_OPTIONS.merge(options)
defaults.each do |k, v|
next if v.nil?
option_method_name = :"option_#{k}"
raise Error, "unknown option: #{k}" unless respond_to?(option_method_name)
value = __send__(option_method_name, v)
instance_variable_set(:"@#{k}", value)
end
end
def access_option(obj, k, ivar_map)
case obj
when Hash

View File

@ -50,15 +50,6 @@ module HTTPX
end
end
module NativeResolverMethods
def transition(nextstate)
state = @state
val = super
meter_elapsed_time("Resolver::Native: #{state} -> #{nextstate}")
val
end
end
module InstanceMethods
def self.included(klass)
klass.prepend TrackTimeMethods
@ -69,13 +60,6 @@ module HTTPX
meter_elapsed_time("Session: initializing...")
super
meter_elapsed_time("Session: initialized!!!")
resolver_type = @options.resolver_class
resolver_type = Resolver.resolver_for(resolver_type)
return unless resolver_type <= Resolver::Native
resolver_type.prepend TrackTimeMethods
resolver_type.prepend NativeResolverMethods
@options = @options.merge(resolver_class: resolver_type)
end
def close(*)
@ -104,33 +88,6 @@ module HTTPX
end
end
module RequestMethods
def self.included(klass)
klass.prepend Loggable
klass.prepend TrackTimeMethods
super
end
def transition(nextstate)
prev_state = @state
super
meter_elapsed_time("Request##{object_id}[#{@verb} #{@uri}: #{prev_state}] -> #{@state}") if prev_state != @state
end
end
module ConnectionMethods
def self.included(klass)
klass.prepend TrackTimeMethods
super
end
def handle_transition(nextstate)
state = @state
super
meter_elapsed_time("Connection##{object_id}[#{@origin}]: #{state} -> #{nextstate}") if nextstate == @state
end
end
module PoolMethods
def self.included(klass)
klass.prepend Loggable
@ -138,12 +95,6 @@ module HTTPX
super
end
def checkout_connection(request_uri, options)
super.tap do |connection|
meter_elapsed_time("Pool##{object_id}: checked out connection for Connection##{connection.object_id}[#{connection.origin}]}")
end
end
def checkin_connection(connection)
super.tap do
meter_elapsed_time("Pool##{object_id}: checked in connection for Connection##{connection.object_id}[#{connection.origin}]}")

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module HTTPX
class HTTPProxyError < ConnectionError; end
class ProxyError < ConnectionError; end
module Plugins
#
@ -15,7 +15,8 @@ module HTTPX
# https://gitlab.com/os85/httpx/wikis/Proxy
#
module Proxy
Error = HTTPProxyError
class ProxyConnectionError < ProxyError; end
PROXY_ERRORS = [TimeoutError, IOError, SystemCallError, Error].freeze
class << self
@ -28,6 +29,12 @@ module HTTPX
def extra_options(options)
options.merge(supported_proxy_protocols: [])
end
def subplugins
{
retries: ProxyRetries,
}
end
end
class Parameters
@ -160,9 +167,9 @@ module HTTPX
next_proxy = proxy.uri
raise Error, "Failed to connect to proxy" unless next_proxy
raise ProxyError, "Failed to connect to proxy" unless next_proxy
raise Error,
raise ProxyError,
"#{next_proxy.scheme}: unsupported proxy protocol" unless options.supported_proxy_protocols.include?(next_proxy.scheme)
if (no_proxy = proxy.no_proxy)
@ -179,20 +186,28 @@ module HTTPX
private
def fetch_response(request, selector, options)
response = super
response = request.response # in case it goes wrong later
if response.is_a?(ErrorResponse) && proxy_error?(request, response, options)
options.proxy.shift
begin
response = super
# return last error response if no more proxies to try
return response if options.proxy.uri.nil?
if response.is_a?(ErrorResponse) && proxy_error?(request, response, options)
options.proxy.shift
log { "failed connecting to proxy, trying next..." }
request.transition(:idle)
send_request(request, selector, options)
return
# return last error response if no more proxies to try
return response if options.proxy.uri.nil?
log { "failed connecting to proxy, trying next..." }
request.transition(:idle)
send_request(request, selector, options)
return
end
response
rescue ProxyError
# may happen if coupled with retries, and there are no more proxies to try, in which case
# it'll end up here
response
end
response
end
def proxy_error?(_request, response, options)
@ -211,7 +226,7 @@ module HTTPX
proxy_uri = URI(options.proxy.uri)
error.message.end_with?(proxy_uri.to_s)
when *PROXY_ERRORS
when ProxyConnectionError
# timeout errors connecting to proxy
true
else
@ -251,6 +266,14 @@ module HTTPX
when :connecting
consume
end
rescue *PROXY_ERRORS => e
if connecting?
error = ProxyConnectionError.new(e.message)
error.set_backtrace(e.backtrace)
raise error
end
raise e
end
def reset
@ -292,13 +315,29 @@ module HTTPX
end
super
end
def purge_after_closed
super
@io = @io.proxy_io if @io.respond_to?(:proxy_io)
end
end
module ProxyRetries
module InstanceMethods
def retryable_error?(ex)
super || ex.is_a?(ProxyConnectionError)
end
end
end
end
register_plugin :proxy, Proxy
end
class ProxySSL < SSL
attr_reader :proxy_io
def initialize(tcp, request_uri, options)
@proxy_io = tcp
@io = tcp.to_io
super(request_uri, tcp.addresses, options)
@hostname = request_uri.host

View File

@ -4,7 +4,7 @@ require "resolv"
require "ipaddr"
module HTTPX
class Socks4Error < HTTPProxyError; end
class Socks4Error < ProxyError; end
module Plugins
module Proxy

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module HTTPX
class Socks5Error < HTTPProxyError; end
class Socks5Error < ProxyError; end
module Plugins
module Proxy

View File

@ -81,7 +81,7 @@ module HTTPX::Plugins
response.body.rewind
::IO.copy_stream(response.body, f)
IO.copy_stream(response.body, f)
end
end
@ -131,7 +131,7 @@ module HTTPX::Plugins
response.original_request = original_request
response.finish!
::IO.copy_stream(f, response.body)
IO.copy_stream(f, response.body)
response
end

View File

@ -119,7 +119,7 @@ module HTTPX
class Signal
def initialize
@closed = false
@pipe_read, @pipe_write = ::IO.pipe
@pipe_read, @pipe_write = IO.pipe
end
def state
@ -127,7 +127,7 @@ module HTTPX
end
# noop
def log(**); end
def log(**, &_); end
def to_io
@pipe_read.to_io

View File

@ -65,6 +65,12 @@ module HTTPX
module ConnectionMethods
attr_reader :upgrade_protocol, :hijacked
def initialize(*)
super
@upgrade_protocol = nil
end
def hijack_io
@hijacked = true

View File

@ -8,6 +8,7 @@ module HTTPX
# as well as maintaining the state machine which manages streaming the request onto the wire.
class Request
extend Forwardable
include Loggable
include Callbacks
using URIExtensions
@ -293,6 +294,7 @@ module HTTPX
return if @state == :expect
end
log(level: 3) { "#{@state}] -> #{nextstate}" }
@state = nextstate
emit(@state, self)
nil

View File

@ -56,7 +56,7 @@ module HTTPX
block.call(chunk)
end
# TODO: use copy_stream once bug is resolved: https://bugs.ruby-lang.org/issues/21131
# ::IO.copy_stream(body, ProcIO.new(block))
# IO.copy_stream(body, ProcIO.new(block))
elsif body.respond_to?(:each)
body.each(&block)
else

View File

@ -83,6 +83,18 @@ module HTTPX
end
end
def cached_lookup_evict(hostname, ip)
ip = ip.to_s
lookup_synchronize do |lookups|
entries = lookups[hostname]
return unless entries
lookups.delete_if { |entry| entry["data"] == ip }
end
end
# do not use directly!
def lookup(hostname, lookups, ttl)
return unless lookups.key?(hostname)

View File

@ -35,6 +35,10 @@ module HTTPX
@resolvers.each { |r| r.__send__(__method__, s) }
end
def log(*args, **kwargs, &blk)
@resolvers.each { |r| r.__send__(__method__, *args, **kwargs, &blk) }
end
def closed?
@resolvers.all?(&:closed?)
end

View File

@ -480,6 +480,7 @@ module HTTPX
@write_buffer.clear
@read_buffer.clear
end
log(level: 3) { "#{@state} -> #{nextstate}" }
@state = nextstate
rescue Errno::ECONNREFUSED,
Errno::EADDRNOTAVAIL,

View File

@ -127,7 +127,7 @@ module HTTPX
when :open
return unless @state == :idle
@pipe_read, @pipe_write = ::IO.pipe
@pipe_read, @pipe_write = IO.pipe
when :closed
return unless @state == :open

View File

@ -136,7 +136,7 @@ module HTTPX
if dest.respond_to?(:path) && @buffer.respond_to?(:path)
FileUtils.mv(@buffer.path, dest.path)
else
::IO.copy_stream(@buffer, dest)
IO.copy_stream(@buffer, dest)
end
end

View File

@ -110,7 +110,7 @@ module HTTPX
if aux
aux.rewind
::IO.copy_stream(aux, @buffer)
IO.copy_stream(aux, @buffer)
aux.close
end

View File

@ -63,7 +63,10 @@ module HTTPX
# array may change during iteration
selectables = @selectables.reject(&:inflight?)
selectables.each(&:terminate)
selectables.delete_if do |sel|
sel.terminate
sel.state == :closed
end
until selectables.empty?
next_tick

View File

@ -136,6 +136,9 @@ module HTTPX
alias_method :select_resolver, :select_connection
def deselect_connection(connection, selector, cloned = false)
connection.log(level: 2) do
"deregistering connection##{connection.object_id}(#{connection.state}) from selector##{selector.object_id}"
end
selector.deregister(connection)
# when connections coalesce
@ -145,14 +148,19 @@ module HTTPX
return if @closing && connection.state == :closed
connection.log(level: 2) { "check-in connection##{connection.object_id}(#{connection.state}) in pool##{@pool.object_id}" }
@pool.checkin_connection(connection)
end
def deselect_resolver(resolver, selector)
resolver.log(level: 2) do
"deregistering resolver##{resolver.object_id}(#{resolver.state}) from selector##{selector.object_id}"
end
selector.deregister(resolver)
return if @closing && resolver.closed?
resolver.log(level: 2) { "check-in resolver##{resolver.object_id}(#{resolver.state}) in pool##{@pool.object_id}" }
@pool.checkin_resolver(resolver)
end
@ -221,7 +229,11 @@ module HTTPX
def fetch_response(request, _selector, _options)
response = request.response
response if response && response.finished?
return unless response && response.finished?
log(level: 2) { "response fetched" }
response
end
# sends the +request+ to the corresponding HTTPX::Connection
@ -303,8 +315,7 @@ module HTTPX
# returns the array of HTTPX::Response objects corresponding to the array of HTTPX::Request +requests+.
def receive_requests(requests, selector)
# @type var responses: Array[response]
responses = []
responses = [] # : Array[response]
# guarantee ordered responses
loop do
@ -326,12 +337,30 @@ module HTTPX
# handshake error, and the error responses have already been emitted, but there was no
# opportunity to traverse the requests, hence we're returning only a fraction of the errors
# we were supposed to. This effectively fetches the existing responses and return them.
while (request = requests.shift)
response = fetch_response(request, selector, request.options)
request.emit(:complete, response) if response
responses << response
exit_from_loop = true
requests_to_remove = [] # : Array[Request]
requests.each do |req|
response = fetch_response(req, selector, request.options)
if exit_from_loop && response
req.emit(:complete, response)
responses << response
requests_to_remove << req
else
# fetch_response may resend requests. when that happens, we need to go back to the initial
# loop and process the selector. we still do a pass-through on the remainder of requests, so
# that every request that need to be resent, is resent.
exit_from_loop = false
raise Error, "something went wrong, responses not found and requests not resent" if selector.empty?
end
end
break
break if exit_from_loop
requests -= requests_to_remove
end
responses
end
@ -380,14 +409,16 @@ module HTTPX
end
def find_resolver_for(connection, selector)
resolver = selector.find_resolver(connection.options)
unless resolver
resolver = @pool.checkout_resolver(connection.options)
resolver.current_session = self
resolver.current_selector = selector
if (resolver = selector.find_resolver(connection.options))
resolver.log(level: 2) { "found resolver##{connection.object_id}(#{connection.state}) in selector##{selector.object_id}" }
return resolver
end
resolver = @pool.checkout_resolver(connection.options)
resolver.log(level: 2) { "found resolver##{connection.object_id}(#{connection.state}) in pool##{@pool.object_id}" }
resolver.current_session = self
resolver.current_selector = selector
resolver
end
@ -397,7 +428,10 @@ module HTTPX
unless conn1.coalescable?(conn2)
conn2.log(level: 2) { "not coalescing with conn##{conn1.object_id}[#{conn1.origin}])" }
select_connection(conn2, selector)
@pool.checkin_connection(conn1) if from_pool
if from_pool
conn1.log(level: 2) { "check-in connection##{conn1.object_id}(#{conn1.state}) in pool##{@pool.object_id}" }
@pool.checkin_connection(conn1)
end
return false
end

View File

@ -44,7 +44,7 @@ module HTTPX
Open3.popen3(*%w[file --mime-type --brief -]) do |stdin, stdout, stderr, thread|
begin
::IO.copy_stream(file, stdin.binmode)
IO.copy_stream(file, stdin.binmode)
rescue Errno::EPIPE
end
file.rewind

View File

@ -63,7 +63,7 @@ module HTTPX
buffer = Response::Buffer.new(
threshold_size: Options::MAX_BODY_THRESHOLD_SIZE
)
::IO.copy_stream(self, buffer)
IO.copy_stream(self, buffer)
buffer.rewind if buffer.respond_to?(:rewind)

View File

@ -1,5 +1,5 @@
# frozen_string_literal: true
module HTTPX
VERSION = "1.5.0"
VERSION = "1.5.1"
end

View File

@ -36,4 +36,5 @@ class Bug_0_22_2_Test < Minitest::Test
assert connection_ipv4.family == Socket::AF_INET
assert connection_ipv6.family == Socket::AF_INET6
end
end if HTTPX::Session.default_options.ip_families.size > 1
# TODO: remove this once gitlab docker allows TCP connectivity alongside DNS
end unless ENV.key?("CI")

View File

@ -11,7 +11,7 @@ class Bug_1_1_0_Test < Minitest::Test
include HTTPHelpers
def test_read_timeout_firing_too_soon_before_select
timeout = { read_timeout: 1 }
timeout = { read_timeout: 2 }
uri = build_uri("/get")

View File

@ -6,7 +6,7 @@ require "support/http_helpers"
class Bug_1_1_1_Test < Minitest::Test
include HTTPHelpers
def test_conection_callbacks_fire_setup_once
def test_connection_callbacks_fire_setup_once
uri = build_uri("/get")
connected = 0

View File

@ -0,0 +1,80 @@
# frozen_string_literal: true
require "test_helper"
require "support/http_helpers"
require "webmock/minitest"
require "httpx/adapters/webmock"
class Bug_1_5_0_Test < Minitest::Test
include HTTPHelpers
def test_persistent_do_not_exhaust_retry_on_eof_error
start_test_servlet(KeepAlivePongThenGoawayServer) do |server|
persistent_session = HTTPX.plugin(SessionWithPool)
.plugin(:persistent)
.with(ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE })
uri = "#{server.origin}/"
# artificially create two connections
responses = 2.times.map do
Thread.new do
Thread.current.abort_on_exception = true
Thread.current.report_on_exception = true
persistent_session.get(uri)
end
end.map(&:value)
responses.each do |response|
verify_status(response, 200)
end
conns1 = persistent_session.connections
assert conns1.size == 2, "should have started two different connections to the same origin"
assert conns1.none? { |c| c.state == :closed }, "all connections should have been open"
sleep(2)
response = persistent_session.get(uri)
verify_status(response, 200) # should not raise GoAwayError
conns2 = persistent_session.connections
assert conns2.size == 2
assert conns2.count { |c| c.state == :closed } == 1, "one of them should have been closed"
ensure
persistent_session.close
end
end
end
class OnPingDisconnectServer < TestHTTP2Server
module GoAwayOnFirstPing
attr_accessor :num_requests
def activate_stream(*, **)
super.tap do
@num_requests += 1
end
end
def ping_management(*)
if @num_requests == 1
@num_requests = 0
goaway
else
super
end
end
end
def initialize(*)
super
@num_requests = Hash.new(0)
end
private
def handle_connection(conn, _)
super
conn.extend(GoAwayOnFirstPing)
conn.num_requests = 0
end
end

View File

@ -8,7 +8,7 @@ module HTTPX
def once: (Symbol) { (*untyped) -> void } -> ^(*untyped) -> void
def emit: (Symbol, *untyped) -> void
def callbacks_for?: (Symbol) -> bool
def callbacks_for?: (Symbol) -> boolish
def callbacks: () -> Hash[Symbol, Array[_Callable]]
| (Symbol) -> Array[_Callable]
end

View File

@ -82,7 +82,7 @@ module HTTPX
def interests: () -> io_interests?
def to_io: () -> ::IO
def to_io: () -> IO
def call: () -> void

View File

@ -1,6 +1,3 @@
module HTTPX
type io_type = "udp" | "tcp" | "ssl" | "unix"
module IO
end
end

View File

@ -14,18 +14,24 @@ module HTTPX
alias host ip
@io: Socket
@hostname: String
@options: Options
@fallback_protocol: String
@keep_open: bool
@ip_index: Integer
# TODO: lift when https://github.com/ruby/rbs/issues/1497 fixed
def initialize: (URI::Generic origin, Array[ipaddr]? addresses, Options options) ?{ (instance) -> void } -> void
def add_addresses: (Array[ipaddr] addrs) -> void
def to_io: () -> ::IO
def to_io: () -> IO
def protocol: () -> String

View File

@ -4,7 +4,7 @@ module HTTPX
def initialize: (String ip, Integer port, Options options) -> void
def to_io: () -> ::IO
def to_io: () -> IO
def connect: () -> void

View File

@ -140,8 +140,6 @@ module HTTPX
def initialize: (?options options) -> void
def do_initialize: (?options options) -> void
def access_option: (Hash[Symbol, untyped] | Object | nil obj, Symbol k, Hash[Symbol, Symbol]? ivar_map) -> untyped
end

View File

@ -1,8 +1,9 @@
module HTTPX
class HTTPProxyError < ConnectionError
class ProxyError < ConnectionError
end
class ProxySSL < SSL
attr_reader proxy_io: TCP | SSL
end
module Plugins
@ -11,7 +12,9 @@ module HTTPX
end
module Proxy
Error: singleton(HTTPProxyError)
class ProxyConnectionError < ProxyError
end
PROXY_ERRORS: Array[singleton(StandardError)]
class Parameters

View File

@ -1,5 +1,5 @@
module HTTPX
class Socks4Error < HTTPProxyError
class Socks4Error < ProxyError
end
module Plugins

View File

@ -1,5 +1,5 @@
module HTTPX
class Socks5Error < HTTPProxyError
class Socks5Error < ProxyError
end
module Plugins

View File

@ -25,8 +25,8 @@ module HTTPX
class Signal
@closed: bool
@pipe_read: ::IO
@pipe_write: ::IO
@pipe_read: IO
@pipe_write: IO
include _Selectable

View File

@ -1,6 +1,7 @@
module HTTPX
class Request
extend Forwardable
include Loggable
include Callbacks
METHODS: Array[Symbol]

View File

@ -32,6 +32,8 @@ module HTTPX
def self?.cached_lookup_set: (String hostname, ip_family family, Array[dns_result] addresses) -> void
def self?.cached_lookup_evict: (String hostname, ipaddr ip) -> void
def self?.lookup: (String hostname, Hash[String, Array[dns_result]] lookups, Numeric ttl) -> Array[IPAddr]?
def self?.generate_id: () -> Integer

View File

@ -11,8 +11,8 @@ module HTTPX
@queries: Array[[ip_family, Connection]]
@ips: Array[[ip_family, Connection, (Array[Addrinfo] | StandardError)]]
@pipe_mutex: Thread::Mutex
@pipe_read: ::IO
@pipe_write: ::IO
@pipe_read: IO
@pipe_write: IO
attr_reader state: Symbol

View File

@ -2,7 +2,7 @@ module HTTPX
interface _Selectable
def state: () -> Symbol
def to_io: () -> ::IO
def to_io: () -> IO
def call: () -> void

View File

@ -90,7 +90,7 @@ module HTTPX
module MimeTypeDetector
DEFAULT_MIMETYPE: String
def self?.call: (::IO | Tempfile file, String filename) -> String?
def self?.call: (IO | Tempfile file, String filename) -> String?
end
end
end

View File

@ -115,11 +115,11 @@ class HTTPSTest < Minitest::Test
# HTTP/2-specific tests
{
http1: { uri: "https://httpbin.org/get", ssl: { alpn_protocols: %w[http/1.1] } },
http1: { uri: "https://aws:4566", ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE, alpn_protocols: %w[http/1.1] } },
http2: {},
}.each do |proto, proto_options|
define_method :"test_multiple_get_max_requests_#{proto}" do
uri = proto_options.delete(:uri) || URI(build_uri("/get"))
uri = proto_options.delete(:uri) || URI(build_uri("/"))
options = { max_requests: 2, **proto_options }
HTTPX.plugin(SessionWithPool).with(options).wrap do |http|
@ -132,7 +132,9 @@ class HTTPSTest < Minitest::Test
verify_body_length(response3)
connection_count = http.connection_count
assert connection_count == 2, "expected to have 2 connections, instead have #{connection_count}"
assert http.connections.size == 1, "expected connection to have been reused on exhaustion"
http.connections.tally(&:family).each_value do |count|
assert count == 1, "expected connection to have been reused on exhaustion"
end
# ssl session ought to be reused
conn = http.connections.first

View File

@ -31,7 +31,7 @@ class ProxyTest < Minitest::Test
def test_proxy_unsupported_scheme
res = HTTPX.plugin(:proxy).with_proxy(uri: "https://proxy:123").get("http://smth.com")
verify_error_response(res, HTTPX::HTTPProxyError)
verify_error_response(res, HTTPX::ProxyError)
verify_error_response(res, "https: unsupported proxy protocol")
end

View File

@ -1,8 +1,9 @@
FROM alpine:3.10.2
FROM alpine:3.22.1
RUN \
NGHTTP2_VERSION='1.46.0' \
BUILD_DEPS='build-base wget' \
RUN_DEPS='ca-certificates libstdc++ openssl-dev libev-dev zlib-dev jansson-dev libxml2-dev c-ares-dev' \
RUN_DEPS='ca-certificates libstdc++ openssl-dev libev-dev zlib-dev jansson-dev libxml2-dev c-ares-dev brotli-dev' \
&& apk --no-cache add $BUILD_DEPS $RUN_DEPS \
&& cd /tmp \
&& wget -qO- "https://github.com/tatsuhiro-t/nghttp2/releases/download/v${NGHTTP2_VERSION}/nghttp2-${NGHTTP2_VERSION}.tar.gz" | tar -xz \

View File

@ -136,5 +136,10 @@ module Requests
end
end
end
def test_callbacks_can_compose_with
http = HTTPX.plugin(:callbacks).with(persistent: true)
assert http.instance_variable_get(:@persistent)
end
end
end

View File

@ -5,7 +5,7 @@ module Requests
module Brotli
def test_brotli
session = HTTPX.plugin(:brotli)
response = session.get("http://httpbin.org/brotli")
response = session.get("http://nghttp2.org/httpbin/brotli")
verify_status(response, 200)
body = json_body(response)
assert body["brotli"], "response should be deflated"

View File

@ -99,6 +99,30 @@ module Requests
http.close
end
end unless RUBY_ENGINE == "jruby"
def test_persistent_proxy_retry_http2_goaway
return unless origin.start_with?("https")
start_test_servlet(KeepAlivePongThenGoawayServer) do |server|
start_test_servlet(ProxyServer) do |proxy|
http = HTTPX.plugin(SessionWithPool)
.plugin(RequestInspector)
.plugin(:persistent) # implicit max_retries == 1
.plugin(:proxy)
.with(
proxy: { uri: proxy.origin },
ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE }
)
uri = "#{server.origin}/"
response = http.get(uri)
verify_status(response, 200)
response = http.get(uri)
verify_status(response, 200)
assert http.calls == 2, "expect request to be built 2 times (was #{http.calls})"
http.close
end
end
end unless RUBY_ENGINE == "jruby"
end
end
end

View File

@ -14,7 +14,7 @@ module Requests
http = HTTPX.plugin(:proxy)
uri = build_uri("/get")
res = http.with_proxy(uri: []).get(uri)
verify_error_response(res, HTTPX::HTTPProxyError)
verify_error_response(res, HTTPX::ProxyError)
end
def test_plugin_http_http_proxy

View File

@ -4,7 +4,7 @@ module Requests
module Plugins
module SsrfFilter
def test_plugin_ssrf_filter_allows
uri = "#{scheme}httpbin.org"
uri = "#{scheme}nghttp2.org"
session = HTTPX.plugin(:ssrf_filter)
response = session.get(uri)

View File

@ -6,28 +6,30 @@ module Requests
def test_plugin_upgrade_h2
return unless origin.start_with?("https://")
http = HTTPX.plugin(SessionWithPool)
start_test_servlet(H2Upgrade, alpn_protocols: %w[http/1.1 h2]) do |server|
http = HTTPX.plugin(SessionWithPool)
http = http.with(ssl: { alpn_protocols: %w[http/1.1] }) # disable alpn negotiation
http = http.with(ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE, alpn_protocols: %w[http/1.1] }) # disable alpn negotiation
http.plugin(:upgrade).wrap do |session|
uri = build_uri("/", "https://stadtschreiber.ruhr")
http.plugin(:upgrade).wrap do |session|
uri = "#{server.origin}/"
request = session.build_request("GET", uri)
request2 = session.build_request("GET", uri)
request = session.build_request("GET", uri)
request2 = session.build_request("GET", uri)
response = session.request(request)
verify_status(response, 200)
assert response.version == "1.1", "first request should be in HTTP/1.1"
response.close
# verifies that first request was used to upgrade the connection
verify_header(response.headers, "upgrade", "h2,h2c")
response2 = session.request(request2)
verify_status(response2, 200)
assert response2.version == "2.0", "second request should already be in HTTP/2"
response2.close
response = session.request(request)
verify_status(response, 200)
assert response.version == "1.1", "first request should be in HTTP/1.1"
response.close
# verifies that first request was used to upgrade the connection
verify_header(response.headers, "upgrade", "h2")
response2 = session.request(request2)
verify_status(response2, 200)
assert response2.version == "2.0", "second request should already be in HTTP/2"
response2.close
end
end
end
end unless RUBY_ENGINE == "jruby"
def test_plugin_upgrade_websockets
return unless origin.start_with?("http://")

View File

@ -33,20 +33,20 @@ class KeepAlivePongThenGoawayServer < TestHTTP2Server
attr_reader :pings, :pongs
def initialize(**)
@sent = false
super
@sent = Hash.new(false)
super()
end
private
def handle_stream(conn, stream)
# responds once, then closes the connection
if @sent
if @sent[conn]
conn.goaway
@sent = false
@sent[conn] = false
else
super
@sent = true
@sent[conn] = true
end
end
end

View File

@ -23,7 +23,7 @@ end
class TestHTTP2Server
attr_reader :origin
def initialize(tls: true)
def initialize(tls: true, alpn_protocols: %w[h2])
@port = 0
@host = "localhost"
@ -44,12 +44,12 @@ class TestHTTP2Server
ctx.key = OpenSSL::PKey.read(File.read(File.join(certs_dir, "server.key")))
ctx.ssl_version = :TLSv1_2
ctx.alpn_protocols = ["h2"]
ctx.alpn_protocols = alpn_protocols
ctx.alpn_select_cb = lambda do |protocols|
raise "Protocol h2 is required" unless protocols.include?("h2")
raise "Protocol h2 is required" if (alpn_protocols & protocols).empty?
"h2"
protocols.first
end
@server = OpenSSL::SSL::SSLServer.new(@server, ctx)
@ -125,7 +125,7 @@ class TestHTTP2Server
close_socket(sock)
return
else
@conns[sock] << data
buffer_to_socket(sock, data)
if sock.closed?
purge_socket(sock)
@ -139,6 +139,10 @@ class TestHTTP2Server
close_socket(sock)
end
def buffer_to_socket(sock, data)
@conns[sock] << data
end
def handle_stream(_conn, stream)
stream.on(:half_close) do
response = "OK"

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class H2Upgrade < TestHTTP2Server
def buffer_to_socket(sock, data)
return super unless @conns[sock].state == :waiting_magic &&
!data.start_with?("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
# assume HTTP/1.1
sock << "HTTP/1.1 200 OK\r\n" \
"Content-Length: 2\r\n" \
"Content-Type: text/plain\r\n" \
"Connection: Upgrade\r\n" \
"Upgrade: h2\r\n\r\n" \
"OK"
sock.close
end
end