loading
Generated 2023-12-05T13:41:37+00:00

All Files ( 96.18% covered at 14247.51 hits/line )

101 files in total.
6734 relevant lines, 6477 lines covered and 257 lines missed. ( 96.18% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/httpx.rb 100.00 % 67 39 39 0 425.31
lib/httpx/adapters/datadog.rb 83.46 % 253 127 106 21 21.94
lib/httpx/adapters/faraday.rb 98.11 % 294 159 156 3 47.48
lib/httpx/adapters/sentry.rb 100.00 % 121 62 62 0 22.45
lib/httpx/adapters/webmock.rb 100.00 % 158 84 84 0 62.43
lib/httpx/altsvc.rb 96.39 % 163 83 80 3 146.66
lib/httpx/buffer.rb 100.00 % 42 21 21 0 79829.67
lib/httpx/callbacks.rb 100.00 % 40 22 22 0 83244.23
lib/httpx/chainable.rb 95.12 % 96 41 39 2 664.73
lib/httpx/connection.rb 96.10 % 714 359 345 14 36936.29
lib/httpx/connection/http1.rb 90.91 % 394 220 200 20 2608.68
lib/httpx/connection/http2.rb 95.92 % 404 245 235 10 47098.46
lib/httpx/domain_name.rb 95.45 % 145 44 42 2 157.48
lib/httpx/errors.rb 97.56 % 107 41 40 1 54.95
lib/httpx/extensions.rb 67.86 % 59 28 19 9 569.54
lib/httpx/headers.rb 100.00 % 175 71 71 0 12297.86
lib/httpx/io.rb 100.00 % 11 5 5 0 20.00
lib/httpx/io/ssl.rb 96.20 % 162 79 76 3 1356.43
lib/httpx/io/tcp.rb 90.27 % 205 113 102 11 4369.52
lib/httpx/io/udp.rb 100.00 % 62 35 35 0 297.23
lib/httpx/io/unix.rb 96.97 % 68 33 32 1 13.70
lib/httpx/loggable.rb 100.00 % 34 14 14 0 16718.43
lib/httpx/options.rb 92.64 % 375 163 151 12 9097.76
lib/httpx/parser/http1.rb 100.00 % 182 109 109 0 4773.97
lib/httpx/plugins/auth.rb 100.00 % 25 9 9 0 15.00
lib/httpx/plugins/auth/basic.rb 100.00 % 20 10 10 0 53.50
lib/httpx/plugins/auth/digest.rb 100.00 % 101 56 56 0 87.20
lib/httpx/plugins/auth/ntlm.rb 100.00 % 35 19 19 0 3.47
lib/httpx/plugins/auth/socks5.rb 100.00 % 22 11 11 0 15.36
lib/httpx/plugins/aws_sdk_authentication.rb 100.00 % 106 43 43 0 8.26
lib/httpx/plugins/aws_sigv4.rb 100.00 % 217 99 99 0 69.74
lib/httpx/plugins/basic_auth.rb 100.00 % 29 12 12 0 22.08
lib/httpx/plugins/brotli.rb 100.00 % 50 25 25 0 7.20
lib/httpx/plugins/callbacks.rb 100.00 % 91 41 41 0 94.49
lib/httpx/plugins/circuit_breaker.rb 96.97 % 138 66 64 2 44.61
lib/httpx/plugins/circuit_breaker/circuit.rb 100.00 % 100 47 47 0 37.21
lib/httpx/plugins/circuit_breaker/circuit_store.rb 100.00 % 53 23 23 0 61.74
lib/httpx/plugins/cookies.rb 100.00 % 104 50 50 0 75.74
lib/httpx/plugins/cookies/cookie.rb 100.00 % 174 76 76 0 201.93
lib/httpx/plugins/cookies/jar.rb 100.00 % 97 47 47 0 165.94
lib/httpx/plugins/cookies/set_cookie_parser.rb 100.00 % 140 70 70 0 100.06
lib/httpx/plugins/digest_auth.rb 100.00 % 62 29 29 0 62.41
lib/httpx/plugins/expect.rb 100.00 % 112 55 55 0 52.60
lib/httpx/plugins/follow_redirects.rb 100.00 % 173 82 82 0 22177.32
lib/httpx/plugins/grpc.rb 100.00 % 279 133 133 0 78.53
lib/httpx/plugins/grpc/call.rb 90.91 % 63 33 30 3 26.79
lib/httpx/plugins/grpc/grpc_encoding.rb 97.83 % 88 46 45 1 49.48
lib/httpx/plugins/grpc/message.rb 95.83 % 55 24 23 1 26.33
lib/httpx/plugins/h2c.rb 97.92 % 96 48 47 1 8.54
lib/httpx/plugins/ntlm_auth.rb 100.00 % 60 30 30 0 4.40
lib/httpx/plugins/oauth.rb 89.29 % 170 84 75 9 29.52
lib/httpx/plugins/persistent.rb 100.00 % 36 11 11 0 97.64
lib/httpx/plugins/proxy.rb 94.27 % 317 157 148 9 217.75
lib/httpx/plugins/proxy/http.rb 100.00 % 179 99 99 0 134.81
lib/httpx/plugins/proxy/socks4.rb 98.72 % 135 78 77 1 123.44
lib/httpx/plugins/proxy/socks5.rb 100.00 % 194 112 112 0 183.79
lib/httpx/plugins/proxy/ssh.rb 92.31 % 92 52 48 4 5.46
lib/httpx/plugins/push_promise.rb 100.00 % 81 41 41 0 6.59
lib/httpx/plugins/rate_limiter.rb 100.00 % 53 16 16 0 26.56
lib/httpx/plugins/response_cache.rb 100.00 % 178 78 78 0 48.41
lib/httpx/plugins/response_cache/store.rb 100.00 % 93 47 47 0 72.45
lib/httpx/plugins/retries.rb 96.67 % 198 90 87 3 38698.18
lib/httpx/plugins/ssrf_filter.rb 95.00 % 141 60 57 3 69.62
lib/httpx/plugins/stream.rb 100.00 % 148 70 70 0 74.81
lib/httpx/plugins/upgrade.rb 100.00 % 83 37 37 0 28.11
lib/httpx/plugins/upgrade/h2.rb 91.67 % 54 24 22 2 5.21
lib/httpx/plugins/webdav.rb 100.00 % 80 35 35 0 14.06
lib/httpx/pmatch_extensions.rb 100.00 % 33 17 17 0 19.29
lib/httpx/pool.rb 82.61 % 290 161 133 28 61650.20
lib/httpx/punycode.rb 100.00 % 22 9 9 0 13.56
lib/httpx/request.rb 99.09 % 252 110 109 1 3234.90
lib/httpx/request/body.rb 100.00 % 158 72 72 0 1861.99
lib/httpx/resolver.rb 100.00 % 154 77 77 0 873.58
lib/httpx/resolver/https.rb 86.99 % 246 146 127 19 19.77
lib/httpx/resolver/multi.rb 100.00 % 75 40 40 0 82296.65
lib/httpx/resolver/native.rb 94.16 % 441 257 242 15 13815.92
lib/httpx/resolver/resolver.rb 90.00 % 120 60 54 6 637.33
lib/httpx/resolver/system.rb 92.68 % 214 123 114 9 16.23
lib/httpx/response.rb 100.00 % 267 103 103 0 1126.68
lib/httpx/response/body.rb 100.00 % 239 108 108 0 1628.45
lib/httpx/response/buffer.rb 100.00 % 96 49 49 0 1113.63
lib/httpx/selector.rb 86.15 % 138 65 56 9 179108.71
lib/httpx/session.rb 96.45 % 359 169 163 6 31022.88
lib/httpx/session_extensions.rb 100.00 % 29 14 14 0 5.14
lib/httpx/timers.rb 96.55 % 114 58 56 2 340732.43
lib/httpx/transcoder.rb 100.00 % 92 53 53 0 170.81
lib/httpx/transcoder/body.rb 100.00 % 57 32 32 0 503.75
lib/httpx/transcoder/chunker.rb 100.00 % 115 66 66 0 129.21
lib/httpx/transcoder/deflate.rb 100.00 % 37 20 20 0 17.50
lib/httpx/transcoder/form.rb 100.00 % 78 41 41 0 274.41
lib/httpx/transcoder/gzip.rb 100.00 % 74 43 43 0 59.72
lib/httpx/transcoder/json.rb 100.00 % 57 32 32 0 24.47
lib/httpx/transcoder/multipart.rb 100.00 % 17 10 10 0 567.40
lib/httpx/transcoder/multipart/decoder.rb 93.83 % 139 81 76 5 19.37
lib/httpx/transcoder/multipart/encoder.rb 100.00 % 110 65 65 0 1316.92
lib/httpx/transcoder/multipart/mime_type_detector.rb 91.89 % 78 37 34 3 108.86
lib/httpx/transcoder/multipart/part.rb 100.00 % 35 18 18 0 299.22
lib/httpx/transcoder/utils/body_reader.rb 96.00 % 46 25 24 1 62.52
lib/httpx/transcoder/utils/deflater.rb 100.00 % 72 36 36 0 56.61
lib/httpx/transcoder/xml.rb 92.31 % 52 26 24 2 38.08
lib/httpx/utils.rb 100.00 % 75 39 39 0 83180.82

lib/httpx.rb

100.0% lines covered

39 relevant lines. 39 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 require "httpx/version"
  3. 20 require "httpx/extensions"
  4. 20 require "httpx/errors"
  5. 20 require "httpx/utils"
  6. 20 require "httpx/punycode"
  7. 20 require "httpx/domain_name"
  8. 20 require "httpx/altsvc"
  9. 20 require "httpx/callbacks"
  10. 20 require "httpx/loggable"
  11. 20 require "httpx/transcoder"
  12. 20 require "httpx/timers"
  13. 20 require "httpx/pool"
  14. 20 require "httpx/headers"
  15. 20 require "httpx/request"
  16. 20 require "httpx/response"
  17. 20 require "httpx/options"
  18. 20 require "httpx/chainable"
  19. # Top-Level Namespace
  20. #
  21. 20 module HTTPX
  22. 20 EMPTY = [].freeze
  23. # All plugins should be stored under this module/namespace. Can register and load
  24. # plugins.
  25. #
  26. 20 module Plugins
  27. 20 @plugins = {}
  28. 20 @plugins_mutex = Thread::Mutex.new
  29. # Loads a plugin based on a name. If the plugin hasn't been loaded, tries to load
  30. # it from the load path under "httpx/plugins/" directory.
  31. #
  32. 20 def self.load_plugin(name)
  33. 3000 h = @plugins
  34. 3000 m = @plugins_mutex
  35. 6000 unless (plugin = m.synchronize { h[name] })
  36. 77 require "httpx/plugins/#{name}"
  37. 154 raise "Plugin #{name} hasn't been registered" unless (plugin = m.synchronize { h[name] })
  38. end
  39. 3000 plugin
  40. end
  41. # Registers a plugin (+mod+) in the central store indexed by +name+.
  42. #
  43. 20 def self.register_plugin(name, mod)
  44. 189 h = @plugins
  45. 189 m = @plugins_mutex
  46. 378 m.synchronize { h[name] = mod }
  47. end
  48. end
  49. 20 extend Chainable
  50. end
  51. 20 require "httpx/session"
  52. 20 require "httpx/session_extensions"
  53. # load integrations when possible
  54. 20 require "httpx/adapters/datadog" if defined?(DDTrace) || defined?(Datadog)
  55. 20 require "httpx/adapters/sentry" if defined?(Sentry)
  56. 20 require "httpx/adapters/webmock" if defined?(WebMock)

lib/httpx/adapters/datadog.rb

83.46% lines covered

127 relevant lines. 106 lines covered and 21 lines missed.
    
  1. # frozen_string_literal: true
  2. 4 require "datadog/tracing/contrib/integration"
  3. 4 require "datadog/tracing/contrib/configuration/settings"
  4. 4 require "datadog/tracing/contrib/patcher"
  5. 4 module Datadog::Tracing
  6. 4 module Contrib
  7. 4 module HTTPX
  8. 4 METADATA_MODULE = Datadog::Tracing::Metadata
  9. 4 TYPE_OUTBOUND = Datadog::Tracing::Metadata::Ext::HTTP::TYPE_OUTBOUND
  10. 4 TAG_PEER_SERVICE = Datadog::Tracing::Metadata::Ext::TAG_PEER_SERVICE
  11. 4 TAG_URL = Datadog::Tracing::Metadata::Ext::HTTP::TAG_URL
  12. 4 TAG_METHOD = Datadog::Tracing::Metadata::Ext::HTTP::TAG_METHOD
  13. 4 TAG_TARGET_HOST = Datadog::Tracing::Metadata::Ext::NET::TAG_TARGET_HOST
  14. 4 TAG_TARGET_PORT = Datadog::Tracing::Metadata::Ext::NET::TAG_TARGET_PORT
  15. 4 TAG_STATUS_CODE = Datadog::Tracing::Metadata::Ext::HTTP::TAG_STATUS_CODE
  16. # HTTPX Datadog Plugin
  17. #
  18. # Enables tracing for httpx requests. A span will be created for each individual requests,
  19. # and it'll trace since the moment it is fed to the connection, until the moment the response is
  20. # fed back to the session.
  21. #
  22. 4 module Plugin
  23. 4 class RequestTracer
  24. 4 include Contrib::HttpAnnotationHelper
  25. 4 SPAN_REQUEST = "httpx.request"
  26. 4 def initialize(request)
  27. 82 @request = request
  28. end
  29. 4 def call
  30. 82 return unless Datadog::Tracing.enabled?
  31. 82 @request.on(:response, &method(:finish))
  32. 82 verb = @request.verb
  33. 82 uri = @request.uri
  34. 82 @span = Datadog::Tracing.trace(
  35. SPAN_REQUEST,
  36. service: service_name(@request.uri.host, configuration, Datadog.configuration_for(self)),
  37. span_type: TYPE_OUTBOUND
  38. )
  39. 82 @span.resource = verb
  40. # Add additional request specific tags to the span.
  41. 82 @span.set_tag(TAG_URL, @request.path)
  42. 82 @span.set_tag(TAG_METHOD, verb)
  43. 82 @span.set_tag(TAG_TARGET_HOST, uri.host)
  44. 82 @span.set_tag(TAG_TARGET_PORT, uri.port.to_s)
  45. # Tag as an external peer service
  46. 82 @span.set_tag(TAG_PEER_SERVICE, @span.service)
  47. Datadog::Tracing::Propagation::HTTP.inject!(Datadog::Tracing.active_trace,
  48. 82 @request.headers) if @configuration[:distributed_tracing]
  49. # Set analytics sample rate
  50. 82 if Contrib::Analytics.enabled?(@configuration[:analytics_enabled])
  51. 8 Contrib::Analytics.set_sample_rate(@span, @configuration[:analytics_sample_rate])
  52. end
  53. rescue StandardError => e
  54. Datadog.logger.error("error preparing span for http request: #{e}")
  55. Datadog.logger.error(e.backtrace)
  56. end
  57. 4 def finish(response)
  58. 82 return unless @span
  59. 82 if response.is_a?(::HTTPX::ErrorResponse)
  60. 8 @span.set_error(response.error)
  61. else
  62. 74 @span.set_tag(TAG_STATUS_CODE, response.status.to_s)
  63. 74 @span.set_error(::HTTPX::HTTPError.new(response)) if response.status >= 400 && response.status <= 599
  64. end
  65. 82 @span.finish
  66. end
  67. 4 private
  68. 4 def configuration
  69. 82 @configuration ||= Datadog.configuration.tracing[:httpx, @request.uri.host]
  70. end
  71. end
  72. 4 module RequestMethods
  73. 4 def __datadog_enable_trace!
  74. 82 return if @__datadog_enable_trace
  75. 82 RequestTracer.new(self).call
  76. 82 @__datadog_enable_trace = true
  77. end
  78. end
  79. 4 module ConnectionMethods
  80. 4 def send(request)
  81. 82 request.__datadog_enable_trace!
  82. 82 super
  83. end
  84. end
  85. end
  86. 4 module Configuration
  87. # Default settings for httpx
  88. #
  89. 4 class Settings < Datadog::Tracing::Contrib::Configuration::Settings
  90. 4 DEFAULT_ERROR_HANDLER = lambda do |response|
  91. Datadog::Ext::HTTP::ERROR_RANGE.cover?(response.status)
  92. end
  93. 4 option :service_name, default: "httpx"
  94. 4 option :distributed_tracing, default: true
  95. 4 option :split_by_domain, default: false
  96. 4 if Gem::Version.new(DDTrace::VERSION::STRING) >= Gem::Version.new("1.13.0")
  97. 4 option :enabled do |o|
  98. 4 o.type :bool
  99. 4 o.env "DD_TRACE_HTTPX_ENABLED"
  100. 4 o.default true
  101. end
  102. 4 option :analytics_enabled do |o|
  103. 4 o.type :bool
  104. 4 o.env "DD_TRACE_HTTPX_ANALYTICS_ENABLED"
  105. 4 o.default false
  106. end
  107. 4 option :analytics_sample_rate do |o|
  108. 4 o.type :float
  109. 4 o.env "DD_TRACE_HTTPX_ANALYTICS_SAMPLE_RATE"
  110. 4 o.default 1.0
  111. end
  112. else
  113. option :enabled do |o|
  114. o.default { env_to_bool("DD_TRACE_HTTPX_ENABLED", true) }
  115. o.lazy
  116. end
  117. option :analytics_enabled do |o|
  118. o.default { env_to_bool(%w[DD_TRACE_HTTPX_ANALYTICS_ENABLED DD_HTTPX_ANALYTICS_ENABLED], false) }
  119. o.lazy
  120. end
  121. option :analytics_sample_rate do |o|
  122. o.default { env_to_float(%w[DD_TRACE_HTTPX_ANALYTICS_SAMPLE_RATE DD_HTTPX_ANALYTICS_SAMPLE_RATE], 1.0) }
  123. o.lazy
  124. end
  125. end
  126. 4 if defined?(Datadog::Tracing::Contrib::SpanAttributeSchema)
  127. 4 option :service_name do |o|
  128. 4 o.default do
  129. 44 Datadog::Tracing::Contrib::SpanAttributeSchema.fetch_service_name(
  130. "DD_TRACE_HTTPX_SERVICE_NAME",
  131. "httpx"
  132. )
  133. end
  134. 4 o.lazy
  135. end
  136. else
  137. option :service_name do |o|
  138. o.default do
  139. ENV.fetch("DD_TRACE_HTTPX_SERVICE_NAME", "httpx")
  140. end
  141. o.lazy
  142. end
  143. end
  144. 4 option :distributed_tracing, default: true
  145. 4 if Gem::Version.new(DDTrace::VERSION::STRING) >= Gem::Version.new("1.15.0")
  146. 4 option :error_handler do |o|
  147. 4 o.type :proc
  148. 4 o.default_proc(&DEFAULT_ERROR_HANDLER)
  149. end
  150. elsif Gem::Version.new(DDTrace::VERSION::STRING) >= Gem::Version.new("1.13.0")
  151. option :error_handler do |o|
  152. o.type :proc
  153. o.experimental_default_proc(&DEFAULT_ERROR_HANDLER)
  154. end
  155. else
  156. option :error_handler, default: DEFAULT_ERROR_HANDLER
  157. end
  158. end
  159. end
  160. # Patcher enables patching of 'httpx' with datadog components.
  161. #
  162. 4 module Patcher
  163. 4 include Datadog::Tracing::Contrib::Patcher
  164. 4 module_function
  165. 4 def target_version
  166. 8 Integration.version
  167. end
  168. # loads a session instannce with the datadog plugin, and replaces the
  169. # base HTTPX::Session with the patched session class.
  170. 4 def patch
  171. 4 datadog_session = ::HTTPX.plugin(Plugin)
  172. 4 ::HTTPX.send(:remove_const, :Session)
  173. 4 ::HTTPX.send(:const_set, :Session, datadog_session.class)
  174. end
  175. end
  176. # Datadog Integration for HTTPX.
  177. #
  178. 4 class Integration
  179. 4 include Contrib::Integration
  180. 4 MINIMUM_VERSION = Gem::Version.new("0.10.2")
  181. 4 register_as :httpx
  182. 4 def self.version
  183. 152 Gem.loaded_specs["httpx"] && Gem.loaded_specs["httpx"].version
  184. end
  185. 4 def self.loaded?
  186. 48 defined?(::HTTPX::Request)
  187. end
  188. 4 def self.compatible?
  189. 48 super && version >= MINIMUM_VERSION
  190. end
  191. 4 def new_configuration
  192. 52 Configuration::Settings.new
  193. end
  194. 4 def patcher
  195. 96 Patcher
  196. end
  197. end
  198. end
  199. end
  200. end

lib/httpx/adapters/faraday.rb

98.11% lines covered

159 relevant lines. 156 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 6 require "delegate"
  3. 6 require "httpx"
  4. 6 require "faraday"
  5. 6 module Faraday
  6. 6 class Adapter
  7. 6 class HTTPX < Faraday::Adapter
  8. 6 module RequestMixin
  9. 6 def build_connection(env)
  10. 136 return @connection if defined?(@connection)
  11. 136 @connection = ::HTTPX.plugin(:persistent).plugin(ReasonPlugin)
  12. 136 @connection = @connection.with(@connection_options) unless @connection_options.empty?
  13. 136 connection_opts = options_from_env(env)
  14. 136 if (bind = env.request.bind)
  15. 5 @bind = TCPSocket.new(bind[:host], bind[:port])
  16. 5 connection_opts[:io] = @bind
  17. end
  18. 136 @connection = @connection.with(connection_opts)
  19. 136 if (proxy = env.request.proxy)
  20. 5 proxy_options = { uri: proxy.uri }
  21. 5 proxy_options[:username] = proxy.user if proxy.user
  22. 5 proxy_options[:password] = proxy.password if proxy.password
  23. 5 @connection = @connection.plugin(:proxy).with(proxy: proxy_options)
  24. end
  25. 136 @connection = @connection.plugin(OnDataPlugin) if env.request.stream_response?
  26. 136 @connection
  27. end
  28. 6 def close
  29. 140 @connection.close if @connection
  30. 140 @bind.close if @bind
  31. end
  32. 6 private
  33. 6 def connect(env, &blk)
  34. 136 connection(env, &blk)
  35. rescue ::HTTPX::TLSError => e
  36. 5 raise Faraday::SSLError, e
  37. rescue Errno::ECONNABORTED,
  38. Errno::ECONNREFUSED,
  39. Errno::ECONNRESET,
  40. Errno::EHOSTUNREACH,
  41. Errno::EINVAL,
  42. Errno::ENETUNREACH,
  43. Errno::EPIPE,
  44. ::HTTPX::ConnectionError => e
  45. 5 raise Faraday::ConnectionFailed, e
  46. end
  47. 6 def build_request(env)
  48. 141 meth = env[:method]
  49. request_options = {
  50. 113 headers: env.request_headers,
  51. body: env.body,
  52. }
  53. 141 [meth.to_s.upcase, env.url, request_options]
  54. end
  55. 6 def options_from_env(env)
  56. 136 timeout_options = {}
  57. 136 req_opts = env.request
  58. 136 if (sec = request_timeout(:read, req_opts))
  59. 5 timeout_options[:read_timeout] = sec
  60. end
  61. 136 if (sec = request_timeout(:write, req_opts))
  62. 5 timeout_options[:write_timeout] = sec
  63. end
  64. 136 if (sec = request_timeout(:open, req_opts))
  65. 5 timeout_options[:connect_timeout] = sec
  66. end
  67. {
  68. 135 ssl: ssl_options_from_env(env),
  69. timeout: timeout_options,
  70. }
  71. end
  72. 6 if defined?(::OpenSSL)
  73. 6 def ssl_options_from_env(env)
  74. 136 ssl_options = {}
  75. 136 unless env.ssl.verify.nil?
  76. 10 ssl_options[:verify_mode] = env.ssl.verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
  77. end
  78. 136 ssl_options[:ca_file] = env.ssl.ca_file if env.ssl.ca_file
  79. 136 ssl_options[:ca_path] = env.ssl.ca_path if env.ssl.ca_path
  80. 136 ssl_options[:cert_store] = env.ssl.cert_store if env.ssl.cert_store
  81. 136 ssl_options[:cert] = env.ssl.client_cert if env.ssl.client_cert
  82. 136 ssl_options[:key] = env.ssl.client_key if env.ssl.client_key
  83. 136 ssl_options[:ssl_version] = env.ssl.version if env.ssl.version
  84. 136 ssl_options[:verify_depth] = env.ssl.verify_depth if env.ssl.verify_depth
  85. 136 ssl_options[:min_version] = env.ssl.min_version if env.ssl.min_version
  86. 136 ssl_options[:max_version] = env.ssl.max_version if env.ssl.max_version
  87. 136 ssl_options
  88. end
  89. else
  90. def ssl_options_from_env(*)
  91. {}
  92. end
  93. end
  94. end
  95. 6 include RequestMixin
  96. 6 module OnDataPlugin
  97. 6 module RequestMethods
  98. 6 attr_writer :response_on_data
  99. 6 def response=(response)
  100. 10 super
  101. 10 return if response.is_a?(::HTTPX::ErrorResponse)
  102. 10 response.body.on_data = @response_on_data
  103. end
  104. end
  105. 6 module ResponseBodyMethods
  106. 6 attr_writer :on_data
  107. 6 def write(chunk)
  108. 27 return super unless @on_data
  109. 27 @on_data.call(chunk, chunk.bytesize)
  110. end
  111. end
  112. end
  113. 6 module ReasonPlugin
  114. 6 def self.load_dependencies(*)
  115. 136 require "net/http/status"
  116. end
  117. 6 module ResponseMethods
  118. 6 def reason
  119. 111 Net::HTTP::STATUS_CODES.fetch(@status)
  120. end
  121. end
  122. end
  123. 6 class ParallelManager
  124. 6 class ResponseHandler < SimpleDelegator
  125. 6 attr_reader :env
  126. 6 def initialize(env)
  127. 20 @env = env
  128. 20 super
  129. end
  130. 6 def on_response(&blk)
  131. 40 if blk
  132. 20 @on_response = ->(response) do
  133. 20 blk.call(response)
  134. end
  135. 20 self
  136. else
  137. 20 @on_response
  138. end
  139. end
  140. 6 def on_complete(&blk)
  141. 40 if blk
  142. 20 @on_complete = blk
  143. 20 self
  144. else
  145. 20 @on_complete
  146. end
  147. end
  148. end
  149. 6 include RequestMixin
  150. 6 def initialize(options)
  151. 20 @handlers = []
  152. 20 @connection_options = options
  153. end
  154. 6 def enqueue(request)
  155. 20 handler = ResponseHandler.new(request)
  156. 20 @handlers << handler
  157. 20 handler
  158. end
  159. 6 def run
  160. 20 return unless @handlers.last
  161. 15 env = @handlers.last.env
  162. 15 connect(env) do |session|
  163. 35 requests = @handlers.map { |handler| session.build_request(*build_request(handler.env)) }
  164. 15 if env.request.stream_response?
  165. 5 requests.each do |request|
  166. 5 request.response_on_data = env.request.on_data
  167. end
  168. end
  169. 15 responses = session.request(*requests)
  170. 15 Array(responses).each_with_index do |response, index|
  171. 20 handler = @handlers[index]
  172. 20 handler.on_response.call(response)
  173. 20 handler.on_complete.call(handler.env)
  174. end
  175. end
  176. rescue ::HTTPX::TimeoutError => e
  177. raise Faraday::TimeoutError, e
  178. end
  179. # from Faraday::Adapter#connection
  180. 6 def connection(env)
  181. 15 conn = build_connection(env)
  182. 15 return conn unless block_given?
  183. 15 yield conn
  184. end
  185. 6 private
  186. # from Faraday::Adapter#request_timeout
  187. 6 def request_timeout(type, options)
  188. 45 key = Faraday::Adapter::TIMEOUT_KEYS[type]
  189. 45 options[key] || options[:timeout]
  190. end
  191. end
  192. 6 self.supports_parallel = true
  193. 6 class << self
  194. 6 def setup_parallel_manager(options = {})
  195. 20 ParallelManager.new(options)
  196. end
  197. end
  198. 6 def call(env)
  199. 141 super
  200. 141 if parallel?(env)
  201. 20 handler = env[:parallel_manager].enqueue(env)
  202. 20 handler.on_response do |response|
  203. 20 if response.is_a?(::HTTPX::Response)
  204. 15 save_response(env, response.status, response.body.to_s, response.headers, response.reason) do |response_headers|
  205. 15 response_headers.merge!(response.headers)
  206. end
  207. else
  208. 5 env[:error] = response.error
  209. 5 save_response(env, 0, "", {}, nil)
  210. end
  211. end
  212. 20 return handler
  213. end
  214. 121 response = connect_and_request(env)
  215. 96 save_response(env, response.status, response.body.to_s, response.headers, response.reason) do |response_headers|
  216. 96 response_headers.merge!(response.headers)
  217. end
  218. 96 @app.call(env)
  219. end
  220. 6 private
  221. 6 def connect_and_request(env)
  222. 121 connect(env) do |session|
  223. 121 request = session.build_request(*build_request(env))
  224. 121 request.response_on_data = env.request.on_data if env.request.stream_response?
  225. 121 response = session.request(request)
  226. # do not call #raise_for_status for HTTP 4xx or 5xx, as faraday has a middleware for that.
  227. 121 response.raise_for_status unless response.is_a?(::HTTPX::Response)
  228. 96 response
  229. end
  230. rescue ::HTTPX::TimeoutError => e
  231. 15 raise Faraday::TimeoutError, e
  232. end
  233. 6 def parallel?(env)
  234. 141 env[:parallel_manager]
  235. end
  236. end
  237. 6 register_middleware httpx: HTTPX
  238. end
  239. end

lib/httpx/adapters/sentry.rb

100.0% lines covered

62 relevant lines. 62 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 4 require "sentry-ruby"
  3. 4 module HTTPX::Plugins
  4. 4 module Sentry
  5. 4 module Tracer
  6. 4 module_function
  7. 4 def call(request)
  8. 28 sentry_span = start_sentry_span
  9. 28 return unless sentry_span
  10. 28 set_sentry_trace_header(request, sentry_span)
  11. 28 request.on(:response, &method(:finish_sentry_span).curry(3)[sentry_span, request])
  12. end
  13. 4 def start_sentry_span
  14. 28 return unless ::Sentry.initialized? && (span = ::Sentry.get_current_scope.get_span)
  15. 28 return if span.sampled == false
  16. 28 span.start_child(op: "httpx.client", start_timestamp: ::Sentry.utc_now.to_f)
  17. end
  18. 4 def set_sentry_trace_header(request, sentry_span)
  19. 28 return unless sentry_span
  20. 28 config = ::Sentry.configuration
  21. 28 url = request.uri.to_s
  22. 56 return unless config.propagate_traces && config.trace_propagation_targets.any? { |target| url.match?(target) }
  23. 28 trace = ::Sentry.get_current_client.generate_sentry_trace(sentry_span)
  24. 28 request.headers[::Sentry::SENTRY_TRACE_HEADER_NAME] = trace if trace
  25. end
  26. 4 def finish_sentry_span(span, request, response)
  27. 28 return unless ::Sentry.initialized?
  28. 28 record_sentry_breadcrumb(request, response)
  29. 28 record_sentry_span(request, response, span)
  30. end
  31. 4 def record_sentry_breadcrumb(req, res)
  32. 28 return unless ::Sentry.configuration.breadcrumbs_logger.include?(:http_logger)
  33. 28 request_info = extract_request_info(req)
  34. 28 data = if res.is_a?(HTTPX::ErrorResponse)
  35. 4 { error: res.error.message, **request_info }
  36. else
  37. 24 { status: res.status, **request_info }
  38. end
  39. 28 crumb = ::Sentry::Breadcrumb.new(
  40. level: :info,
  41. category: "httpx",
  42. type: :info,
  43. data: data
  44. )
  45. 28 ::Sentry.add_breadcrumb(crumb)
  46. end
  47. 4 def record_sentry_span(req, res, sentry_span)
  48. 28 return unless sentry_span
  49. 28 request_info = extract_request_info(req)
  50. 28 sentry_span.set_description("#{request_info[:method]} #{request_info[:url]}")
  51. 28 if res.is_a?(HTTPX::ErrorResponse)
  52. 4 sentry_span.set_data(:error, res.error.message)
  53. else
  54. 24 sentry_span.set_data(:status, res.status)
  55. end
  56. 28 sentry_span.set_timestamp(::Sentry.utc_now.to_f)
  57. end
  58. 4 def extract_request_info(req)
  59. 56 uri = req.uri
  60. result = {
  61. 56 method: req.verb,
  62. }
  63. 56 if ::Sentry.configuration.send_default_pii
  64. 16 uri += "?#{req.query}" unless req.query.empty?
  65. 16 result[:body] = req.body.to_s unless req.body.empty? || req.body.unbounded_body?
  66. end
  67. 56 result[:url] = uri.to_s
  68. 56 result
  69. end
  70. end
  71. 4 module RequestMethods
  72. 4 def __sentry_enable_trace!
  73. 28 return if @__sentry_enable_trace
  74. 28 Tracer.call(self)
  75. 28 @__sentry_enable_trace = true
  76. end
  77. end
  78. 4 module ConnectionMethods
  79. 4 def send(request)
  80. 28 request.__sentry_enable_trace!
  81. 28 super
  82. end
  83. end
  84. end
  85. end
  86. 4 Sentry.register_patch(:httpx) do
  87. 20 sentry_session = HTTPX.plugin(HTTPX::Plugins::Sentry)
  88. 20 HTTPX.send(:remove_const, :Session)
  89. 20 HTTPX.send(:const_set, :Session, sentry_session.class)
  90. end

lib/httpx/adapters/webmock.rb

100.0% lines covered

84 relevant lines. 84 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 5 module WebMock
  3. 5 module HttpLibAdapters
  4. 5 require "net/http/status"
  5. 5 HTTP_REASONS = Net::HTTP::STATUS_CODES
  6. #
  7. # HTTPX plugin for webmock.
  8. #
  9. # Requests are "hijacked" at the session, before they're distributed to a connection.
  10. #
  11. 5 module Plugin
  12. 5 class << self
  13. 5 def build_webmock_request_signature(request)
  14. 97 uri = WebMock::Util::URI.heuristic_parse(request.uri)
  15. 97 uri.query = request.query
  16. 97 uri.path = uri.normalized_path.gsub("[^:]//", "/")
  17. 97 WebMock::RequestSignature.new(
  18. request.verb.downcase.to_sym,
  19. uri.to_s,
  20. body: request.body.each.to_a.join,
  21. headers: request.headers.to_h
  22. )
  23. end
  24. 5 def build_webmock_response(_request, response)
  25. 4 webmock_response = WebMock::Response.new
  26. 4 webmock_response.status = [response.status, HTTP_REASONS[response.status]]
  27. 4 webmock_response.body = response.body.to_s
  28. 4 webmock_response.headers = response.headers.to_h
  29. 4 webmock_response
  30. end
  31. 5 def build_from_webmock_response(request, webmock_response)
  32. 89 return build_error_response(request, HTTPX::TimeoutError.new(1, "Timed out")) if webmock_response.should_timeout
  33. 77 return build_error_response(request, webmock_response.exception) if webmock_response.exception
  34. 73 request.options.response_class.new(request,
  35. webmock_response.status[0],
  36. "2.0",
  37. webmock_response.headers).tap do |res|
  38. 73 res.mocked = true
  39. end
  40. end
  41. 5 def build_error_response(request, exception)
  42. 16 HTTPX::ErrorResponse.new(request, exception, request.options)
  43. end
  44. end
  45. 5 module InstanceMethods
  46. 5 def build_connection(*)
  47. 77 connection = super
  48. 77 connection.once(:unmock_connection) do
  49. 4 unless connection.addresses
  50. 4 connection.__send__(:callbacks)[:connect_error].clear
  51. 4 pool.__send__(:unregister_connection, connection)
  52. end
  53. 4 pool.__send__(:resolve_connection, connection)
  54. end
  55. 77 connection
  56. end
  57. end
  58. 5 module ResponseMethods
  59. 5 attr_accessor :mocked
  60. 5 def initialize(*)
  61. 77 super
  62. 77 @mocked = false
  63. end
  64. end
  65. 5 module ResponseBodyMethods
  66. 5 def decode_chunk(chunk)
  67. 36 return chunk if @response.mocked
  68. 4 super
  69. end
  70. end
  71. 5 module ConnectionMethods
  72. 5 def initialize(*)
  73. 77 super
  74. 77 @mocked = true
  75. end
  76. 5 def open?
  77. 81 return true if @mocked
  78. 4 super
  79. end
  80. 5 def interests
  81. 1472 return if @mocked
  82. 32 super
  83. end
  84. 5 def send(request)
  85. 97 request_signature = Plugin.build_webmock_request_signature(request)
  86. 97 WebMock::RequestRegistry.instance.requested_signatures.put(request_signature)
  87. 97 if (mock_response = WebMock::StubRegistry.instance.response_for_request(request_signature))
  88. 89 response = Plugin.build_from_webmock_response(request, mock_response)
  89. 89 WebMock::CallbackRegistry.invoke_callbacks({ lib: :httpx }, request_signature, mock_response)
  90. 89 log { "mocking #{request.uri} with #{mock_response.inspect}" }
  91. 89 request.response = response
  92. 89 request.emit(:response, response)
  93. 89 response << mock_response.body.dup unless response.is_a?(HTTPX::ErrorResponse)
  94. 8 elsif WebMock.net_connect_allowed?(request_signature.uri)
  95. 4 if WebMock::CallbackRegistry.any_callbacks?
  96. 4 request.on(:response) do |resp|
  97. 4 unless resp.is_a?(HTTPX::ErrorResponse)
  98. 4 webmock_response = Plugin.build_webmock_response(request, resp)
  99. 4 WebMock::CallbackRegistry.invoke_callbacks(
  100. { lib: :httpx, real_request: true }, request_signature,
  101. webmock_response
  102. )
  103. end
  104. end
  105. end
  106. 4 @mocked = false
  107. 4 emit(:unmock_connection, self)
  108. 4 super
  109. else
  110. 4 raise WebMock::NetConnectNotAllowedError, request_signature
  111. end
  112. end
  113. end
  114. end
  115. 5 class HttpxAdapter < HttpLibAdapter
  116. 5 adapter_for :httpx
  117. 5 class << self
  118. 5 def enable!
  119. 190 @original_session ||= HTTPX::Session
  120. 190 webmock_session = HTTPX.plugin(Plugin)
  121. 190 HTTPX.send(:remove_const, :Session)
  122. 190 HTTPX.send(:const_set, :Session, webmock_session.class)
  123. end
  124. 5 def disable!
  125. 190 return unless @original_session
  126. 185 HTTPX.send(:remove_const, :Session)
  127. 185 HTTPX.send(:const_set, :Session, @original_session)
  128. end
  129. end
  130. end
  131. end
  132. end

lib/httpx/altsvc.rb

96.39% lines covered

83 relevant lines. 80 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 require "strscan"
  3. 20 module HTTPX
  4. 20 module AltSvc
  5. # makes connections able to accept requests destined to primary service.
  6. 20 module ConnectionMixin
  7. 20 using URIExtensions
  8. 20 def send(request)
  9. 5 request.headers["alt-used"] = @origin.authority if @parser && !@write_buffer.full? && match_altsvcs?(request.uri)
  10. 5 super
  11. end
  12. 20 def match?(uri, options)
  13. 5 return false if !used? && (@state == :closing || @state == :closed)
  14. 5 match_altsvcs?(uri) && match_altsvc_options?(uri, options)
  15. end
  16. 20 private
  17. # checks if this is connection is an alternative service of
  18. # +uri+
  19. 20 def match_altsvcs?(uri)
  20. 15 @origins.any? { |origin| altsvc_match?(uri, origin) } ||
  21. AltSvc.cached_altsvc(@origin).any? do |altsvc|
  22. origin = altsvc["origin"]
  23. altsvc_match?(origin, uri.origin)
  24. end
  25. end
  26. 20 def match_altsvc_options?(uri, options)
  27. 5 return @options == options unless @options.ssl.all? do |k, v|
  28. 5 v == (k == :hostname ? uri.host : options.ssl[k])
  29. end
  30. 5 @options.options_equals?(options, Options::REQUEST_BODY_IVARS + %i[@ssl])
  31. end
  32. 20 def altsvc_match?(uri, other_uri)
  33. 10 other_uri = URI(other_uri)
  34. 10 uri.origin == other_uri.origin || begin
  35. 5 case uri.scheme
  36. when "h2"
  37. (other_uri.scheme == "https" || other_uri.scheme == "h2") &&
  38. uri.host == other_uri.host &&
  39. uri.port == other_uri.port
  40. else
  41. 5 false
  42. end
  43. end
  44. end
  45. end
  46. 20 @altsvc_mutex = Thread::Mutex.new
  47. 35 @altsvcs = Hash.new { |h, k| h[k] = [] }
  48. 20 module_function
  49. 20 def cached_altsvc(origin)
  50. 25 now = Utils.now
  51. 25 @altsvc_mutex.synchronize do
  52. 25 lookup(origin, now)
  53. end
  54. end
  55. 20 def cached_altsvc_set(origin, entry)
  56. 15 now = Utils.now
  57. 15 @altsvc_mutex.synchronize do
  58. 15 return if @altsvcs[origin].any? { |altsvc| altsvc["origin"] == entry["origin"] }
  59. 15 entry["TTL"] = Integer(entry["ma"]) + now if entry.key?("ma")
  60. 15 @altsvcs[origin] << entry
  61. 15 entry
  62. end
  63. end
  64. 20 def lookup(origin, ttl)
  65. 25 return [] unless @altsvcs.key?(origin)
  66. 20 @altsvcs[origin] = @altsvcs[origin].select do |entry|
  67. 15 !entry.key?("TTL") || entry["TTL"] > ttl
  68. end
  69. 30 @altsvcs[origin].reject { |entry| entry["noop"] }
  70. end
  71. 20 def emit(request, response)
  72. 4666 return unless response.respond_to?(:headers)
  73. # Alt-Svc
  74. 4649 return unless response.headers.key?("alt-svc")
  75. 56 origin = request.origin
  76. 56 host = request.uri.host
  77. 56 altsvc = response.headers["alt-svc"]
  78. # https://datatracker.ietf.org/doc/html/rfc7838#section-3
  79. # A field value containing the special value "clear" indicates that the
  80. # origin requests all alternatives for that origin to be invalidated
  81. # (including those specified in the same response, in case of an
  82. # invalid reply containing both "clear" and alternative services).
  83. 56 if altsvc == "clear"
  84. 5 @altsvc_mutex.synchronize do
  85. 5 @altsvcs[origin].clear
  86. end
  87. 5 return
  88. end
  89. 51 parse(altsvc) do |alt_origin, alt_params|
  90. 5 alt_origin.host ||= host
  91. 5 yield(alt_origin, origin, alt_params)
  92. end
  93. end
  94. 20 def parse(altsvc)
  95. 121 return enum_for(__method__, altsvc) unless block_given?
  96. 86 scanner = StringScanner.new(altsvc)
  97. 86 until scanner.eos?
  98. 86 alt_service = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
  99. 86 alt_params = []
  100. 86 loop do
  101. 101 alt_param = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
  102. 101 alt_params << alt_param.strip if alt_param
  103. 101 scanner.skip(/;/)
  104. 101 break if scanner.eos? || scanner.scan(/ *, */)
  105. end
  106. 172 alt_params = Hash[alt_params.map { |field| field.split("=") }]
  107. 86 alt_proto, alt_authority = alt_service.split("=")
  108. 86 alt_origin = parse_altsvc_origin(alt_proto, alt_authority)
  109. 86 return unless alt_origin
  110. 30 yield(alt_origin, alt_params.merge("proto" => alt_proto))
  111. end
  112. end
  113. 20 def parse_altsvc_scheme(alt_proto)
  114. 101 case alt_proto
  115. when "h2c"
  116. 5 "http"
  117. when "h2"
  118. 35 "https"
  119. end
  120. end
  121. 20 def parse_altsvc_origin(alt_proto, alt_origin)
  122. 86 alt_scheme = parse_altsvc_scheme(alt_proto)
  123. 86 return unless alt_scheme
  124. 30 alt_origin = alt_origin[1..-2] if alt_origin.start_with?("\"") && alt_origin.end_with?("\"")
  125. 30 URI.parse("#{alt_scheme}://#{alt_origin}")
  126. end
  127. end
  128. end

lib/httpx/buffer.rb

100.0% lines covered

21 relevant lines. 21 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 require "forwardable"
  3. 20 module HTTPX
  4. 20 class Buffer
  5. 20 extend Forwardable
  6. 20 def_delegator :@buffer, :<<
  7. 20 def_delegator :@buffer, :to_s
  8. 20 def_delegator :@buffer, :to_str
  9. 20 def_delegator :@buffer, :empty?
  10. 20 def_delegator :@buffer, :bytesize
  11. 20 def_delegator :@buffer, :clear
  12. 20 def_delegator :@buffer, :replace
  13. 20 attr_reader :limit
  14. 20 def initialize(limit)
  15. 8841 @buffer = "".b
  16. 8841 @limit = limit
  17. end
  18. 20 def full?
  19. 1645280 @buffer.bytesize >= @limit
  20. end
  21. 20 def capacity
  22. 4 @limit - @buffer.bytesize
  23. end
  24. 20 def shift!(fin)
  25. 13137 @buffer = @buffer.byteslice(fin..-1) || "".b
  26. end
  27. end
  28. end

lib/httpx/callbacks.rb

100.0% lines covered

22 relevant lines. 22 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 module HTTPX
  3. 20 module Callbacks
  4. 20 def on(type, &action)
  5. 177511 callbacks(type) << action
  6. 177511 self
  7. end
  8. 20 def once(type, &block)
  9. 77732 on(type) do |*args, &callback|
  10. 77040 block.call(*args, &callback)
  11. 77000 :delete
  12. end
  13. 77732 self
  14. end
  15. 20 def only(type, &block)
  16. 8438 callbacks(type).clear
  17. 8438 on(type, &block)
  18. end
  19. 20 def emit(type, *args)
  20. 200920 callbacks(type).delete_if { |pr| :delete == pr.call(*args) } # rubocop:disable Style/YodaCondition
  21. end
  22. 20 def callbacks_for?(type)
  23. 1842 @callbacks.key?(type) && @callbacks[type].any?
  24. end
  25. 20 protected
  26. 20 def callbacks(type = nil)
  27. 270608 return @callbacks unless type
  28. 405882 @callbacks ||= Hash.new { |h, k| h[k] = [] }
  29. 270539 @callbacks[type]
  30. end
  31. end
  32. end

lib/httpx/chainable.rb

95.12% lines covered

41 relevant lines. 39 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 module HTTPX
  3. 20 module Chainable
  4. 20 %w[head get post put delete trace options connect patch].each do |meth|
  5. 171 class_eval(<<-MOD, __FILE__, __LINE__ + 1)
  6. def #{meth}(*uri, **options) # def get(*uri, **options)
  7. request("#{meth.upcase}", uri, **options) # request("GET", uri, **options)
  8. end # end
  9. MOD
  10. end
  11. 20 def request(*args, **options)
  12. 1512 branch(default_options).request(*args, **options)
  13. end
  14. 20 def accept(type)
  15. 10 with(headers: { "accept" => String(type) })
  16. end
  17. 20 def wrap(&blk)
  18. 56 branch(default_options).wrap(&blk)
  19. end
  20. 20 def plugin(pl, options = nil, &blk)
  21. 2757 klass = is_a?(S) ? self.class : Session
  22. 2757 klass = Class.new(klass)
  23. 2757 klass.instance_variable_set(:@default_options, klass.default_options.merge(default_options))
  24. 2757 klass.plugin(pl, options, &blk).new
  25. end
  26. 20 def with(options, &blk)
  27. 1525 branch(default_options.merge(options), &blk)
  28. end
  29. 20 private
  30. 20 def default_options
  31. 5885 @options || Session.default_options
  32. end
  33. 20 def branch(options, &blk)
  34. 3093 return self.class.new(options, &blk) if is_a?(S)
  35. 1789 Session.new(options, &blk)
  36. end
  37. 20 def method_missing(meth, *args, **options, &blk)
  38. 454 case meth
  39. when /\Awith_(.+)/
  40. 448 option = Regexp.last_match(1)
  41. 448 return super unless option
  42. 448 with(option.to_sym => args.first || options)
  43. when /\Aon_(.+)/
  44. 6 callback = Regexp.last_match(1)
  45. 5 return super unless %w[
  46. connection_opened connection_closed
  47. request_error
  48. request_started request_body_chunk request_completed
  49. response_started response_body_chunk response_completed
  50. ].include?(callback)
  51. 6 warn "DEPRECATION WARNING: calling `.#{meth}` on plain HTTPX sessions is deprecated. " \
  52. "Use HTTPX.plugin(:callbacks).#{meth} instead."
  53. 6 plugin(:callbacks).__send__(meth, *args, **options, &blk)
  54. else
  55. super
  56. end
  57. end
  58. 20 def respond_to_missing?(meth, *)
  59. 35 case meth
  60. when /\Awith_(.+)/
  61. 25 option = Regexp.last_match(1)
  62. 25 default_options.respond_to?(option) || super
  63. when /\Aon_(.+)/
  64. 10 callback = Regexp.last_match(1)
  65. 8 %w[
  66. connection_opened connection_closed
  67. request_error
  68. request_started request_body_chunk request_completed
  69. response_started response_body_chunk response_completed
  70. 1 ].include?(callback) || super
  71. else
  72. super
  73. end
  74. end
  75. end
  76. end

lib/httpx/connection.rb

96.1% lines covered

359 relevant lines. 345 lines covered and 14 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 require "resolv"
  3. 20 require "forwardable"
  4. 20 require "httpx/io"
  5. 20 require "httpx/buffer"
  6. 20 module HTTPX
  7. # The Connection can be watched for IO events.
  8. #
  9. # It contains the +io+ object to read/write from, and knows what to do when it can.
  10. #
  11. # It defers connecting until absolutely necessary. Connection should be triggered from
  12. # the IO selector (until then, any request will be queued).
  13. #
  14. # A connection boots up its parser after connection is established. All pending requests
  15. # will be redirected there after connection.
  16. #
  17. # A connection can be prevented from closing by the parser, that is, if there are pending
  18. # requests. This will signal that the connection was prematurely closed, due to a possible
  19. # number of conditions:
  20. #
  21. # * Remote peer closed the connection ("Connection: close");
  22. # * Remote peer doesn't support pipelining;
  23. #
  24. # A connection may also route requests for a different host for which the +io+ was connected
  25. # to, provided that the IP is the same and the port and scheme as well. This will allow to
  26. # share the same socket to send HTTP/2 requests to different hosts.
  27. #
  28. 20 class Connection
  29. 20 extend Forwardable
  30. 20 include Loggable
  31. 20 include Callbacks
  32. 20 using URIExtensions
  33. 20 require "httpx/connection/http2"
  34. 20 require "httpx/connection/http1"
  35. 20 def_delegator :@io, :closed?
  36. 20 def_delegator :@write_buffer, :empty?
  37. 20 attr_reader :type, :io, :origin, :origins, :state, :pending, :options, :ssl_session
  38. 20 attr_writer :timers
  39. 20 attr_accessor :family
  40. 20 def initialize(type, uri, options)
  41. 4200 @type = type
  42. 4200 @origins = [uri.origin]
  43. 4200 @origin = Utils.to_uri(uri.origin)
  44. 4200 @options = Options.new(options)
  45. 4200 @window_size = @options.window_size
  46. 4200 @read_buffer = Buffer.new(@options.buffer_size)
  47. 4200 @write_buffer = Buffer.new(@options.buffer_size)
  48. 4200 @pending = []
  49. 4200 on(:error, &method(:on_error))
  50. 4200 if @options.io
  51. # if there's an already open IO, get its
  52. # peer address, and force-initiate the parser
  53. 41 transition(:already_open)
  54. 41 @io = build_socket
  55. 41 parser
  56. else
  57. 4159 transition(:idle)
  58. end
  59. 4200 @inflight = 0
  60. 4200 @keep_alive_timeout = @options.timeout[:keep_alive_timeout]
  61. 4200 @intervals = []
  62. 4200 self.addresses = @options.addresses if @options.addresses
  63. end
  64. # this is a semi-private method, to be used by the resolver
  65. # to initiate the io object.
  66. 20 def addresses=(addrs)
  67. 4082 if @io
  68. 138 @io.add_addresses(addrs)
  69. else
  70. 3944 @io = build_socket(addrs)
  71. end
  72. end
  73. 20 def addresses
  74. 11053 @io && @io.addresses
  75. end
  76. 20 def match?(uri, options)
  77. 4180 return false if !used? && (@state == :closing || @state == :closed)
  78. 185 (
  79. 3995 @origins.include?(uri.origin) &&
  80. # if there is more than one origin to match, it means that this connection
  81. # was the result of coalescing. To prevent blind trust in the case where the
  82. # origin came from an ORIGIN frame, we're going to verify the hostname with the
  83. # SSL certificate
  84. 1906 (@origins.size == 1 || @origin == uri.origin || (@io.is_a?(SSL) && @io.verify_hostname(uri.host)))
  85. ) && @options == options
  86. end
  87. 20 def expired?
  88. return false unless @io
  89. @io.expired?
  90. end
  91. 20 def mergeable?(connection)
  92. 2559 return false if @state == :closing || @state == :closed || !@io
  93. 1370 return false unless connection.addresses
  94. (
  95. 1370 (open? && @origin == connection.origin) ||
  96. 1314 !(@io.addresses & (connection.addresses || [])).empty?
  97. ) && @options == connection.options
  98. end
  99. # coalescable connections need to be mergeable!
  100. # but internally, #mergeable? is called before #coalescable?
  101. 20 def coalescable?(connection)
  102. 12 if @io.protocol == "h2" &&
  103. @origin.scheme == "https" &&
  104. connection.origin.scheme == "https" &&
  105. @io.can_verify_peer?
  106. 5 @io.verify_hostname(connection.origin.host)
  107. else
  108. 7 @origin == connection.origin
  109. end
  110. end
  111. 20 def create_idle(options = {})
  112. 5 self.class.new(@type, @origin, @options.merge(options))
  113. end
  114. 20 def merge(connection)
  115. 19 @origins |= connection.instance_variable_get(:@origins)
  116. 19 if connection.ssl_session
  117. 4 @ssl_session = connection.ssl_session
  118. @io.session_new_cb do |sess|
  119. @ssl_session = sess
  120. 4 end if @io
  121. end
  122. 19 connection.purge_pending do |req|
  123. 5 send(req)
  124. end
  125. end
  126. 20 def purge_pending(&block)
  127. 19 pendings = []
  128. 19 if @parser
  129. 10 @inflight -= @parser.pending.size
  130. 10 pendings << @parser.pending
  131. end
  132. 19 pendings << @pending
  133. 19 pendings.each do |pending|
  134. 29 pending.reject!(&block)
  135. end
  136. end
  137. 20 def connecting?
  138. 1662074 @state == :idle
  139. end
  140. 20 def inflight?
  141. 4039 @parser && !@parser.empty? && !@write_buffer.empty?
  142. end
  143. 20 def interests
  144. # connecting
  145. 1655558 if connecting?
  146. 6312 connect
  147. 6311 return @io.interests if connecting?
  148. end
  149. # if the write buffer is full, we drain it
  150. 1649841 return :w unless @write_buffer.empty?
  151. 1624674 return @parser.interests if @parser
  152. nil
  153. end
  154. 20 def to_io
  155. 14299 @io.to_io
  156. end
  157. 20 def call
  158. 11936 case @state
  159. when :idle
  160. 5610 connect
  161. 5601 consume
  162. when :closed
  163. return
  164. when :closing
  165. consume
  166. transition(:closed)
  167. when :open
  168. 6190 consume
  169. end
  170. 1525 nil
  171. end
  172. 20 def close
  173. 4062 transition(:active) if @state == :inactive
  174. 4062 @parser.close if @parser
  175. end
  176. 20 def terminate
  177. 4062 @connected_at = nil if @state == :closed
  178. 4062 close
  179. end
  180. # bypasses the state machine to force closing of connections still connecting.
  181. # **only** used for Happy Eyeballs v2.
  182. 20 def force_reset
  183. 97 @state = :closing
  184. 97 transition(:closed)
  185. end
  186. 20 def reset
  187. 6212 return if @state == :closing || @state == :closed
  188. 4120 transition(:closing)
  189. 4120 unless @write_buffer.empty?
  190. # handshakes, try sending
  191. 1565 consume
  192. 1565 @write_buffer.clear
  193. end
  194. 4120 transition(:closed)
  195. end
  196. 20 def send(request)
  197. 5270 if @parser && !@write_buffer.full?
  198. 232 if @response_received_at && @keep_alive_timeout &&
  199. Utils.elapsed_time(@response_received_at) > @keep_alive_timeout
  200. # when pushing a request into an existing connection, we have to check whether there
  201. # is the possibility that the connection might have extended the keep alive timeout.
  202. # for such cases, we want to ping for availability before deciding to shovel requests.
  203. 5 log(level: 3) { "keep alive timeout expired, pinging connection..." }
  204. 5 @pending << request
  205. 5 parser.ping
  206. 5 transition(:active) if @state == :inactive
  207. 5 return
  208. end
  209. 227 send_request_to_parser(request)
  210. else
  211. 5038 @pending << request
  212. end
  213. end
  214. 20 def timeout
  215. 1638999 return @timeout if @timeout
  216. 1622047 return @options.timeout[:connect_timeout] if @state == :idle
  217. 1622047 @options.timeout[:operation_timeout]
  218. end
  219. 20 def idling
  220. 421 purge_after_closed
  221. 421 @write_buffer.clear
  222. 421 transition(:idle)
  223. 421 @parser = nil if @parser
  224. end
  225. 20 def used?
  226. 8972 @connected_at
  227. end
  228. 20 def deactivate
  229. 654 transition(:inactive)
  230. end
  231. 20 def open?
  232. 5464 @state == :open || @state == :inactive
  233. end
  234. 20 def handle_socket_timeout(interval)
  235. 284 @intervals.delete_if(&:elapsed?)
  236. 284 unless @intervals.empty?
  237. # remove the intervals which will elapse
  238. 264 return
  239. end
  240. 20 error = HTTPX::TimeoutError.new(interval, "timed out while waiting on select")
  241. 20 error.set_backtrace(caller)
  242. 20 on_error(error)
  243. end
  244. 20 private
  245. 20 def connect
  246. 11251 transition(:open)
  247. end
  248. 20 def consume
  249. 13593 return unless @io
  250. 13593 catch(:called) do
  251. 13593 epiped = false
  252. 13593 loop do
  253. # connection may have
  254. 25786 return if @state == :idle
  255. 24069 parser.consume
  256. # we exit if there's no more requests to process
  257. #
  258. # this condition takes into account:
  259. #
  260. # * the number of inflight requests
  261. # * the number of pending requests
  262. # * whether the write buffer has bytes (i.e. for close handshake)
  263. 24059 if @pending.empty? && @inflight.zero? && @write_buffer.empty?
  264. 1686 log(level: 3) { "NO MORE REQUESTS..." }
  265. 1668 return
  266. end
  267. 22391 @timeout = @current_timeout
  268. 22391 read_drained = false
  269. 22391 write_drained = nil
  270. #
  271. # tight read loop.
  272. #
  273. # read as much of the socket as possible.
  274. #
  275. # this tight loop reads all the data it can from the socket and pipes it to
  276. # its parser.
  277. #
  278. loop do
  279. 28586 siz = @io.read(@window_size, @read_buffer)
  280. 28659 log(level: 3, color: :cyan) { "IO READ: #{siz} bytes..." }
  281. 28586 unless siz
  282. 6 ex = EOFError.new("descriptor closed")
  283. 6 ex.set_backtrace(caller)
  284. 6 on_error(ex)
  285. 6 return
  286. end
  287. # socket has been drained. mark and exit the read loop.
  288. 28580 if siz.zero?
  289. 5370 read_drained = @read_buffer.empty?
  290. 5370 epiped = false
  291. 5370 break
  292. end
  293. 23210 parser << @read_buffer.to_s
  294. # continue reading if possible.
  295. 20755 break if interests == :w && !epiped
  296. # exit the read loop if connection is preparing to be closed
  297. 16134 break if @state == :closing || @state == :closed
  298. # exit #consume altogether if all outstanding requests have been dealt with
  299. 16130 return if @pending.empty? && @inflight.zero?
  300. 22391 end unless ((ints = interests).nil? || ints == :w || @state == :closing) && !epiped
  301. #
  302. # tight write loop.
  303. #
  304. # flush as many bytes as the sockets allow.
  305. #
  306. loop do
  307. # buffer has been drainned, mark and exit the write loop.
  308. 14887 if @write_buffer.empty?
  309. # we only mark as drained on the first loop
  310. 2166 write_drained = write_drained.nil? && @inflight.positive?
  311. 2166 break
  312. end
  313. begin
  314. 12721 siz = @io.write(@write_buffer)
  315. rescue Errno::EPIPE
  316. # this can happen if we still have bytes in the buffer to send to the server, but
  317. # the server wants to respond immediately with some message, or an error. An example is
  318. # when one's uploading a big file to an unintended endpoint, and the server stops the
  319. # consumption, and responds immediately with an authorization of even method not allowed error.
  320. # at this point, we have to let the connection switch to read-mode.
  321. 6 log(level: 2) { "pipe broken, could not flush buffer..." }
  322. 6 epiped = true
  323. 6 read_drained = false
  324. 6 break
  325. end
  326. 12768 log(level: 3, color: :cyan) { "IO WRITE: #{siz} bytes..." }
  327. 12713 unless siz
  328. ex = EOFError.new("descriptor closed")
  329. ex.set_backtrace(caller)
  330. on_error(ex)
  331. return
  332. end
  333. # socket closed for writing. mark and exit the write loop.
  334. 12713 if siz.zero?
  335. 19 write_drained = !@write_buffer.empty?
  336. 19 break
  337. end
  338. # exit write loop if marked to consume from peer, or is closing.
  339. 12694 break if interests == :r || @state == :closing || @state == :closed
  340. 2059 write_drained = false
  341. 18187 end unless (ints = interests) == :r
  342. 18185 send_pending if @state == :open
  343. # return if socket is drained
  344. 18185 next unless (ints != :r || read_drained) && (ints != :w || write_drained)
  345. # gotta go back to the event loop. It happens when:
  346. #
  347. # * the socket is drained of bytes or it's not the interest of the conn to read;
  348. # * theres nothing more to write, or it's not in the interest of the conn to write;
  349. 6013 log(level: 3) { "(#{ints}): WAITING FOR EVENTS..." }
  350. 5992 return
  351. end
  352. end
  353. end
  354. 20 def send_pending
  355. 52139 while !@write_buffer.full? && (request = @pending.shift)
  356. 15313 send_request_to_parser(request)
  357. end
  358. end
  359. 20 def parser
  360. 67683 @parser ||= build_parser
  361. end
  362. 20 def send_request_to_parser(request)
  363. 15538 @inflight += 1
  364. 15538 request.peer_address = @io.ip
  365. 15538 parser.send(request)
  366. 15538 set_request_timeouts(request)
  367. 15538 return unless @state == :inactive
  368. 43 transition(:active)
  369. end
  370. 20 def build_parser(protocol = @io.protocol)
  371. 4220 parser = self.class.parser_type(protocol).new(@write_buffer, @options)
  372. 4220 set_parser_callbacks(parser)
  373. 4220 parser
  374. end
  375. 20 def set_parser_callbacks(parser)
  376. 4300 parser.on(:response) do |request, response|
  377. 4661 AltSvc.emit(request, response) do |alt_origin, origin, alt_params|
  378. 5 emit(:altsvc, alt_origin, origin, alt_params)
  379. end
  380. 4661 @response_received_at = Utils.now
  381. 4661 @inflight -= 1
  382. 4661 request.emit(:response, response)
  383. end
  384. 4300 parser.on(:altsvc) do |alt_origin, origin, alt_params|
  385. emit(:altsvc, alt_origin, origin, alt_params)
  386. end
  387. 4300 parser.on(:pong, &method(:send_pending))
  388. 4300 parser.on(:promise) do |request, stream|
  389. 15 request.emit(:promise, parser, stream)
  390. end
  391. 4300 parser.on(:exhausted) do
  392. 5 @pending.concat(parser.pending)
  393. 5 emit(:exhausted)
  394. end
  395. 4300 parser.on(:origin) do |origin|
  396. @origins |= [origin]
  397. end
  398. 4300 parser.on(:close) do |force|
  399. 3802 if force
  400. 3802 reset
  401. 3797 emit(:terminate)
  402. end
  403. end
  404. 4300 parser.on(:close_handshake) do
  405. 5 consume
  406. end
  407. 4300 parser.on(:reset) do
  408. 2219 @pending.concat(parser.pending) unless parser.empty?
  409. 2219 reset
  410. 2214 idling unless @pending.empty?
  411. end
  412. 4300 parser.on(:current_timeout) do
  413. 1801 @current_timeout = @timeout = parser.timeout
  414. end
  415. 4300 parser.on(:timeout) do |tout|
  416. 1715 @timeout = tout
  417. end
  418. 4300 parser.on(:error) do |request, ex|
  419. 279 case ex
  420. when MisdirectedRequestError
  421. 5 emit(:misdirected, request)
  422. else
  423. 274 response = ErrorResponse.new(request, ex, @options)
  424. 274 request.response = response
  425. 274 request.emit(:response, response)
  426. end
  427. end
  428. end
  429. 20 def transition(nextstate)
  430. 26822 handle_transition(nextstate)
  431. rescue Errno::ECONNABORTED,
  432. Errno::ECONNREFUSED,
  433. Errno::ECONNRESET,
  434. Errno::EADDRNOTAVAIL,
  435. Errno::EHOSTUNREACH,
  436. Errno::EINVAL,
  437. Errno::ENETUNREACH,
  438. Errno::EPIPE,
  439. Errno::ENOENT,
  440. SocketError => e
  441. # connect errors, exit gracefully
  442. 48 error = ConnectionError.new(e.message)
  443. 48 error.set_backtrace(e.backtrace)
  444. 48 connecting? && callbacks_for?(:connect_error) ? emit(:connect_error, error) : handle_error(error)
  445. 48 @state = :closed
  446. 48 emit(:close)
  447. rescue TLSError => e
  448. # connect errors, exit gracefully
  449. 15 handle_error(e)
  450. 15 connecting? && callbacks_for?(:connect_error) ? emit(:connect_error, e) : handle_error(e)
  451. 15 @state = :closed
  452. 15 emit(:close)
  453. end
  454. 20 def handle_transition(nextstate)
  455. 26495 case nextstate
  456. when :idle
  457. 4590 @timeout = @current_timeout = @options.timeout[:connect_timeout]
  458. 4590 @connected_at = nil
  459. when :open
  460. 11453 return if @state == :closed
  461. 11453 @io.connect
  462. 11390 emit(:tcp_open, self) if @io.state == :connected
  463. 11390 return unless @io.connected?
  464. 4198 @connected_at = Utils.now
  465. 4198 send_pending
  466. 4198 @timeout = @current_timeout = parser.timeout
  467. 4198 emit(:open)
  468. when :inactive
  469. 654 return unless @state == :open
  470. when :closing
  471. 4469 return unless @state == :idle || @state == :open
  472. when :closed
  473. 4529 return unless @state == :closing
  474. 4526 return unless @write_buffer.empty?
  475. 4450 purge_after_closed
  476. 4450 emit(:close) if @pending.empty?
  477. when :already_open
  478. 41 nextstate = :open
  479. # the first check for given io readiness must still use a timeout.
  480. # connect is the reasonable choice in such a case.
  481. 41 @timeout = @options.timeout[:connect_timeout]
  482. 41 send_pending
  483. when :active
  484. 380 return unless @state == :inactive
  485. 380 nextstate = :open
  486. 380 emit(:activate)
  487. end
  488. 18875 @state = nextstate
  489. end
  490. 20 def purge_after_closed
  491. 4876 @io.close if @io
  492. 4876 @read_buffer.clear
  493. 4876 @timeout = nil
  494. end
  495. 20 def build_socket(addrs = nil)
  496. 3985 case @type
  497. when "tcp"
  498. 2246 TCP.new(@origin, addrs, @options)
  499. when "ssl"
  500. 1723 SSL.new(@origin, addrs, @options) do |sock|
  501. 1709 sock.ssl_session = @ssl_session
  502. 1709 sock.session_new_cb do |sess|
  503. 2699 @ssl_session = sess
  504. 2699 sock.ssl_session = sess
  505. end
  506. end
  507. when "unix"
  508. 16 UNIX.new(@origin, addrs, @options)
  509. else
  510. raise Error, "unsupported transport (#{@type})"
  511. end
  512. end
  513. 20 def on_error(error)
  514. 488 if error.instance_of?(TimeoutError)
  515. # inactive connections do not contribute to the select loop, therefore
  516. # they should not fail due to such errors.
  517. 20 return if @state == :inactive
  518. 20 if @timeout
  519. 20 @timeout -= error.timeout
  520. 20 return unless @timeout <= 0
  521. end
  522. 20 error = error.to_connection_error if connecting?
  523. end
  524. 488 handle_error(error)
  525. 488 reset
  526. end
  527. 20 def handle_error(error)
  528. 566 parser.handle_error(error) if @parser && parser.respond_to?(:handle_error)
  529. 1275 while (request = @pending.shift)
  530. 268 response = ErrorResponse.new(request, error, request.options)
  531. 268 request.response = response
  532. 268 request.emit(:response, response)
  533. end
  534. end
  535. 20 def set_request_timeouts(request)
  536. 15538 write_timeout = request.write_timeout
  537. 15538 read_timeout = request.read_timeout
  538. 15538 request_timeout = request.request_timeout
  539. 15538 unless write_timeout.nil? || write_timeout.infinite?
  540. 15538 set_request_timeout(request, write_timeout, :headers, %i[done response]) do
  541. 15 write_timeout_callback(request, write_timeout)
  542. end
  543. end
  544. 15538 unless read_timeout.nil? || read_timeout.infinite?
  545. 15364 set_request_timeout(request, read_timeout, :done, :response) do
  546. 15 read_timeout_callback(request, read_timeout)
  547. end
  548. end
  549. 15538 return if request_timeout.nil? || request_timeout.infinite?
  550. 290 set_request_timeout(request, request_timeout, :headers, :response) do
  551. 217 read_timeout_callback(request, request_timeout, RequestTimeoutError)
  552. end
  553. end
  554. 20 def write_timeout_callback(request, write_timeout)
  555. 15 return if request.state == :done
  556. 15 @write_buffer.clear
  557. 15 error = WriteTimeoutError.new(request, nil, write_timeout)
  558. 15 on_error(error)
  559. end
  560. 20 def read_timeout_callback(request, read_timeout, error_type = ReadTimeoutError)
  561. 232 response = request.response
  562. 232 return if response && response.finished?
  563. 232 @write_buffer.clear
  564. 232 error = error_type.new(request, request.response, read_timeout)
  565. 232 on_error(error)
  566. end
  567. 20 def set_request_timeout(request, timeout, start_event, finish_events, &callback)
  568. 31242 request.once(start_event) do
  569. 30840 interval = @timers.after(timeout, callback)
  570. 30840 Array(finish_events).each do |event|
  571. # clean up reques timeouts if the connection errors out
  572. 46237 request.once(event) do
  573. 46142 if @intervals.include?(interval)
  574. 45896 interval.delete(callback)
  575. 45896 @intervals.delete(interval) if interval.no_callbacks?
  576. end
  577. end
  578. end
  579. 30840 @intervals << interval
  580. end
  581. end
  582. 20 class << self
  583. 20 def parser_type(protocol)
  584. 4322 case protocol
  585. 1803 when "h2" then HTTP2
  586. 2519 when "http/1.1" then HTTP1
  587. else
  588. raise Error, "unsupported protocol (##{protocol})"
  589. end
  590. end
  591. end
  592. end
  593. end

lib/httpx/connection/http1.rb

90.91% lines covered

220 relevant lines. 200 lines covered and 20 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 require "httpx/parser/http1"
  3. 20 module HTTPX
  4. 20 class Connection::HTTP1
  5. 20 include Callbacks
  6. 20 include Loggable
  7. 20 MAX_REQUESTS = 200
  8. 20 CRLF = "\r\n"
  9. 20 attr_reader :pending, :requests
  10. 20 def initialize(buffer, options)
  11. 2519 @options = Options.new(options)
  12. 2519 @max_concurrent_requests = @options.max_concurrent_requests || MAX_REQUESTS
  13. 2519 @max_requests = @options.max_requests
  14. 2519 @parser = Parser::HTTP1.new(self)
  15. 2519 @buffer = buffer
  16. 2519 @version = [1, 1]
  17. 2519 @pending = []
  18. 2519 @requests = []
  19. 2519 @handshake_completed = false
  20. end
  21. 20 def timeout
  22. 2407 @options.timeout[:operation_timeout]
  23. end
  24. 20 def interests
  25. # this means we're processing incoming response already
  26. 18600 return :r if @request
  27. 16661 return if @requests.empty?
  28. 16216 request = @requests.first
  29. 16216 return unless request
  30. 16216 return :w if request.interests == :w || !@buffer.empty?
  31. 13571 :r
  32. end
  33. 20 def reset
  34. 4351 @max_requests = @options.max_requests || MAX_REQUESTS
  35. 4351 @parser.reset!
  36. 4351 @handshake_completed = false
  37. 4351 @pending.concat(@requests) unless @requests.empty?
  38. end
  39. 20 def close
  40. 2102 reset
  41. 2102 emit(:close, true)
  42. end
  43. 20 def exhausted?
  44. 532 !@max_requests.positive?
  45. end
  46. 20 def empty?
  47. # this means that for every request there's an available
  48. # partial response, so there are no in-flight requests waiting.
  49. 4299 @requests.empty? || (
  50. # checking all responses can be time-consuming. Alas, as in HTTP/1, responses
  51. # do not come out of order, we can get away with checking first and last.
  52. 219 !@requests.first.response.nil? &&
  53. 81 (@requests.size == 1 || !@requests.last.response.nil?)
  54. )
  55. end
  56. 20 def <<(data)
  57. 4165 @parser << data
  58. end
  59. 20 def send(request)
  60. 13518 unless @max_requests.positive?
  61. @pending << request
  62. return
  63. end
  64. 13518 return if @requests.include?(request)
  65. 13518 @requests << request
  66. 13518 @pipelining = true if @requests.size > 1
  67. end
  68. 20 def consume
  69. 9739 requests_limit = [@max_requests, @requests.size].min
  70. 9739 concurrent_requests_limit = [@max_concurrent_requests, requests_limit].min
  71. 9739 @requests.each_with_index do |request, idx|
  72. 11682 break if idx >= concurrent_requests_limit
  73. 9658 next if request.state == :done
  74. 3980 handle(request)
  75. end
  76. end
  77. # HTTP Parser callbacks
  78. #
  79. # must be public methods, or else they won't be reachable
  80. 20 def on_start
  81. 2888 log(level: 2) { "parsing begins" }
  82. end
  83. 20 def on_headers(h)
  84. 2873 @request = @requests.first
  85. 2873 return if @request.response
  86. 2888 log(level: 2) { "headers received" }
  87. 2873 headers = @request.options.headers_class.new(h)
  88. 2873 response = @request.options.response_class.new(@request,
  89. @parser.status_code,
  90. @parser.http_version.join("."),
  91. headers)
  92. 2888 log(color: :yellow) { "-> HEADLINE: #{response.status} HTTP/#{@parser.http_version.join(".")}" }
  93. 3008 log(color: :yellow) { response.headers.each.map { |f, v| "-> HEADER: #{f}: #{v}" }.join("\n") }
  94. 2873 @request.response = response
  95. 2868 on_complete if response.finished?
  96. end
  97. 20 def on_trailers(h)
  98. 5 return unless @request
  99. 5 response = @request.response
  100. 5 log(level: 2) { "trailer headers received" }
  101. 5 log(color: :yellow) { h.each.map { |f, v| "-> HEADER: #{f}: #{v.join(", ")}" }.join("\n") }
  102. 5 response.merge_headers(h)
  103. end
  104. 20 def on_data(chunk)
  105. 3254 request = @request
  106. 3254 return unless request
  107. 3269 log(color: :green) { "-> DATA: #{chunk.bytesize} bytes..." }
  108. 3269 log(level: 2, color: :green) { "-> #{chunk.inspect}" }
  109. 3254 response = request.response
  110. 3254 response << chunk
  111. rescue StandardError => e
  112. 9 error_response = ErrorResponse.new(request, e, request.options)
  113. 9 request.response = error_response
  114. 9 dispatch
  115. end
  116. 20 def on_complete
  117. 2854 request = @request
  118. 2854 return unless request
  119. 2869 log(level: 2) { "parsing complete" }
  120. 2854 dispatch
  121. end
  122. 20 def dispatch
  123. 2863 request = @request
  124. 2863 if request.expects?
  125. 45 @parser.reset!
  126. 45 return handle(request)
  127. end
  128. 2818 @request = nil
  129. 2818 @requests.shift
  130. 2818 response = request.response
  131. 2818 response.finish! unless response.is_a?(ErrorResponse)
  132. 2818 emit(:response, request, response)
  133. 2781 if @parser.upgrade?
  134. 20 response << @parser.upgrade_data
  135. 20 throw(:called)
  136. end
  137. 2761 @parser.reset!
  138. 2761 @max_requests -= 1
  139. 2761 if response.is_a?(ErrorResponse)
  140. 9 disable
  141. else
  142. 2752 manage_connection(request, response)
  143. end
  144. 532 if exhausted?
  145. @pending.concat(@requests)
  146. @requests.clear
  147. emit(:exhausted)
  148. else
  149. 532 send(@pending.shift) unless @pending.empty?
  150. end
  151. end
  152. 20 def handle_error(ex)
  153. 129 if (ex.is_a?(EOFError) || ex.is_a?(TimeoutError)) && @request && @request.response &&
  154. !@request.response.headers.key?("content-length") &&
  155. !@request.response.headers.key?("transfer-encoding")
  156. # if the response does not contain a content-length header, the server closing the
  157. # connnection is the indicator of response consumed.
  158. # https://greenbytes.de/tech/webdav/rfc2616.html#rfc.section.4.4
  159. 10 catch(:called) { on_complete }
  160. 5 return
  161. end
  162. 124 if @pipelining
  163. catch(:called) { disable }
  164. else
  165. 124 @requests.each do |request|
  166. 122 emit(:error, request, ex)
  167. end
  168. 124 @pending.each do |request|
  169. emit(:error, request, ex)
  170. end
  171. end
  172. end
  173. 20 def ping
  174. reset
  175. emit(:reset)
  176. emit(:exhausted)
  177. end
  178. 20 private
  179. 20 def manage_connection(request, response)
  180. 2752 connection = response.headers["connection"]
  181. 2752 case connection
  182. when /keep-alive/i
  183. 532 if @handshake_completed
  184. if @max_requests.zero?
  185. @pending.concat(@requests)
  186. @requests.clear
  187. emit(:exhausted)
  188. end
  189. return
  190. end
  191. 532 keep_alive = response.headers["keep-alive"]
  192. 532 return unless keep_alive
  193. 110 parameters = Hash[keep_alive.split(/ *, */).map do |pair|
  194. 110 pair.split(/ *= */)
  195. end]
  196. 110 @max_requests = parameters["max"].to_i - 1 if parameters.key?("max")
  197. 110 if parameters.key?("timeout")
  198. keep_alive_timeout = parameters["timeout"].to_i
  199. emit(:timeout, keep_alive_timeout)
  200. end
  201. 110 @handshake_completed = true
  202. when /close/i
  203. 2220 disable
  204. when nil
  205. # In HTTP/1.1, it's keep alive by default
  206. return if response.version == "1.1" && request.headers["connection"] != "close"
  207. disable
  208. end
  209. end
  210. 20 def disable
  211. 2229 disable_pipelining
  212. 2229 reset
  213. 2229 emit(:reset)
  214. 2224 throw(:called)
  215. end
  216. 20 def disable_pipelining
  217. 2229 return if @requests.empty?
  218. # do not disable pipelining if already set to 1 request at a time
  219. 150 return if @max_concurrent_requests == 1
  220. 13 @requests.each do |r|
  221. 13 r.transition(:idle)
  222. # when we disable pipelining, we still want to try keep-alive.
  223. # only when keep-alive with one request fails, do we fallback to
  224. # connection: close.
  225. 13 r.headers["connection"] = "close" if @max_concurrent_requests == 1
  226. end
  227. # server doesn't handle pipelining, and probably
  228. # doesn't support keep-alive. Fallback to send only
  229. # 1 keep alive request.
  230. 13 @max_concurrent_requests = 1
  231. 13 @pipelining = false
  232. end
  233. 20 def set_protocol_headers(request)
  234. 2968 if !request.headers.key?("content-length") &&
  235. request.body.bytesize == Float::INFINITY
  236. 20 request.body.chunk!
  237. end
  238. 2968 connection = request.headers["connection"]
  239. 2968 connection ||= if request.persistent?
  240. # when in a persistent connection, the request can't be at
  241. # the edge of a renegotiation
  242. 763 if @requests.index(request) + 1 < @max_requests
  243. 654 "keep-alive"
  244. else
  245. 109 "close"
  246. end
  247. else
  248. # when it's not a persistent connection, it sets "Connection: close" always
  249. # on the last request of the possible batch (either allowed max requests,
  250. # or if smaller, the size of the batch itself)
  251. 2190 requests_limit = [@max_requests, @requests.size].min
  252. 2190 if request == @requests[requests_limit - 1]
  253. 2161 "close"
  254. else
  255. 29 "keep-alive"
  256. end
  257. end
  258. 2968 extra_headers = { "connection" => connection }
  259. 2968 extra_headers["host"] = request.authority unless request.headers.key?("host")
  260. 2968 extra_headers
  261. end
  262. 20 def handle(request)
  263. 4025 catch(:buffer_full) do
  264. 4025 request.transition(:headers)
  265. 4020 join_headers(request) if request.state == :headers
  266. 4020 request.transition(:body)
  267. 4020 join_body(request) if request.state == :body
  268. 3080 request.transition(:trailers)
  269. # HTTP/1.1 trailers should only work for chunked encoding
  270. 3080 join_trailers(request) if request.body.chunked? && request.state == :trailers
  271. 3080 request.transition(:done)
  272. end
  273. end
  274. 20 def join_headline(request)
  275. 2918 "#{request.verb} #{request.path} HTTP/#{@version.join(".")}"
  276. end
  277. 20 def join_headers(request)
  278. 2968 headline = join_headline(request)
  279. 2968 @buffer << headline << CRLF
  280. 2983 log(color: :yellow) { "<- HEADLINE: #{headline.chomp.inspect}" }
  281. 2968 extra_headers = set_protocol_headers(request)
  282. 2968 join_headers2(request.headers.each(extra_headers))
  283. 2983 log { "<- " }
  284. 2968 @buffer << CRLF
  285. end
  286. 20 def join_body(request)
  287. 3893 return if request.body.empty?
  288. 4345 while (chunk = request.drain_body)
  289. 2460 log(color: :green) { "<- DATA: #{chunk.bytesize} bytes..." }
  290. 2460 log(level: 2, color: :green) { "<- #{chunk.inspect}" }
  291. 2460 @buffer << chunk
  292. 2460 throw(:buffer_full, request) if @buffer.full?
  293. end
  294. 897 return unless (error = request.drain_error)
  295. raise error
  296. end
  297. 20 def join_trailers(request)
  298. 60 return unless request.trailers? && request.callbacks_for?(:trailers)
  299. 20 join_headers2(request.trailers)
  300. 20 log { "<- " }
  301. 20 @buffer << CRLF
  302. end
  303. 20 def join_headers2(headers)
  304. 2988 buffer = "".b
  305. 2988 headers.each do |field, value|
  306. 18055 buffer << "#{capitalized(field)}: #{value}" << CRLF
  307. 18130 log(color: :yellow) { "<- HEADER: #{buffer.chomp}" }
  308. 18055 @buffer << buffer
  309. 18055 buffer.clear
  310. end
  311. end
  312. 20 UPCASED = {
  313. "www-authenticate" => "WWW-Authenticate",
  314. "http2-settings" => "HTTP2-Settings",
  315. }.freeze
  316. 20 def capitalized(field)
  317. 18055 UPCASED[field] || field.split("-").map(&:capitalize).join("-")
  318. end
  319. end
  320. end

lib/httpx/connection/http2.rb

95.92% lines covered

245 relevant lines. 235 lines covered and 10 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 require "securerandom"
  3. 20 require "http/2/next"
  4. 20 module HTTPX
  5. 20 class Connection::HTTP2
  6. 20 include Callbacks
  7. 20 include Loggable
  8. 20 MAX_CONCURRENT_REQUESTS = HTTP2Next::DEFAULT_MAX_CONCURRENT_STREAMS
  9. 20 class Error < Error
  10. 20 def initialize(id, code)
  11. 21 super("stream #{id} closed with error: #{code}")
  12. end
  13. end
  14. 20 class GoawayError < Error
  15. 20 def initialize
  16. 9 super(0, :no_error)
  17. end
  18. end
  19. 20 attr_reader :streams, :pending
  20. 20 def initialize(buffer, options)
  21. 1818 @options = Options.new(options)
  22. 1818 @settings = @options.http2_settings
  23. 1818 @pending = []
  24. 1818 @streams = {}
  25. 1818 @drains = {}
  26. 1818 @pings = []
  27. 1818 @buffer = buffer
  28. 1818 @handshake_completed = false
  29. 1818 @wait_for_handshake = @settings.key?(:wait_for_handshake) ? @settings.delete(:wait_for_handshake) : true
  30. 1818 @max_concurrent_requests = @options.max_concurrent_requests || MAX_CONCURRENT_REQUESTS
  31. 1818 @max_requests = @options.max_requests
  32. 1818 init_connection
  33. end
  34. 20 def timeout
  35. 3592 return @options.timeout[:operation_timeout] if @handshake_completed
  36. 1791 @options.timeout[:settings_timeout]
  37. end
  38. 20 def interests
  39. # waiting for WINDOW_UPDATE frames
  40. 1606062 return :r if @buffer.full?
  41. 1606062 if @connection.state == :closed
  42. 1659 return unless @handshake_completed
  43. 1636 return :w
  44. end
  45. 1604403 unless @connection.state == :connected && @handshake_completed
  46. 7417 return @buffer.empty? ? :r : :rw
  47. end
  48. 1596986 return :w if !@pending.empty? && can_buffer_more_requests?
  49. 1596986 return :w unless @drains.empty?
  50. 1596444 if @buffer.empty?
  51. 1596444 return if @streams.empty? && @pings.empty?
  52. 24332 return :r
  53. end
  54. :rw
  55. end
  56. 20 def close
  57. 1724 unless @connection.state == :closed
  58. 1715 @connection.goaway
  59. 1715 emit(:timeout, @options.timeout[:close_handshake_timeout])
  60. end
  61. 1724 emit(:close, true)
  62. end
  63. 20 def empty?
  64. 1696 @connection.state == :closed || @streams.empty?
  65. end
  66. 20 def exhausted?
  67. 1814 !@max_requests.positive?
  68. end
  69. 20 def <<(data)
  70. 18855 @connection << data
  71. end
  72. 20 def can_buffer_more_requests?
  73. 4267 (@handshake_completed || !@wait_for_handshake) &&
  74. @streams.size < @max_concurrent_requests &&
  75. @streams.size < @max_requests
  76. end
  77. 20 def send(request)
  78. 3979 unless can_buffer_more_requests?
  79. 1941 @pending << request
  80. 1941 return
  81. end
  82. 2038 unless (stream = @streams[request])
  83. 2038 stream = @connection.new_stream
  84. 2038 handle_stream(stream, request)
  85. 2038 @streams[request] = stream
  86. 2038 @max_requests -= 1
  87. end
  88. 2038 handle(request, stream)
  89. 2028 true
  90. rescue HTTP2Next::Error::StreamLimitExceeded
  91. @pending.unshift(request)
  92. end
  93. 20 def consume
  94. 13848 @streams.each do |request, stream|
  95. 5317 next if request.state == :done
  96. 620 handle(request, stream)
  97. end
  98. end
  99. 20 def handle_error(ex)
  100. 155 if ex.instance_of?(TimeoutError) && !@handshake_completed && @connection.state != :closed
  101. 5 @connection.goaway(:settings_timeout, "closing due to settings timeout")
  102. 5 emit(:close_handshake)
  103. 5 settings_ex = SettingsTimeoutError.new(ex.timeout, ex.message)
  104. 5 settings_ex.set_backtrace(ex.backtrace)
  105. 5 ex = settings_ex
  106. end
  107. 155 @streams.each_key do |request|
  108. 134 emit(:error, request, ex)
  109. end
  110. 155 @pending.each do |request|
  111. 18 emit(:error, request, ex)
  112. end
  113. end
  114. 20 def ping
  115. 5 ping = SecureRandom.gen_random(8)
  116. 5 @connection.ping(ping)
  117. ensure
  118. 5 @pings << ping
  119. end
  120. 20 private
  121. 20 def send_pending
  122. 4777 while (request = @pending.shift)
  123. 1867 break unless send(request)
  124. end
  125. end
  126. 20 def handle(request, stream)
  127. 2693 catch(:buffer_full) do
  128. 2693 request.transition(:headers)
  129. 2688 join_headers(stream, request) if request.state == :headers
  130. 2688 request.transition(:body)
  131. 2688 join_body(stream, request) if request.state == :body
  132. 2141 request.transition(:trailers)
  133. 2141 join_trailers(stream, request) if request.state == :trailers && !request.body.empty?
  134. 2141 request.transition(:done)
  135. end
  136. end
  137. 20 def init_connection
  138. 1818 @connection = HTTP2Next::Client.new(@settings)
  139. 1818 @connection.on(:frame, &method(:on_frame))
  140. 1818 @connection.on(:frame_sent, &method(:on_frame_sent))
  141. 1818 @connection.on(:frame_received, &method(:on_frame_received))
  142. 1818 @connection.on(:origin, &method(:on_origin))
  143. 1818 @connection.on(:promise, &method(:on_promise))
  144. 1818 @connection.on(:altsvc) { |frame| on_altsvc(frame[:origin], frame) }
  145. 1818 @connection.on(:settings_ack, &method(:on_settings))
  146. 1818 @connection.on(:ack, &method(:on_pong))
  147. 1818 @connection.on(:goaway, &method(:on_close))
  148. #
  149. # Some servers initiate HTTP/2 negotiation right away, some don't.
  150. # As such, we have to check the socket buffer. If there is something
  151. # to read, the server initiated the negotiation. If not, we have to
  152. # initiate it.
  153. #
  154. 1818 @connection.send_connection_preface
  155. end
  156. 20 alias_method :reset, :init_connection
  157. 20 public :reset
  158. 20 def handle_stream(stream, request)
  159. 2048 request.on(:refuse, &method(:on_stream_refuse).curry(3)[stream, request])
  160. 2048 stream.on(:close, &method(:on_stream_close).curry(3)[stream, request])
  161. 2048 stream.on(:half_close) do
  162. 2035 log(level: 2) { "#{stream.id}: waiting for response..." }
  163. end
  164. 2048 stream.on(:altsvc, &method(:on_altsvc).curry(2)[request.origin])
  165. 2048 stream.on(:headers, &method(:on_stream_headers).curry(3)[stream, request])
  166. 2048 stream.on(:data, &method(:on_stream_data).curry(3)[stream, request])
  167. end
  168. 20 def set_protocol_headers(request)
  169. {
  170. 2032 ":scheme" => request.scheme,
  171. ":method" => request.verb,
  172. ":path" => request.path,
  173. ":authority" => request.authority,
  174. }
  175. end
  176. 20 def join_headers(stream, request)
  177. 2033 extra_headers = set_protocol_headers(request)
  178. 2033 if request.headers.key?("host")
  179. 5 log { "forbidden \"host\" header found (#{request.headers["host"]}), will use it as authority..." }
  180. 5 extra_headers[":authority"] = request.headers["host"]
  181. end
  182. 2033 log(level: 1, color: :yellow) do
  183. 90 request.headers.merge(extra_headers).each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{v}" }.join("\n")
  184. end
  185. 2033 stream.headers(request.headers.each(extra_headers), end_stream: request.body.empty?)
  186. end
  187. 20 def join_trailers(stream, request)
  188. 833 unless request.trailers?
  189. 828 stream.data("", end_stream: true) if request.callbacks_for?(:trailers)
  190. 828 return
  191. end
  192. 5 log(level: 1, color: :yellow) do
  193. 10 request.trailers.each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{v}" }.join("\n")
  194. end
  195. 5 stream.headers(request.trailers.each, end_stream: true)
  196. end
  197. 20 def join_body(stream, request)
  198. 2580 return if request.body.empty?
  199. 1380 chunk = @drains.delete(request) || request.drain_body
  200. 1380 while chunk
  201. 1659 next_chunk = request.drain_body
  202. 1674 log(level: 1, color: :green) { "#{stream.id}: -> DATA: #{chunk.bytesize} bytes..." }
  203. 1674 log(level: 2, color: :green) { "#{stream.id}: -> #{chunk.inspect}" }
  204. 1659 stream.data(chunk, end_stream: !(next_chunk || request.trailers? || request.callbacks_for?(:trailers)))
  205. 1659 if next_chunk && (@buffer.full? || request.body.unbounded_body?)
  206. 547 @drains[request] = next_chunk
  207. 547 throw(:buffer_full)
  208. end
  209. 1112 chunk = next_chunk
  210. end
  211. 833 return unless (error = request.drain_error)
  212. 8 on_stream_refuse(stream, request, error)
  213. end
  214. ######
  215. # HTTP/2 Callbacks
  216. ######
  217. 20 def on_stream_headers(stream, request, h)
  218. 2022 response = request.response
  219. 2022 if response.is_a?(Response) && response.version == "2.0"
  220. 76 on_stream_trailers(stream, response, h)
  221. 76 return
  222. end
  223. 1946 log(color: :yellow) do
  224. 90 h.map { |k, v| "#{stream.id}: <- HEADER: #{k}: #{v}" }.join("\n")
  225. end
  226. 1946 _, status = h.shift
  227. 1946 headers = request.options.headers_class.new(h)
  228. 1946 response = request.options.response_class.new(request, status, "2.0", headers)
  229. 1946 request.response = response
  230. 1941 @streams[request] = stream
  231. 1941 handle(request, stream) if request.expects?
  232. end
  233. 20 def on_stream_trailers(stream, response, h)
  234. 76 log(color: :yellow) do
  235. h.map { |k, v| "#{stream.id}: <- HEADER: #{k}: #{v}" }.join("\n")
  236. end
  237. 76 response.merge_headers(h)
  238. end
  239. 20 def on_stream_data(stream, request, data)
  240. 3453 log(level: 1, color: :green) { "#{stream.id}: <- DATA: #{data.bytesize} bytes..." }
  241. 3453 log(level: 2, color: :green) { "#{stream.id}: <- #{data.inspect}" }
  242. 3437 request.response << data
  243. end
  244. 20 def on_stream_refuse(stream, request, error)
  245. 8 on_stream_close(stream, request, error)
  246. 8 stream.close
  247. end
  248. 20 def on_stream_close(stream, request, error)
  249. 1898 return if error == :stream_closed && !@streams.key?(request)
  250. 1900 log(level: 2) { "#{stream.id}: closing stream" }
  251. 1890 @drains.delete(request)
  252. 1890 @streams.delete(request)
  253. 1890 if error
  254. 8 ex = Error.new(stream.id, error)
  255. 8 ex.set_backtrace(caller)
  256. 8 response = ErrorResponse.new(request, ex, request.options)
  257. 8 request.response = response
  258. 8 emit(:response, request, response)
  259. else
  260. 1882 response = request.response
  261. 1882 if response && response.is_a?(Response) && response.status == 421
  262. 5 ex = MisdirectedRequestError.new(response)
  263. 5 ex.set_backtrace(caller)
  264. 5 emit(:error, request, ex)
  265. else
  266. 1877 emit(:response, request, response)
  267. end
  268. end
  269. 1885 send(@pending.shift) unless @pending.empty?
  270. 1885 return unless @streams.empty? && exhausted?
  271. 5 close
  272. 5 emit(:exhausted) unless @pending.empty?
  273. end
  274. 20 def on_frame(bytes)
  275. 11594 @buffer << bytes
  276. end
  277. 20 def on_settings(*)
  278. 1801 @handshake_completed = true
  279. 1801 emit(:current_timeout)
  280. 1801 @max_concurrent_requests = [@max_concurrent_requests, @connection.remote_settings[:settings_max_concurrent_streams]].min
  281. 1801 send_pending
  282. end
  283. 20 def on_close(_last_frame, error, _payload)
  284. 13 is_connection_closed = @connection.state == :closed
  285. 13 if error
  286. 13 @buffer.clear if is_connection_closed
  287. 13 if error == :no_error
  288. 9 ex = GoawayError.new
  289. 9 @pending.unshift(*@streams.keys)
  290. 9 @drains.clear
  291. 9 @streams.clear
  292. else
  293. 4 ex = Error.new(0, error)
  294. end
  295. 13 ex.set_backtrace(caller)
  296. 13 handle_error(ex)
  297. end
  298. 13 return unless is_connection_closed && @streams.empty?
  299. 13 emit(:close, is_connection_closed)
  300. end
  301. 20 def on_frame_sent(frame)
  302. 9831 log(level: 2) { "#{frame[:stream]}: frame was sent!" }
  303. 9771 log(level: 2, color: :blue) do
  304. 60 payload = frame
  305. 60 payload = payload.merge(payload: frame[:payload].bytesize) if frame[:type] == :data
  306. 60 "#{frame[:stream]}: #{payload}"
  307. end
  308. end
  309. 20 def on_frame_received(frame)
  310. 9948 log(level: 2) { "#{frame[:stream]}: frame was received!" }
  311. 9902 log(level: 2, color: :magenta) do
  312. 46 payload = frame
  313. 46 payload = payload.merge(payload: frame[:payload].bytesize) if frame[:type] == :data
  314. 46 "#{frame[:stream]}: #{payload}"
  315. end
  316. end
  317. 20 def on_altsvc(origin, frame)
  318. log(level: 2) { "#{frame[:stream]}: altsvc frame was received" }
  319. log(level: 2) { "#{frame[:stream]}: #{frame.inspect}" }
  320. alt_origin = URI.parse("#{frame[:proto]}://#{frame[:host]}:#{frame[:port]}")
  321. params = { "ma" => frame[:max_age] }
  322. emit(:altsvc, origin, alt_origin, origin, params)
  323. end
  324. 20 def on_promise(stream)
  325. 15 emit(:promise, @streams.key(stream.parent), stream)
  326. end
  327. 20 def on_origin(origin)
  328. emit(:origin, origin)
  329. end
  330. 20 def on_pong(ping)
  331. 5 if @pings.delete(ping.to_s)
  332. 5 emit(:pong)
  333. else
  334. close(:protocol_error, "ping payload did not match")
  335. end
  336. end
  337. end
  338. end

lib/httpx/domain_name.rb

95.45% lines covered

44 relevant lines. 42 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. #
  3. # domain_name.rb - Domain Name manipulation library for Ruby
  4. #
  5. # Copyright (C) 2011-2017 Akinori MUSHA, All rights reserved.
  6. #
  7. # Redistribution and use in source and binary forms, with or without
  8. # modification, are permitted provided that the following conditions
  9. # are met:
  10. # 1. Redistributions of source code must retain the above copyright
  11. # notice, this list of conditions and the following disclaimer.
  12. # 2. Redistributions in binary form must reproduce the above copyright
  13. # notice, this list of conditions and the following disclaimer in the
  14. # documentation and/or other materials provided with the distribution.
  15. #
  16. # THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
  17. # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  18. # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
  19. # ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
  20. # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
  21. # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
  22. # OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
  23. # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
  24. # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
  25. # OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
  26. # SUCH DAMAGE.
  27. 20 require "ipaddr"
  28. 20 module HTTPX
  29. # Represents a domain name ready for extracting its registered domain
  30. # and TLD.
  31. 20 class DomainName
  32. 20 include Comparable
  33. # The full host name normalized, ASCII-ized and downcased using the
  34. # Unicode NFC rules and the Punycode algorithm. If initialized with
  35. # an IP address, the string representation of the IP address
  36. # suitable for opening a connection to.
  37. 20 attr_reader :hostname
  38. # The Unicode representation of the #hostname property.
  39. #
  40. # :attr_reader: hostname_idn
  41. # The least "universally original" domain part of this domain name.
  42. # For example, "example.co.uk" for "www.sub.example.co.uk". This
  43. # may be nil if the hostname does not have one, like when it is an
  44. # IP address, an effective TLD or higher itself, or of a
  45. # non-canonical domain.
  46. 20 attr_reader :domain
  47. 20 class << self
  48. 20 def new(domain)
  49. 535 return domain if domain.is_a?(self)
  50. 495 super(domain)
  51. end
  52. # Normalizes a _domain_ using the Punycode algorithm as necessary.
  53. # The result will be a downcased, ASCII-only string.
  54. 20 def normalize(domain)
  55. 475 unless domain.ascii_only?
  56. domain = domain.chomp(".").unicode_normalize(:nfc)
  57. domain = Punycode.encode_hostname(domain)
  58. end
  59. 475 domain.downcase
  60. end
  61. end
  62. # Parses _hostname_ into a DomainName object. An IP address is also
  63. # accepted. An IPv6 address may be enclosed in square brackets.
  64. 20 def initialize(hostname)
  65. 495 hostname = String(hostname)
  66. 495 raise ArgumentError, "domain name must not start with a dot: #{hostname}" if hostname.start_with?(".")
  67. begin
  68. 495 @ipaddr = IPAddr.new(hostname)
  69. 20 @hostname = @ipaddr.to_s
  70. 20 return
  71. rescue IPAddr::Error
  72. 475 nil
  73. end
  74. 475 @hostname = DomainName.normalize(hostname)
  75. 475 tld = if (last_dot = @hostname.rindex("."))
  76. 115 @hostname[(last_dot + 1)..-1]
  77. else
  78. 360 @hostname
  79. end
  80. # unknown/local TLD
  81. 475 @domain = if last_dot
  82. # fallback - accept cookies down to second level
  83. # cf. http://www.dkim-reputation.org/regdom-libs/
  84. 115 if (penultimate_dot = @hostname.rindex(".", last_dot - 1))
  85. 30 @hostname[(penultimate_dot + 1)..-1]
  86. else
  87. 85 @hostname
  88. end
  89. else
  90. # no domain part - must be a local hostname
  91. 360 tld
  92. end
  93. end
  94. # Checks if the server represented by this domain is qualified to
  95. # send and receive cookies with a domain attribute value of
  96. # _domain_. A true value given as the second argument represents
  97. # cookies without a domain attribute value, in which case only
  98. # hostname equality is checked.
  99. 20 def cookie_domain?(domain, host_only = false)
  100. # RFC 6265 #5.3
  101. # When the user agent "receives a cookie":
  102. 20 return self == @domain if host_only
  103. 20 domain = DomainName.new(domain)
  104. # RFC 6265 #5.1.3
  105. # Do not perform subdomain matching against IP addresses.
  106. 20 @hostname == domain.hostname if @ipaddr
  107. # RFC 6265 #4.1.1
  108. # Domain-value must be a subdomain.
  109. 20 @domain && self <= domain && domain <= @domain
  110. end
  111. 20 def <=>(other)
  112. 30 other = DomainName.new(other)
  113. 30 othername = other.hostname
  114. 30 if othername == @hostname
  115. 10 0
  116. 19 elsif @hostname.end_with?(othername) && @hostname[-othername.size - 1, 1] == "."
  117. # The other is higher
  118. 10 -1
  119. else
  120. # The other is lower
  121. 10 1
  122. end
  123. end
  124. end
  125. end

lib/httpx/errors.rb

97.56% lines covered

41 relevant lines. 40 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 module HTTPX
  3. # the default exception class for exceptions raised by HTTPX.
  4. 20 class Error < StandardError; end
  5. 20 class UnsupportedSchemeError < Error; end
  6. 20 class ConnectionError < Error; end
  7. # Error raised when there was a timeout. Its subclasses allow for finer-grained
  8. # control of which timeout happened.
  9. 20 class TimeoutError < Error
  10. # The timeout value which caused this error to be raised.
  11. 20 attr_reader :timeout
  12. # initializes the timeout exception with the +timeout+ causing the error, and the
  13. # error +message+ for it.
  14. 20 def initialize(timeout, message)
  15. 308 @timeout = timeout
  16. 308 super(message)
  17. end
  18. # clones this error into a HTTPX::ConnectionTimeoutError.
  19. 20 def to_connection_error
  20. 15 ex = ConnectTimeoutError.new(@timeout, message)
  21. 15 ex.set_backtrace(backtrace)
  22. 15 ex
  23. end
  24. end
  25. # Error raised when there was a timeout establishing the connection to a server.
  26. # This may be raised due to timeouts during TCP and TLS (when applicable) connection
  27. # establishment.
  28. 20 class ConnectTimeoutError < TimeoutError; end
  29. # Error raised when there was a timeout while sending a request, or receiving a response
  30. # from the server.
  31. 20 class RequestTimeoutError < TimeoutError
  32. # The HTTPX::Request request object this exception refers to.
  33. 20 attr_reader :request
  34. # initializes the exception with the +request+ and +response+ it refers to, and the
  35. # +timeout+ causing the error, and the
  36. 20 def initialize(request, response, timeout)
  37. 247 @request = request
  38. 247 @response = response
  39. 247 super(timeout, "Timed out after #{timeout} seconds")
  40. end
  41. 20 def marshal_dump
  42. [message]
  43. end
  44. end
  45. # Error raised when there was a timeout while receiving a response from the server.
  46. 20 class ReadTimeoutError < RequestTimeoutError; end
  47. # Error raised when there was a timeout while sending a request from the server.
  48. 20 class WriteTimeoutError < RequestTimeoutError; end
  49. # Error raised when there was a timeout while waiting for the HTTP/2 settings frame from the server.
  50. 20 class SettingsTimeoutError < TimeoutError; end
  51. # Error raised when there was a timeout while resolving a domain to an IP.
  52. 20 class ResolveTimeoutError < TimeoutError; end
  53. # Error raised when there was an error while resolving a domain to an IP.
  54. 20 class ResolveError < Error; end
  55. # Error raised when there was an error while resolving a domain to an IP
  56. # using a HTTPX::Resolver::Native resolver.
  57. 20 class NativeResolveError < ResolveError
  58. 20 attr_reader :connection, :host
  59. # initializes the exception with the +connection+ it refers to, the +host+ domain
  60. # which failed to resolve, and the error +message+.
  61. 20 def initialize(connection, host, message = "Can't resolve #{host}")
  62. 73 @connection = connection
  63. 73 @host = host
  64. 73 super(message)
  65. end
  66. end
  67. # The exception class for HTTP responses with 4xx or 5xx status.
  68. 20 class HTTPError < Error
  69. # The HTTPX::Response response object this exception refers to.
  70. 20 attr_reader :response
  71. # Creates the instance and assigns the HTTPX::Response +response+.
  72. 20 def initialize(response)
  73. 51 @response = response
  74. 51 super("HTTP Error: #{@response.status} #{@response.headers}\n#{@response.body}")
  75. end
  76. # The HTTP response status.
  77. #
  78. # error.status #=> 404
  79. 20 def status
  80. 10 @response.status
  81. end
  82. end
  83. # error raised when a request was sent a server which can't reproduce a response, and
  84. # has therefore returned an HTTP response using the 421 status code.
  85. 20 class MisdirectedRequestError < HTTPError; end
  86. end

lib/httpx/extensions.rb

67.86% lines covered

28 relevant lines. 19 lines covered and 9 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 require "uri"
  3. 20 module HTTPX
  4. 20 module ArrayExtensions
  5. 20 module FilterMap
  6. refine Array do
  7. # Ruby 2.7 backport
  8. def filter_map
  9. return to_enum(:filter_map) unless block_given?
  10. each_with_object([]) do |item, res|
  11. processed = yield(item)
  12. res << processed if processed
  13. end
  14. end
  15. 19 end unless Array.method_defined?(:filter_map)
  16. end
  17. 20 module Intersect
  18. refine Array do
  19. # Ruby 3.1 backport
  20. 4 def intersect?(arr)
  21. if size < arr.size
  22. smaller = self
  23. else
  24. smaller, arr = arr, self
  25. end
  26. (arr & smaller).size > 0
  27. end
  28. 19 end unless Array.method_defined?(:intersect?)
  29. end
  30. end
  31. 20 module URIExtensions
  32. # uri 0.11 backport, ships with ruby 3.1
  33. 20 refine URI::Generic do
  34. 20 def non_ascii_hostname
  35. 264 @non_ascii_hostname
  36. end
  37. 20 def non_ascii_hostname=(hostname)
  38. 20 @non_ascii_hostname = hostname
  39. end
  40. def authority
  41. 8341 return host if port == default_port
  42. 587 "#{host}:#{port}"
  43. 19 end unless URI::HTTP.method_defined?(:authority)
  44. def origin
  45. 6475 "#{scheme}://#{authority}"
  46. 19 end unless URI::HTTP.method_defined?(:origin)
  47. end
  48. end
  49. end

lib/httpx/headers.rb

100.0% lines covered

71 relevant lines. 71 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 module HTTPX
  3. 20 class Headers
  4. 20 class << self
  5. 20 def new(headers = nil)
  6. 21123 return headers if headers.is_a?(self)
  7. 13150 super
  8. end
  9. end
  10. 20 def initialize(headers = nil)
  11. 13150 @headers = {}
  12. 13150 return unless headers
  13. 13041 headers.each do |field, value|
  14. 38743 array_value(value).each do |v|
  15. 38951 add(downcased(field), v)
  16. end
  17. end
  18. end
  19. # cloned initialization
  20. 20 def initialize_clone(orig)
  21. 5 super
  22. 5 @headers = orig.instance_variable_get(:@headers).clone
  23. end
  24. # dupped initialization
  25. 20 def initialize_dup(orig)
  26. 2646 super
  27. 2646 @headers = orig.instance_variable_get(:@headers).dup
  28. end
  29. # freezes the headers hash
  30. 20 def freeze
  31. 9350 @headers.freeze
  32. 9350 super
  33. end
  34. 20 def same_headers?(headers)
  35. 20 @headers.empty? || begin
  36. 20 headers.each do |k, v|
  37. 45 next unless key?(k)
  38. 45 return false unless v == self[k]
  39. end
  40. 10 true
  41. end
  42. end
  43. # merges headers with another header-quack.
  44. # the merge rule is, if the header already exists,
  45. # ignore what the +other+ headers has. Otherwise, set
  46. #
  47. 20 def merge(other)
  48. 2636 headers = dup
  49. 2636 other.each do |field, value|
  50. 2176 headers[downcased(field)] = value
  51. end
  52. 2636 headers
  53. end
  54. # returns the comma-separated values of the header field
  55. # identified by +field+, or nil otherwise.
  56. #
  57. 20 def [](field)
  58. 55751 a = @headers[downcased(field)] || return
  59. 15898 a.join(", ")
  60. end
  61. # sets +value+ (if not nil) as single value for the +field+ header.
  62. #
  63. 20 def []=(field, value)
  64. 23003 return unless value
  65. 23003 @headers[downcased(field)] = array_value(value)
  66. end
  67. # deletes all values associated with +field+ header.
  68. #
  69. 20 def delete(field)
  70. 98 canonical = downcased(field)
  71. 98 @headers.delete(canonical) if @headers.key?(canonical)
  72. end
  73. # adds additional +value+ to the existing, for header +field+.
  74. #
  75. 20 def add(field, value)
  76. 39221 (@headers[downcased(field)] ||= []) << String(value)
  77. end
  78. # helper to be used when adding an header field as a value to another field
  79. #
  80. # h2_headers.add_header("vary", "accept-encoding")
  81. # h2_headers["vary"] #=> "accept-encoding"
  82. # h1_headers.add_header("vary", "accept-encoding")
  83. # h1_headers["vary"] #=> "Accept-Encoding"
  84. #
  85. 20 alias_method :add_header, :add
  86. # returns the enumerable headers store in pairs of header field + the values in
  87. # the comma-separated string format
  88. #
  89. 20 def each(extra_headers = nil)
  90. 43696 return enum_for(__method__, extra_headers) { @headers.size } unless block_given?
  91. 26158 @headers.each do |field, value|
  92. 29209 yield(field, value.join(", ")) unless value.empty?
  93. end
  94. 4199 extra_headers.each do |field, value|
  95. 14124 yield(field, value) unless value.empty?
  96. 26147 end if extra_headers
  97. end
  98. 20 def ==(other)
  99. 11704 other == to_hash
  100. end
  101. # the headers store in Hash format
  102. 20 def to_hash
  103. 12403 Hash[to_a]
  104. end
  105. 20 alias_method :to_h, :to_hash
  106. # the headers store in array of pairs format
  107. 20 def to_a
  108. 12417 Array(each)
  109. end
  110. # headers as string
  111. 20 def to_s
  112. 1256 @headers.to_s
  113. end
  114. skipped # :nocov:
  115. skipped def inspect
  116. skipped to_hash.inspect
  117. skipped end
  118. skipped # :nocov:
  119. # this is internal API and doesn't abide to other public API
  120. # guarantees, like downcasing strings.
  121. # Please do not use this outside of core!
  122. #
  123. 20 def key?(downcased_key)
  124. 33912 @headers.key?(downcased_key)
  125. end
  126. # returns the values for the +field+ header in array format.
  127. # This method is more internal, and for this reason doesn't try
  128. # to "correct" the user input, i.e. it doesn't downcase the key.
  129. #
  130. 20 def get(field)
  131. 160 @headers[field] || EMPTY
  132. end
  133. 20 private
  134. 20 def array_value(value)
  135. 61746 case value
  136. when Array
  137. 57205 value.map { |val| String(val).strip }
  138. else
  139. 36061 [String(value).strip]
  140. end
  141. end
  142. 20 def downcased(field)
  143. 159200 String(field).downcase
  144. end
  145. end
  146. end

lib/httpx/io.rb

100.0% lines covered

5 relevant lines. 5 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 require "socket"
  3. 20 require "httpx/io/udp"
  4. 20 require "httpx/io/tcp"
  5. 20 require "httpx/io/unix"
  6. begin
  7. 20 require "httpx/io/ssl"
  8. rescue LoadError
  9. end

lib/httpx/io/ssl.rb

96.2% lines covered

79 relevant lines. 76 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 require "openssl"
  3. 20 module HTTPX
  4. 20 TLSError = OpenSSL::SSL::SSLError
  5. 20 class SSL < TCP
  6. # rubocop:disable Style/MutableConstant
  7. 20 TLS_OPTIONS = { alpn_protocols: %w[h2 http/1.1].freeze }
  8. # https://github.com/jruby/jruby-openssl/issues/284
  9. 20 TLS_OPTIONS[:verify_hostname] = true if RUBY_ENGINE == "jruby"
  10. # rubocop:enable Style/MutableConstant
  11. 20 TLS_OPTIONS.freeze
  12. 20 attr_writer :ssl_session
  13. 20 def initialize(_, _, options)
  14. 1780 super
  15. 1780 ctx_options = TLS_OPTIONS.merge(options.ssl)
  16. 1780 @sni_hostname = ctx_options.delete(:hostname) || @hostname
  17. 1780 if @keep_open && @io.is_a?(OpenSSL::SSL::SSLSocket)
  18. # externally initiated ssl socket
  19. 14 @ctx = @io.context
  20. 14 @state = :negotiated
  21. else
  22. 1766 @ctx = OpenSSL::SSL::SSLContext.new
  23. 1766 @ctx.set_params(ctx_options) unless ctx_options.empty?
  24. 1766 unless @ctx.session_cache_mode.nil? # a dummy method on JRuby
  25. 1453 @ctx.session_cache_mode =
  26. OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT | OpenSSL::SSL::SSLContext::SESSION_CACHE_NO_INTERNAL_STORE
  27. end
  28. 1766 yield(self) if block_given?
  29. end
  30. 1780 @verify_hostname = @ctx.verify_hostname
  31. end
  32. 20 if OpenSSL::SSL::SSLContext.method_defined?(:session_new_cb=)
  33. 19 def session_new_cb(&pr)
  34. 4105 @ctx.session_new_cb = proc { |_, sess| pr.call(sess) }
  35. end
  36. else
  37. # session_new_cb not implemented under JRuby
  38. 1 def session_new_cb; end
  39. end
  40. 20 def protocol
  41. 1524 @io.alpn_protocol || super
  42. rescue StandardError
  43. 18 super
  44. end
  45. 20 if RUBY_ENGINE == "jruby"
  46. # in jruby, alpn_protocol may return ""
  47. # https://github.com/jruby/jruby-openssl/issues/287
  48. 1 def protocol
  49. 326 proto = @io.alpn_protocol
  50. 324 return super if proto.nil? || proto.empty?
  51. 323 proto
  52. rescue StandardError
  53. 2 super
  54. end
  55. end
  56. 20 def can_verify_peer?
  57. 9 @ctx.verify_mode == OpenSSL::SSL::VERIFY_PEER
  58. end
  59. 20 def verify_hostname(host)
  60. 11 return false if @ctx.verify_mode == OpenSSL::SSL::VERIFY_NONE
  61. 11 return false if !@io.respond_to?(:peer_cert) || @io.peer_cert.nil?
  62. 11 OpenSSL::SSL.verify_certificate_identity(@io.peer_cert, host)
  63. end
  64. 20 def connected?
  65. 6625 @state == :negotiated
  66. end
  67. 20 def expired?
  68. super || ssl_session_expired?
  69. end
  70. 20 def ssl_session_expired?
  71. 1808 @ssl_session.nil? || Process.clock_gettime(Process::CLOCK_REALTIME) >= (@ssl_session.time.to_f + @ssl_session.timeout)
  72. end
  73. 20 def connect
  74. 6657 super
  75. 6640 return if @state == :negotiated ||
  76. @state != :connected
  77. 4634 unless @io.is_a?(OpenSSL::SSL::SSLSocket)
  78. 1808 if (hostname_is_ip = (@ip == @sni_hostname))
  79. # IPv6 address would be "[::1]", must turn to "0000:0000:0000:0000:0000:0000:0000:0001" for cert SAN check
  80. 20 @sni_hostname = @ip.to_string
  81. # IP addresses in SNI is not valid per RFC 6066, section 3.
  82. 20 @ctx.verify_hostname = false
  83. end
  84. 1808 @io = OpenSSL::SSL::SSLSocket.new(@io, @ctx)
  85. 1808 @io.hostname = @sni_hostname unless hostname_is_ip
  86. 1808 @io.session = @ssl_session unless ssl_session_expired?
  87. 1808 @io.sync_close = true
  88. end
  89. 4634 try_ssl_connect
  90. end
  91. 20 def try_ssl_connect
  92. 4634 ret = @io.connect_nonblock(exception: false)
  93. 4646 log(level: 3, color: :cyan) { "TLS CONNECT: #{ret}..." }
  94. 4620 case ret
  95. when :wait_readable
  96. 2841 @interests = :r
  97. 2841 return
  98. when :wait_writable
  99. @interests = :w
  100. return
  101. end
  102. 1779 @io.post_connection_check(@sni_hostname) if @ctx.verify_mode != OpenSSL::SSL::VERIFY_NONE && @verify_hostname
  103. 1778 transition(:negotiated)
  104. 1778 @interests = :w
  105. end
  106. 20 private
  107. 20 def transition(nextstate)
  108. 7016 case nextstate
  109. when :negotiated
  110. 1778 return unless @state == :connected
  111. when :closed
  112. 1719 return unless @state == :negotiated ||
  113. @state == :connected
  114. end
  115. 7016 do_transition(nextstate)
  116. end
  117. 20 def log_transition_state(nextstate)
  118. 44 return super unless nextstate == :negotiated
  119. 10 server_cert = @io.peer_cert
  120. 10 "#{super}\n\n" \
  121. "SSL connection using #{@io.ssl_version} / #{Array(@io.cipher).first}\n" \
  122. "ALPN, server accepted to use #{protocol}\n" \
  123. "Server certificate:\n " \
  124. "subject: #{server_cert.subject}\n " \
  125. "start date: #{server_cert.not_before}\n " \
  126. "expire date: #{server_cert.not_after}\n " \
  127. "issuer: #{server_cert.issuer}\n " \
  128. "SSL certificate verify ok."
  129. end
  130. end
  131. end

lib/httpx/io/tcp.rb

90.27% lines covered

113 relevant lines. 102 lines covered and 11 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 require "resolv"
  3. 20 require "ipaddr"
  4. 20 module HTTPX
  5. 20 class TCP
  6. 20 include Loggable
  7. 20 using URIExtensions
  8. 20 attr_reader :ip, :port, :addresses, :state, :interests
  9. 20 alias_method :host, :ip
  10. 20 def initialize(origin, addresses, options)
  11. 4034 @state = :idle
  12. 4034 @addresses = []
  13. 4034 @hostname = origin.host
  14. 4034 @options = Options.new(options)
  15. 4034 @fallback_protocol = @options.fallback_protocol
  16. 4034 @port = origin.port
  17. 4034 @interests = :w
  18. 4034 if @options.io
  19. 33 @io = case @options.io
  20. when Hash
  21. 10 @options.io[origin.authority]
  22. else
  23. 23 @options.io
  24. end
  25. 33 raise Error, "Given IO objects do not match the request authority" unless @io
  26. 33 _, _, _, @ip = @io.addr
  27. 33 @addresses << @ip
  28. 33 @keep_open = true
  29. 33 @state = :connected
  30. else
  31. 4001 add_addresses(addresses)
  32. end
  33. 4034 @ip_index = @addresses.size - 1
  34. end
  35. 20 def socket
  36. 123 @io
  37. end
  38. 20 def add_addresses(addrs)
  39. 4139 return if addrs.empty?
  40. 10796 addrs = addrs.map { |addr| addr.is_a?(IPAddr) ? addr : IPAddr.new(addr) }
  41. 4139 ip_index = @ip_index || (@addresses.size - 1)
  42. 4139 if addrs.first.ipv6?
  43. # should be the next in line
  44. 138 @addresses = [*@addresses[0, ip_index], *addrs, *@addresses[ip_index..-1]]
  45. else
  46. 4001 @addresses.unshift(*addrs)
  47. 4001 @ip_index += addrs.size if @ip_index
  48. end
  49. end
  50. 20 def to_io
  51. 14376 @io.to_io
  52. end
  53. 20 def protocol
  54. 2535 @fallback_protocol
  55. end
  56. 20 def connect
  57. 13459 return unless closed?
  58. 10486 if !@io || @io.closed?
  59. 4362 transition(:idle)
  60. 4362 @io = build_socket
  61. end
  62. 10486 try_connect
  63. rescue Errno::ECONNREFUSED,
  64. Errno::EADDRNOTAVAIL,
  65. Errno::EHOSTUNREACH,
  66. SocketError => e
  67. 402 raise e if @ip_index <= 0
  68. 370 log { "failed connecting to #{@ip} (#{e.message}), trying next..." }
  69. 362 @ip_index -= 1
  70. 362 @io = build_socket
  71. 362 retry
  72. rescue Errno::ETIMEDOUT => e
  73. raise ConnectTimeoutError.new(@options.timeout[:connect_timeout], e.message) if @ip_index <= 0
  74. log { "failed connecting to #{@ip} (#{e.message}), trying next..." }
  75. @ip_index -= 1
  76. @io = build_socket
  77. retry
  78. end
  79. 20 def try_connect
  80. 10486 ret = @io.connect_nonblock(Socket.sockaddr_in(@port, @ip.to_s), exception: false)
  81. 8950 log(level: 3, color: :cyan) { "TCP CONNECT: #{ret}..." }
  82. 8892 case ret
  83. when :wait_readable
  84. @interests = :r
  85. return
  86. when :wait_writable
  87. 4574 @interests = :w
  88. 4574 return
  89. end
  90. 4318 transition(:connected)
  91. 4318 @interests = :w
  92. rescue Errno::EALREADY
  93. 1188 @interests = :w
  94. end
  95. 20 private :try_connect
  96. 20 def read(size, buffer)
  97. 28598 ret = @io.read_nonblock(size, buffer, exception: false)
  98. 28598 if ret == :wait_readable
  99. 5370 buffer.clear
  100. 5370 return 0
  101. end
  102. 23228 return if ret.nil?
  103. 23274 log { "READ: #{buffer.bytesize} bytes..." }
  104. 23222 buffer.bytesize
  105. end
  106. 20 def write(buffer)
  107. 12729 siz = @io.write_nonblock(buffer, exception: false)
  108. 12721 return 0 if siz == :wait_writable
  109. 12702 return if siz.nil?
  110. 12757 log { "WRITE: #{siz} bytes..." }
  111. 12702 buffer.shift!(siz)
  112. 12702 siz
  113. end
  114. 20 def close
  115. 4742 return if @keep_open || closed?
  116. begin
  117. 4965 @io.close
  118. ensure
  119. 4209 transition(:closed)
  120. end
  121. end
  122. 20 def connected?
  123. 6421 @state == :connected
  124. end
  125. 20 def closed?
  126. 18172 @state == :idle || @state == :closed
  127. end
  128. 20 def expired?
  129. # do not mess with external sockets
  130. return false if @options.io
  131. return true unless @addresses
  132. resolver_addresses = Resolver.nolookup_resolve(@hostname)
  133. (Array(resolver_addresses) & @addresses).empty?
  134. end
  135. skipped # :nocov:
  136. skipped def inspect
  137. skipped "#<#{self.class}: #{@ip}:#{@port} (state: #{@state})>"
  138. skipped end
  139. skipped # :nocov:
  140. 20 private
  141. 20 def build_socket
  142. 4724 @ip = @addresses[@ip_index]
  143. 4724 Socket.new(@ip.family, :STREAM, 0)
  144. end
  145. 20 def transition(nextstate)
  146. 7663 case nextstate
  147. # when :idle
  148. when :connected
  149. 2575 return unless @state == :idle
  150. when :closed
  151. 2490 return unless @state == :connected
  152. end
  153. 7663 do_transition(nextstate)
  154. end
  155. 20 def do_transition(nextstate)
  156. 14772 log(level: 1) { log_transition_state(nextstate) }
  157. 14679 @state = nextstate
  158. end
  159. 20 def log_transition_state(nextstate)
  160. 93 case nextstate
  161. when :connected
  162. 25 "Connected to #{host} (##{@io.fileno})"
  163. else
  164. 68 "#{host} #{@state} -> #{nextstate}"
  165. end
  166. end
  167. end
  168. end

lib/httpx/io/udp.rb

100.0% lines covered

35 relevant lines. 35 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 require "ipaddr"
  3. 20 module HTTPX
  4. 20 class UDP
  5. 20 include Loggable
  6. 20 def initialize(ip, port, options)
  7. 673 @host = ip
  8. 673 @port = port
  9. 673 @io = UDPSocket.new(IPAddr.new(ip).family)
  10. 673 @options = options
  11. end
  12. 20 def to_io
  13. 766 @io.to_io
  14. end
  15. 20 def connect; end
  16. 20 def connected?
  17. 673 true
  18. end
  19. 20 def close
  20. 1140 @io.close
  21. end
  22. 20 if RUBY_ENGINE == "jruby"
  23. # In JRuby, sendmsg_nonblock is not implemented
  24. 1 def write(buffer)
  25. 51 siz = @io.send(buffer.to_s, 0, @host, @port)
  26. 51 log { "WRITE: #{siz} bytes..." }
  27. 51 buffer.shift!(siz)
  28. 51 siz
  29. end
  30. else
  31. 19 def write(buffer)
  32. 384 siz = @io.sendmsg_nonblock(buffer.to_s, 0, Socket.sockaddr_in(@port, @host.to_s), exception: false)
  33. 384 return 0 if siz == :wait_writable
  34. 384 return if siz.nil?
  35. 384 log { "WRITE: #{siz} bytes..." }
  36. 384 buffer.shift!(siz)
  37. 384 siz
  38. end
  39. end
  40. 20 def read(size, buffer)
  41. 595 ret = @io.recvfrom_nonblock(size, 0, buffer, exception: false)
  42. 595 return 0 if ret == :wait_readable
  43. 398 return if ret.nil?
  44. 398 log { "READ: #{buffer.bytesize} bytes..." }
  45. 398 buffer.bytesize
  46. rescue IOError
  47. end
  48. end
  49. end

lib/httpx/io/unix.rb

96.97% lines covered

33 relevant lines. 32 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 module HTTPX
  3. 20 class UNIX < TCP
  4. 20 using URIExtensions
  5. 20 attr_reader :path
  6. 20 alias_method :host, :path
  7. 20 def initialize(origin, addresses, options)
  8. 16 @addresses = []
  9. 16 @hostname = origin.host
  10. 16 @state = :idle
  11. 16 @options = Options.new(options)
  12. 16 @fallback_protocol = @options.fallback_protocol
  13. 16 if @options.io
  14. 8 @io = case @options.io
  15. when Hash
  16. 4 @options.io[origin.authority]
  17. else
  18. 4 @options.io
  19. end
  20. 8 raise Error, "Given IO objects do not match the request authority" unless @io
  21. 8 @path = @io.path
  22. 8 @keep_open = true
  23. 8 @state = :connected
  24. else
  25. 8 @path = addresses.first
  26. end
  27. 16 @io ||= build_socket
  28. end
  29. 20 def connect
  30. 12 return unless closed?
  31. begin
  32. 12 if @io.closed?
  33. 4 transition(:idle)
  34. 4 @io = build_socket
  35. end
  36. 12 @io.connect_nonblock(Socket.sockaddr_un(@path))
  37. rescue Errno::EISCONN
  38. end
  39. 8 transition(:connected)
  40. rescue Errno::EINPROGRESS,
  41. Errno::EALREADY,
  42. ::IO::WaitReadable
  43. end
  44. 20 def expired?
  45. false
  46. end
  47. skipped # :nocov:
  48. skipped def inspect
  49. skipped "#<#{self.class}(path: #{@path}): (state: #{@state})>"
  50. skipped end
  51. skipped # :nocov:
  52. 20 private
  53. 20 def build_socket
  54. 12 Socket.new(Socket::PF_UNIX, :STREAM, 0)
  55. end
  56. end
  57. end

lib/httpx/loggable.rb

100.0% lines covered

14 relevant lines. 14 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 module HTTPX
  3. 20 module Loggable
  4. 20 COLORS = {
  5. black: 30,
  6. red: 31,
  7. green: 32,
  8. yellow: 33,
  9. blue: 34,
  10. magenta: 35,
  11. cyan: 36,
  12. white: 37,
  13. }.freeze
  14. 20 def log(level: @options.debug_level, color: nil, &msg)
  15. 228149 return unless @options.debug
  16. 1027 return unless @options.debug_level >= level
  17. 1027 debug_stream = @options.debug
  18. 1027 message = (+"" << msg.call << "\n")
  19. 1027 message = "\e[#{COLORS[color]}m#{message}\e[0m" if color && debug_stream.respond_to?(:isatty) && debug_stream.isatty
  20. 1027 debug_stream << message
  21. end
  22. 20 def log_exception(ex, level: @options.debug_level, color: nil)
  23. 650 return unless @options.debug
  24. 8 return unless @options.debug_level >= level
  25. 16 log(level: level, color: color) { ex.full_message }
  26. end
  27. end
  28. end

lib/httpx/options.rb

92.64% lines covered

163 relevant lines. 151 lines covered and 12 lines missed.
    
  1. # frozen_string_literal: true
  2. 20 require "socket"
  3. 20 module HTTPX
  4. # Contains a set of options which are passed and shared across from session to its requests or
  5. # responses.
  6. 20 class Options
  7. 20 BUFFER_SIZE = 1 << 14
  8. 20 WINDOW_SIZE = 1 << 14 # 16K
  9. 20 MAX_BODY_THRESHOLD_SIZE = (1 << 10) * 112 # 112K
  10. 20 KEEP_ALIVE_TIMEOUT = 20
  11. 20 SETTINGS_TIMEOUT = 10
  12. 20 CLOSE_HANDSHAKE_TIMEOUT = 10
  13. 20 CONNECT_TIMEOUT = READ_TIMEOUT = WRITE_TIMEOUT = 60
  14. 20 REQUEST_TIMEOUT = OPERATION_TIMEOUT = nil
  15. # https://github.com/ruby/resolv/blob/095f1c003f6073730500f02acbdbc55f83d70987/lib/resolv.rb#L408
  16. 1 ip_address_families = begin
  17. 20 list = Socket.ip_address_list
  18. 63 if list.any? { |a| a.ipv6? && !a.ipv6_loopback? && !a.ipv6_linklocal? && !a.ipv6_unique_local? }
  19. [Socket::AF_INET6, Socket::AF_INET]
  20. else
  21. 20 [Socket::AF_INET]
  22. end
  23. rescue NotImplementedError
  24. [Socket::AF_INET]
  25. end
  26. 1 DEFAULT_OPTIONS = {
  27. 19 :max_requests => Float::INFINITY,
  28. 19 :debug => ENV.key?("HTTPX_DEBUG") ? $stderr : nil,
  29. 20 :debug_level => (ENV["HTTPX_DEBUG"] || 1).to_i,
  30. :ssl => {},
  31. :http2_settings => { settings_enable_push: 0 },
  32. :fallback_protocol => "http/1.1",
  33. :supported_compression_formats => %w[gzip deflate],
  34. :decompress_response_body => true,
  35. :compress_request_body => true,
  36. :timeout => {
  37. connect_timeout: CONNECT_TIMEOUT,
  38. settings_timeout: SETTINGS_TIMEOUT,
  39. close_handshake_timeout: CLOSE_HANDSHAKE_TIMEOUT,
  40. operation_timeout: OPERATION_TIMEOUT,
  41. keep_alive_timeout: KEEP_ALIVE_TIMEOUT,
  42. read_timeout: READ_TIMEOUT,
  43. write_timeout: WRITE_TIMEOUT,
  44. request_timeout: REQUEST_TIMEOUT,
  45. },
  46. :headers => {},
  47. :window_size => WINDOW_SIZE,
  48. :buffer_size => BUFFER_SIZE,
  49. :body_threshold_size => MAX_BODY_THRESHOLD_SIZE,
  50. :request_class => Class.new(Request),
  51. :response_class => Class.new(Response),
  52. :headers_class => Class.new(Headers),
  53. :request_body_class => Class.new(Request::Body),
  54. :response_body_class => Class.new(Response::Body),
  55. :connection_class => Class.new(Connection),
  56. :options_class => Class.new(self),
  57. :transport => nil,
  58. :addresses => nil,
  59. :persistent => false,
  60. 20 :resolver_class => (ENV["HTTPX_RESOLVER"] || :native).to_sym,
  61. :resolver_options => { cache: true },
  62. :ip_families => ip_address_families,
  63. }.freeze
  64. 20 class << self
  65. 20 def new(options = {})
  66. # let enhanced options go through
  67. 21572 return options if self == Options && options.class < self
  68. 14505 return options if options.is_a?(self)
  69. 2568 super
  70. end
  71. 20 def method_added(meth)
  72. 10974 super
  73. 10974 return unless meth =~ /^option_(.+)$/
  74. 5085 optname = Regexp.last_match(1).to_sym
  75. 5085 attr_reader(optname)
  76. end
  77. end
  78. # creates a new options instance from a given hash, which optionally define the following:
  79. #
  80. # :debug :: an object which log messages are written to (must respond to <tt><<</tt>)
  81. # :debug_level :: the log level of messages (can be 1, 2, or 3).
  82. # :ssl :: a hash of options which can be set as params of OpenSSL::SSL::SSLContext (see HTTPX::IO::SSL)
  83. # :http2_settings :: a hash of options to be passed to a HTTP2Next::Connection (ex: <tt>{ max_concurrent_streams: 2 }</tt>)
  84. # :fallback_protocol :: version of HTTP protocol to use by default in the absence of protocol negotiation
  85. # like ALPN (defaults to <tt>"http/1.1"</tt>)
  86. # :supported_compression_formats :: list of compressions supported by the transcoder layer (defaults to <tt>%w[gzip deflate]</tt>).
  87. # :decompress_response_body :: whether to auto-decompress response body (defaults to <tt>true</tt>).
  88. # :compress_request_body :: whether to auto-decompress response body (defaults to <tt>true</tt>)
  89. # :timeout :: hash of timeout configurations (supports <tt>:connect_timeout</tt>, <tt>:settings_timeout</tt>,
  90. # <tt>:operation_timeout</tt>, <tt>:keep_alive_timeout</tt>, <tt>:read_timeout</tt>, <tt>:write_timeout</tt>
  91. # and <tt>:request_timeout</tt>
  92. # :headers :: hash of HTTP headers (ex: <tt>{ "x-custom-foo" => "bar" }</tt>)
  93. # :window_size :: number of bytes to read from a socket
  94. # :buffer_size :: internal read and write buffer size in bytes
  95. # :body_threshold_size :: maximum size in bytes of response payload that is buffered in memory.
  96. # :request_class :: class used to instantiate a request
  97. # :response_class :: class used to instantiate a response
  98. # :headers_class :: class used to instantiate headers
  99. # :request_body_class :: class used to instantiate a request body
  100. # :response_body_class :: class used to instantiate a response body
  101. # :connection_class :: class used to instantiate connections
  102. # :options_class :: class used to instantiate options
  103. # :transport :: type of transport to use (set to "unix" for UNIX sockets)
  104. # :addresses :: bucket of peer addresses (can be a list of IP addresses, a hash of domain to list of adddresses;
  105. # paths should be used for UNIX sockets instead)
  106. # :io :: open socket, or domain/ip-to-socket hash, which requests should be sent to
  107. # :persistent :: whether to persist connections in between requests (defaults to <tt>true</tt>)
  108. # :resolver_class :: which resolver to use (defaults to <tt>:native</tt>, can also be <tt>:system<tt> for
  109. # using getaddrinfo or <tt>:https</tt> for DoH resolver, or a custom class)
  110. # :resolver_options :: hash of options passed to the resolver
  111. # :ip_families :: which socket families are supported (system-dependent)
  112. # :origin :: HTTP origin to set on requests with relative path (ex: "https://api.serv.com")
  113. # :base_path :: path to prefix given relative paths with (ex: "/v2")
  114. # :max_concurrent_requests :: max number of requests which can be set concurrently
  115. # :max_requests :: max number of requests which can be made on socket before it reconnects.
  116. # :params :: hash or array of key-values which will be encoded and set in the query string of request uris.
  117. # :form :: hash of array of key-values which will be form-or-multipart-encoded in requests body payload.
  118. # :json :: hash of array of key-values which will be JSON-encoded in requests body payload.
  119. # :xml :: Nokogiri XML nodes which will be encoded in requests body payload.
  120. #
  121. # This list of options are enhanced with each loaded plugin, see the plugin docs for details.
  122. 20 def initialize(options = {})
  123. 2568 do_initialize(options)
  124. 2563 freeze
  125. end
  126. 20 def freeze
  127. 6541 super
  128. 6541 @origin.freeze
  129. 6541 @base_path.freeze
  130. 6541 @timeout.freeze
  131. 6541 @headers.freeze
  132. 6541 @addresses.freeze
  133. 6541 @supported_compression_formats.freeze
  134. end
  135. 20 def option_origin(value)
  136. 392 URI(value)
  137. end
  138. 20 def option_base_path(value)
  139. 20 String(value)
  140. end
  141. 20 def option_headers(value)
  142. 5053 Headers.new(value)
  143. end
  144. 20 def option_timeout(value)
  145. 4729 Hash[value]
  146. end
  147. 20 def option_supported_compression_formats(value)
  148. 4249 Array(value).map(&:to_s)
  149. end
  150. 20 def option_max_concurrent_requests(value)
  151. 562 raise TypeError, ":max_concurrent_requests must be positive" unless value.positive?
  152. 562 value
  153. end
  154. 20 def option_max_requests(value)
  155. 4240 raise TypeError, ":max_requests must be positive" unless value.positive?
  156. 4240 value
  157. end
  158. 20 def option_window_size(value)
  159. 4243 value = Integer(value)
  160. 4243 raise TypeError, ":window_size must be positive" unless value.positive?
  161. 4243 value
  162. end
  163. 20 def option_buffer_size(value)
  164. 4243 value = Integer(value)
  165. 4243 raise TypeError, ":buffer_size must be positive" unless value.positive?
  166. 4243 value
  167. end
  168. 20 def option_body_threshold_size(value)
  169. 4233 bytes = Integer(value)
  170. 4233 raise TypeError, ":body_threshold_size must be positive" unless bytes.positive?
  171. 4233 bytes
  172. end
  173. 20 def option_transport(value)
  174. 28 transport = value.to_s
  175. 28 raise TypeError, "#{transport} is an unsupported transport type" unless %w[unix].include?(transport)
  176. 28 transport
  177. end
  178. 20 def option_addresses(value)
  179. 16 Array(value)
  180. end
  181. 20 def option_ip_families(value)
  182. 4233 Array(value)
  183. end
  184. 20 %i[
  185. params form json xml body ssl http2_settings
  186. request_class response_class headers_class request_body_class
  187. response_body_class connection_class options_class
  188. io fallback_protocol debug debug_level resolver_class resolver_options
  189. compress_request_body decompress_response_body
  190. persistent
  191. ].each do |method_name|
  192. 460 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  193. def option_#{method_name}(v); v; end # def option_smth(v); v; end
  194. OUT
  195. end
  196. 20 REQUEST_BODY_IVARS = %i[@headers @params @form @xml @json @body].freeze
  197. 20 def ==(other)
  198. 2080 super || options_equals?(other)
  199. end
  200. 20 def options_equals?(other, ignore_ivars = REQUEST_BODY_IVARS)
  201. # headers and other request options do not play a role, as they are
  202. # relevant only for the request.
  203. 929 ivars = instance_variables - ignore_ivars
  204. 929 other_ivars = other.instance_variables - ignore_ivars
  205. 929 return false if ivars.size != other_ivars.size
  206. 633 return false if ivars.sort != other_ivars.sort
  207. 616 ivars.all? do |ivar|
  208. 8388 instance_variable_get(ivar) == other.instance_variable_get(ivar)
  209. end
  210. end
  211. 20 OTHER_LOOKUP = ->(obj, k, ivar_map) {
  212. 191316 case obj
  213. when Hash
  214. 19025 obj[ivar_map[k]]
  215. else
  216. 172291 obj.instance_variable_get(k)
  217. end
  218. }
  219. 20 def merge(other)
  220. 22436 ivar_map = nil
  221. 22436 other_ivars = case other
  222. when Hash
  223. 27851 ivar_map = other.keys.to_h { |k| [:"@#{k}", k] }
  224. 16448 ivar_map.keys
  225. else
  226. 5988 other.instance_variables
  227. end
  228. 22436 return self if other_ivars.empty?
  229. 152070 return self if other_ivars.all? { |ivar| instance_variable_get(ivar) == OTHER_LOOKUP[other, ivar, ivar_map] }
  230. 9025 opts = dup
  231. 9025 other_ivars.each do |ivar|
  232. 52709 v = OTHER_LOOKUP[other, ivar, ivar_map]
  233. 52709 unless v
  234. 2042 opts.instance_variable_set(ivar, v)
  235. 2042 next
  236. end
  237. 50667 v = opts.__send__(:"option_#{ivar[1..-1]}", v)
  238. 50667 orig_v = instance_variable_get(ivar)
  239. 50667 v = orig_v.merge(v) if orig_v.respond_to?(:merge) && v.respond_to?(:merge)
  240. 50667 opts.instance_variable_set(ivar, v)
  241. end
  242. 9025 opts
  243. end
  244. 20 def merge2(other)
  245. raise ArgumentError, "#{other} is not a valid set of options" unless other.respond_to?(:to_hash)
  246. h2 = other.to_hash