loading
Generated 2023-01-25T08:06:38+00:00

All Files ( 96.25% covered at 73226.93 hits/line )

93 files in total.
6367 relevant lines, 6128 lines covered and 239 lines missed. ( 96.25% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/httpx.rb 100.00 % 75 39 39 0 970.13
lib/httpx/adapters/datadog.rb 97.62 % 259 126 123 3 45.87
lib/httpx/adapters/faraday.rb 98.57 % 264 140 138 2 66.65
lib/httpx/adapters/sentry.rb 89.09 % 106 55 49 6 48.55
lib/httpx/adapters/webmock.rb 100.00 % 142 76 76 0 138.32
lib/httpx/altsvc.rb 98.18 % 126 55 54 1 476.24
lib/httpx/buffer.rb 100.00 % 38 19 19 0 182219.58
lib/httpx/callbacks.rb 100.00 % 38 20 20 0 77808.70
lib/httpx/chainable.rb 90.63 % 89 32 29 3 1520.06
lib/httpx/connection.rb 96.01 % 667 351 337 14 87643.91
lib/httpx/connection/http1.rb 92.49 % 371 213 197 16 14258.63
lib/httpx/connection/http2.rb 96.12 % 416 258 248 10 93147.69
lib/httpx/domain_name.rb 100.00 % 148 44 44 0 452.14
lib/httpx/errors.rb 97.62 % 77 42 41 1 77.93
lib/httpx/extensions.rb 83.52 % 198 91 76 15 822517.70
lib/httpx/headers.rb 100.00 % 175 72 72 0 19093.78
lib/httpx/io.rb 100.00 % 17 12 12 0 30.00
lib/httpx/io/ssl.rb 96.77 % 156 62 60 2 2172.32
lib/httpx/io/tcp.rb 92.45 % 230 106 98 8 7708.67
lib/httpx/io/udp.rb 100.00 % 95 17 17 0 26469.76
lib/httpx/io/unix.rb 100.00 % 75 35 35 0 27.86
lib/httpx/loggable.rb 100.00 % 49 22 22 0 18959.32
lib/httpx/options.rb 97.16 % 296 141 137 4 28018.81
lib/httpx/parser/http1.rb 100.00 % 182 110 110 0 9736.86
lib/httpx/plugins/authentication.rb 100.00 % 20 7 7 0 44.00
lib/httpx/plugins/authentication/basic.rb 91.67 % 24 12 11 1 88.58
lib/httpx/plugins/authentication/digest.rb 98.36 % 102 61 60 1 142.41
lib/httpx/plugins/authentication/ntlm.rb 100.00 % 37 20 20 0 17.05
lib/httpx/plugins/authentication/socks5.rb 100.00 % 24 12 12 0 36.67
lib/httpx/plugins/aws_sdk_authentication.rb 95.65 % 108 46 44 2 19.91
lib/httpx/plugins/aws_sigv4.rb 100.00 % 218 102 102 0 155.08
lib/httpx/plugins/basic_authentication.rb 100.00 % 30 13 13 0 54.15
lib/httpx/plugins/circuit_breaker.rb 94.83 % 115 58 55 3 53.48
lib/httpx/plugins/circuit_breaker/circuit.rb 88.57 % 76 35 31 4 38.69
lib/httpx/plugins/circuit_breaker/circuit_store.rb 100.00 % 44 22 22 0 55.23
lib/httpx/plugins/compression.rb 98.80 % 164 83 82 1 270.02
lib/httpx/plugins/compression/brotli.rb 100.00 % 54 29 29 0 24.03
lib/httpx/plugins/compression/deflate.rb 100.00 % 49 29 29 0 100.34
lib/httpx/plugins/compression/gzip.rb 100.00 % 88 50 50 0 77.20
lib/httpx/plugins/cookies.rb 100.00 % 94 46 46 0 172.43
lib/httpx/plugins/cookies/cookie.rb 100.00 % 174 77 77 0 542.78
lib/httpx/plugins/cookies/jar.rb 95.74 % 97 47 45 2 376.79
lib/httpx/plugins/cookies/set_cookie_parser.rb 100.00 % 142 71 71 0 340.55
lib/httpx/plugins/digest_authentication.rb 90.32 % 65 31 28 3 94.39
lib/httpx/plugins/expect.rb 100.00 % 111 58 58 0 154.84
lib/httpx/plugins/follow_redirects.rb 95.59 % 138 68 65 3 59108.71
lib/httpx/plugins/grpc.rb 100.00 % 273 137 137 0 216.48
lib/httpx/plugins/grpc/call.rb 90.91 % 64 33 30 3 58.24
lib/httpx/plugins/grpc/message.rb 97.50 % 85 40 39 1 68.43
lib/httpx/plugins/h2c.rb 98.04 % 101 51 50 1 22.43
lib/httpx/plugins/multipart.rb 100.00 % 84 36 36 0 1027.97
lib/httpx/plugins/multipart/decoder.rb 93.90 % 137 82 77 5 36.94
lib/httpx/plugins/multipart/encoder.rb 100.00 % 110 65 65 0 2799.65
lib/httpx/plugins/multipart/mime_type_detector.rb 92.11 % 78 38 35 3 238.79
lib/httpx/plugins/multipart/part.rb 100.00 % 34 18 18 0 649.39
lib/httpx/plugins/ntlm_authentication.rb 96.67 % 60 30 29 1 17.60
lib/httpx/plugins/persistent.rb 100.00 % 36 11 11 0 106.73
lib/httpx/plugins/proxy.rb 93.63 % 295 157 147 10 13768.99
lib/httpx/plugins/proxy/http.rb 98.11 % 176 106 104 2 10156.06
lib/httpx/plugins/proxy/socks4.rb 97.44 % 133 78 76 2 27233.51
lib/httpx/plugins/proxy/socks5.rb 98.21 % 192 112 110 2 28499.48
lib/httpx/plugins/proxy/ssh.rb 92.45 % 92 53 49 4 17.28
lib/httpx/plugins/push_promise.rb 100.00 % 81 41 41 0 15.61
lib/httpx/plugins/rate_limiter.rb 100.00 % 53 18 18 0 58.22
lib/httpx/plugins/response_cache.rb 97.67 % 178 86 84 2 91.74
lib/httpx/plugins/response_cache/store.rb 100.00 % 76 38 38 0 124.18
lib/httpx/plugins/retries.rb 98.61 % 150 72 71 1 253988.26
lib/httpx/plugins/stream.rb 94.44 % 151 72 68 4 108.44
lib/httpx/plugins/upgrade.rb 100.00 % 87 40 40 0 76.20
lib/httpx/plugins/upgrade/h2.rb 91.67 % 54 24 22 2 15.54
lib/httpx/plugins/webdav.rb 91.67 % 80 36 33 3 26.00
lib/httpx/pmatch_extensions.rb 100.00 % 33 17 17 0 14.47
lib/httpx/pool.rb 88.00 % 265 150 132 18 342915.19
lib/httpx/registry.rb 100.00 % 85 25 25 0 6184.52
lib/httpx/request.rb 100.00 % 285 153 153 0 4701.17
lib/httpx/resolver.rb 98.68 % 140 76 75 1 1742.88
lib/httpx/resolver/https.rb 88.97 % 228 136 121 15 34.48
lib/httpx/resolver/multi.rb 100.00 % 79 44 44 0 345065.82
lib/httpx/resolver/native.rb 94.23 % 350 208 196 12 177980.83
lib/httpx/resolver/resolver.rb 90.91 % 106 55 50 5 1359.91
lib/httpx/resolver/system.rb 89.92 % 209 119 107 12 20.61
lib/httpx/response.rb 98.01 % 374 201 197 4 2328.19
lib/httpx/selector.rb 88.41 % 140 69 61 8 856221.88
lib/httpx/session.rb 96.65 % 317 179 173 6 128278.97
lib/httpx/session_extensions.rb 100.00 % 26 11 11 0 9.00
lib/httpx/timers.rb 90.91 % 87 44 40 4 1963715.64
lib/httpx/transcoder.rb 100.00 % 94 53 53 0 360.55
lib/httpx/transcoder/body.rb 97.06 % 59 34 33 1 939.26
lib/httpx/transcoder/chunker.rb 100.00 % 116 67 67 0 428.94
lib/httpx/transcoder/form.rb 100.00 % 59 31 31 0 291.16
lib/httpx/transcoder/json.rb 100.00 % 60 34 34 0 38.32
lib/httpx/transcoder/xml.rb 93.10 % 55 29 27 2 52.10
lib/httpx/utils.rb 100.00 % 85 43 43 0 350850.84

lib/httpx.rb

100.0% lines covered

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

lib/httpx/adapters/datadog.rb

97.62% lines covered

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

lib/httpx/adapters/faraday.rb

98.57% lines covered

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

lib/httpx/adapters/sentry.rb

89.09% lines covered

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

lib/httpx/adapters/webmock.rb

100.0% lines covered

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

lib/httpx/altsvc.rb

98.18% lines covered

55 relevant lines. 54 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "strscan"
  3. 30 module HTTPX
  4. 30 module AltSvc
  5. 30 @altsvc_mutex = Mutex.new
  6. 63 @altsvcs = Hash.new { |h, k| h[k] = [] }
  7. 30 module_function
  8. 30 def cached_altsvc(origin)
  9. 1395 now = Utils.now
  10. 1395 @altsvc_mutex.synchronize do
  11. 1395 lookup(origin, now)
  12. end
  13. end
  14. 30 def cached_altsvc_set(origin, entry)
  15. 33 now = Utils.now
  16. 33 @altsvc_mutex.synchronize do
  17. 33 return if @altsvcs[origin].any? { |altsvc| altsvc["origin"] == entry["origin"] }
  18. 33 entry["TTL"] = Integer(entry["ma"]) + now if entry.key?("ma")
  19. 33 @altsvcs[origin] << entry
  20. 33 entry
  21. end
  22. end
  23. 30 def lookup(origin, ttl)
  24. 1395 return [] unless @altsvcs.key?(origin)
  25. 44 @altsvcs[origin] = @altsvcs[origin].select do |entry|
  26. 33 !entry.key?("TTL") || entry["TTL"] > ttl
  27. end
  28. 66 @altsvcs[origin].reject { |entry| entry["noop"] }
  29. end
  30. 30 def emit(request, response)
  31. 8127 return unless response.respond_to?(:headers)
  32. # Alt-Svc
  33. 8111 return unless response.headers.key?("alt-svc")
  34. 88 origin = request.origin
  35. 88 host = request.uri.host
  36. 88 altsvc = response.headers["alt-svc"]
  37. # https://tools.ietf.org/html/rfc7838#section-3
  38. # A field value containing the special value "clear" indicates that the
  39. # origin requests all alternatives for that origin to be invalidated
  40. # (including those specified in the same response, in case of an
  41. # invalid reply containing both "clear" and alternative services).
  42. 88 if altsvc == "clear"
  43. 11 @altsvc_mutex.synchronize do
  44. 11 @altsvcs[origin].clear
  45. end
  46. 11 return
  47. end
  48. 77 parse(altsvc) do |alt_origin, alt_params|
  49. 11 alt_origin.host ||= host
  50. 11 yield(alt_origin, origin, alt_params)
  51. end
  52. end
  53. 30 def parse(altsvc)
  54. 231 return enum_for(__method__, altsvc) unless block_given?
  55. 154 scanner = StringScanner.new(altsvc)
  56. 154 until scanner.eos?
  57. 154 alt_service = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
  58. 154 alt_params = []
  59. 154 loop do
  60. 187 alt_param = scanner.scan(/[^=]+=("[^"]+"|[^;,]+)/)
  61. 187 alt_params << alt_param.strip if alt_param
  62. 187 scanner.skip(/;/)
  63. 187 break if scanner.eos? || scanner.scan(/ *, */)
  64. end
  65. 308 alt_params = Hash[alt_params.map { |field| field.split("=") }]
  66. 154 alt_proto, alt_authority = alt_service.split("=")
  67. 154 alt_origin = parse_altsvc_origin(alt_proto, alt_authority)
  68. 154 return unless alt_origin
  69. 66 yield(alt_origin, alt_params.merge("proto" => alt_proto))
  70. end
  71. end
  72. 30 def parse_altsvc_scheme(alt_proto)
  73. 154 case alt_proto
  74. when "h2c"
  75. "http"
  76. when "h2"
  77. 66 "https"
  78. end
  79. end
  80. skipped # :nocov:
  81. skipped if RUBY_VERSION < "2.2"
  82. skipped def parse_altsvc_origin(alt_proto, alt_origin)
  83. skipped alt_scheme = parse_altsvc_scheme(alt_proto) or return
  84. skipped
  85. skipped alt_origin = alt_origin[1..-2] if alt_origin.start_with?("\"") && alt_origin.end_with?("\"")
  86. skipped if alt_origin.start_with?(":")
  87. skipped alt_origin = "#{alt_scheme}://dummy#{alt_origin}"
  88. skipped uri = URI.parse(alt_origin)
  89. skipped uri.host = nil
  90. skipped uri
  91. skipped else
  92. skipped URI.parse("#{alt_scheme}://#{alt_origin}")
  93. skipped end
  94. skipped end
  95. skipped else
  96. skipped def parse_altsvc_origin(alt_proto, alt_origin)
  97. skipped alt_scheme = parse_altsvc_scheme(alt_proto) or return
  98. skipped alt_origin = alt_origin[1..-2] if alt_origin.start_with?("\"") && alt_origin.end_with?("\"")
  99. skipped
  100. skipped URI.parse("#{alt_scheme}://#{alt_origin}")
  101. skipped end
  102. skipped end
  103. skipped # :nocov:
  104. end
  105. end

lib/httpx/buffer.rb

100.0% lines covered

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

lib/httpx/callbacks.rb

100.0% lines covered

20 relevant lines. 20 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 module Callbacks
  4. 30 def on(type, &action)
  5. 178486 callbacks(type) << action
  6. end
  7. 30 def once(type, &block)
  8. 656 on(type) do |*args, &callback|
  9. 378 block.call(*args, &callback)
  10. 290 :delete
  11. end
  12. end
  13. 30 def only(type, &block)
  14. 23931 callbacks(type).clear
  15. 23931 on(type, &block)
  16. end
  17. 30 def emit(type, *args)
  18. 188739 callbacks(type).delete_if { |pr| :delete == pr.call(*args) } # rubocop:disable Style/YodaCondition
  19. end
  20. 30 protected
  21. 30 def callbacks_for?(type)
  22. 2953 @callbacks.key?(type) && !@callbacks[type].empty?
  23. end
  24. 30 def callbacks(type = nil)
  25. 310631 return @callbacks unless type
  26. 515397 @callbacks ||= Hash.new { |h, k| h[k] = [] }
  27. 310512 @callbacks[type]
  28. end
  29. end
  30. end

lib/httpx/chainable.rb

90.63% lines covered

32 relevant lines. 29 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 module Chainable
  4. 30 %i[head get post put delete trace options connect patch].each do |meth|
  5. 270 class_eval(<<-MOD, __FILE__, __LINE__ + 1)
  6. def #{meth}(*uri, **options) # def get(*uri, **options)
  7. request(:#{meth}, uri, **options) # request(:get, uri, **options)
  8. end # end
  9. MOD
  10. end
  11. 30 def request(*args, **options)
  12. 1593 branch(default_options).request(*args, **options)
  13. end
  14. skipped # :nocov:
  15. skipped def timeout(**args)
  16. skipped warn ":#{__method__} is deprecated, use :with_timeout instead"
  17. skipped with(timeout: args)
  18. skipped end
  19. skipped
  20. skipped def headers(headers)
  21. skipped warn ":#{__method__} is deprecated, use :with_headers instead"
  22. skipped with(headers: headers)
  23. skipped end
  24. skipped # :nocov:
  25. 30 def accept(type)
  26. 22 with(headers: { "accept" => String(type) })
  27. end
  28. 30 def wrap(&blk)
  29. 131 branch(default_options).wrap(&blk)
  30. end
  31. 30 def plugin(pl, options = nil, &blk)
  32. 5663 klass = is_a?(Session) ? self.class : Session
  33. 5663 klass = Class.new(klass)
  34. 5663 klass.instance_variable_set(:@default_options, klass.default_options.merge(default_options))
  35. 5663 klass.plugin(pl, options, &blk).new
  36. end
  37. # deprecated
  38. skipped # :nocov:
  39. skipped def plugins(pls)
  40. skipped warn ":#{__method__} is deprecated, use :plugin instead"
  41. skipped klass = is_a?(Session) ? self.class : Session
  42. skipped klass = Class.new(klass)
  43. skipped klass.instance_variable_set(:@default_options, klass.default_options.merge(default_options))
  44. skipped klass.plugins(pls).new
  45. skipped end
  46. skipped # :nocov:
  47. 30 def with(options, &blk)
  48. 2698 branch(default_options.merge(options), &blk)
  49. end
  50. 30 private
  51. 30 def default_options
  52. 10103 @options || Session.default_options
  53. end
  54. 30 def branch(options, &blk)
  55. 4422 return self.class.new(options, &blk) if is_a?(Session)
  56. 2131 Session.new(options, &blk)
  57. end
  58. 30 def method_missing(meth, *args, **options)
  59. 1024 return super unless meth =~ /\Awith_(.+)/
  60. 1024 option = Regexp.last_match(1)
  61. 1024 return super unless option
  62. 1024 with(option.to_sym => (args.first || options))
  63. end
  64. 30 def respond_to_missing?(meth, *)
  65. return super unless meth =~ /\Awith_(.+)/
  66. option = Regexp.last_match(1)
  67. default_options.respond_to?(option) || super
  68. end
  69. end
  70. end

lib/httpx/connection.rb

96.01% lines covered

351 relevant lines. 337 lines covered and 14 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "resolv"
  3. 30 require "forwardable"
  4. 30 require "httpx/io"
  5. 30 require "httpx/buffer"
  6. 30 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. 30 class Connection
  29. 30 extend Forwardable
  30. 30 include Registry
  31. 30 include Loggable
  32. 30 include Callbacks
  33. 30 using URIExtensions
  34. 30 using NumericExtensions
  35. 30 require "httpx/connection/http2"
  36. 30 require "httpx/connection/http1"
  37. 30 BUFFER_SIZE = 1 << 14
  38. 30 def_delegator :@io, :closed?
  39. 30 def_delegator :@write_buffer, :empty?
  40. 30 attr_reader :type, :io, :origin, :origins, :state, :pending, :options
  41. 30 attr_writer :timers
  42. 30 attr_accessor :family
  43. 30 def initialize(type, uri, options)
  44. 7712 @type = type
  45. 7712 @origins = [uri.origin]
  46. 7712 @origin = Utils.to_uri(uri.origin)
  47. 7712 @options = Options.new(options)
  48. 7712 @window_size = @options.window_size
  49. 7712 @read_buffer = Buffer.new(BUFFER_SIZE)
  50. 7712 @write_buffer = Buffer.new(BUFFER_SIZE)
  51. 7712 @pending = []
  52. 7712 on(:error, &method(:on_error))
  53. 7712 if @options.io
  54. # if there's an already open IO, get its
  55. # peer address, and force-initiate the parser
  56. 84 transition(:already_open)
  57. 84 @io = IO.registry(@type).new(@origin, nil, @options)
  58. 84 parser
  59. else
  60. 7628 transition(:idle)
  61. end
  62. 7712 @inflight = 0
  63. 7712 @keep_alive_timeout = @options.timeout[:keep_alive_timeout]
  64. 7712 @total_timeout = @options.timeout[:total_timeout]
  65. 7712 self.addresses = @options.addresses if @options.addresses
  66. end
  67. # this is a semi-private method, to be used by the resolver
  68. # to initiate the io object.
  69. 30 def addresses=(addrs)
  70. 7414 if @io
  71. @io.add_addresses(addrs)
  72. else
  73. 7414 @io = IO.registry(@type).new(@origin, addrs, @options)
  74. end
  75. end
  76. 30 def addresses
  77. 24864 @io && @io.addresses
  78. end
  79. 30 def match?(uri, options)
  80. 3550 return false if @state == :closing || @state == :closed
  81. 3392 return false if exhausted?
  82. (
  83. (
  84. 2262 @origins.include?(uri.origin) &&
  85. # if there is more than one origin to match, it means that this connection
  86. # was the result of coalescing. To prevent blind trust in the case where the
  87. # origin came from an ORIGIN frame, we're going to verify the hostname with the
  88. # SSL certificate
  89. 2680 (@origins.size == 1 || @origin == uri.origin || (@io && @io.verify_hostname(uri.host)))
  90. 98 ) && @options == options
  91. 3254 ) || (match_altsvcs?(uri) && match_altsvc_options?(uri, options))
  92. end
  93. 30 def mergeable?(connection)
  94. 2190 return false if @state == :closing || @state == :closed || !@io
  95. 1239 return false if exhausted?
  96. 1230 return false unless connection.addresses
  97. (
  98. 1218 (open? && @origin == connection.origin) ||
  99. 1477 !(@io.addresses & connection.addresses).empty?
  100. 619 ) && @options == connection.options
  101. end
  102. # coalescable connections need to be mergeable!
  103. # but internally, #mergeable? is called before #coalescable?
  104. 30 def coalescable?(connection)
  105. 10 if @io.protocol == "h2" &&
  106. 2 @origin.scheme == "https" &&
  107. connection.origin.scheme == "https" &&
  108. @io.can_verify_peer?
  109. 9 @io.verify_hostname(connection.origin.host)
  110. else
  111. 3 @origin == connection.origin
  112. end
  113. end
  114. 30 def create_idle(options = {})
  115. 27 self.class.new(@type, @origin, @options.merge(options))
  116. end
  117. 30 def merge(connection)
  118. 47 @origins |= connection.instance_variable_get(:@origins)
  119. 47 connection.purge_pending do |req|
  120. 27 send(req)
  121. end
  122. end
  123. 30 def purge_pending(&block)
  124. 58 pendings = []
  125. 58 if @parser
  126. 49 @inflight -= @parser.pending.size
  127. 49 pendings << @parser.pending
  128. end
  129. 58 pendings << @pending
  130. 58 pendings.each do |pending|
  131. 107 pending.reject!(&block)
  132. end
  133. end
  134. # checks if this is connection is an alternative service of
  135. # +uri+
  136. 30 def match_altsvcs?(uri)
  137. 3983 @origins.any? { |origin| uri.altsvc_match?(origin) } ||
  138. 29 AltSvc.cached_altsvc(@origin).any? do |altsvc|
  139. origin = altsvc["origin"]
  140. origin.altsvc_match?(uri.origin)
  141. 1004 end
  142. end
  143. 30 def match_altsvc_options?(uri, options)
  144. 625 return @options == options unless @options.ssl[:hostname] == uri.host
  145. 11 dup_options = @options.merge(ssl: { hostname: nil })
  146. 11 dup_options.ssl.delete(:hostname)
  147. 11 dup_options == options
  148. end
  149. 30 def connecting?
  150. 4525585 @state == :idle
  151. end
  152. 30 def inflight?
  153. 7160 @parser && !@parser.empty? && !@write_buffer.empty?
  154. end
  155. 30 def interests
  156. # connecting
  157. 4509423 if connecting?
  158. 15707 connect
  159. 15707 return @io.interests if connecting?
  160. end
  161. # if the write buffer is full, we drain it
  162. 4501258 return :w unless @write_buffer.empty?
  163. 4454214 return @parser.interests if @parser
  164. 5 nil
  165. end
  166. 30 def to_io
  167. 28884 @io.to_io
  168. end
  169. 30 def call
  170. 24608 case @state
  171. when :closed
  172. return
  173. when :closing
  174. 2513 consume
  175. 2513 transition(:closed)
  176. 2513 emit(:close)
  177. when :open
  178. 13956 consume
  179. end
  180. 1774 nil
  181. end
  182. 30 def close
  183. 7195 transition(:active) if @state == :inactive
  184. 7195 @parser.close if @parser
  185. end
  186. # bypasses the state machine to force closing of connections still connecting.
  187. # **only** used for Happy Eyeballs v2.
  188. 30 def force_reset
  189. @state = :closing
  190. transition(:closed)
  191. emit(:close)
  192. end
  193. 30 def reset
  194. 4651 transition(:closing)
  195. 4651 transition(:closed)
  196. 4651 emit(:close)
  197. end
  198. 30 def send(request)
  199. 8579 if @parser && !@write_buffer.full?
  200. 518 request.headers["alt-used"] = @origin.authority if match_altsvcs?(request.uri)
  201. 518 if @response_received_at && @keep_alive_timeout &&
  202. 46 Utils.elapsed_time(@response_received_at) > @keep_alive_timeout
  203. # when pushing a request into an existing connection, we have to check whether there
  204. # is the possibility that the connection might have extended the keep alive timeout.
  205. # for such cases, we want to ping for availability before deciding to shovel requests.
  206. 9 log(level: 3) { "keep alive timeout expired, pinging connection..." }
  207. 9 @pending << request
  208. 9 parser.ping
  209. 9 transition(:active) if @state == :inactive
  210. 9 return
  211. end
  212. 509 send_request_to_parser(request)
  213. else
  214. 8061 @pending << request
  215. end
  216. end
  217. 30 def timeout
  218. 5330094 if @total_timeout
  219. 1513 return @total_timeout unless @connected_at
  220. 554 elapsed_time = @total_timeout - Utils.elapsed_time(@connected_at)
  221. 554 if elapsed_time.negative?
  222. 1 ex = TotalTimeoutError.new(@total_timeout, "Timed out after #{@total_timeout} seconds")
  223. 1 ex.set_backtrace(caller)
  224. 1 on_error(ex)
  225. 1 return
  226. end
  227. 553 return elapsed_time
  228. end
  229. 5328581 return @timeout if defined?(@timeout)
  230. 84 return @options.timeout[:connect_timeout] if @state == :idle
  231. 84 @options.timeout[:operation_timeout]
  232. end
  233. 30 def deactivate
  234. 1137 transition(:inactive)
  235. end
  236. 30 def open?
  237. 8797 @state == :open || @state == :inactive
  238. end
  239. 30 def raise_timeout_error(interval)
  240. 441 error = HTTPX::TimeoutError.new(interval, "timed out while waiting on select")
  241. 441 error.set_backtrace(caller)
  242. 441 on_error(error)
  243. end
  244. 30 private
  245. 30 def connect
  246. 14721 transition(:open)
  247. end
  248. 30 def exhausted?
  249. 4631 @parser && parser.exhausted?
  250. end
  251. 30 def consume
  252. 16795 return unless @io
  253. 16795 catch(:called) do
  254. 16795 epiped = false
  255. 16795 loop do
  256. 37456 parser.consume
  257. # we exit if there's no more requests to process
  258. #
  259. # this condition takes into account:
  260. #
  261. # * the number of inflight requests
  262. # * the number of pending requests
  263. # * whether the write buffer has bytes (i.e. for close handshake)
  264. 37456 if @pending.empty? && @inflight.zero? && @write_buffer.empty?
  265. 2818 log(level: 3) { "NO MORE REQUESTS..." }
  266. 2809 return
  267. end
  268. 34647 @timeout = @current_timeout
  269. 34647 read_drained = false
  270. 34647 write_drained = nil
  271. #
  272. # tight read loop.
  273. #
  274. # read as much of the socket as possible.
  275. #
  276. # this tight loop reads all the data it can from the socket and pipes it to
  277. # its parser.
  278. #
  279. 1491 loop do
  280. 58753 siz = @io.read(@window_size, @read_buffer)
  281. 58859 log(level: 3, color: :cyan) { "IO READ: #{siz} bytes..." }
  282. 58753 unless siz
  283. 9 ex = EOFError.new("descriptor closed")
  284. 9 ex.set_backtrace(caller)
  285. 9 on_error(ex)
  286. 9 return
  287. end
  288. # socket has been drained. mark and exit the read loop.
  289. 58744 if siz.zero?
  290. 6035 read_drained = @read_buffer.empty?
  291. 6035 epiped = false
  292. 6035 break
  293. end
  294. 52709 parser << @read_buffer.to_s
  295. # continue reading if possible.
  296. 47953 break if interests == :w && !epiped
  297. # exit the read loop if connection is preparing to be closed
  298. 40867 break if @state == :closing || @state == :closed
  299. # exit #consume altogether if all outstanding requests have been dealt with
  300. 40859 return if @pending.empty? && @inflight.zero?
  301. 34647 end unless ((ints = interests).nil? || ints == :w || @state == :closing) && !epiped
  302. #
  303. # tight write loop.
  304. #
  305. # flush as many bytes as the sockets allow.
  306. #
  307. 1845 loop do
  308. # buffer has been drainned, mark and exit the write loop.
  309. 25510 if @write_buffer.empty?
  310. # we only mark as drained on the first loop
  311. 3860 write_drained = write_drained.nil? && @inflight.positive?
  312. 3860 break
  313. end
  314. 10236 begin
  315. 21650 siz = @io.write(@write_buffer)
  316. rescue Errno::EPIPE
  317. # this can happen if we still have bytes in the buffer to send to the server, but
  318. # the server wants to respond immediately with some message, or an error. An example is
  319. # when one's uploading a big file to an unintended endpoint, and the server stops the
  320. # consumption, and responds immediately with an authorization of even method not allowed error.
  321. # at this point, we have to let the connection switch to read-mode.
  322. 23 log(level: 2) { "pipe broken, could not flush buffer..." }
  323. 23 epiped = true
  324. 23 read_drained = false
  325. 23 break
  326. end
  327. 21698 log(level: 3, color: :cyan) { "IO WRITE: #{siz} bytes..." }
  328. 21627 unless siz
  329. ex = EOFError.new("descriptor closed")
  330. ex.set_backtrace(caller)
  331. on_error(ex)
  332. return
  333. end
  334. # socket closed for writing. mark and exit the write loop.
  335. 21627 if siz.zero?
  336. 14 write_drained = !@write_buffer.empty?
  337. 14 break
  338. end
  339. # exit write loop if marked to consume from peer, or is closing.
  340. 21613 break if interests == :r || @state == :closing || @state == :closed
  341. 4265 write_drained = false
  342. 27271 end unless (ints = interests) == :r
  343. 27271 send_pending if @state == :open
  344. # return if socket is drained
  345. 27271 next unless (ints != :r || read_drained) && (ints != :w || write_drained)
  346. # gotta go back to the event loop. It happens when:
  347. #
  348. # * the socket is drained of bytes or it's not the interest of the conn to read;
  349. # * theres nothing more to write, or it's not in the interest of the conn to write;
  350. 6625 log(level: 3) { "(#{ints}): WAITING FOR EVENTS..." }
  351. 6610 return
  352. end
  353. end
  354. end
  355. 30 def send_pending
  356. 68668 while !@write_buffer.full? && (request = @pending.shift)
  357. 8056 send_request_to_parser(request)
  358. end
  359. end
  360. 30 def parser
  361. 110055 @parser ||= build_parser
  362. end
  363. 30 def send_request_to_parser(request)
  364. 8563 @inflight += 1
  365. 8563 parser.send(request)
  366. 8563 set_request_timeouts(request)
  367. 8563 return unless @state == :inactive
  368. 168 transition(:active)
  369. end
  370. 628 def build_parser(protocol = @io.protocol)
  371. 7167 parser = registry(protocol).new(@write_buffer, @options)
  372. 7167 set_parser_callbacks(parser)
  373. 7167 parser
  374. end
  375. 30 def set_parser_callbacks(parser)
  376. 7318 parser.on(:response) do |request, response|
  377. 8116 AltSvc.emit(request, response) do |alt_origin, origin, alt_params|
  378. 11 emit(:altsvc, alt_origin, origin, alt_params)
  379. end
  380. 8116 @response_received_at = Utils.now
  381. 8116 @inflight -= 1
  382. 8116 request.emit(:response, response)
  383. end
  384. 7318 parser.on(:altsvc) do |alt_origin, origin, alt_params|
  385. emit(:altsvc, alt_origin, origin, alt_params)
  386. end
  387. 7318 parser.on(:pong, &method(:send_pending))
  388. 7318 parser.on(:promise) do |request, stream|
  389. 27 request.emit(:promise, parser, stream)
  390. end
  391. 7318 parser.on(:exhausted) do
  392. 18 emit(:exhausted)
  393. end
  394. 7318 parser.on(:origin) do |origin|
  395. @origins |= [origin]
  396. end
  397. 7318 parser.on(:close) do |force|
  398. 6941 transition(:closing)
  399. 6941 if force || @state == :idle
  400. 4235 transition(:closed)
  401. 4235 emit(:close)
  402. end
  403. end
  404. 7318 parser.on(:close_handshake) do
  405. 9 consume
  406. end
  407. 7318 parser.on(:reset) do
  408. 4482 if parser.empty?
  409. 4256 reset
  410. else
  411. 226 transition(:closing)
  412. 226 transition(:closed)
  413. 226 emit(:reset)
  414. 226 @parser.reset if @parser
  415. 226 transition(:idle)
  416. 226 transition(:open)
  417. end
  418. end
  419. 7318 parser.on(:current_timeout) do
  420. 2716 @current_timeout = @timeout = parser.timeout
  421. end
  422. 7318 parser.on(:timeout) do |tout|
  423. @timeout = tout
  424. end
  425. 7318 parser.on(:error) do |request, ex|
  426. 419 case ex
  427. when MisdirectedRequestError
  428. 9 emit(:misdirected, request)
  429. else
  430. 410 response = ErrorResponse.new(request, ex, @options)
  431. 410 request.response = response
  432. 410 request.emit(:response, response)
  433. end
  434. end
  435. end
  436. 30 def transition(nextstate)
  437. 49939 handle_transition(nextstate)
  438. rescue Errno::ECONNABORTED,
  439. Errno::ECONNREFUSED,
  440. Errno::ECONNRESET,
  441. Errno::EADDRNOTAVAIL,
  442. Errno::EHOSTUNREACH,
  443. Errno::EINVAL,
  444. Errno::ENETUNREACH,
  445. Errno::EPIPE,
  446. Errno::ENOENT,
  447. SocketError => e
  448. # connect errors, exit gracefully
  449. 82 error = ConnectionError.new(e.message)
  450. 82 error.set_backtrace(e.backtrace)
  451. 82 connecting? && callbacks(:connect_error).any? ? emit(:connect_error, error) : handle_error(error)
  452. 82 @state = :closed
  453. 82 emit(:close)
  454. rescue TLSError => e
  455. # connect errors, exit gracefully
  456. 33 handle_error(e)
  457. 33 @state = :closed
  458. 33 emit(:close)
  459. end
  460. 30 def handle_transition(nextstate)
  461. 49490 case nextstate
  462. when :idle
  463. 7875 @timeout = @current_timeout = @options.timeout[:connect_timeout]
  464. when :open
  465. 15222 return if @state == :closed
  466. 15222 @io.connect
  467. 15107 emit(:tcp_open, self) if @io.state == :connected
  468. 15107 return unless @io.connected?
  469. 7437 @connected_at = Utils.now
  470. 7437 send_pending
  471. 7437 @timeout = @current_timeout = parser.timeout
  472. 7437 emit(:open)
  473. when :inactive
  474. 1137 return unless @state == :open
  475. when :closing
  476. 12151 return unless @state == :open
  477. when :closed
  478. 11903 return unless @state == :closing
  479. 7605 return unless @write_buffer.empty?
  480. 7555 purge_after_closed
  481. when :already_open
  482. 84 nextstate = :open
  483. 84 send_pending
  484. when :active
  485. 471 return unless @state == :inactive
  486. 471 nextstate = :open
  487. 471 emit(:activate)
  488. end
  489. 32203 @state = nextstate
  490. end
  491. 30 def purge_after_closed
  492. 7565 @io.close if @io
  493. 7565 @read_buffer.clear
  494. 7565 remove_instance_variable(:@timeout) if defined?(@timeout)
  495. end
  496. 30 def on_error(error)
  497. 721 if error.instance_of?(TimeoutError)
  498. 441 if @total_timeout && @connected_at &&
  499. 30 Utils.elapsed_time(@connected_at) > @total_timeout
  500. 342 ex = TotalTimeoutError.new(@total_timeout, "Timed out after #{@total_timeout} seconds")
  501. 342 ex.set_backtrace(error.backtrace)
  502. 342 error = ex
  503. else
  504. # inactive connections do not contribute to the select loop, therefore
  505. # they should not fail due to such errors.
  506. 99 return if @state == :inactive
  507. 99 if @timeout
  508. 99 @timeout -= error.timeout
  509. 99 return unless @timeout <= 0
  510. end
  511. 22 error = error.to_connection_error if connecting?
  512. end
  513. end
  514. 644 handle_error(error)
  515. 640 reset
  516. end
  517. 30 def handle_error(error)
  518. 759 parser.handle_error(error) if @parser && parser.respond_to?(:handle_error)
  519. 1783 while (request = @pending.shift)
  520. 345 response = ErrorResponse.new(request, error, request.options)
  521. 345 request.response = response
  522. 345 request.emit(:response, response)
  523. end
  524. end
  525. 30 def set_request_timeouts(request)
  526. 8563 write_timeout = request.write_timeout
  527. 1 request.once(:headers) do
  528. 44 @timers.after(write_timeout) { write_timeout_callback(request, write_timeout) }
  529. 8563 end unless write_timeout.nil? || write_timeout.infinite?
  530. 8563 read_timeout = request.read_timeout
  531. 1 request.once(:done) do
  532. 33 @timers.after(read_timeout) { read_timeout_callback(request, read_timeout) }
  533. 8563 end unless read_timeout.nil? || read_timeout.infinite?
  534. 8563 request_timeout = request.request_timeout
  535. 21 request.once(:headers) do
  536. 33 @timers.after(request_timeout) { read_timeout_callback(request, request_timeout, RequestTimeoutError) }
  537. 8563 end unless request_timeout.nil? || request_timeout.infinite?
  538. end
  539. 30 def write_timeout_callback(request, write_timeout)
  540. 22 return if request.state == :done
  541. 11 @write_buffer.clear
  542. 11 error = WriteTimeoutError.new(request, nil, write_timeout)
  543. 11 on_error(error)
  544. end
  545. 30 def read_timeout_callback(request, read_timeout, error_type = ReadTimeoutError)
  546. 22 response = request.response
  547. 22 return if response && response.finished?
  548. 22 @write_buffer.clear
  549. 22 error = error_type.new(request, request.response, read_timeout)
  550. 22 on_error(error)
  551. end
  552. end
  553. end

lib/httpx/connection/http1.rb

92.49% lines covered

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

lib/httpx/connection/http2.rb

96.12% lines covered

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

lib/httpx/domain_name.rb

100.0% lines covered

44 relevant lines. 44 lines covered and 0 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. 30 require "ipaddr"
  28. 30 module HTTPX
  29. # Represents a domain name ready for extracting its registered domain
  30. # and TLD.
  31. 30 class DomainName
  32. 30 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. 30 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. 30 attr_reader :domain
  47. 30 DOT = "." # :nodoc:
  48. 30 class << self
  49. 30 def new(domain)
  50. 1551 return domain if domain.is_a?(self)
  51. 1375 super(domain)
  52. end
  53. # Normalizes a _domain_ using the Punycode algorithm as necessary.
  54. # The result will be a downcased, ASCII-only string.
  55. 30 def normalize(domain)
  56. 1287 domain = domain.ascii_only? ? domain : domain.chomp(DOT).unicode_normalize(:nfc)
  57. 1287 Punycode.encode_hostname(domain).downcase
  58. end
  59. end
  60. # Parses _hostname_ into a DomainName object. An IP address is also
  61. # accepted. An IPv6 address may be enclosed in square brackets.
  62. 30 def initialize(hostname)
  63. 1375 hostname = String(hostname)
  64. 1375 raise ArgumentError, "domain name must not start with a dot: #{hostname}" if hostname.start_with?(DOT)
  65. 750 begin
  66. 1375 @ipaddr = IPAddr.new(hostname)
  67. 88 @hostname = @ipaddr.to_s
  68. 88 return
  69. rescue IPAddr::Error
  70. 1287 nil
  71. end
  72. 1287 @hostname = DomainName.normalize(hostname)
  73. 1287 tld = if (last_dot = @hostname.rindex(DOT))
  74. 264 @hostname[(last_dot + 1)..-1]
  75. else
  76. 1023 @hostname
  77. end
  78. # unknown/local TLD
  79. 1287 @domain = if last_dot
  80. # fallback - accept cookies down to second level
  81. # cf. http://www.dkim-reputation.org/regdom-libs/
  82. 264 if (penultimate_dot = @hostname.rindex(DOT, last_dot - 1))
  83. 132 @hostname[(penultimate_dot + 1)..-1]
  84. else
  85. 132 @hostname
  86. end
  87. else
  88. # no domain part - must be a local hostname
  89. 1023 tld
  90. end
  91. end
  92. # Checks if the server represented by this domain is qualified to
  93. # send and receive cookies with a domain attribute value of
  94. # _domain_. A true value given as the second argument represents
  95. # cookies without a domain attribute value, in which case only
  96. # hostname equality is checked.
  97. 30 def cookie_domain?(domain, host_only = false)
  98. # RFC 6265 #5.3
  99. # When the user agent "receives a cookie":
  100. 88 return self == @domain if host_only
  101. 88 domain = DomainName.new(domain)
  102. # RFC 6265 #5.1.3
  103. # Do not perform subdomain matching against IP addresses.
  104. 88 @hostname == domain.hostname if @ipaddr
  105. # RFC 6265 #4.1.1
  106. # Domain-value must be a subdomain.
  107. 88 @domain && self <= domain && domain <= @domain
  108. end
  109. # def ==(other)
  110. # other = DomainName.new(other)
  111. # other.hostname == @hostname
  112. # end
  113. 30 def <=>(other)
  114. 132 other = DomainName.new(other)
  115. 132 othername = other.hostname
  116. 132 if othername == @hostname
  117. 44 0
  118. 87 elsif @hostname.end_with?(othername) && @hostname[-othername.size - 1, 1] == DOT
  119. # The other is higher
  120. 44 -1
  121. else
  122. # The other is lower
  123. 44 1
  124. end
  125. end
  126. end
  127. end

lib/httpx/errors.rb

97.62% lines covered

42 relevant lines. 41 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 class Error < StandardError; end
  4. 30 class UnsupportedSchemeError < Error; end
  5. 30 class ConnectionError < Error; end
  6. 30 class TimeoutError < Error
  7. 30 attr_reader :timeout
  8. 30 def initialize(timeout, message)
  9. 882 @timeout = timeout
  10. 882 super(message)
  11. end
  12. 30 def to_connection_error
  13. 11 ex = ConnectTimeoutError.new(@timeout, message)
  14. 11 ex.set_backtrace(backtrace)
  15. 11 ex
  16. end
  17. end
  18. 30 class TotalTimeoutError < TimeoutError; end
  19. 30 class ConnectTimeoutError < TimeoutError; end
  20. 30 class RequestTimeoutError < TimeoutError
  21. 30 attr_reader :request
  22. 30 def initialize(request, response, timeout)
  23. 33 @request = request
  24. 33 @response = response
  25. 33 super(timeout, "Timed out after #{timeout} seconds")
  26. end
  27. 30 def marshal_dump
  28. [message]
  29. end
  30. end
  31. 30 class ReadTimeoutError < RequestTimeoutError; end
  32. 30 class WriteTimeoutError < RequestTimeoutError; end
  33. 30 class SettingsTimeoutError < TimeoutError; end
  34. 30 class ResolveTimeoutError < TimeoutError; end
  35. 30 class ResolveError < Error; end
  36. 30 class NativeResolveError < ResolveError
  37. 30 attr_reader :connection, :host
  38. 30 def initialize(connection, host, message = "Can't resolve #{host}")
  39. 113 @connection = connection
  40. 113 @host = host
  41. 113 super(message)
  42. end
  43. end
  44. 30 class HTTPError < Error
  45. 30 attr_reader :response
  46. 30 def initialize(response)
  47. 103 @response = response
  48. 103 super("HTTP Error: #{@response.status} #{@response.headers}\n#{@response.body}")
  49. end
  50. 30 def status
  51. 22 @response.status
  52. end
  53. end
  54. 30 class MisdirectedRequestError < HTTPError; end
  55. end

lib/httpx/extensions.rb

83.52% lines covered

91 relevant lines. 76 lines covered and 15 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "uri"
  3. 30 module HTTPX
  4. 30 unless Method.method_defined?(:curry)
  5. # Backport
  6. #
  7. # Ruby 2.1 and lower implement curry only for Procs.
  8. #
  9. # Why not using Refinements? Because they don't work for Method (tested with ruby 2.1.9).
  10. #
  11. 2 module CurryMethods
  12. # Backport for the Method#curry method, which is part of ruby core since 2.2 .
  13. #
  14. 2 def curry(*args)
  15. 761 to_proc.curry(*args)
  16. end
  17. end
  18. 2 Method.__send__(:include, CurryMethods)
  19. end
  20. 30 unless String.method_defined?(:+@)
  21. # Backport for +"", to initialize unfrozen strings from the string literal.
  22. #
  23. 4 module LiteralStringExtensions
  24. 4 def +@
  25. 402 frozen? ? dup : self
  26. end
  27. end
  28. 4 String.__send__(:include, LiteralStringExtensions)
  29. end
  30. 30 unless Numeric.method_defined?(:positive?)
  31. # Ruby 2.3 Backport (Numeric#positive?)
  32. #
  33. 4 module PosMethods
  34. 4 def positive?
  35. 3083 self > 0
  36. end
  37. end
  38. 4 Numeric.__send__(:include, PosMethods)
  39. end
  40. 30 unless Numeric.method_defined?(:negative?)
  41. # Ruby 2.3 Backport (Numeric#negative?)
  42. #
  43. 4 module NegMethods
  44. 4 def negative?
  45. 166577 self < 0
  46. end
  47. end
  48. 4 Numeric.__send__(:include, NegMethods)
  49. end
  50. 30 module NumericExtensions
  51. # Ruby 2.4 backport
  52. 30 refine Numeric do
  53. def infinite?
  54. 18 self == Float::INFINITY
  55. 30 end unless Numeric.method_defined?(:infinite?)
  56. end
  57. end
  58. 30 module StringExtensions
  59. 30 refine String do
  60. # Ruby 2.5 backport
  61. def delete_suffix!(suffix)
  62. suffix = Backports.coerce_to_str(suffix)
  63. chomp! if frozen?
  64. len = suffix.length
  65. if len > 0 && index(suffix, -len)
  66. self[-len..-1] = ''
  67. self
  68. else
  69. nil
  70. end
  71. 30 end unless String.method_defined?(:delete_suffix!)
  72. end
  73. end
  74. 30 module HashExtensions
  75. 30 refine Hash do
  76. # Ruby 2.4 backport
  77. def compact
  78. 61 h = {}
  79. 61 each do |key, value|
  80. 209 h[key] = value unless value == nil
  81. end
  82. 61 h
  83. 30 end unless Hash.method_defined?(:compact)
  84. end
  85. end
  86. 30 module ArrayExtensions
  87. 30 module FilterMap
  88. refine Array do
  89. # Ruby 2.7 backport
  90. 13 def filter_map
  91. 19756581 return to_enum(:filter_map) unless block_given?
  92. 19756581 each_with_object([]) do |item, res|
  93. 17550737 processed = yield(item)
  94. 17550737 res << processed if processed
  95. end
  96. end
  97. 30 end unless Array.method_defined?(:filter_map)
  98. end
  99. 30 module Sum
  100. refine Array do
  101. # Ruby 2.6 backport
  102. 6 def sum(accumulator = 0, &block)
  103. 191 values = block_given? ? map(&block) : self
  104. 191 values.inject(accumulator, :+)
  105. end
  106. 30 end unless Array.method_defined?(:sum)
  107. end
  108. 30 module Intersect
  109. refine Array do
  110. # Ruby 3.1 backport
  111. 17 def intersect?(arr)
  112. if size < arr.size
  113. smaller = self
  114. else
  115. smaller, arr = arr, self
  116. end
  117. (arr & smaller).size > 0
  118. end
  119. 30 end unless Array.method_defined?(:intersect?)
  120. end
  121. end
  122. 30 module IOExtensions
  123. 30 refine IO do
  124. # Ruby 2.3 backport
  125. # provides a fallback for rubies where IO#wait isn't implemented,
  126. # but IO#wait_readable and IO#wait_writable are.
  127. def wait(timeout = nil, _mode = :read_write)
  128. r, w = IO.select([self], [self], nil, timeout)
  129. return unless r || w
  130. self
  131. 30 end unless IO.method_defined?(:wait) && IO.instance_method(:wait).arity == 2
  132. end
  133. end
  134. 30 module RegexpExtensions
  135. 30 refine(Regexp) do
  136. # Ruby 2.4 backport
  137. 30 def match?(*args)
  138. 1009 !match(*args).nil?
  139. end
  140. end
  141. end
  142. 30 module URIExtensions
  143. # uri 0.11 backport, ships with ruby 3.1
  144. 30 refine URI::Generic do
  145. 30 def non_ascii_hostname
  146. 433 @non_ascii_hostname
  147. end
  148. 30 def non_ascii_hostname=(hostname)
  149. 36 @non_ascii_hostname = hostname
  150. end
  151. def authority
  152. 29142 return host if port == default_port
  153. 1440 "#{host}:#{port}"
  154. 30 end unless URI::HTTP.method_defined?(:authority)
  155. def origin
  156. 21942 "#{scheme}://#{authority}"
  157. 30 end unless URI::HTTP.method_defined?(:origin)
  158. 30 def altsvc_match?(uri)
  159. 2504 uri = URI.parse(uri)
  160. 1494 origin == uri.origin || begin
  161. 1361 case scheme
  162. when "h2"
  163. (uri.scheme == "https" || uri.scheme == "h2") &&
  164. host == uri.host &&
  165. (port || default_port) == (uri.port || uri.default_port)
  166. else
  167. 1361 false
  168. end
  169. 1010 end
  170. end
  171. end
  172. end
  173. end

lib/httpx/headers.rb

100.0% lines covered

72 relevant lines. 72 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 class Headers
  4. 30 class << self
  5. 30 def new(headers = nil)
  6. 48358 return headers if headers.is_a?(self)
  7. 21265 super
  8. end
  9. end
  10. 30 def initialize(headers = nil)
  11. 21265 @headers = {}
  12. 21265 return unless headers
  13. 21038 headers.each do |field, value|
  14. 65750 array_value(value).each do |v|
  15. 65884 add(downcased(field), v)
  16. end
  17. end
  18. end
  19. # cloned initialization
  20. 30 def initialize_clone(orig)
  21. 11 super
  22. 11 @headers = orig.instance_variable_get(:@headers).clone
  23. end
  24. # dupped initialization
  25. 30 def initialize_dup(orig)
  26. 13920 super
  27. 13920 @headers = orig.instance_variable_get(:@headers).dup
  28. end
  29. # freezes the headers hash
  30. 30 def freeze
  31. 34488 @headers.freeze
  32. 34488 super
  33. end
  34. 30 def same_headers?(headers)
  35. 28 @headers.empty? || begin
  36. 44 headers.each do |k, v|
  37. 77 next unless key?(k)
  38. 77 return false unless v == self[k]
  39. end
  40. 22 true
  41. 16 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. 30 def merge(other)
  48. 4987 headers = dup
  49. 4987 other.each do |field, value|
  50. 4591 headers[downcased(field)] = value
  51. end
  52. 4987 headers
  53. end
  54. # returns the comma-separated values of the header field
  55. # identified by +field+, or nil otherwise.
  56. #
  57. 30 def [](field)
  58. 90767 a = @headers[downcased(field)] || return
  59. 27939 a.join(", ")
  60. end
  61. # sets +value+ (if not nil) as single value for the +field+ header.
  62. #
  63. 30 def []=(field, value)
  64. 33505 return unless value
  65. 33505 @headers[downcased(field)] = array_value(value)
  66. end
  67. # deletes all values associated with +field+ header.
  68. #
  69. 30 def delete(field)
  70. 144 canonical = downcased(field)
  71. 144 @headers.delete(canonical) if @headers.key?(canonical)
  72. end
  73. # adds additional +value+ to the existing, for header +field+.
  74. #
  75. 30 def add(field, value)
  76. 66486 (@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. 30 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. 30 def each(extra_headers = nil)
  90. 45372 return enum_for(__method__, extra_headers) { @headers.size } unless block_given?
  91. 29621 @headers.each do |field, value|
  92. 41981 yield(field, value.join(", ")) unless value.empty?
  93. end
  94. 8597 extra_headers.each do |field, value|
  95. 23718 yield(field, value) unless value.empty?
  96. 29599 end if extra_headers
  97. end
  98. 30 def ==(other)
  99. 6116 other == to_hash
  100. end
  101. # the headers store in Hash format
  102. 30 def to_hash
  103. 6888 Hash[to_a]
  104. end
  105. 30 alias_method :to_h, :to_hash
  106. # the headers store in array of pairs format
  107. 30 def to_a
  108. 6913 Array(each)
  109. end
  110. # headers as string
  111. 30 def to_s
  112. 1478 @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. 30 def key?(downcased_key)
  124. 37714 @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. 30 def get(field)
  131. 744 @headers[field] || EMPTY
  132. end
  133. 30 private
  134. 30 def array_value(value)
  135. 99255 case value
  136. when Array
  137. 80439 value.map { |val| String(val).strip }
  138. else
  139. 59680 [String(value).strip]
  140. end
  141. end
  142. 30 def downcased(field)
  143. 261377 String(field).downcase
  144. end
  145. end
  146. end

lib/httpx/io.rb

100.0% lines covered

12 relevant lines. 12 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "socket"
  3. 30 require "httpx/io/udp"
  4. 30 require "httpx/io/tcp"
  5. 30 require "httpx/io/unix"
  6. 30 require "httpx/io/ssl"
  7. 30 module HTTPX
  8. 30 module IO
  9. 30 extend Registry
  10. 30 register "udp", UDP
  11. 30 register "unix", HTTPX::UNIX
  12. 30 register "tcp", TCP
  13. 30 register "ssl", SSL
  14. end
  15. end

lib/httpx/io/ssl.rb

96.77% lines covered

62 relevant lines. 60 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "openssl"
  3. 30 module HTTPX
  4. 30 TLSError = OpenSSL::SSL::SSLError
  5. 30 IPRegex = Regexp.union(Resolv::IPv4::Regex, Resolv::IPv6::Regex)
  6. 30 class SSL < TCP
  7. 30 using RegexpExtensions unless Regexp.method_defined?(:match?)
  8. 30 TLS_OPTIONS = if OpenSSL::SSL::SSLContext.instance_methods.include?(:alpn_protocols)
  9. 26 { alpn_protocols: %w[h2 http/1.1].freeze }.freeze
  10. else
  11. 4 {}.freeze
  12. end
  13. 30 def initialize(_, _, options)
  14. 3286 super
  15. 3286 @ctx = OpenSSL::SSL::SSLContext.new
  16. 3286 ctx_options = TLS_OPTIONS.merge(options.ssl)
  17. 3286 @sni_hostname = ctx_options.delete(:hostname) || @hostname
  18. 3286 @ctx.set_params(ctx_options) unless ctx_options.empty?
  19. 3286 @state = :negotiated if @keep_open
  20. 3286 @hostname_is_ip = IPRegex.match?(@sni_hostname)
  21. end
  22. 30 def protocol
  23. 3236 @io.alpn_protocol || super
  24. rescue StandardError
  25. 568 super
  26. end
  27. 30 def can_verify_peer?
  28. 9 @ctx.verify_mode == OpenSSL::SSL::VERIFY_PEER
  29. end
  30. 30 def verify_hostname(host)
  31. 22 return false if @ctx.verify_mode == OpenSSL::SSL::VERIFY_NONE
  32. 22 return false if !@io.respond_to?(:peer_cert) || @io.peer_cert.nil?
  33. 11 OpenSSL::SSL.verify_certificate_identity(@io.peer_cert, host)
  34. end
  35. 30 def close
  36. 3206 super
  37. # allow reconnections
  38. # connect only works if initial @io is a socket
  39. 3206 @io = @io.io if @io.respond_to?(:io)
  40. end
  41. 30 def connected?
  42. 6795 @state == :negotiated
  43. end
  44. 30 def connect
  45. 6848 super
  46. 4403 return if @state == :negotiated ||
  47. 2971 @state != :connected
  48. 3616 unless @io.is_a?(OpenSSL::SSL::SSLSocket)
  49. 3249 @io = OpenSSL::SSL::SSLSocket.new(@io, @ctx)
  50. 3249 @io.hostname = @sni_hostname unless @hostname_is_ip
  51. 3249 @io.sync_close = true
  52. end
  53. 3616 try_ssl_connect
  54. end
  55. 30 if RUBY_VERSION < "2.3"
  56. skipped # :nocov:
  57. skipped def try_ssl_connect
  58. skipped @io.connect_nonblock
  59. skipped @io.post_connection_check(@sni_hostname) if @ctx.verify_mode != OpenSSL::SSL::VERIFY_NONE && !@hostname_is_ip
  60. skipped transition(:negotiated)
  61. skipped @interests = :w
  62. skipped rescue ::IO::WaitReadable
  63. skipped @interests = :r
  64. skipped rescue ::IO::WaitWritable
  65. skipped @interests = :w
  66. skipped end
  67. skipped
  68. skipped def read(_, buffer)
  69. skipped super
  70. skipped rescue ::IO::WaitWritable
  71. skipped buffer.clear
  72. skipped 0
  73. skipped end
  74. skipped
  75. skipped def write(*)
  76. skipped super
  77. skipped rescue ::IO::WaitReadable
  78. skipped 0
  79. skipped end
  80. skipped # :nocov:
  81. else
  82. 26 def try_ssl_connect
  83. 2985 case @io.connect_nonblock(exception: false)
  84. when :wait_readable
  85. 329 @interests = :r
  86. 329 return
  87. when :wait_writable
  88. @interests = :w
  89. return
  90. end
  91. 2632 @io.post_connection_check(@sni_hostname) if @ctx.verify_mode != OpenSSL::SSL::VERIFY_NONE && !@hostname_is_ip
  92. 2629 transition(:negotiated)
  93. 2629 @interests = :w
  94. end
  95. skipped # :nocov:
  96. skipped if OpenSSL::VERSION < "2.0.6"
  97. skipped def read(size, buffer)
  98. skipped @io.read_nonblock(size, buffer)
  99. skipped buffer.bytesize
  100. skipped rescue ::IO::WaitReadable,
  101. skipped ::IO::WaitWritable
  102. skipped buffer.clear
  103. skipped 0
  104. skipped rescue EOFError
  105. skipped nil
  106. skipped end
  107. skipped end
  108. skipped # :nocov:
  109. end
  110. 30 private
  111. 30 def transition(nextstate)
  112. 12703 case nextstate
  113. when :negotiated
  114. 3207 return unless @state == :connected
  115. when :closed
  116. 2028 return unless @state == :negotiated ||
  117. 1146 @state == :connected
  118. end
  119. 12703 do_transition(nextstate)
  120. end
  121. 30 def log_transition_state(nextstate)
  122. 54 return super unless nextstate == :negotiated
  123. 11 server_cert = @io.peer_cert
  124. 11 "#{super}\n\n" \
  125. 1 "SSL connection using #{@io.ssl_version} / #{Array(@io.cipher).first}\n" \
  126. "ALPN, server accepted to use #{protocol}\n" \
  127. "Server certificate:\n " \
  128. "subject: #{server_cert.subject}\n " \
  129. "start date: #{server_cert.not_before}\n " \
  130. "expire date: #{server_cert.not_after}\n " \
  131. "issuer: #{server_cert.issuer}\n " \
  132. "SSL certificate verify ok."
  133. end
  134. end
  135. end

lib/httpx/io/tcp.rb

92.45% lines covered

106 relevant lines. 98 lines covered and 8 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "resolv"
  3. 30 require "ipaddr"
  4. 30 module HTTPX
  5. 30 class TCP
  6. 30 include Loggable
  7. 30 using URIExtensions
  8. 30 attr_reader :ip, :port, :addresses, :state, :interests
  9. 30 alias_method :host, :ip
  10. 30 def initialize(origin, addresses, options)
  11. 7557 @state = :idle
  12. 7557 @addresses = []
  13. 7557 @hostname = origin.host
  14. 7557 @options = Options.new(options)
  15. 7557 @fallback_protocol = @options.fallback_protocol
  16. 7557 @port = origin.port
  17. 7557 @interests = :w
  18. 7557 if @options.io
  19. 64 @io = case @options.io
  20. when Hash
  21. 22 @options.io[origin.authority]
  22. else
  23. 42 @options.io
  24. end
  25. 64 raise Error, "Given IO objects do not match the request authority" unless @io
  26. 64 _, _, _, @ip = @io.addr
  27. 64 @addresses << @ip
  28. 64 @keep_open = true
  29. 64 @state = :connected
  30. else
  31. 7493 add_addresses(addresses)
  32. end
  33. 7557 @ip_index = @addresses.size - 1
  34. # @io ||= build_socket
  35. end
  36. 30 def add_addresses(addrs)
  37. 7493 return if addrs.empty?
  38. 26628 addrs = addrs.map { |addr| addr.is_a?(IPAddr) ? addr : IPAddr.new(addr) }
  39. 7493 ip_index = @ip_index || (@addresses.size - 1)
  40. 7493 if addrs.first.ipv6?
  41. # should be the next in line
  42. @addresses = [*@addresses[0, ip_index], *addrs, *@addresses[ip_index..-1]]
  43. else
  44. 7493 @addresses.unshift(*addrs)
  45. 7493 @ip_index += addrs.size if @ip_index
  46. end
  47. end
  48. 30 def to_io
  49. 28983 @io.to_io
  50. end
  51. 30 def protocol
  52. 4696 @fallback_protocol
  53. end
  54. 30 def connect
  55. 15924 return unless closed?
  56. 15285 if !@io || @io.closed?
  57. 7622 transition(:idle)
  58. 7622 @io = build_socket
  59. end
  60. 15285 try_connect
  61. rescue Errno::ECONNREFUSED,
  62. Errno::EADDRNOTAVAIL,
  63. Errno::EHOSTUNREACH,
  64. SocketError => e
  65. 82 raise e if @ip_index <= 0
  66. 10 log { "failed connecting to #{@ip} (#{e.message}), trying next..." }
  67. 10 @ip_index -= 1
  68. 10 @io = build_socket
  69. 10 retry
  70. rescue Errno::ETIMEDOUT => e
  71. raise ConnectTimeoutError.new(@options.timeout[:connect_timeout], e.message) if @ip_index <= 0
  72. log { "failed connecting to #{@ip} (#{e.message}), trying next..." }
  73. @ip_index -= 1
  74. @io = build_socket
  75. retry
  76. end
  77. 30 if RUBY_VERSION < "2.3"
  78. skipped # :nocov:
  79. skipped def try_connect
  80. skipped @io.connect_nonblock(Socket.sockaddr_in(@port, @ip.to_s))
  81. skipped rescue ::IO::WaitWritable, Errno::EALREADY
  82. skipped @interests = :w
  83. skipped rescue ::IO::WaitReadable
  84. skipped @interests = :r
  85. skipped rescue Errno::EISCONN
  86. skipped transition(:connected)
  87. skipped @interests = :w
  88. skipped else
  89. skipped transition(:connected)
  90. skipped @interests = :w
  91. skipped end
  92. skipped private :try_connect
  93. skipped
  94. skipped def read(size, buffer)
  95. skipped @io.read_nonblock(size, buffer)
  96. skipped log { "READ: #{buffer.bytesize} bytes..." }
  97. skipped buffer.bytesize
  98. skipped rescue ::IO::WaitReadable
  99. skipped buffer.clear
  100. skipped 0
  101. skipped rescue EOFError
  102. skipped nil
  103. skipped end
  104. skipped
  105. skipped def write(buffer)
  106. skipped siz = @io.write_nonblock(buffer)
  107. skipped log { "WRITE: #{siz} bytes..." }
  108. skipped buffer.shift!(siz)
  109. skipped siz
  110. skipped rescue ::IO::WaitWritable
  111. skipped 0
  112. skipped rescue EOFError
  113. skipped nil
  114. skipped end
  115. skipped # :nocov:
  116. else
  117. 26 def try_connect
  118. 12599 case @io.connect_nonblock(Socket.sockaddr_in(@port, @ip.to_s), exception: false)
  119. when :wait_readable
  120. @interests = :r
  121. return
  122. when :wait_writable
  123. 6270 @interests = :w
  124. 6270 return
  125. end
  126. 6220 transition(:connected)
  127. 6220 @interests = :w
  128. rescue Errno::EALREADY
  129. 43 @interests = :w
  130. end
  131. 26 private :try_connect
  132. 26 def read(size, buffer)
  133. 45468 ret = @io.read_nonblock(size, buffer, exception: false)
  134. 45468 if ret == :wait_readable
  135. 4865 buffer.clear
  136. 4865 return 0
  137. end
  138. 40603 return if ret.nil?
  139. 40668 log { "READ: #{buffer.bytesize} bytes..." }
  140. 40594 buffer.bytesize
  141. end
  142. 26 def write(buffer)
  143. 19556 siz = @io.write_nonblock(buffer, exception: false)
  144. 19538 return 0 if siz == :wait_writable
  145. 19526 return if siz.nil?
  146. 19589 log { "WRITE: #{siz} bytes..." }
  147. 19526 buffer.shift!(siz)
  148. 19526 siz
  149. end
  150. end
  151. 30 def close
  152. 7543 return if @keep_open || closed?
  153. 3933 begin
  154. 7448 @io.close
  155. ensure
  156. 7448 transition(:closed)
  157. end
  158. end
  159. 30 def connected?
  160. 9034 @state == :connected
  161. end
  162. 30 def closed?
  163. 23413 @state == :idle || @state == :closed
  164. end
  165. skipped # :nocov:
  166. skipped def inspect
  167. skipped "#<#{self.class}: #{@ip}:#{@port} (state: #{@state})>"
  168. skipped end
  169. skipped # :nocov:
  170. 30 private
  171. 30 def build_socket
  172. 7632 @ip = @addresses[@ip_index]
  173. 7632 Socket.new(@ip.family, :STREAM, 0)
  174. end
  175. 30 def transition(nextstate)
  176. 13152 case nextstate
  177. # when :idle
  178. when :connected
  179. 4418 return unless @state == :idle
  180. when :closed
  181. 4274 return unless @state == :connected
  182. end
  183. 13152 do_transition(nextstate)
  184. end
  185. 30 def do_transition(nextstate)
  186. 26018 log(level: 1) { log_transition_state(nextstate) }
  187. 25855 @state = nextstate
  188. end
  189. 30 def log_transition_state(nextstate)
  190. 163 case nextstate
  191. when :connected
  192. 44 "Connected to #{host} (##{@io.fileno})"
  193. else
  194. 119 "#{host} #{@state} -> #{nextstate}"
  195. end
  196. end
  197. end
  198. end

lib/httpx/io/udp.rb

100.0% lines covered

17 relevant lines. 17 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "ipaddr"
  3. 30 module HTTPX
  4. 30 class UDP
  5. 30 include Loggable
  6. 30 def initialize(ip, port, options)
  7. 918 @host = ip
  8. 918 @port = port
  9. 918 @io = UDPSocket.new(IPAddr.new(ip).family)
  10. 918 @options = options
  11. end
  12. 30 def to_io
  13. 437199 @io.to_io
  14. end
  15. 30 def connect; end
  16. 30 def connected?
  17. 918 true
  18. end
  19. 30 if RUBY_VERSION < "2.3"
  20. skipped # :nocov:
  21. skipped def close
  22. skipped @io.close
  23. skipped rescue StandardError
  24. skipped nil
  25. skipped end
  26. skipped # :nocov:
  27. else
  28. 26 def close
  29. 1131 @io.close
  30. end
  31. end
  32. skipped # :nocov:
  33. skipped if (RUBY_ENGINE == "truffleruby" && RUBY_ENGINE_VERSION < "21.1.0") ||
  34. skipped RUBY_VERSION < "2.3"
  35. skipped def write(buffer)
  36. skipped siz = @io.sendmsg_nonblock(buffer.to_s, 0, Socket.sockaddr_in(@port, @host.to_s))
  37. skipped log { "WRITE: #{siz} bytes..." }
  38. skipped buffer.shift!(siz)
  39. skipped siz
  40. skipped rescue ::IO::WaitWritable
  41. skipped 0
  42. skipped rescue EOFError
  43. skipped nil
  44. skipped end
  45. skipped
  46. skipped def read(size, buffer)
  47. skipped data, _ = @io.recvfrom_nonblock(size)
  48. skipped buffer.replace(data)
  49. skipped log { "READ: #{buffer.bytesize} bytes..." }
  50. skipped buffer.bytesize
  51. skipped rescue ::IO::WaitReadable
  52. skipped 0
  53. skipped rescue IOError
  54. skipped end
  55. skipped else
  56. skipped
  57. skipped def write(buffer)
  58. skipped siz = @io.sendmsg_nonblock(buffer.to_s, 0, Socket.sockaddr_in(@port, @host.to_s), exception: false)
  59. skipped return 0 if siz == :wait_writable
  60. skipped return if siz.nil?
  61. skipped
  62. skipped log { "WRITE: #{siz} bytes..." }
  63. skipped
  64. skipped buffer.shift!(siz)
  65. skipped siz
  66. skipped end
  67. skipped
  68. skipped def read(size, buffer)
  69. skipped ret = @io.recvfrom_nonblock(size, 0, buffer, exception: false)
  70. skipped return 0 if ret == :wait_readable
  71. skipped return if ret.nil?
  72. skipped
  73. skipped buffer.bytesize
  74. skipped rescue IOError
  75. skipped end
  76. skipped end
  77. skipped
  78. skipped # In JRuby, sendmsg_nonblock is not implemented
  79. skipped def write(buffer)
  80. skipped siz = @io.send(buffer.to_s, 0, @host, @port)
  81. skipped log { "WRITE: #{siz} bytes..." }
  82. skipped buffer.shift!(siz)
  83. skipped siz
  84. skipped end if RUBY_ENGINE == "jruby"
  85. skipped # :nocov:
  86. end
  87. end

lib/httpx/io/unix.rb

100.0% lines covered

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

lib/httpx/loggable.rb

100.0% lines covered

22 relevant lines. 22 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 module HTTPX
  3. 30 module Loggable
  4. 27 COLORS = {
  5. 2 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. 12140 def log(level: @options.debug_level, color: nil, &msg)
  15. 396771 return unless @options.debug
  16. 1405 return unless @options.debug_level >= level
  17. 1405 debug_stream = @options.debug
  18. 1405 message = (+"" << msg.call << "\n")
  19. 1405 message = "\e[#{COLORS[color]}m#{message}\e[0m" if color && debug_stream.respond_to?(:isatty) && debug_stream.isatty
  20. 1405 debug_stream << message
  21. end
  22. 30 if Exception.instance_methods.include?(:full_message)
  23. 84 def log_exception(ex, level: @options.debug_level, color: nil)
  24. 589 return unless @options.debug
  25. 12 return unless @options.debug_level >= level
  26. 24 log(level: level, color: color) { ex.full_message }
  27. end
  28. else
  29. 8 def log_exception(ex, level: @options.debug_level, color: nil)
  30. 293 return unless @options.debug
  31. 8 return unless @options.debug_level >= level
  32. 8 message = +"#{ex.message} (#{ex.class})"
  33. 8 message << "\n" << ex.backtrace.join("\n") unless ex.backtrace.nil?
  34. 16 log(level: level, color: color) { message }
  35. end
  36. end
  37. end
  38. end

lib/httpx/options.rb

97.16% lines covered

141 relevant lines. 137 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 30 require "socket"
  3. 30 module HTTPX
  4. 30 class Options
  5. 30 WINDOW_SIZE = 1 << 14 # 16K
  6. 30 MAX_BODY_THRESHOLD_SIZE = (1 << 10) * 112 # 112K
  7. 30 CONNECT_TIMEOUT = 60
  8. 30 OPERATION_TIMEOUT = 60
  9. 30 KEEP_ALIVE_TIMEOUT = 20
  10. 30 SETTINGS_TIMEOUT = 10
  11. 30 READ_TIMEOUT = WRITE_TIMEOUT = REQUEST_TIMEOUT = Float::INFINITY
  12. # https://github.com/ruby/resolv/blob/095f1c003f6073730500f02acbdbc55f83d70987/lib/resolv.rb#L408
  13. 11 ip_address_families = begin
  14. 30 list = Socket.ip_address_list
  15. 93 if list.any? { |a| a.ipv6? && !a.ipv6_loopback? && !a.ipv6_linklocal? && !a.ipv6_unique_local? }
  16. [Socket::AF_INET6, Socket::AF_INET]
  17. else
  18. 30 [Socket::AF_INET]
  19. end
  20. rescue NotImplementedError
  21. [Socket::AF_INET]
  22. end
  23. 10 DEFAULT_OPTIONS = {
  24. 44 :debug => ENV.key?("HTTPX_DEBUG") ? $stderr : nil,
  25. 30 :debug_level => (ENV["HTTPX_DEBUG"] || 1).to_i,
  26. :ssl => {},
  27. :http2_settings => { settings_enable_push: 0 },
  28. :fallback_protocol => "http/1.1",
  29. :timeout => {
  30. connect_timeout: CONNECT_TIMEOUT,
  31. settings_timeout: SETTINGS_TIMEOUT,
  32. operation_timeout: OPERATION_TIMEOUT,
  33. keep_alive_timeout: KEEP_ALIVE_TIMEOUT,
  34. read_timeout: READ_TIMEOUT,
  35. write_timeout: WRITE_TIMEOUT,
  36. request_timeout: REQUEST_TIMEOUT,
  37. },
  38. :headers => {},
  39. :window_size => WINDOW_SIZE,
  40. :body_threshold_size => MAX_BODY_THRESHOLD_SIZE,
  41. :request_class => Class.new(Request),
  42. :response_class => Class.new(Response),
  43. :headers_class => Class.new(Headers),
  44. :request_body_class => Class.new(Request::Body),
  45. :response_body_class => Class.new(Response::Body),
  46. :connection_class => Class.new(Connection),
  47. :options_class => Class.new(self),
  48. :transport => nil,
  49. :transport_options => nil,
  50. :addresses => nil,
  51. :persistent => false,
  52. 30 :resolver_class => (ENV["HTTPX_RESOLVER"] || :native).to_sym,
  53. :resolver_options => { cache: true },
  54. :ip_families => ip_address_families,
  55. }.freeze
  56. begin
  57. 4 module HashExtensions
  58. 4 refine Hash do
  59. 4 def >=(other)
  60. 4762 Hash[other] <= self
  61. end
  62. 4 def <=(other)
  63. 4762 other = Hash[other]
  64. 4762 return false unless size <= other.size
  65. 4564 each do |k, v|
  66. 29660 v2 = other.fetch(k) { return false }
  67. 27280 return false unless v2 == v
  68. end
  69. 2568 true
  70. end
  71. end
  72. end
  73. 4 using HashExtensions
  74. 30 end unless Hash.method_defined?(:>=)
  75. 30 class << self
  76. 30 def new(options = {})
  77. # let enhanced options go through
  78. 53558 return options if self == Options && options.class < self
  79. 40792 return options if options.is_a?(self)
  80. 20398 super
  81. end
  82. 30 def method_added(meth)
  83. 31610 super
  84. 31610 return unless meth =~ /^option_(.+)$/
  85. 7804 optname = Regexp.last_match(1).to_sym
  86. 7804 attr_reader(optname)
  87. end
  88. 30 def def_option(optname, *args, &block)
  89. 690 if args.empty? && !block
  90. 671 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  91. def option_#{optname}(v); v; end # def option_smth(v); v; end
  92. OUT
  93. 671 return
  94. end
  95. 19 deprecated_def_option(optname, *args, &block)
  96. end
  97. 30 def deprecated_def_option(optname, layout = nil, &interpreter)
  98. 19 warn "DEPRECATION WARNING: using `def_option(#{optname})` for setting options is deprecated. " \
  99. "Define module OptionsMethods and `def option_#{optname}(val)` instead."
  100. 19 if layout
  101. 11 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  102. def option_#{optname}(value) # def option_origin(v)
  103. #{layout} # URI(v)
  104. end # end
  105. OUT
  106. 7 elsif interpreter
  107. 8 define_method(:"option_#{optname}") do |value|
  108. 8 instance_exec(value, &interpreter)
  109. end
  110. end
  111. end
  112. end
  113. 30 def initialize(options = {})
  114. 20398 __initialize__(options)
  115. 20387 freeze
  116. end
  117. 30 def freeze
  118. 29357 super
  119. 29357 @origin.freeze
  120. 29357 @base_path.freeze
  121. 29357 @timeout.freeze
  122. 29357 @headers.freeze
  123. 29357 @addresses.freeze
  124. end
  125. 30 def option_origin(value)
  126. 1296 URI(value)
  127. end
  128. 30 def option_base_path(value)
  129. 44 String(value)
  130. end
  131. 30 def option_headers(value)
  132. 20398 Headers.new(value)
  133. end
  134. 30 def option_timeout(value)
  135. 20398 timeouts = Hash[value]
  136. 20398 if timeouts.key?(:loop_timeout)
  137. warn ":loop_timeout is deprecated, use :operation_timeout instead"
  138. timeouts[:operation_timeout] = timeouts.delete(:loop_timeout)
  139. end
  140. 20398 timeouts
  141. end
  142. 30 def option_max_concurrent_requests(value)
  143. 2321 raise TypeError, ":max_concurrent_requests must be positive" unless value.positive?
  144. 2321 value
  145. end
  146. 30 def option_max_requests(value)
  147. 692 raise TypeError, ":max_requests must be positive" unless value.positive?
  148. 692 value
  149. end
  150. 30 def option_window_size(value)
  151. 20398 Integer(value)
  152. end
  153. 30 def option_body_threshold_size(value)
  154. 20398 Integer(value)
  155. end
  156. 30 def option_transport(value)
  157. 110 transport = value.to_s
  158. 110 raise TypeError, "\#{transport} is an unsupported transport type" unless IO.registry.key?(transport)
  159. 110 transport
  160. end
  161. 30 def option_addresses(value)
  162. 60 Array(value)
  163. end
  164. 30 def option_ip_families(value)
  165. 20398 Array(value)
  166. end
  167. 19 %i[
  168. params form json xml body ssl http2_settings
  169. request_class response_class headers_class request_body_class
  170. response_body_class connection_class options_class
  171. io fallback_protocol debug debug_level transport_options resolver_class resolver_options
  172. persistent
  173. 11 ].each do |method_name|
  174. 660 def_option(method_name)
  175. end
  176. 30 REQUEST_IVARS = %i[@params @form @xml @json @body].freeze
  177. 30 private_constant :REQUEST_IVARS
  178. 30 def ==(other)
  179. 3261 ivars = instance_variables | other.instance_variables
  180. 3261 ivars.all? do |ivar|
  181. 45351 case ivar
  182. when :@headers
  183. # currently, this is used to pick up an available matching connection.
  184. # the headers do not play a role, as they are relevant only for the request.
  185. 3061 true
  186. when *REQUEST_IVARS
  187. 343 true
  188. else
  189. 41947 instance_variable_get(ivar) == other.instance_variable_get(ivar)
  190. end
  191. end
  192. end
  193. 30 def merge(other)
  194. 47009 raise ArgumentError, "#{other} is not a valid set of options" unless other.respond_to?(:to_hash)
  195. 47009 h2 = other.to_hash
  196. 47009 return self if h2.empty?
  197. 31001 h1 = to_hash
  198. 31001 return self if h1 >= h2
  199. 15825 merged = h1.merge(h2) do |_k, v1, v2|
  200. 68169 if v1.respond_to?(:merge) && v2.respond_to?(:merge)
  201. 19167 v1.merge(v2)
  202. else
  203. 49002 v2
  204. end
  205. end
  206. 15825 self.class.new(merged)
  207. end
  208. 30 def to_hash
  209. 44435 instance_variables.each_with_object({}) do |ivar, hs|
  210. 886951 hs[ivar[1..-1].to_sym] = instance_variable_get(ivar)
  211. end
  212. end
  213. 30 if RUBY_VERSION > "2.4.0"
  214. 24 def initialize_dup(other)
  215. 6746 instance_variables.each do |ivar|
  216. 134215 instance_variable_set(ivar, other.instance_variable_get(ivar).dup)
  217. end
  218. end
  219. else