loading
Generated 2026-07-30T15:54:21+00:00

All Files ( 42.07% covered at 8.05 hits/line )

872 files in total.
49913 relevant lines, 20996 lines covered and 28917 lines missed. ( 42.07% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
integration_tests/datadog_helpers.rb 90.67 % 133 75 68 7 10.80
integration_tests/faraday_datadog_test.rb 82.40 % 198 125 103 22 1.34
integration_tests/sentry_test.rb 100.00 % 149 95 95 0 2.08
integration_tests/webmock_test.rb 100.00 % 418 254 254 0 3.10
lib/httpx.rb 100.00 % 66 39 39 0 9.95
lib/httpx/adapters/datadog.rb 81.18 % 343 170 138 32 4.57
lib/httpx/adapters/faraday.rb 63.06 % 293 157 99 58 4.23
lib/httpx/adapters/sentry.rb 100.00 % 121 62 62 0 15.74
lib/httpx/adapters/webmock.rb 100.00 % 189 98 98 0 20.20
lib/httpx/altsvc.rb 29.41 % 167 85 25 60 1.05
lib/httpx/base64.rb 13.33 % 27 15 2 13 0.13
lib/httpx/buffer.rb 77.78 % 61 27 21 6 20.00
lib/httpx/callbacks.rb 100.00 % 35 19 19 0 511.95
lib/httpx/chainable.rb 58.54 % 106 41 24 17 18.44
lib/httpx/connection.rb 60.14 % 1152 552 332 220 22.69
lib/httpx/connection/http1.rb 70.56 % 416 231 163 68 24.38
lib/httpx/connection/http2.rb 62.99 % 549 308 194 114 3.63
lib/httpx/domain_name.rb 27.27 % 145 44 12 32 0.27
lib/httpx/errors.rb 82.22 % 118 45 37 8 1.27
lib/httpx/extensions.rb 57.14 % 45 21 12 9 0.71
lib/httpx/headers.rb 90.41 % 176 73 66 7 95.37
lib/httpx/io.rb 100.00 % 11 5 5 0 1.00
lib/httpx/io/ssl.rb 77.27 % 184 88 68 20 3.23
lib/httpx/io/tcp.rb 69.12 % 262 136 94 42 20.65
lib/httpx/io/udp.rb 85.71 % 62 35 30 5 5.03
lib/httpx/io/unix.rb 28.95 % 75 38 11 27 0.29
lib/httpx/loggable.rb 55.17 % 75 29 16 13 200.62
lib/httpx/options.rb 82.08 % 607 240 197 43 96.89
lib/httpx/parser/http1.rb 84.48 % 202 116 98 18 49.03
lib/httpx/plugins/callbacks.rb 31.94 % 153 72 23 49 0.43
lib/httpx/plugins/fiber_concurrency.rb 63.57 % 279 129 82 47 6.81
lib/httpx/plugins/follow_redirects.rb 68.10 % 251 116 79 37 0.84
lib/httpx/plugins/h2c.rb 38.60 % 117 57 22 35 0.39
lib/httpx/plugins/persistent.rb 75.68 % 96 37 28 9 6.76
lib/httpx/plugins/retries.rb 61.02 % 282 118 72 46 3.19
lib/httpx/plugins/stream.rb 75.44 % 238 114 86 28 1.72
lib/httpx/plugins/tracing.rb 82.46 % 143 57 47 10 5.25
lib/httpx/pmatch_extensions.rb 70.59 % 33 17 12 5 0.71
lib/httpx/pool.rb 80.00 % 231 110 88 22 39.78
lib/httpx/punycode.rb 55.56 % 22 9 5 4 0.56
lib/httpx/request.rb 84.57 % 384 162 137 25 32.13
lib/httpx/request/body.rb 75.71 % 158 70 53 17 14.13
lib/httpx/resolver.rb 80.00 % 111 55 44 11 3.51
lib/httpx/resolver/cache.rb 100.00 % 18 4 4 0 1.00
lib/httpx/resolver/cache/base.rb 75.00 % 129 60 45 15 8.30
lib/httpx/resolver/cache/memory.rb 86.36 % 42 22 19 3 8.14
lib/httpx/resolver/entry.rb 81.25 % 30 16 13 3 12.06
lib/httpx/resolver/https.rb 16.13 % 326 186 30 156 0.16
lib/httpx/resolver/multi.rb 97.78 % 84 45 44 1 23.09
lib/httpx/resolver/native.rb 58.36 % 599 341 199 142 5.31
lib/httpx/resolver/resolver.rb 65.35 % 213 101 66 35 5.60
lib/httpx/resolver/system.rb 20.78 % 284 154 32 122 0.21
lib/httpx/response.rb 80.15 % 331 131 105 26 16.49
lib/httpx/response/body.rb 67.89 % 246 109 74 35 14.00
lib/httpx/response/buffer.rb 45.76 % 115 59 27 32 4.80
lib/httpx/selector.rb 62.76 % 327 145 91 54 28.31
lib/httpx/session.rb 77.89 % 601 294 229 65 37.66
lib/httpx/session_extensions.rb 15.79 % 30 19 3 16 0.16
lib/httpx/timers.rb 88.06 % 134 67 59 8 28.64
lib/httpx/transcoder.rb 36.00 % 89 50 18 32 0.82
lib/httpx/transcoder/body.rb 65.38 % 43 26 17 9 1.54
lib/httpx/transcoder/chunker.rb 34.85 % 115 66 23 43 0.35
lib/httpx/transcoder/deflate.rb 47.62 % 42 21 10 11 0.48
lib/httpx/transcoder/form.rb 80.00 % 68 35 28 7 1.97
lib/httpx/transcoder/gzip.rb 45.45 % 76 44 20 24 0.45
lib/httpx/transcoder/json.rb 60.61 % 71 33 20 13 0.61
lib/httpx/transcoder/multipart.rb 90.91 % 39 22 20 2 2.68
lib/httpx/transcoder/multipart/decoder.rb 24.69 % 141 81 20 61 0.25
lib/httpx/transcoder/multipart/encoder.rb 100.00 % 120 69 69 0 6.84
lib/httpx/transcoder/multipart/mime_type_detector.rb 56.76 % 78 37 21 16 1.43
lib/httpx/transcoder/multipart/part.rb 72.22 % 35 18 13 5 2.94
lib/httpx/transcoder/utils/body_reader.rb 33.33 % 45 24 8 16 0.33
lib/httpx/transcoder/utils/deflater.rb 33.33 % 74 36 12 24 0.33
lib/httpx/utils.rb 45.45 % 88 44 20 24 10.05
test/support/assertion_helpers.rb 58.18 % 135 55 32 23 1.65
test/support/connect_timeout_helpers.rb 36.36 % 26 11 4 7 0.36
test/support/faraday_helpers.rb 100.00 % 53 27 27 0 4.96
test/support/grpc_helpers.rb 31.53 % 199 111 35 76 0.32
test/support/http_helpers.rb 76.19 % 42 21 16 5 4.90
test/support/minitest_extensions.rb 72.22 % 67 36 26 10 14.25
test/support/proxy_helper.rb 33.93 % 102 56 19 37 0.34
test/support/proxy_response_detector.rb 61.54 % 27 13 8 5 0.62
test/support/proxy_retry.rb 36.36 % 19 11 4 7 6.00
test/support/request_inspector.rb 41.67 % 42 24 10 14 0.42
test/support/requests/altsvc.rb 20.00 % 23 15 3 12 0.20
test/support/requests/callbacks.rb 16.48 % 145 91 15 76 0.23
test/support/requests/chunked_get.rb 19.05 % 32 21 4 17 0.19
test/support/requests/coalescing.rb 7.02 % 88 57 4 53 0.07
test/support/requests/compression.rb 15.05 % 132 93 14 79 0.15
test/support/requests/errors.rb 32.89 % 151 76 25 51 0.41
test/support/requests/get.rb 17.44 % 137 86 15 71 0.17
test/support/requests/head.rb 42.86 % 12 7 3 4 0.43
test/support/requests/headers.rb 25.00 % 24 16 4 12 0.25
test/support/requests/io.rb 21.88 % 49 32 7 25 0.22
test/support/requests/limits.rb 18.75 % 46 32 6 26 0.19
test/support/requests/multipart.rb 13.22 % 264 174 23 151 0.44
test/support/requests/plugins/auth.rb 13.36 % 335 217 29 188 0.16
test/support/requests/plugins/aws_authentication.rb 45.45 % 64 22 10 12 0.45
test/support/requests/plugins/brotli.rb 12.82 % 57 39 5 34 0.13
test/support/requests/plugins/cache.rb 16.22 % 56 37 6 31 0.16
test/support/requests/plugins/circuit_breaker.rb 10.47 % 157 86 9 77 0.10
test/support/requests/plugins/content_digest.rb 18.92 % 130 74 14 60 0.19
test/support/requests/plugins/cookies.rb 15.00 % 150 100 15 85 0.15
test/support/requests/plugins/expect.rb 14.89 % 71 47 7 40 0.15
test/support/requests/plugins/follow_redirects.rb 17.60 % 202 125 22 103 0.18
test/support/requests/plugins/grpc.rb 14.29 % 210 112 16 96 0.14
test/support/requests/plugins/h2c.rb 19.23 % 40 26 5 21 0.19
test/support/requests/plugins/oauth.rb 12.17 % 261 115 14 101 0.12
test/support/requests/plugins/persistent.rb 6.51 % 371 215 14 201 0.07
test/support/requests/plugins/proxy.rb 14.29 % 350 210 30 180 0.14
test/support/requests/plugins/push_promise.rb 28.57 % 53 35 10 25 0.29
test/support/requests/plugins/query.rb 30.00 % 33 20 6 14 0.30
test/support/requests/plugins/rate_limiter.rb 23.68 % 74 38 9 29 0.24
test/support/requests/plugins/response_cache.rb 14.29 % 105 70 10 60 0.14
test/support/requests/plugins/retries.rb 14.69 % 264 143 21 122 0.15
test/support/requests/plugins/server_sent_events.rb 9.30 % 144 86 8 78 0.09
test/support/requests/plugins/ssrf_filter.rb 25.64 % 66 39 10 29 0.26
test/support/requests/plugins/stream.rb 15.56 % 167 90 14 76 0.16
test/support/requests/plugins/stream_bidi.rb 8.76 % 297 137 12 125 0.09
test/support/requests/plugins/tracing.rb 18.09 % 146 94 17 77 0.18
test/support/requests/plugins/upgrade.rb 12.50 % 65 40 5 35 0.13
test/support/requests/plugins/webdav.rb 15.58 % 127 77 12 65 0.16
test/support/requests/plugins/xml.rb 28.00 % 37 25 7 18 0.28
test/support/requests/resolvers.rb 12.17 % 528 304 37 267 0.16
test/support/requests/response_body.rb 10.87 % 68 46 5 41 0.11
test/support/requests/with_body.rb 10.92 % 250 174 19 155 0.39
test/support/requests/with_chunked_body.rb 17.86 % 47 28 5 23 0.39
test/support/resolver_cache_helpers.rb 10.42 % 73 48 5 43 0.10
test/support/resolver_helpers.rb 29.11 % 150 79 23 56 0.29
test/support/response_cache_store_tests.rb 15.00 % 206 140 21 119 0.15
test/support/servlets/bidi.rb 36.36 % 27 11 4 7 0.36
test/support/servlets/bidi_fail_after_data.rb 38.46 % 40 13 5 8 0.38
test/support/servlets/bidi_fail_once.rb 41.67 % 38 12 5 7 0.42
test/support/servlets/by_ip_cert_server.rb 36.36 % 22 11 4 7 0.36
test/support/servlets/cache.rb 50.00 % 18 10 5 5 0.50
test/support/servlets/close_after_x_seconds.rb 20.00 % 47 25 5 20 0.20
test/support/servlets/close_stream_early.rb 35.71 % 32 14 5 9 0.36
test/support/servlets/content_digest.rb 31.91 % 70 47 15 32 0.32
test/support/servlets/digest.rb 30.77 % 44 26 8 18 0.31
test/support/servlets/dns_error.rb 80.00 % 13 5 4 1 0.80
test/support/servlets/dns_no_address.rb 83.33 % 14 6 5 1 0.83
test/support/servlets/dns_same_relative_name.rb 55.56 % 20 9 5 4 0.56
test/support/servlets/dns_serv_fail_once.rb 53.85 % 26 13 7 6 0.54
test/support/servlets/expect100.rb 38.89 % 29 18 7 11 0.39
test/support/servlets/keep_alive.rb 34.92 % 104 63 22 41 0.35
test/support/servlets/misdirected.rb 31.25 % 32 16 5 11 0.31
test/support/servlets/no_content_length.rb 37.50 % 52 32 12 20 0.38
test/support/servlets/ntlm.rb 27.78 % 39 18 5 13 0.28
test/support/servlets/oauth_provider.rb 17.31 % 109 52 9 43 0.17
test/support/servlets/ping.rb 41.67 % 24 12 5 7 0.42
test/support/servlets/proxy_redirector.rb 35.00 % 39 20 7 13 0.35
test/support/servlets/redirector_307.rb 58.33 % 23 12 7 5 0.58
test/support/servlets/settings_timeout.rb 45.45 % 21 11 5 6 0.45
test/support/servlets/slow_dns.rb 36.84 % 35 19 7 12 0.37
test/support/servlets/slow_reader.rb 23.08 % 46 26 6 20 0.23
test/support/servlets/socks5_with_none_server.rb 26.32 % 38 19 5 14 0.26
test/support/servlets/sse.rb 8.75 % 162 80 7 73 0.09
test/support/servlets/test.rb 17.26 % 377 197 34 163 0.17
test/support/servlets/trailers.rb 38.10 % 37 21 8 13 0.38
test/support/servlets/upgrade.rb 40.00 % 17 5 2 3 0.40
test/support/session_with_frame_delay.rb 60.00 % 14 5 3 2 0.60
test/support/session_with_mock_response.rb 47.62 % 41 21 10 11 0.48
test/support/session_with_pool.rb 44.62 % 113 65 29 36 0.45
test/support/test_fiber_scheduler.rb 28.57 % 505 252 72 180 0.54
test/support/thread_backtraces.rb 20.00 % 9 5 1 4 0.20
test/support/websocket_test_plugin.rb 34.07 % 160 91 31 60 0.34
vendor/ruby/3.1.0/gems/addressable-2.9.0/lib/addressable/idna.rb 100.00 % 26 2 2 0 1.00
vendor/ruby/3.1.0/gems/addressable-2.9.0/lib/addressable/idna/native.rb 3.85 % 66 26 1 25 0.04
vendor/ruby/3.1.0/gems/addressable-2.9.0/lib/addressable/idna/pure.rb 25.44 % 4710 228 58 170 5.81
vendor/ruby/3.1.0/gems/addressable-2.9.0/lib/addressable/template.rb 22.30 % 1040 296 66 230 0.22
vendor/ruby/3.1.0/gems/addressable-2.9.0/lib/addressable/uri.rb 50.00 % 2602 994 497 497 185.03
vendor/ruby/3.1.0/gems/addressable-2.9.0/lib/addressable/version.rb 100.00 % 31 7 7 0 1.00
vendor/ruby/3.1.0/gems/ast-2.4.3/lib/ast.rb 100.00 % 17 4 4 0 1.00
vendor/ruby/3.1.0/gems/ast-2.4.3/lib/ast/node.rb 36.84 % 268 76 28 48 0.37
vendor/ruby/3.1.0/gems/ast-2.4.3/lib/ast/processor.rb 100.00 % 12 4 4 0 1.00
vendor/ruby/3.1.0/gems/ast-2.4.3/lib/ast/processor/mixin.rb 37.50 % 288 16 6 10 0.38
vendor/ruby/3.1.0/gems/ast-2.4.3/lib/ast/sexp.rb 75.00 % 30 4 3 1 0.75
vendor/ruby/3.1.0/gems/aws-eventstream-1.4.0/lib/aws-eventstream.rb 100.00 % 9 6 6 0 1.00
vendor/ruby/3.1.0/gems/aws-eventstream-1.4.0/lib/aws-eventstream/decoder.rb 34.67 % 220 75 26 49 0.35
vendor/ruby/3.1.0/gems/aws-eventstream-1.4.0/lib/aws-eventstream/encoder.rb 29.27 % 142 41 12 29 0.29
vendor/ruby/3.1.0/gems/aws-eventstream-1.4.0/lib/aws-eventstream/errors.rb 68.18 % 49 22 15 7 0.68
vendor/ruby/3.1.0/gems/aws-eventstream-1.4.0/lib/aws-eventstream/header_value.rb 50.00 % 48 20 10 10 0.50
vendor/ruby/3.1.0/gems/aws-eventstream-1.4.0/lib/aws-eventstream/message.rb 75.00 % 22 8 6 2 0.75
vendor/ruby/3.1.0/gems/aws-eventstream-1.4.0/lib/aws-eventstream/types.rb 71.43 % 42 7 5 2 0.71
vendor/ruby/3.1.0/gems/aws-partitions-1.1275.0/lib/aws-partitions.rb 47.73 % 681 44 21 23 0.48
vendor/ruby/3.1.0/gems/aws-partitions-1.1275.0/lib/aws-partitions/endpoint_provider.rb 32.32 % 268 99 32 67 0.32
vendor/ruby/3.1.0/gems/aws-partitions-1.1275.0/lib/aws-partitions/metadata.rb 46.67 % 32 15 7 8 0.47
vendor/ruby/3.1.0/gems/aws-partitions-1.1275.0/lib/aws-partitions/partition.rb 42.86 % 116 42 18 24 0.43
vendor/ruby/3.1.0/gems/aws-partitions-1.1275.0/lib/aws-partitions/partition_list.rb 38.46 % 114 39 15 24 0.38
vendor/ruby/3.1.0/gems/aws-partitions-1.1275.0/lib/aws-partitions/region.rb 58.33 % 68 24 14 10 0.58
vendor/ruby/3.1.0/gems/aws-partitions-1.1275.0/lib/aws-partitions/service.rb 45.00 % 104 40 18 22 0.45
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-defaults.rb 100.00 % 6 3 3 0 1.00
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-core.rb 84.27 % 181 89 75 14 0.84
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-core/client_side_monitoring.rb 100.00 % 9 4 4 0 1.00
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-core/deprecations.rb 25.00 % 75 12 3 9 0.25
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-core/log.rb 100.00 % 10 5 5 0 1.00
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-core/plugins.rb 100.00 % 39 32 32 0 1.00
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-core/plugins/global_configuration.rb 59.09 % 89 22 13 9 0.82
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-core/resources.rb 100.00 % 8 3 3 0 1.00
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-core/stubbing.rb 100.00 % 22 15 15 0 1.00
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-core/waiters.rb 100.00 % 5 3 3 0 1.00
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-core/waiters/errors.rb 70.37 % 68 27 19 8 0.70
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-core/waiters/poller.rb 36.96 % 123 46 17 29 0.37
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-core/waiters/waiter.rb 40.48 % 134 42 17 25 0.40
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-signin.rb 88.24 % 63 17 15 2 0.88
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-signin/customizations.rb 100.00 % 1 0 0 0 0.00
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-sso.rb 88.24 % 63 17 15 2 0.88
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-sso/customizations.rb 100.00 % 1 0 0 0 0.00
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-ssooidc.rb 88.24 % 63 17 15 2 0.88
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-ssooidc/customizations.rb 100.00 % 1 0 0 0 0.00
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-sts.rb 88.24 % 63 17 15 2 0.88
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/aws-sdk-sts/customizations.rb 100.00 % 7 3 3 0 1.00
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse.rb 100.00 % 63 39 39 0 1.00
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/async_base.rb 55.56 % 50 18 10 8 0.56
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/async_response.rb 30.56 % 83 36 11 25 0.31
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/base.rb 40.74 % 225 81 33 48 0.41
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/block_io.rb 46.67 % 36 15 7 8 0.47
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/configuration.rb 41.27 % 233 63 26 37 0.41
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/h2/connection.rb 22.66 % 241 128 29 99 0.30
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/h2/handler.rb 23.29 % 168 73 17 56 0.23
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/handler.rb 70.00 % 28 10 7 3 0.70
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/handler_builder.rb 50.00 % 51 24 12 12 1.13
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/handler_list.rb 57.69 % 195 52 30 22 1.54
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/handler_list_entry.rb 76.74 % 112 43 33 10 2.79
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/http/async_response.rb 28.00 % 44 25 7 18 0.28
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/http/headers.rb 52.38 % 120 42 22 20 0.52
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/http/request.rb 42.42 % 70 33 14 19 0.42
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/http/response.rb 28.92 % 188 83 24 59 0.29
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/logging/formatter.rb 45.22 % 320 115 52 63 0.45
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/logging/handler.rb 57.14 % 39 14 8 6 0.57
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/managed_file.rb 80.00 % 16 5 4 1 0.80
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/net_http/connection_pool.rb 35.71 % 366 126 45 81 0.46
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/net_http/handler.rb 30.59 % 215 85 26 59 0.31
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/net_http/patches.rb 40.00 % 67 30 12 18 0.40
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/networking_error.rb 70.59 % 45 17 12 5 0.71
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/plugin.rb 71.64 % 152 67 48 19 8.93
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/plugin_list.rb 62.30 % 146 61 38 23 2.07
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/plugins/content_length.rb 61.54 % 33 13 8 5 0.62
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/plugins/endpoint.rb 45.45 % 53 22 10 12 0.45
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/plugins/h2.rb 94.74 % 69 19 18 1 0.95
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/plugins/logging.rb 81.82 % 37 11 9 2 0.82
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/plugins/net_http.rb 63.89 % 112 36 23 13 0.64
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/plugins/raise_response_errors.rb 61.54 % 31 13 8 5 0.62
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/plugins/request_callback.rb 42.62 % 141 61 26 35 0.43
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/plugins/response_target.rb 34.38 % 79 32 11 21 0.34
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/request.rb 66.67 % 77 12 8 4 0.67
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/request_context.rb 58.06 % 85 31 18 13 0.58
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/client/response.rb 44.12 % 90 34 15 19 0.44
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/model/api.rb 43.90 % 85 41 18 23 0.44
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/model/authorizer.rb 77.78 % 23 9 7 2 0.78
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/model/operation.rb 74.19 % 78 31 23 8 0.74
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/model/shapes.rb 58.20 % 296 122 71 51 0.58
vendor/ruby/3.1.0/gems/aws-sdk-core-3.254.0/lib/seahorse/util.rb 69.23 % 30 13 9 4 0.69
vendor/ruby/3.1.0/gems/aws-sdk-kms-1.130.0/lib/aws-sdk-kms.rb 100.00 % 61 16 16 0 1.00
vendor/ruby/3.1.0/gems/aws-sdk-kms-1.130.0/lib/aws-sdk-kms/customizations.rb 100.00 % 0 0 0 0 0.00
vendor/ruby/3.1.0/gems/aws-sdk-s3-1.228.1/lib/aws-sdk-s3.rb 100.00 % 82 37 37 0 1.00
vendor/ruby/3.1.0/gems/aws-sdk-s3-1.228.1/lib/aws-sdk-s3/customizations.rb 100.00 % 39 28 28 0 1.00
vendor/ruby/3.1.0/gems/aws-sigv4-1.12.1/lib/aws-sigv4.rb 100.00 % 13 8 8 0 1.00
vendor/ruby/3.1.0/gems/aws-sigv4-1.12.1/lib/aws-sigv4/asymmetric_credentials.rb 21.28 % 99 47 10 37 0.21
vendor/ruby/3.1.0/gems/aws-sigv4-1.12.1/lib/aws-sigv4/credentials.rb 57.14 % 68 21 12 9 0.57
vendor/ruby/3.1.0/gems/aws-sigv4-1.12.1/lib/aws-sigv4/errors.rb 77.78 % 27 9 7 2 0.78
vendor/ruby/3.1.0/gems/aws-sigv4-1.12.1/lib/aws-sigv4/signature.rb 83.33 % 42 12 10 2 0.83
vendor/ruby/3.1.0/gems/aws-sigv4-1.12.1/lib/aws-sigv4/signer.rb 21.35 % 819 267 57 210 0.21
vendor/ruby/3.1.0/gems/bigdecimal-4.1.2/lib/bigdecimal.rb 10.10 % 404 208 21 187 0.10
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/atomic/atomic_reference.rb 35.29 % 142 34 12 22 0.35
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/atomic/event.rb 50.00 % 109 34 17 17 2.29
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/atomic_reference/atomic_direct_update.rb 31.58 % 37 19 6 13 0.32
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/atomic_reference/mutex_atomic.rb 60.00 % 66 35 21 14 0.60
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/atomic_reference/numeric_cas_wrapper.rb 26.67 % 36 15 4 11 0.27
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/collection/map/mri_map_backend.rb 53.13 % 66 32 17 15 0.53
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/collection/map/non_concurrent_map_backend.rb 36.00 % 148 75 27 48 0.36
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/collection/ruby_timeout_queue.rb 35.48 % 55 31 11 20 0.35
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/collection/timeout_queue.rb 87.50 % 18 8 7 1 0.88
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/concern/deprecation.rb 47.06 % 34 17 8 9 0.47
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/concern/dereferenceable.rb 65.22 % 73 23 15 8 2.26
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/concern/logging.rb 40.00 % 121 50 20 30 0.40
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/concern/obligation.rb 56.00 % 220 75 42 33 1.15
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/configuration.rb 78.38 % 105 37 29 8 0.78
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/constants.rb 100.00 % 8 2 2 0 1.00
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/delay.rb 50.75 % 199 67 34 33 2.34
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/errors.rb 77.78 % 74 27 21 6 0.78
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/executor/abstract_executor_service.rb 52.94 % 131 51 27 24 0.53
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/executor/cached_thread_pool.rb 43.75 % 62 16 7 9 0.44
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/executor/executor_service.rb 61.54 % 185 13 8 5 0.62
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/executor/fixed_thread_pool.rb 55.56 % 222 9 5 4 0.56
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/executor/immediate_executor.rb 57.14 % 66 28 16 12 0.75
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/executor/ruby_executor_service.rb 36.96 % 82 46 17 29 0.37
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/executor/ruby_thread_pool_executor.rb 31.22 % 396 189 59 130 0.31
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/executor/serial_executor_service.rb 83.33 % 34 6 5 1 0.83
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/executor/thread_pool_executor.rb 80.00 % 88 10 8 2 0.80
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/map.rb 32.38 % 350 105 34 71 0.32
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/synchronization/abstract_lockable_object.rb 48.00 % 102 25 12 13 0.48
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/synchronization/abstract_object.rb 75.00 % 22 8 6 2 0.75
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/synchronization/full_memory_barrier.rb 41.67 % 29 12 5 7 0.42
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/synchronization/jruby_lockable_object.rb 80.00 % 15 5 4 1 0.80
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/synchronization/lockable_object.rb 71.43 % 75 14 10 4 0.71
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/synchronization/mutex_lockable_object.rb 61.70 % 89 47 29 18 4.32
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/synchronization/object.rb 52.27 % 151 44 23 21 1.48
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/synchronization/safe_initialization.rb 100.00 % 36 7 7 0 8.43
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/synchronization/volatile.rb 41.38 % 101 29 12 17 0.41
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/utility/engine.rb 70.00 % 45 20 14 6 1.30
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/utility/monotonic_time.rb 75.00 % 19 4 3 1 0.75
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/utility/native_extension_loader.rb 69.44 % 77 36 25 11 0.86
vendor/ruby/3.1.0/gems/concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/utility/processor_counter.rb 48.24 % 220 85 41 44 0.79
vendor/ruby/3.1.0/gems/crack-1.0.1/lib/crack/xml.rb 41.18 % 240 119 49 70 0.50
vendor/ruby/3.1.0/gems/ddtrace-1.23.3/lib/datadog/tracing/contrib/faraday/middleware.rb 98.18 % 110 55 54 1 5.76
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday.rb 84.91 % 158 53 45 8 0.85
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/adapter.rb 95.24 % 102 42 40 2 6.48
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/adapter/test.rb 26.36 % 319 129 34 95 0.26
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/adapter_registry.rb 93.33 % 30 15 14 1 16.33
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/connection.rb 71.43 % 567 161 115 46 4.96
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/encoders/flat_params_encoder.rb 21.28 % 106 47 10 37 0.21
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/encoders/nested_params_encoder.rb 38.00 % 193 100 38 62 1.11
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/error.rb 72.73 % 202 77 56 21 1.03
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/logging/formatter.rb 38.33 % 118 60 23 37 0.38
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/methods.rb 100.00 % 6 3 3 0 1.00
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/middleware.rb 78.13 % 72 32 25 7 3.91
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/middleware_registry.rb 79.17 % 83 24 19 5 10.83
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/options.rb 56.36 % 219 110 62 48 14.06
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/options/connection_options.rb 100.00 % 23 8 8 0 4.75
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/options/env.rb 56.34 % 204 71 40 31 14.38
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/options/proxy_options.rb 43.75 % 39 16 7 9 0.44
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/options/request_options.rb 62.50 % 23 8 5 3 2.75
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/options/ssl_options.rb 62.50 % 76 8 5 3 0.63
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/parameters.rb 100.00 % 5 3 3 0 1.00
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/rack_builder.rb 81.65 % 248 109 89 20 7.18
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/request.rb 66.00 % 139 50 33 17 2.42
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/request/authorization.rb 37.50 % 54 24 9 15 0.38
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/request/instrumentation.rb 64.71 % 58 17 11 6 0.65
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/request/json.rb 41.18 % 70 34 14 20 0.41
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/request/url_encoded.rb 88.46 % 60 26 23 3 3.35
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/response.rb 74.47 % 95 47 35 12 3.32
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/response/json.rb 35.90 % 74 39 14 25 0.36
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/response/logger.rb 57.14 % 39 21 12 9 0.57
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/response/raise_error.rb 83.33 % 83 24 20 4 1.13
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/utils.rb 66.10 % 121 59 39 20 3.29
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/utils/headers.rb 55.95 % 150 84 47 37 14.45
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/utils/params_hash.rb 62.50 % 61 32 20 12 1.44
vendor/ruby/3.1.0/gems/faraday-2.14.3/lib/faraday/version.rb 100.00 % 5 2 2 0 1.00
vendor/ruby/3.1.0/gems/faraday-net_http-3.4.4/lib/faraday/adapter/net_http.rb 24.24 % 205 99 24 75 0.24
vendor/ruby/3.1.0/gems/faraday-net_http-3.4.4/lib/faraday/net_http.rb 100.00 % 10 5 5 0 1.00
vendor/ruby/3.1.0/gems/faraday-net_http-3.4.4/lib/faraday/net_http/version.rb 100.00 % 7 3 3 0 1.00
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi.rb 18.75 % 27 16 3 13 0.19
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/abstract_memory.rb 83.33 % 44 6 5 1 0.83
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/autopointer.rb 36.17 % 186 47 17 30 0.36
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/callback.rb 100.00 % 4 0 0 0 0.00
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/compat.rb 54.55 % 54 11 6 5 1.45
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/data_converter.rb 76.92 % 67 13 10 3 0.85
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/dynamic_library.rb 66.67 % 118 39 26 13 0.79
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/enum.rb 22.64 % 302 106 24 82 0.23
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/errno.rb 60.00 % 43 5 3 2 0.60
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/ffi.rb 61.82 % 109 55 34 21 0.62
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/function.rb 53.33 % 94 30 16 14 0.93
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/io.rb 44.44 % 62 9 4 5 0.44
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/library.rb 44.59 % 592 157 70 87 1.35
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/library_path.rb 77.42 % 109 31 24 7 1.26
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/managedstruct.rb 50.00 % 84 6 3 3 0.50
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/memorypointer.rb 100.00 % 1 0 0 0 0.00
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/platform.rb 57.69 % 187 78 45 33 0.73
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/pointer.rb 37.50 % 167 40 15 25 0.38
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/struct.rb 38.66 % 317 119 46 73 0.39
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/struct_by_reference.rb 47.06 % 72 17 8 9 0.47
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/struct_layout.rb 56.00 % 96 25 14 11 0.56
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/struct_layout_builder.rb 30.65 % 227 62 19 43 0.31
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/types.rb 77.27 % 222 44 34 10 22.91
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/union.rb 57.14 % 43 7 4 3 0.57
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/variadic.rb 22.73 % 80 22 5 17 0.23
vendor/ruby/3.1.0/gems/ffi-1.17.4-x86_64-linux-gnu/lib/ffi/version.rb 100.00 % 3 2 2 0 1.00
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc.rb 100.00 % 37 18 18 0 1.00
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/core/status_codes.rb 100.00 % 135 20 20 0 1.00
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/core/time_consts.rb 35.29 % 56 17 6 11 0.35
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/errors.rb 46.88 % 277 96 45 51 0.47
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/generic/active_call.rb 23.73 % 679 236 56 180 0.29
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/generic/bidi_call.rb 16.67 % 237 102 17 85 0.17
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/generic/client_stub.rb 19.05 % 503 84 16 68 0.19
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/generic/interceptor_registry.rb 41.67 % 53 12 5 7 0.42
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/generic/interceptors.rb 39.53 % 186 43 17 26 0.40
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/generic/rpc_desc.rb 28.57 % 204 77 22 55 0.32
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/generic/rpc_server.rb 20.09 % 551 234 47 187 0.20
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/generic/service.rb 58.23 % 211 79 46 33 2.61
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/google_rpc_status_utils.rb 36.36 % 40 11 4 7 0.36
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/grpc.rb 80.00 % 22 5 4 1 0.80
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/logconfig.rb 68.75 % 57 16 11 5 0.69
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/notifier.rb 36.84 % 45 19 7 12 0.37
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/structs.rb 100.00 % 15 1 1 0 1.00
vendor/ruby/3.1.0/gems/grpc-1.80.0-x86_64-linux-gnu/src/ruby/lib/grpc/version.rb 100.00 % 18 2 2 0 1.00
vendor/ruby/3.1.0/gems/hashdiff-1.2.1/lib/hashdiff.rb 100.00 % 10 8 8 0 1.00
vendor/ruby/3.1.0/gems/hashdiff-1.2.1/lib/hashdiff/compare_hashes.rb 7.14 % 101 56 4 52 0.07
vendor/ruby/3.1.0/gems/hashdiff-1.2.1/lib/hashdiff/diff.rb 7.14 % 185 56 4 52 0.07
vendor/ruby/3.1.0/gems/hashdiff-1.2.1/lib/hashdiff/lcs.rb 4.65 % 66 43 2 41 0.05
vendor/ruby/3.1.0/gems/hashdiff-1.2.1/lib/hashdiff/lcs_compare_arrays.rb 28.57 % 32 14 4 10 0.29
vendor/ruby/3.1.0/gems/hashdiff-1.2.1/lib/hashdiff/linear_compare_array.rb 26.92 % 159 78 21 57 0.27
vendor/ruby/3.1.0/gems/hashdiff-1.2.1/lib/hashdiff/patch.rb 8.11 % 88 37 3 34 0.08
vendor/ruby/3.1.0/gems/hashdiff-1.2.1/lib/hashdiff/util.rb 20.90 % 155 67 14 53 0.21
vendor/ruby/3.1.0/gems/hashdiff-1.2.1/lib/hashdiff/version.rb 100.00 % 5 2 2 0 1.00
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2.rb 100.00 % 19 14 14 0 1.00
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/base64.rb 8.00 % 45 25 2 23 0.08
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/client.rb 73.53 % 82 34 25 9 5.12
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/connection.rb 54.40 % 862 318 173 145 3.32
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/emitter.rb 100.00 % 40 12 12 0 31.75
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/error.rb 100.00 % 67 22 22 0 2.82
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/extensions.rb 64.44 % 84 45 29 16 97.00
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/flow_buffer.rb 56.16 % 173 73 41 32 2.36
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/framer.rb 51.88 % 510 239 124 115 4.63
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/header.rb 100.00 % 35 16 16 0 1.00
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/header/compressor.rb 75.34 % 184 73 55 18 12.40
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/header/decompressor.rb 90.14 % 152 71 64 7 23.73
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/header/encoding_context.rb 77.68 % 351 112 87 25 19.54
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/header/huffman.rb 100.00 % 333 32 32 0 114.91
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/header/huffman_statemachine.rb 100.00 % 274 6 6 0 726.17
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/server.rb 26.19 % 160 42 11 31 0.26
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/stream.rb 59.73 % 695 226 135 91 2.87
vendor/ruby/3.1.0/gems/http-2-1.2.1/lib/http/2/version.rb 100.00 % 5 2 2 0 1.00
vendor/ruby/3.1.0/gems/http-form_data-2.3.0/lib/http/form_data.rb 56.00 % 82 25 14 11 0.56
vendor/ruby/3.1.0/gems/http-form_data-2.3.0/lib/http/form_data/composite_io.rb 33.33 % 88 39 13 26 0.33
vendor/ruby/3.1.0/gems/http-form_data-2.3.0/lib/http/form_data/file.rb 37.50 % 70 24 9 15 0.38
vendor/ruby/3.1.0/gems/http-form_data-2.3.0/lib/http/form_data/multipart.rb 69.57 % 59 23 16 7 0.70
vendor/ruby/3.1.0/gems/http-form_data-2.3.0/lib/http/form_data/multipart/param.rb 41.67 % 92 36 15 21 0.42
vendor/ruby/3.1.0/gems/http-form_data-2.3.0/lib/http/form_data/part.rb 72.73 % 30 11 8 3 0.73
vendor/ruby/3.1.0/gems/http-form_data-2.3.0/lib/http/form_data/readable.rb 50.00 % 40 14 7 7 0.50
vendor/ruby/3.1.0/gems/http-form_data-2.3.0/lib/http/form_data/urlencoded.rb 68.42 % 85 19 13 6 0.68
vendor/ruby/3.1.0/gems/http-form_data-2.3.0/lib/http/form_data/version.rb 100.00 % 8 3 3 0 1.00
vendor/ruby/3.1.0/gems/idnx-0.1.1/lib/idnx.rb 66.67 % 26 12 8 4 0.67
vendor/ruby/3.1.0/gems/idnx-0.1.1/lib/idnx/idn2.rb 56.25 % 58 32 18 14 0.56
vendor/ruby/3.1.0/gems/idnx-0.1.1/lib/idnx/version.rb 100.00 % 5 2 2 0 1.00
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath.rb 70.83 % 39 24 17 7 0.71
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/caching_parser.rb 43.75 % 29 16 7 9 0.44
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/errors.rb 100.00 % 19 9 9 0 1.00
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/lexer.rb 40.82 % 382 147 60 87 0.41
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes.rb 93.10 % 40 29 27 2 0.93
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/and.rb 46.15 % 24 13 6 7 0.46
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/comparator.rb 55.56 % 88 45 25 20 0.56
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/condition.rb 50.00 % 146 78 39 39 0.50
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/current.rb 80.00 % 11 5 4 1 0.80
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/expression.rb 66.67 % 25 12 8 4 0.67
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/field.rb 33.33 % 75 45 15 30 0.33
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/flatten.rb 42.86 % 28 14 6 8 0.43
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/function.rb 29.84 % 653 382 114 268 0.30
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/index.rb 100.00 % 7 3 3 0 1.00
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/literal.rb 75.00 % 17 8 6 2 0.75
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/multi_select_hash.rb 55.56 % 38 18 10 8 0.56
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/multi_select_list.rb 54.55 % 23 11 6 5 0.55
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/not.rb 66.67 % 18 9 6 3 0.67
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/or.rb 46.15 % 25 13 6 7 0.46
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/pipe.rb 100.00 % 7 3 3 0 1.00
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/projection.rb 50.00 % 77 40 20 20 0.50
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/slice.rb 24.00 % 86 50 12 38 0.24
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/nodes/subexpression.rb 37.14 % 64 35 13 22 0.37
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/parser.rb 20.50 % 334 200 41 159 0.21
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/runtime.rb 57.14 % 70 14 8 6 0.57
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/token.rb 83.33 % 41 6 5 1 0.83
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/token_stream.rb 42.86 % 59 28 12 16 0.43
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/util.rb 26.32 % 34 19 5 14 0.26
vendor/ruby/3.1.0/gems/jmespath-1.6.2/lib/jmespath/version.rb 100.00 % 4 2 2 0 1.00
vendor/ruby/3.1.0/gems/little-plugger-1.1.4/lib/little-plugger.rb 28.99 % 323 69 20 49 0.29
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging.rb 31.82 % 597 198 63 135 0.70
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/appender.rb 24.18 % 324 91 22 69 0.24
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/appenders.rb 76.19 % 62 21 16 5 0.76
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/appenders/buffering.rb 21.51 % 485 172 37 135 0.22
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/appenders/console.rb 32.26 % 82 31 10 21 0.32
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/appenders/file.rb 23.91 % 106 46 11 35 0.24
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/appenders/io.rb 28.95 % 90 38 11 27 0.29
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/appenders/rolling_file.rb 17.50 % 435 160 28 132 0.18
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/appenders/string_io.rb 34.29 % 92 35 12 23 0.40
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/appenders/syslog.rb 25.00 % 214 52 13 39 0.25
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/color_scheme.rb 90.22 % 265 92 83 9 1.12
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/diagnostic_context.rb 40.15 % 482 132 53 79 5.06
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/filter.rb 66.67 % 25 6 4 2 0.67
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/filters.rb 100.00 % 4 3 3 0 1.00
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/filters/level.rb 60.00 % 32 10 6 4 0.60
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/layout.rb 21.84 % 260 87 19 68 0.22
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/layouts.rb 100.00 % 9 5 5 0 1.00
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/layouts/basic.rb 50.00 % 38 8 4 4 0.50
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/layouts/parseable.rb 23.75 % 297 80 19 61 0.24
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/layouts/pattern.rb 23.08 % 566 143 33 110 0.23
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/log_event.rb 30.00 % 52 20 6 14 0.30
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/logger.rb 21.60 % 518 162 35 127 0.22
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/proxy.rb 43.75 % 59 16 7 9 11.56
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/rails_compat.rb 85.71 % 30 7 6 1 0.86
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/repository.rb 28.07 % 216 57 16 41 0.28
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/root_logger.rb 40.00 % 60 20 8 12 0.55
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/utils.rb 31.25 % 170 64 20 44 0.31
vendor/ruby/3.1.0/gems/logging-2.4.0/lib/logging/version.rb 75.00 % 8 4 3 1 0.75
vendor/ruby/3.1.0/gems/minitest-5.27.0/lib/minitest.rb 76.69 % 1250 489 375 114 8.44
vendor/ruby/3.1.0/gems/minitest-5.27.0/lib/minitest/assertions.rb 29.39 % 850 296 87 209 5.53
vendor/ruby/3.1.0/gems/minitest-5.27.0/lib/minitest/autorun.rb 100.00 % 6 5 5 0 1.00
vendor/ruby/3.1.0/gems/minitest-5.27.0/lib/minitest/compress.rb 7.14 % 94 42 3 39 0.07
vendor/ruby/3.1.0/gems/minitest-5.27.0/lib/minitest/expectations.rb 100.00 % 321 35 35 0 1.00
vendor/ruby/3.1.0/gems/minitest-5.27.0/lib/minitest/mock.rb 19.69 % 327 127 25 102 1.15
vendor/ruby/3.1.0/gems/minitest-5.27.0/lib/minitest/parallel.rb 79.31 % 72 29 23 6 1.52
vendor/ruby/3.1.0/gems/minitest-5.27.0/lib/minitest/pride_plugin.rb 39.29 % 135 56 22 34 0.39
vendor/ruby/3.1.0/gems/minitest-5.27.0/lib/minitest/spec.rb 43.93 % 353 107 47 60 2.35
vendor/ruby/3.1.0/gems/minitest-5.27.0/lib/minitest/test.rb 61.33 % 238 75 46 29 16.72
vendor/ruby/3.1.0/gems/multi_json-1.19.1/lib/multi_json.rb 64.15 % 235 53 34 19 0.66
vendor/ruby/3.1.0/gems/multi_json-1.19.1/lib/multi_json/adapter_error.rb 57.14 % 33 7 4 3 0.57
vendor/ruby/3.1.0/gems/multi_json-1.19.1/lib/multi_json/adapter_selector.rb 33.33 % 142 48 16 32 0.33
vendor/ruby/3.1.0/gems/multi_json-1.19.1/lib/multi_json/options.rb 54.17 % 86 24 13 11 0.54
vendor/ruby/3.1.0/gems/multi_json-1.19.1/lib/multi_json/options_cache.rb 70.37 % 98 27 19 8 0.78
vendor/ruby/3.1.0/gems/multi_json-1.19.1/lib/multi_json/parse_error.rb 60.00 % 46 10 6 4 0.60
vendor/ruby/3.1.0/gems/multi_json-1.19.1/lib/multi_json/version.rb 100.00 % 28 10 10 0 1.00
vendor/ruby/3.1.0/gems/net-http-0.9.1/lib/net/http/status.rb 20.00 % 84 15 3 12 0.20
vendor/ruby/3.1.0/gems/net-http-0.9.1/lib/net/https.rb 100.00 % 23 2 2 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri.rb 60.00 % 128 35 21 14 0.60
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/class_resolver.rb 31.25 % 67 16 5 11 0.31
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/css.rb 42.42 % 132 33 14 19 0.42
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/css/node.rb 44.44 % 58 27 12 15 0.44
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/css/parser.rb 37.24 % 772 239 89 150 0.37
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/css/parser_extras.rb 50.00 % 36 18 9 9 0.50
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/css/selector_cache.rb 58.82 % 38 17 10 7 0.59
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/css/syntax_error.rb 100.00 % 9 4 4 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/css/tokenizer.rb 17.98 % 155 89 16 73 0.18
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/css/xpath_visitor.rb 22.99 % 376 174 40 134 0.25
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/decorators/slop.rb 30.00 % 42 20 6 14 0.30
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/encoding_handler.rb 37.50 % 57 24 9 15 0.46
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/extension.rb 33.33 % 32 6 2 4 0.33
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/gumbo.rb 100.00 % 15 5 5 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html.rb 100.00 % 48 12 12 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html4.rb 85.00 % 42 20 17 3 0.85
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html4/builder.rb 80.00 % 37 5 4 1 0.80
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html4/document.rb 20.48 % 235 83 17 66 0.20
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html4/document_fragment.rb 15.15 % 166 33 5 28 0.15
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html4/element_description.rb 66.67 % 25 9 6 3 0.67
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html4/element_description_defaults.rb 85.62 % 2040 153 131 22 5.02
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html4/encoding_reader.rb 28.33 % 121 60 17 43 0.28
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html4/entity_lookup.rb 83.33 % 15 6 5 1 0.83
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html4/sax/parser.rb 100.00 % 48 4 4 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html4/sax/parser_context.rb 100.00 % 15 4 4 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html4/sax/push_parser.rb 60.00 % 37 15 9 6 0.60
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html5.rb 29.17 % 368 48 14 34 0.29
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html5/builder.rb 80.00 % 40 5 4 1 0.80
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html5/document.rb 43.48 % 199 46 20 26 0.43
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html5/document_fragment.rb 27.27 % 200 44 12 32 0.27
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/html5/node.rb 27.78 % 103 36 10 26 0.28
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/syntax_error.rb 100.00 % 6 2 2 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/version.rb 100.00 % 4 2 2 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/version/constant.rb 100.00 % 6 2 2 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/version/info.rb 81.90 % 224 116 95 21 1.16
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml.rb 90.70 % 65 43 39 4 0.91
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/attr.rb 81.82 % 66 11 9 2 0.82
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/attribute_decl.rb 91.67 % 22 12 11 1 0.92
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/builder.rb 23.15 % 494 108 25 83 0.23
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/cdata.rb 80.00 % 13 5 4 1 0.80
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/character_data.rb 100.00 % 9 4 4 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/document.rb 38.74 % 514 111 43 68 0.39
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/document_fragment.rb 32.88 % 276 73 24 49 0.33
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/dtd.rb 68.42 % 34 19 13 6 0.68
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/element_content.rb 88.89 % 46 18 16 2 0.89
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/element_decl.rb 88.89 % 17 9 8 1 0.89
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/entity_decl.rb 84.62 % 23 13 11 2 0.85
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/entity_reference.rb 71.43 % 20 7 5 2 0.71
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/namespace.rb 80.00 % 57 10 8 2 0.80
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/node.rb 36.09 % 1650 363 131 232 0.36
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/node/save_options.rb 70.00 % 76 30 21 9 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/node_set.rb 36.09 % 449 133 48 85 0.36
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/notation.rb 100.00 % 19 3 3 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/parse_options.rb 81.25 % 213 48 39 9 1.83
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/pp.rb 100.00 % 4 2 2 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/pp/character_data.rb 60.00 % 21 10 6 4 0.60
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/pp/node.rb 18.42 % 73 38 7 31 0.18
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/processing_instruction.rb 80.00 % 11 5 4 1 0.80
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/reader.rb 72.50 % 139 40 29 11 0.73
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/relax_ng.rb 70.00 % 75 10 7 3 0.70
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/sax.rb 100.00 % 54 7 7 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/sax/document.rb 75.00 % 258 24 18 6 0.75
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/sax/parser.rb 45.71 % 199 35 16 19 0.46
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/sax/parser_context.rb 40.00 % 129 25 10 15 0.40
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/sax/push_parser.rb 60.00 % 64 15 9 6 0.60
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/schema.rb 55.00 % 140 20 11 9 0.55
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/searchable.rb 33.33 % 274 60 20 40 0.33
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/syntax_error.rb 56.82 % 94 44 25 19 0.57
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/text.rb 80.00 % 11 5 4 1 0.80
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/xpath.rb 100.00 % 21 8 8 0 1.00
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/xpath/syntax_error.rb 83.33 % 13 6 5 1 0.83
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xml/xpath_context.rb 41.67 % 27 12 5 7 0.42
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xslt.rb 37.50 % 129 24 9 15 0.38
vendor/ruby/3.1.0/gems/nokogiri-1.18.10-x86_64-linux-gnu/lib/nokogiri/xslt/stylesheet.rb 80.00 % 49 5 4 1 0.80
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga.rb 100.00 % 67 48 48 0 1.02
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/blacklist.rb 75.00 % 30 12 9 3 3.25
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/css/lexer.rb 20.85 % 1019 307 64 243 0.21
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/css/parser.rb 39.72 % 1018 287 114 173 0.40
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/entity_decoder.rb 57.14 % 17 7 4 3 0.57
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/html/entities.rb 83.33 % 2146 6 5 1 0.83
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/html/parser.rb 66.67 % 21 6 4 2 0.67
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/html/sax_parser.rb 66.67 % 14 6 4 2 0.67
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/lru.rb 43.75 % 136 48 21 27 0.65
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/oga.rb 55.56 % 49 9 5 4 0.56
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/ruby/generator.rb 25.00 % 225 76 19 57 0.25
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/ruby/node.rb 52.17 % 189 46 24 22 0.52
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/version.rb 100.00 % 3 2 2 0 1.00
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/whitelist.rb 83.33 % 14 6 5 1 0.83
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/attribute.rb 40.82 % 120 49 20 29 0.41
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/cdata.rb 100.00 % 7 3 3 0 1.00
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/character_node.rb 66.67 % 24 9 6 3 0.67
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/comment.rb 100.00 % 7 3 3 0 1.00
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/default_namespace.rb 100.00 % 11 3 3 0 1.00
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/doctype.rb 47.62 % 60 21 10 11 0.48
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/document.rb 45.71 % 100 35 16 19 0.46
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/element.rb 29.01 % 366 131 38 93 0.29
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/entities.rb 56.67 % 120 30 17 13 0.57
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/expanded_name.rb 80.00 % 12 5 4 1 0.80
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/generator.rb 17.89 % 225 95 17 78 0.18
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/html_void_elements.rb 100.00 % 13 3 3 0 1.00
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/lexer.rb 40.77 % 453 130 53 77 0.56
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/namespace.rb 78.57 % 38 14 11 3 0.93
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/node.rb 32.86 % 197 70 23 47 0.33
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/node_set.rb 31.40 % 349 121 38 83 0.31
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/parser.rb 44.16 % 557 154 68 86 0.44
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/processing_instruction.rb 66.67 % 24 9 6 3 0.67
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/pull_parser.rb 60.71 % 153 28 17 11 0.82
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/querying.rb 41.18 % 104 17 7 10 0.41
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/sax_parser.rb 41.03 % 178 39 16 23 0.85
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/text.rb 42.11 % 44 19 8 11 0.42
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/to_xml.rb 83.33 % 14 6 5 1 0.83
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/traversal.rb 33.33 % 48 12 4 8 0.33
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xml/xml_declaration.rb 42.11 % 45 19 8 11 0.42
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xpath/compiler.rb 16.98 % 1597 642 109 533 0.19
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xpath/context.rb 80.00 % 17 5 4 1 0.80
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xpath/conversion.rb 18.75 % 102 48 9 39 0.19
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xpath/lexer.rb 23.28 % 2565 262 61 201 0.23
vendor/ruby/3.1.0/gems/oga-3.5/lib/oga/xpath/parser.rb 43.22 % 651 199 86 113 0.43
vendor/ruby/3.1.0/gems/public_suffix-6.0.2/lib/public_suffix.rb 33.33 % 177 42 14 28 0.33
vendor/ruby/3.1.0/gems/public_suffix-6.0.2/lib/public_suffix/domain.rb 54.55 % 235 22 12 10 0.55
vendor/ruby/3.1.0/gems/public_suffix-6.0.2/lib/public_suffix/errors.rb 100.00 % 41 4 4 0 1.00
vendor/ruby/3.1.0/gems/public_suffix-6.0.2/lib/public_suffix/list.rb 34.29 % 247 70 24 46 0.34
vendor/ruby/3.1.0/gems/public_suffix-6.0.2/lib/public_suffix/rule.rb 48.44 % 350 64 31 33 0.48
vendor/ruby/3.1.0/gems/public_suffix-6.0.2/lib/public_suffix/version.rb 100.00 % 14 2 2 0 1.00
vendor/ruby/3.1.0/gems/racc-1.8.1/lib/racc/info.rb 100.00 % 18 4 4 0 1.00
vendor/ruby/3.1.0/gems/racc-1.8.1/lib/racc/parser.rb 19.46 % 630 221 43 178 0.19
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake.rb 100.00 % 69 36 36 0 1.00
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/application.rb 20.82 % 847 389 81 308 0.21
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/backtrace.rb 86.67 % 25 15 13 2 2.80
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/cpu_counter.rb 61.54 % 122 13 8 5 0.62
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/default_loader.rb 75.00 % 15 4 3 1 0.75
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/dsl_definition.rb 46.15 % 196 39 18 21 0.46
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/early_time.rb 77.78 % 22 9 7 2 0.78
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/file_creation_task.rb 75.00 % 25 8 6 2 0.75
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/file_task.rb 45.45 % 58 22 10 12 0.45
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/invocation_chain.rb 58.33 % 57 24 14 10 0.58
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/invocation_exception_mixin.rb 66.67 % 17 6 4 2 0.67
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/late_time.rb 77.78 % 18 9 7 2 0.78
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/linked_list.rb 43.75 % 112 48 21 27 0.44
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/multi_task.rb 80.00 % 14 5 4 1 0.80
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/name_space.rb 50.00 % 38 10 5 5 0.50
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/options.rb 100.00 % 31 22 22 0 1.00
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/private_reader.rb 100.00 % 21 8 8 0 1.00
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/promise.rb 29.79 % 100 47 14 33 0.30
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/pseudo_status.rb 61.54 % 30 13 8 5 0.62
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/rake_module.rb 43.48 % 67 23 10 13 0.43
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/rule_recursion_overflow_error.rb 55.56 % 20 9 5 4 0.56
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/scope.rb 52.63 % 43 19 10 9 0.53
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/task.rb 33.17 % 434 205 68 137 0.33
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/task_argument_error.rb 100.00 % 8 2 2 0 1.00
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/task_arguments.rb 54.90 % 113 51 28 23 0.55
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/task_manager.rb 20.69 % 333 174 36 138 0.21
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/thread_history_display.rb 30.00 % 49 30 9 21 0.30
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/thread_pool.rb 21.43 % 157 70 15 55 0.21
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/trace_output.rb 30.00 % 23 10 3 7 0.30
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/version.rb 100.00 % 10 5 5 0 1.00
vendor/ruby/3.1.0/gems/rake-13.4.2/lib/rake/win32.rb 83.33 % 17 6 5 1 0.83
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/attlistdecl.rb 60.00 % 63 20 12 8 0.60
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/attribute.rb 36.99 % 210 73 27 46 0.37
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/cdata.rb 55.00 % 68 20 11 9 0.55
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/child.rb 56.67 % 96 30 17 13 0.90
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/comment.rb 48.28 % 80 29 14 15 0.48
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/doctype.rb 31.37 % 306 153 48 105 0.31
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/document.rb 37.19 % 471 121 45 76 0.37
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/element.rb 21.58 % 2578 431 93 338 0.22
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/encoding.rb 70.37 % 48 27 19 8 0.70
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/entity.rb 52.00 % 142 75 39 36 0.89
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/formatters/default.rb 22.22 % 116 54 12 42 0.22
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/formatters/pretty.rb 21.05 % 142 76 16 60 0.21
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/functions.rb 25.65 % 446 191 49 142 0.62
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/instruction.rb 36.11 % 79 36 13 23 0.36
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/light/node.rb 29.36 % 188 109 32 77 0.29
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/namespace.rb 31.58 % 63 38 12 26 0.32
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/node.rb 31.71 % 80 41 13 28 0.32
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/output.rb 50.00 % 30 16 8 8 0.50
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/parent.rb 35.53 % 166 76 27 49 0.36
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/parseexception.rb 27.59 % 53 29 8 21 0.28
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/parsers/baseparser.rb 22.28 % 949 597 133 464 0.23
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/parsers/streamparser.rb 27.78 % 67 36 10 26 0.28
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/parsers/treeparser.rb 16.33 % 89 49 8 41 0.16
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/parsers/xpathparser.rb 9.35 % 739 460 43 417 0.09
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/rexml.rb 100.00 % 39 7 7 0 1.00
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/security.rb 66.67 % 28 12 8 4 0.67
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/source.rb 24.41 % 388 213 52 161 0.26
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/text.rb 26.88 % 420 186 50 136 0.32
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/undefinednamespaceexception.rb 80.00 % 9 5 4 1 0.80
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/validation/validationexception.rb 80.00 % 10 5 4 1 0.80
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/xmldecl.rb 59.46 % 130 74 44 30 0.59
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/xmltokens.rb 100.00 % 85 15 15 0 1.00
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/xpath.rb 45.00 % 70 20 9 11 0.45
vendor/ruby/3.1.0/gems/rexml-3.4.4/lib/rexml/xpath_parser.rb 11.29 % 980 558 63 495 0.11
vendor/ruby/3.1.0/gems/ruby-ll-2.2.0/lib/ll/configuration_compiler.rb 15.85 % 240 82 13 69 0.16
vendor/ruby/3.1.0/gems/ruby-ll-2.2.0/lib/ll/driver.rb 31.58 % 67 19 6 13 0.32
vendor/ruby/3.1.0/gems/ruby-ll-2.2.0/lib/ll/driver_config.rb 100.00 % 36 11 11 0 1.73
vendor/ruby/3.1.0/gems/ruby-ll-2.2.0/lib/ll/parser_error.rb 100.00 % 8 2 2 0 1.00
vendor/ruby/3.1.0/gems/ruby-ll-2.2.0/lib/ll/setup.rb 100.00 % 14 6 6 0 1.17
vendor/ruby/3.1.0/gems/ruby-ll-2.2.0/lib/ll/version.rb 100.00 % 3 2 2 0 1.00
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry-ruby.rb 65.96 % 721 235 155 80 8.60
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/attachment.rb 42.86 % 40 21 9 12 0.43
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/background_worker.rb 70.27 % 75 37 26 11 1.89
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/backpressure_monitor.rb 38.46 % 45 26 10 16 0.38
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/backtrace.rb 30.23 % 97 43 13 30 0.30
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/backtrace/line.rb 36.54 % 106 52 19 33 0.37
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/baggage.rb 40.00 % 70 30 12 18 0.40
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/breadcrumb.rb 80.00 % 77 30 24 6 6.40
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/breadcrumb_buffer.rb 76.92 % 64 26 20 6 3.58
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/check_in_event.rb 51.85 % 61 27 14 13 0.52
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/client.rb 29.17 % 322 144 42 102 0.51
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/configuration.rb 83.29 % 921 347 289 58 4.31
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/core_ext/object/deep_dup.rb 47.06 % 61 17 8 9 0.47
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/core_ext/object/duplicable.rb 57.50 % 155 40 23 17 0.58
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/cron/configuration.rb 100.00 % 23 6 6 0 1.00
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/cron/monitor_check_ins.rb 30.56 % 77 36 11 25 0.31
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/cron/monitor_config.rb 60.00 % 53 20 12 8 0.60
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/cron/monitor_schedule.rb 72.22 % 42 18 13 5 0.72
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/debug_structured_logger.rb 47.73 % 94 44 21 23 0.73
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/dsn.rb 67.74 % 117 62 42 20 2.92
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/envelope.rb 61.54 % 27 13 8 5 0.62
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/envelope/item.rb 32.65 % 88 49 16 33 0.49
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/error_event.rb 46.67 % 38 15 7 8 0.47
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/event.rb 44.93 % 153 69 31 38 0.45
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/exceptions.rb 100.00 % 12 4 4 0 1.00
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/excon.rb 20.00 % 10 5 1 4 0.20
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/faraday.rb 36.11 % 77 36 13 23 0.36
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/graphql.rb 25.00 % 9 4 1 3 0.25
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/hub.rb 38.97 % 410 195 76 119 4.01
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/interface.rb 90.00 % 17 10 9 1 0.90
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/interfaces/exception.rb 36.84 % 45 19 7 12 0.37
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/interfaces/mechanism.rb 71.43 % 20 7 5 2 0.71
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/interfaces/request.rb 34.38 % 145 64 22 42 0.34
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/interfaces/single_exception.rb 34.21 % 69 38 13 25 0.34
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/interfaces/stacktrace.rb 43.75 % 62 32 14 18 0.44
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/interfaces/stacktrace_builder.rb 60.53 % 108 38 23 15 1.45
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/interfaces/threads.rb 38.46 % 42 13 5 8 0.38
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/linecache.rb 38.10 % 44 21 8 13 0.57
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/log_event.rb 41.18 % 101 51 21 30 0.41
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/log_event_buffer.rb 85.71 % 28 7 6 1 0.86
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/logger.rb 83.33 % 20 12 10 2 2.17
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/metric_event.rb 50.00 % 49 20 10 10 0.50
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/metric_event_buffer.rb 100.00 % 28 7 7 0 1.57
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/metrics.rb 53.85 % 61 13 7 6 0.54
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/net/http.rb 41.03 % 86 39 16 23 0.41
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/profiler.rb 33.33 % 203 102 34 68 0.88
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/profiler/helpers.rb 53.85 % 26 13 7 6 0.54
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/propagation_context.rb 40.00 % 212 95 38 57 0.95
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/puma.rb 7.69 % 32 13 1 12 0.08
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/rack.rb 50.00 % 5 2 1 1 0.50
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/rake.rb 60.00 % 30 15 9 6 0.60
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/redis.rb 35.19 % 109 54 19 35 0.35
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/release_detector.rb 61.90 % 44 21 13 8 1.38
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/scope.rb 41.21 % 413 182 75 107 1.14
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/session.rb 60.00 % 33 15 9 6 0.60
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/session_flusher.rb 50.00 % 62 38 19 19 1.24
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/span.rb 79.25 % 308 106 84 22 5.98
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/std_lib_logger.rb 27.27 % 59 22 6 16 0.27
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/structured_logger.rb 54.55 % 138 22 12 10 0.55
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/telemetry_event_buffer.rb 50.79 % 133 63 32 31 1.40
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/threaded_periodic_worker.rb 56.52 % 39 23 13 10 2.13
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/transaction.rb 60.90 % 339 156 95 61 2.20
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/transaction_event.rb 36.11 % 82 36 13 23 0.36
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/transport.rb 36.04 % 228 111 40 71 0.65
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/transport/configuration.rb 95.00 % 97 20 19 1 2.15
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/transport/debug_transport.rb 47.06 % 70 34 16 18 0.47
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/transport/dummy_transport.rb 66.67 % 29 15 10 5 1.47
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/transport/http_transport.rb 24.04 % 205 104 25 79 0.24
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/transport/spotlight_transport.rb 39.29 % 50 28 11 17 0.39
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/utils/argument_checking_helper.rb 66.67 % 25 12 8 4 3.42
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/utils/custom_inspection.rb 42.86 % 14 7 3 4 0.43
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/utils/encoding_helper.rb 50.00 % 28 14 7 7 0.50
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/utils/env_helper.rb 100.00 % 21 11 11 0 5.09
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/utils/exception_cause_chain.rb 40.00 % 20 10 4 6 0.40
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/utils/filename_cache.rb 38.46 % 47 26 10 16 0.85
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/utils/http_tracing.rb 28.21 % 80 39 11 28 0.28
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/utils/logging_helper.rb 47.37 % 45 19 9 10 3.68
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/utils/real_ip.rb 35.48 % 84 31 11 20 0.35
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/utils/request_id.rb 62.50 % 18 8 5 3 0.63
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/utils/sample_rand.rb 45.10 % 97 51 23 28 1.16
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/utils/telemetry_attributes.rb 46.15 % 30 13 6 7 0.46
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/utils/uuid.rb 100.00 % 13 6 6 0 8.33
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/vernier/output.rb 35.71 % 90 42 15 27 0.36
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/vernier/profiler.rb 28.17 % 135 71 20 51 0.28
vendor/ruby/3.1.0/gems/sentry-ruby-6.6.2/lib/sentry/version.rb 100.00 % 5 2 2 0 1.00
vendor/ruby/3.1.0/gems/simplecov-0.22.0/lib/minitest/simplecov_plugin.rb 100.00 % 15 6 6 0 1.00
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock.rb 100.00 % 61 49 49 0 1.00
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/api.rb 91.67 % 111 60 55 5 13.48
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/assertion_failure.rb 100.00 % 13 7 7 0 1.57
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/callback_registry.rb 100.00 % 37 19 19 0 3.53
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/config.rb 100.00 % 20 13 13 0 1.00
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/deprecation.rb 80.00 % 11 5 4 1 0.80
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/errors.rb 100.00 % 19 6 6 0 1.00
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/http_lib_adapters/async_http_client_adapter.rb 1.72 % 228 116 2 114 0.02
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/http_lib_adapters/curb_adapter.rb 1.02 % 354 197 2 195 0.01
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/http_lib_adapters/em_http_request_adapter.rb 1.57 % 239 127 2 125 0.02
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/http_lib_adapters/excon_adapter.rb 2.15 % 167 93 2 91 0.02
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/http_lib_adapters/http_lib_adapter.rb 100.00 % 9 4 4 0 1.25
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/http_lib_adapters/http_lib_adapter_registry.rb 100.00 % 21 10 10 0 8.40
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/http_lib_adapters/http_rb_adapter.rb 10.53 % 39 19 2 17 0.11
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/http_lib_adapters/httpclient_adapter.rb 1.37 % 260 146 2 144 0.01
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/http_lib_adapters/manticore_adapter.rb 2.63 % 147 76 2 74 0.03
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/http_lib_adapters/net_http.rb 43.37 % 310 166 72 94 18.64
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/http_lib_adapters/net_http_response.rb 25.00 % 36 12 3 9 0.25
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/http_lib_adapters/patron_adapter.rb 3.08 % 132 65 2 63 0.03
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/http_lib_adapters/typhoeus_hydra_adapter.rb 2.35 % 190 85 2 83 0.02
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/matchers/any_arg_matcher.rb 83.33 % 15 6 5 1 0.83
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/matchers/hash_argument_matcher.rb 80.00 % 23 10 8 2 2.00
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/matchers/hash_excluding_matcher.rb 85.71 % 17 7 6 1 1.86
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/matchers/hash_including_matcher.rb 75.00 % 19 8 6 2 1.38
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/minitest.rb 87.50 % 43 24 21 3 9.42
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/rack_response.rb 23.53 % 73 34 8 26 0.24
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/request_body_diff.rb 63.64 % 66 33 21 12 1.24
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/request_execution_verifier.rb 85.71 % 79 42 36 6 8.95
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/request_pattern.rb 76.02 % 428 221 168 53 38.01
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/request_registry.rb 100.00 % 37 19 19 0 13.53
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/request_signature.rb 96.67 % 56 30 29 1 40.10
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/request_signature_snippet.rb 76.47 % 63 34 26 8 1.65
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/request_stub.rb 69.33 % 134 75 52 23 19.63
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/response.rb 69.23 % 161 91 63 28 48.30
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/responses_sequence.rb 90.48 % 42 21 19 2 27.05
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/stub_registry.rb 64.10 % 84 39 25 14 25.59
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/stub_request_snippet.rb 86.36 % 40 22 19 3 3.59
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/util/hash_counter.rb 91.67 % 45 24 22 2 27.83
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/util/hash_keys_stringifier.rb 100.00 % 27 13 13 0 134.77
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/util/hash_validator.rb 88.89 % 19 9 8 1 164.22
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/util/headers.rb 78.05 % 77 41 32 9 104.95
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/util/parsers/json.rb 25.71 % 72 35 9 26 0.26
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/util/parsers/parse_error.rb 100.00 % 7 4 4 0 1.00
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/util/parsers/xml.rb 77.78 % 16 9 7 2 0.78
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/util/query_mapper.rb 53.03 % 283 132 70 62 9.89
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/util/uri.rb 62.07 % 113 58 36 22 26.17
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/util/values_stringifier.rb 44.44 % 22 9 4 5 0.67
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/util/version_checker.rb 34.04 % 113 47 16 31 0.34
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/version.rb 100.00 % 5 2 2 0 1.00
vendor/ruby/3.1.0/gems/webmock-3.26.2/lib/webmock/webmock.rb 65.88 % 175 85 56 29 10.95
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick.rb 100.00 % 232 18 18 0 1.00
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/accesslog.rb 27.08 % 157 48 13 35 0.27
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/compat.rb 100.00 % 36 4 4 0 1.00
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/config.rb 81.25 % 158 16 13 3 0.81
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/cookie.rb 26.03 % 172 73 19 54 0.26
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/htmlutils.rb 36.36 % 30 11 4 7 0.36
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpauth.rb 50.00 % 96 22 11 11 0.50
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpauth/authenticator.rb 44.90 % 117 49 22 27 0.45
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpauth/basicauth.rb 38.46 % 116 39 15 24 0.38
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpauth/digestauth.rb 15.31 % 395 209 32 177 0.15
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpauth/htdigest.rb 25.93 % 132 54 14 40 0.26
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpauth/htgroup.rb 27.27 % 97 33 9 24 0.27
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpauth/htpasswd.rb 23.73 % 158 59 14 45 0.24
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpauth/userdb.rb 70.00 % 53 10 7 3 0.70
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpproxy.rb 18.93 % 354 169 32 137 0.19
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httprequest.rb 21.50 % 668 321 69 252 0.21
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpresponse.rb 20.97 % 588 267 56 211 0.21
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/https.rb 44.64 % 152 56 25 31 0.45
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpserver.rb 23.78 % 294 143 34 109 0.24
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpservlet.rb 100.00 % 23 9 9 0 1.00
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpservlet/abstract.rb 45.45 % 152 33 15 18 0.45
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpservlet/cgihandler.rb 19.40 % 126 67 13 54 0.19
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpservlet/erbhandler.rb 38.46 % 88 26 10 16 0.38
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpservlet/filehandler.rb 13.82 % 552 275 38 237 0.14
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpservlet/prochandler.rb 75.00 % 48 12 9 3 0.75
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpstatus.rb 84.91 % 194 53 45 8 11.15
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httputils.rb 33.48 % 543 230 77 153 1.05
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/httpversion.rb 59.09 % 76 22 13 9 0.59
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/log.rb 54.90 % 156 51 28 23 0.55
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/server.rb 20.27 % 380 148 30 118 0.20
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/ssl.rb 21.79 % 219 78 17 61 0.22
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/utils.rb 31.53 % 265 111 35 76 0.32
vendor/ruby/3.1.0/gems/webrick-1.9.2/lib/webrick/version.rb 100.00 % 18 2 2 0 1.00
vendor/ruby/3.1.0/gems/websocket-driver-0.8.2/lib/websocket/driver.rb 40.54 % 248 148 60 88 0.41
vendor/ruby/3.1.0/gems/websocket-driver-0.8.2/lib/websocket/driver/event_emitter.rb 37.93 % 54 29 11 18 0.38
vendor/ruby/3.1.0/gems/websocket-driver-0.8.2/lib/websocket/driver/hybi.rb 18.70 % 430 246 46 200 0.19
vendor/ruby/3.1.0/gems/websocket-extensions-0.1.5/lib/websocket/extensions.rb 17.65 % 181 102 18 84 0.18

integration_tests/datadog_helpers.rb

90.67% lines covered

75 relevant lines. 68 lines covered and 7 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module DatadogHelpers
  3. 1 DATADOG_VERSION = defined?(DDTrace) ? DDTrace::VERSION : Datadog::VERSION
  4. 1 ERROR_TAG = if Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("1.8.0")
  5. 1 "error.message"
  6. else
  7. "error.msg"
  8. end
  9. 1 private
  10. 1 def verify_instrumented_request(status, verb:, uri:, span: fetch_spans.first, service: datadog_service_name.to_s, error: nil)
  11. 25 if Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("2.0.0")
  12. assert span.type == "http"
  13. else
  14. 25 assert span.span_type == "http"
  15. end
  16. 25 assert span.name == "#{datadog_service_name}.request"
  17. 25 assert span.service == service
  18. 25 assert span.get_tag("out.host") == uri.host
  19. 25 assert span.get_tag("out.port") == 80
  20. 25 assert span.get_tag("http.method") == verb
  21. 25 assert span.get_tag("http.url") == uri.path
  22. 25 if status && status >= 400
  23. 7 verify_http_error_span(span, status, error)
  24. 18 elsif error
  25. 2 verify_error_span(span)
  26. else
  27. 16 assert span.status.zero?
  28. 16 assert span.get_tag("http.status_code") == status.to_s
  29. # peer service
  30. # assert span.get_tag("peer.service") == span.service
  31. end
  32. end
  33. 1 def verify_http_error_span(span, status, error)
  34. 5 assert span.get_tag("http.status_code") == status.to_s
  35. 5 assert span.get_tag("error.type") == error
  36. 5 assert !span.get_tag(ERROR_TAG).nil?
  37. 5 assert span.status == 1
  38. end
  39. 1 def verify_error_span(span)
  40. 2 assert span.get_tag("error.type") == "HTTPX::NativeResolveError"
  41. 2 assert !span.get_tag(ERROR_TAG).nil?
  42. 2 assert span.status == 1
  43. end
  44. 1 def verify_no_distributed_headers(request_headers)
  45. 1 assert !request_headers.key?("x-datadog-parent-id")
  46. 1 assert !request_headers.key?("x-datadog-trace-id")
  47. 1 assert !request_headers.key?("x-datadog-sampling-priority")
  48. end
  49. 1 def verify_distributed_headers(request_headers, span: fetch_spans.first, sampling_priority: 1)
  50. 13 if Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("2.0.0")
  51. assert request_headers["x-datadog-parent-id"] == span.id.to_s
  52. else
  53. 13 assert request_headers["x-datadog-parent-id"] == span.span_id.to_s
  54. end
  55. 13 assert request_headers["x-datadog-trace-id"] == trace_id(span)
  56. 13 assert request_headers["x-datadog-sampling-priority"] == sampling_priority.to_s
  57. end
  58. 1 if Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("1.17.0")
  59. 1 def trace_id(span)
  60. 13 Datadog::Tracing::Utils::TraceId.to_low_order(span.trace_id).to_s
  61. end
  62. else
  63. def trace_id(span)
  64. span.trace_id.to_s
  65. end
  66. end
  67. 1 def verify_analytics_headers(span, sample_rate: nil)
  68. 8 assert span.get_metric("_dd1.sr.eausr") == sample_rate
  69. end
  70. 1 def set_datadog(options = {}, &blk)
  71. 22 Datadog.configure do |c|
  72. 22 c.tracing.instrument(datadog_service_name, options, &blk)
  73. end
  74. 22 tracer # initialize tracer patches
  75. end
  76. 1 def tracer
  77. 80 @tracer ||= begin
  78. 22 tr = Datadog::Tracing.send(:tracer)
  79. 22 def tr.write(trace)
  80. 25 @traces ||= []
  81. 25 @traces << trace
  82. end
  83. 22 tr
  84. end
  85. end
  86. 1 def trace_with_sampling_priority(priority)
  87. 2 tracer.trace("foo.bar") do
  88. 2 tracer.active_trace.sampling_priority = priority
  89. 2 yield
  90. end
  91. end
  92. # Returns spans and caches it (similar to +let(:spans)+).
  93. 1 def spans
  94. @spans ||= fetch_spans
  95. end
  96. # Retrieves and sorts all spans in the current tracer instance.
  97. # This method does not cache its results.
  98. 1 def fetch_spans
  99. 54 spans = (tracer.instance_variable_get(:@traces) || []).map(&:spans)
  100. 54 spans.flatten.sort! do |a, b|
  101. 10 if a.name == b.name
  102. 6 if a.resource == b.resource
  103. 4 if a.start_time == b.start_time
  104. a.end_time <=> b.end_time
  105. else
  106. 4 a.start_time <=> b.start_time
  107. end
  108. else
  109. 2 a.resource <=> b.resource
  110. end
  111. else
  112. 4 a.name <=> b.name
  113. end
  114. end
  115. end
  116. end

integration_tests/faraday_datadog_test.rb

82.4% lines covered

125 relevant lines. 103 lines covered and 22 lines missed.
    
  1. # frozen_string_literal: true
  2. begin
  3. # upcoming 2.0
  4. 1 require "datadog"
  5. rescue LoadError
  6. 1 require "ddtrace"
  7. end
  8. 1 require "test_helper"
  9. 1 require "support/http_helpers"
  10. 1 require "httpx/adapters/faraday"
  11. 1 require_relative "datadog_helpers"
  12. 1 DATADOG_VERSION = defined?(DDTrace) ? DDTrace::VERSION : Datadog::VERSION
  13. 1 class FaradayDatadogTest < Minitest::Test
  14. 1 include HTTPHelpers
  15. 1 include DatadogHelpers
  16. 1 include FaradayHelpers
  17. 1 def test_faraday_datadog_successful_get_request
  18. 1 set_datadog
  19. 1 uri = URI(build_uri("/status/200"))
  20. 1 response = faraday_connection.get(uri)
  21. 1 verify_status(response, 200)
  22. 1 assert !fetch_spans.empty?, "expected to have spans"
  23. 1 verify_instrumented_request(response.status, verb: "GET", uri: uri)
  24. 1 verify_distributed_headers(request_headers(response))
  25. end
  26. 1 def test_faraday_datadog_successful_post_request
  27. 1 set_datadog
  28. 1 uri = URI(build_uri("/status/200"))
  29. 1 response = faraday_connection.post(uri, "bla")
  30. 1 verify_status(response, 200)
  31. 1 assert !fetch_spans.empty?, "expected to have spans"
  32. 1 verify_instrumented_request(response.status, verb: "POST", uri: uri)
  33. 1 verify_distributed_headers(request_headers(response))
  34. end
  35. 1 def test_faraday_datadog_server_error_request
  36. 1 set_datadog
  37. 1 uri = URI(build_uri("/status/500"))
  38. 1 ex = assert_raises(Faraday::ServerError) do
  39. 1 faraday_connection.tap do |conn|
  40. 1 adapter_handler = conn.builder.handlers.last
  41. 1 conn.builder.insert_before adapter_handler, Faraday::Response::RaiseError
  42. end.get(uri)
  43. end
  44. 1 assert !fetch_spans.empty?, "expected to have spans"
  45. 1 verify_instrumented_request(ex.response[:status], verb: "GET", uri: uri, error: "Error 500")
  46. 1 verify_distributed_headers(request_headers(ex.response))
  47. end
  48. 1 def test_faraday_datadog_client_error_request
  49. 1 set_datadog
  50. 1 uri = URI(build_uri("/status/404"))
  51. 1 ex = assert_raises(Faraday::ResourceNotFound) do
  52. 1 faraday_connection.tap do |conn|
  53. 1 adapter_handler = conn.builder.handlers.last
  54. 1 conn.builder.insert_before adapter_handler, Faraday::Response::RaiseError
  55. end.get(uri)
  56. end
  57. 1 assert !fetch_spans.empty?, "expected to have spans"
  58. 1 verify_instrumented_request(ex.response[:status], verb: "GET", uri: uri, error: "Error 404")
  59. 1 verify_distributed_headers(request_headers(ex.response))
  60. end
  61. 1 def test_faraday_datadog_some_other_error
  62. 1 set_datadog
  63. 1 uri = URI("http://unexisting/")
  64. 2 assert_raises(HTTPX::NativeResolveError) { faraday_connection.get(uri) }
  65. 1 assert !fetch_spans.empty?, "expected to have spans"
  66. 1 verify_instrumented_request(nil, verb: "GET", uri: uri, error: "HTTPX::NativeResolveError")
  67. end
  68. 1 def test_faraday_datadog_host_config
  69. 1 uri = URI(build_uri("/status/200"))
  70. 1 set_datadog(describe: /#{uri.host}/) do |http|
  71. 1 http.service_name = "httpbin"
  72. 1 http.split_by_domain = false
  73. end
  74. 1 response = faraday_connection.get(uri)
  75. 1 verify_status(response, 200)
  76. 1 assert !fetch_spans.empty?, "expected to have spans"
  77. 1 verify_instrumented_request(response.status, service: "httpbin", verb: "GET", uri: uri)
  78. 1 verify_distributed_headers(request_headers(response))
  79. end
  80. 1 def test_faraday_datadog_split_by_domain
  81. 1 uri = URI(build_uri("/status/200"))
  82. 1 set_datadog do |http|
  83. 1 http.split_by_domain = true
  84. end
  85. 1 response = faraday_connection.get(uri)
  86. 1 verify_status(response, 200)
  87. 1 assert !fetch_spans.empty?, "expected to have spans"
  88. 1 verify_instrumented_request(response.status, service: uri.host, verb: "GET", uri: uri)
  89. 1 verify_distributed_headers(request_headers(response))
  90. end
  91. def test_faraday_datadog_distributed_headers_disabled
  92. set_datadog(distributed_tracing: false)
  93. uri = URI(build_uri("/status/200"))
  94. sampling_priority = 10
  95. response = trace_with_sampling_priority(sampling_priority) do
  96. faraday_connection.get(uri)
  97. end
  98. verify_status(response, 200)
  99. assert !fetch_spans.empty?, "expected to have spans"
  100. span = fetch_spans.last
  101. verify_instrumented_request(response.status, span: span, verb: "GET", uri: uri)
  102. verify_no_distributed_headers(request_headers(response))
  103. verify_analytics_headers(span)
  104. 1 end unless ENV.key?("CI") # TODO: https://github.com/DataDog/dd-trace-rb/issues/4308
  105. def test_faraday_datadog_distributed_headers_sampling_priority
  106. set_datadog
  107. uri = URI(build_uri("/status/200"))
  108. sampling_priority = 10
  109. response = trace_with_sampling_priority(sampling_priority) do
  110. faraday_connection.get(uri)
  111. end
  112. verify_status(response, 200)
  113. assert !fetch_spans.empty?, "expected to have spans"
  114. span = fetch_spans.last
  115. verify_instrumented_request(response.status, span: span, verb: "GET", uri: uri)
  116. verify_distributed_headers(request_headers(response), span: span, sampling_priority: sampling_priority)
  117. verify_analytics_headers(span)
  118. 1 end unless ENV.key?("CI") # TODO: https://github.com/DataDog/dd-trace-rb/issues/4308
  119. 1 def test_faraday_datadog_analytics_enabled
  120. 1 set_datadog(analytics_enabled: true)
  121. 1 uri = URI(build_uri("/status/200"))
  122. 1 response = faraday_connection.get(uri)
  123. 1 verify_status(response, 200)
  124. 1 assert !fetch_spans.empty?, "expected to have spans"
  125. 1 span = fetch_spans.last
  126. 1 verify_instrumented_request(response.status, span: span, verb: "GET", uri: uri)
  127. 1 verify_analytics_headers(span, sample_rate: 1.0)
  128. end
  129. 1 def test_faraday_datadog_analytics_sample_rate
  130. 1 set_datadog(analytics_enabled: true, analytics_sample_rate: 0.5)
  131. 1 uri = URI(build_uri("/status/200"))
  132. 1 response = faraday_connection.get(uri)
  133. 1 verify_status(response, 200)
  134. 1 assert !fetch_spans.empty?, "expected to have spans"
  135. 1 span = fetch_spans.last
  136. 1 verify_instrumented_request(response.status, span: span, verb: "GET", uri: uri)
  137. 1 verify_analytics_headers(span, sample_rate: 0.5)
  138. end
  139. 1 private
  140. 1 def setup
  141. 9 super
  142. 9 Datadog.registry[:faraday].reset_configuration!
  143. end
  144. 1 def teardown
  145. 9 super
  146. 9 Datadog.registry[:faraday].reset_configuration!
  147. end
  148. 1 def datadog_service_name
  149. 25 :faraday
  150. end
  151. 1 def origin(orig = httpbin)
  152. 8 "http://#{orig}"
  153. end
  154. end

integration_tests/sentry_test.rb

100.0% lines covered

95 relevant lines. 95 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "logger"
  3. 1 require "stringio"
  4. 1 require "sentry-ruby"
  5. 1 require "test_helper"
  6. 1 require "support/http_helpers"
  7. 1 require "httpx/adapters/sentry"
  8. 1 class SentryTest < Minitest::Test
  9. 1 include HTTPHelpers
  10. 1 DUMMY_DSN = "http://12345:67890@sentry.localdomain/sentry/42"
  11. 1 def test_sentry_send_yes_pii
  12. 1 before_pii = Sentry.configuration.send_default_pii
  13. begin
  14. 1 Sentry.configuration.send_default_pii = true
  15. 1 transaction = Sentry.start_transaction
  16. 1 Sentry.get_current_scope.set_span(transaction)
  17. 1 uri = build_uri("/get")
  18. 1 response = HTTPX.get(uri, params: { "foo" => "bar" })
  19. 1 verify_status(response, 200)
  20. 1 verify_spans(transaction, response, description: "GET #{uri}?foo=bar")
  21. 1 crumb = Sentry.get_current_scope.breadcrumbs.peek
  22. 1 assert crumb.category == "httpx"
  23. 1 assert crumb.data == { status: 200, method: "GET", url: "#{uri}?foo=bar" }
  24. ensure
  25. 1 Sentry.configuration.send_default_pii = before_pii
  26. end
  27. end
  28. 1 def test_sentry_send_no_pii
  29. 1 before_pii = Sentry.configuration.send_default_pii
  30. begin
  31. 1 Sentry.configuration.send_default_pii = false
  32. 1 transaction = Sentry.start_transaction
  33. 1 Sentry.get_current_scope.set_span(transaction)
  34. 1 uri = build_uri("/get")
  35. 1 response = HTTPX.get(uri, params: { "foo" => "bar" })
  36. 1 verify_status(response, 200)
  37. 1 verify_spans(transaction, response, description: "GET #{uri}")
  38. 1 crumb = Sentry.get_current_scope.breadcrumbs.peek
  39. 1 assert crumb.category == "httpx"
  40. 1 assert crumb.data == { status: 200, method: "GET", url: uri }
  41. ensure
  42. 1 Sentry.configuration.send_default_pii = before_pii
  43. end
  44. end
  45. 1 def test_sentry_post_request
  46. 1 before_pii = Sentry.configuration.send_default_pii
  47. begin
  48. 1 Sentry.configuration.send_default_pii = true
  49. 1 transaction = Sentry.start_transaction
  50. 1 Sentry.get_current_scope.set_span(transaction)
  51. 1 uri = build_uri("/post")
  52. 1 response = HTTPX.post(uri, form: { foo: "bar" })
  53. 1 verify_status(response, 200)
  54. 1 verify_spans(transaction, response, verb: "POST")
  55. 1 crumb = Sentry.get_current_scope.breadcrumbs.peek
  56. 1 assert crumb.category == "httpx"
  57. 1 assert crumb.data == { status: 200, method: "POST", url: uri, body: "foo=bar" }
  58. ensure
  59. 1 Sentry.configuration.send_default_pii = before_pii
  60. end
  61. end
  62. 1 def test_sentry_multiple_requests
  63. 1 transaction = Sentry.start_transaction
  64. 1 Sentry.get_current_scope.set_span(transaction)
  65. 1 responses = HTTPX.get(build_uri("/status/200"), build_uri("/status/404"))
  66. 1 verify_status(responses[0], 200)
  67. 1 verify_status(responses[1], 404)
  68. 1 verify_spans(transaction, *responses)
  69. end
  70. 1 def test_sentry_server_error_request
  71. 1 transaction = Sentry.start_transaction
  72. 1 Sentry.get_current_scope.set_span(transaction)
  73. 1 uri = URI("http://unexisting/")
  74. 1 response = HTTPX.get(uri)
  75. 1 verify_error_response(response, /name or service not known/)
  76. 1 assert response.is_a?(HTTPX::ErrorResponse), "response should contain errors"
  77. 1 verify_spans(transaction, response, verb: "GET")
  78. 1 crumb = Sentry.get_current_scope.breadcrumbs.peek
  79. 1 assert crumb.category == "httpx"
  80. 1 assert crumb.data == { error: "name or service not known", method: "GET", url: uri.to_s }
  81. end
  82. 1 private
  83. 1 def verify_spans(transaction, *responses, verb: nil, description: nil)
  84. 5 assert transaction.span_recorder.spans.count == responses.size + 1
  85. 5 assert transaction.span_recorder.spans[0] == transaction
  86. 5 response_spans = transaction.span_recorder.spans[1..-1]
  87. 5 responses.each_with_index do |response, idx|
  88. 6 request_span = response_spans[idx]
  89. 6 assert request_span.op == "httpx.client"
  90. 6 assert !request_span.start_timestamp.nil?
  91. 6 assert !request_span.timestamp.nil?
  92. 6 assert request_span.start_timestamp != request_span.timestamp
  93. 6 assert request_span.description == (description || "#{verb || "GET"} #{response.uri}")
  94. 6 if response.is_a?(HTTPX::ErrorResponse)
  95. 1 assert request_span.data == { error: response.error.message }
  96. else
  97. 5 assert request_span.data == { status: response.status }
  98. end
  99. end
  100. end
  101. 1 def setup
  102. 5 super
  103. 5 mock_io = StringIO.new
  104. 5 mock_logger = Logger.new(mock_io)
  105. 5 Sentry.init do |config|
  106. 5 config.traces_sample_rate = 1.0
  107. 5 config.sdk_logger = mock_logger
  108. 5 config.dsn = DUMMY_DSN
  109. 5 config.transport.transport_class = Sentry::DummyTransport
  110. 5 config.background_worker_threads = 0
  111. 5 config.breadcrumbs_logger = [:http_logger]
  112. 5 config.enabled_patches << :httpx
  113. # so the events will be sent synchronously for testing
  114. end
  115. end
  116. 1 def origin
  117. 5 "https://#{httpbin}"
  118. end
  119. end

integration_tests/webmock_test.rb

100.0% lines covered

254 relevant lines. 254 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "webmock/minitest"
  3. 1 require "httpx/adapters/webmock"
  4. 1 require "test_helper"
  5. 1 require "support/http_helpers"
  6. 1 class WebmockTest < Minitest::Test
  7. 1 include HTTPHelpers
  8. 1 include FiberSchedulerTestHelpers
  9. 1 MOCK_URL_HTTP = "http://www.example.com"
  10. 1 MOCK_URL_HTTP_SAME_ORIGIN = "http://www.example.com/other"
  11. 1 MOCK_URL_HTTP_OTHER_ORIGIN = "http://www.example2.com"
  12. 1 MOCK_URL_HTTP_EXCEPTION = "http://exception.example.com"
  13. 1 MOCK_URL_HTTP_TIMEOUT = "http://timeout.example.com"
  14. 1 MOCK_URL_HTTP_TIMEOUT_RETRIES = "http://timeout-x-times.example.com"
  15. 1 def setup
  16. 36 super
  17. 36 WebMock.enable!
  18. 36 WebMock.disable_net_connect!
  19. 36 @stub_http = stub_http_request(:any, MOCK_URL_HTTP)
  20. 36 @stub_http_same_origin = stub_http_request(:any, MOCK_URL_HTTP_SAME_ORIGIN)
  21. 36 @stub_http_other_origin = stub_http_request(:any, MOCK_URL_HTTP_OTHER_ORIGIN)
  22. 36 @exception_class = Class.new(StandardError)
  23. 36 @stub_exception = stub_http_request(:any, MOCK_URL_HTTP_EXCEPTION).to_raise(@exception_class.new("exception message"))
  24. 36 @stub_timeout = stub_http_request(:any, MOCK_URL_HTTP_TIMEOUT).to_timeout
  25. 36 @stub_timeout_retries = stub_http_request(:any, MOCK_URL_HTTP_TIMEOUT_RETRIES).to_timeout.times(2).then.to_return(body: "body")
  26. end
  27. 1 def teardown
  28. 36 super
  29. 36 WebMock.reset!
  30. 36 WebMock.allow_net_connect!
  31. 36 WebMock.disable!
  32. end
  33. 1 def test_assert_requested_with_stub_and_block_raises_error
  34. 1 assert_raises ArgumentError do
  35. 1 assert_requested(@stub_http) {}
  36. end
  37. end
  38. 1 def test_assert_not_requested_with_stub_and_block_raises_error
  39. 1 assert_raises ArgumentError do
  40. 1 assert_not_requested(@stub_http) {}
  41. end
  42. end
  43. 1 def test_to_raise
  44. 1 response = http_request(:get, MOCK_URL_HTTP_EXCEPTION)
  45. 1 assert_requested(@stub_exception)
  46. 1 assert_equal(@exception_class.new("exception message"), response.error)
  47. end
  48. 1 def test_response_not_decoded
  49. 1 request = stub_request(:get, MOCK_URL_HTTP).to_return(body: "body", headers: { content_encoding: "gzip" })
  50. 1 response = HTTPX.get(MOCK_URL_HTTP)
  51. 1 assert !response.body.empty?
  52. 1 assert_equal("body", response.body.to_s)
  53. 1 assert_requested(request)
  54. 1 assert response.mocked?
  55. end
  56. 1 def test_to_timeout
  57. 1 response = http_request(:get, MOCK_URL_HTTP_TIMEOUT)
  58. 1 assert_requested(@stub_timeout)
  59. 1 assert_equal(HTTPX::TimeoutError.new(1, "Timed out"), response.error)
  60. end
  61. 1 def test_to_timeout_with_retries
  62. 1 response = HTTPX.plugin(:retries, max_retries: 3).get(MOCK_URL_HTTP_TIMEOUT_RETRIES)
  63. 1 assert_requested(@stub_timeout_retries, times: 3)
  64. 1 assert !response.is_a?(HTTPX::ErrorResponse)
  65. 1 assert_equal("body", response.to_s)
  66. 1 assert response.mocked?
  67. end
  68. 1 def test_error_on_non_stubbed_request
  69. 1 assert_raise_with_message(WebMock::NetConnectNotAllowedError, Regexp.new(
  70. "Real HTTP connections are disabled. " \
  71. "Unregistered request: GET http://www.example.net/ with headers"
  72. )) do
  73. 1 http_request(:get, "http://www.example.net/")
  74. end
  75. end
  76. 1 def test_verification_that_expected_request_occured
  77. 1 http_request(:get, "#{MOCK_URL_HTTP}/")
  78. 1 assert_requested(:get, MOCK_URL_HTTP, times: 1)
  79. 1 assert_requested(:get, MOCK_URL_HTTP)
  80. end
  81. 1 def test_verification_that_expected_stub_occured
  82. 1 http_request(:get, "#{MOCK_URL_HTTP}/")
  83. 1 assert_requested(@stub_http, times: 1)
  84. 1 assert_requested(@stub_http)
  85. end
  86. 1 def test_multi_same_url
  87. 1 http_request(:get, "#{MOCK_URL_HTTP}/", "#{MOCK_URL_HTTP}/")
  88. 1 assert_requested(@stub_http, times: 2)
  89. end
  90. 1 def test_multi_same_origin
  91. 1 http_request(:get, "#{MOCK_URL_HTTP}/", MOCK_URL_HTTP_SAME_ORIGIN)
  92. 1 assert_requested(@stub_http)
  93. 1 assert_requested(@stub_http_same_origin)
  94. end
  95. 1 def test_multi_other_origin
  96. 1 http_request(:get, "#{MOCK_URL_HTTP}/", "#{MOCK_URL_HTTP_OTHER_ORIGIN}/")
  97. 1 assert_requested(@stub_http)
  98. 1 assert_requested(@stub_http_other_origin)
  99. end
  100. 1 if Fiber.respond_to?(:set_scheduler) && RUBY_VERSION >= "3.1.0"
  101. 1 def test_multi_fiber_same_url
  102. 1 http = HTTPX.plugin(:fiber_concurrency)
  103. 1 with_test_fiber_scheduler do
  104. 1 2.times do
  105. 2 Fiber.schedule do
  106. 2 http.get("#{MOCK_URL_HTTP}/")
  107. end
  108. end
  109. 1 assert_requested(@stub_http, times: 2)
  110. end
  111. end
  112. 1 def test_multi_fiber_same_origin
  113. 1 http = HTTPX.plugin(:fiber_concurrency)
  114. 1 with_test_fiber_scheduler do
  115. 1 Fiber.schedule do
  116. 1 http.get("#{MOCK_URL_HTTP}/")
  117. end
  118. 1 Fiber.schedule do
  119. 1 http.get(MOCK_URL_HTTP_SAME_ORIGIN)
  120. end
  121. 1 assert_requested(@stub_http)
  122. 1 assert_requested(@stub_http_same_origin)
  123. end
  124. end
  125. 1 def test_multi_fiber_other_origin
  126. 1 http = HTTPX.plugin(:fiber_concurrency)
  127. 1 with_test_fiber_scheduler do
  128. 1 Fiber.schedule do
  129. 1 http.get("#{MOCK_URL_HTTP}/")
  130. end
  131. 1 Fiber.schedule do
  132. 1 http.get(MOCK_URL_HTTP_OTHER_ORIGIN)
  133. end
  134. 1 assert_requested(@stub_http)
  135. 1 assert_requested(@stub_http_other_origin)
  136. end
  137. end
  138. end
  139. 1 def test_verification_that_expected_request_didnt_occur
  140. 1 expected_message = "The request GET #{MOCK_URL_HTTP}/ was expected to execute 1 time but it executed 0 times" \
  141. "\n\nThe following requests were made:\n\nNo requests were made.\n" \
  142. "============================================================"
  143. 1 assert_raise_with_message(Minitest::Assertion, expected_message) do
  144. 1 assert_requested(:get, MOCK_URL_HTTP)
  145. end
  146. end
  147. 1 def test_verification_that_expected_stub_didnt_occur
  148. 1 expected_message = "The request ANY #{MOCK_URL_HTTP}/ was expected to execute 1 time but it executed 0 times" \
  149. "\n\nThe following requests were made:\n\nNo requests were made.\n" \
  150. "============================================================"
  151. 1 assert_raise_with_message(Minitest::Assertion, expected_message) do
  152. 1 assert_requested(@stub_http)
  153. end
  154. end
  155. 1 def test_verification_that_expected_request_occured_with_body_and_headers
  156. 1 http_request(:get, "#{MOCK_URL_HTTP}/",
  157. body: "abc", headers: { "A" => "a" })
  158. 1 assert_requested(:get, MOCK_URL_HTTP,
  159. body: "abc", headers: { "A" => "a" })
  160. end
  161. 1 def test_verification_that_expected_request_occured_with_query_params
  162. 1 stub_request(:any, MOCK_URL_HTTP).with(query: hash_including("a" => %w[b c]))
  163. 1 http_request(:get, "#{MOCK_URL_HTTP}/?a[]=b&a[]=c&x=1")
  164. 1 assert_requested(:get, MOCK_URL_HTTP,
  165. query: hash_including("a" => %w[b c]))
  166. end
  167. 1 def test_verification_that_requests_with_query_parameters_correctly_called
  168. 1 stub_request(:get, MOCK_URL_HTTP).to_return(body: "1")
  169. 1 stub_request(:get, "#{MOCK_URL_HTTP}/?a[]=b&a[]=c").to_return(body: "2")
  170. 1 stub_request(:get, "#{MOCK_URL_HTTP}/?test=value").to_return(body: "3")
  171. 1 response_1 = http_request(:get, MOCK_URL_HTTP)
  172. 1 response_2 = http_request(:get, MOCK_URL_HTTP, params: { "a" => %w[b c] })
  173. 1 response_3 = http_request(:get, MOCK_URL_HTTP, params: { test: "value" })
  174. 1 assert response_1.mocked?
  175. 1 assert response_2.mocked?
  176. 1 assert response_3.mocked?
  177. 1 assert_equal "1", response_1.body.to_s
  178. 1 assert_equal "2", response_2.body.to_s
  179. 1 assert_equal "3", response_3.body.to_s
  180. 1 assert_requested(:get, MOCK_URL_HTTP)
  181. 1 assert_requested(:get, "#{MOCK_URL_HTTP}/?a[]=b&a[]=c")
  182. 1 assert_requested(:get, "#{MOCK_URL_HTTP}/?test=value")
  183. end
  184. 1 def test_verification_that_expected_request_not_occured_with_query_params
  185. 1 stub_request(:any, MOCK_URL_HTTP).with(query: hash_including(a: %w[b c]))
  186. 1 stub_request(:any, MOCK_URL_HTTP).with(query: hash_excluding(a: %w[b c]))
  187. 1 http_request(:get, "#{MOCK_URL_HTTP}/?a[]=b&a[]=c&x=1")
  188. 1 assert_not_requested(:get, MOCK_URL_HTTP, query: hash_excluding("a" => %w[b c]))
  189. end
  190. 1 def test_verification_that_expected_request_occured_with_excluding_query_params
  191. 1 stub_request(:any, MOCK_URL_HTTP).with(query: hash_excluding("a" => %w[b c]))
  192. 1 http_request(:get, "#{MOCK_URL_HTTP}/?a[]=x&a[]=y&x=1")
  193. 1 assert_requested(:get, MOCK_URL_HTTP, query: hash_excluding("a" => %w[b c]))
  194. end
  195. 1 def test_verification_that_expected_request_with_hash_as_body
  196. 1 stub_request(:post, MOCK_URL_HTTP).with(body: { foo: "bar" })
  197. 1 http_request(:post, MOCK_URL_HTTP, form: { foo: "bar" })
  198. 1 assert_requested(:post, MOCK_URL_HTTP, body: { foo: "bar" })
  199. end
  200. 1 def test_verification_that_expected_request_occured_with_form_file
  201. 1 file = File.new(fixture_file_path)
  202. 1 stub_request(:post, MOCK_URL_HTTP)
  203. 1 http_request(:post, MOCK_URL_HTTP, form: { file: file })
  204. # TODO: webmock does not support matching multipart request body
  205. 1 assert_requested(:post, MOCK_URL_HTTP)
  206. end
  207. 1 def test_verification_that_expected_request_occured_with_form_tempfile
  208. 1 stub_request(:post, MOCK_URL_HTTP)
  209. 1 Tempfile.open("tmp") do |file|
  210. 1 http_request(:post, MOCK_URL_HTTP, form: { file: file })
  211. end
  212. # TODO: webmock does not support matching multipart request body
  213. 1 assert_requested(:post, MOCK_URL_HTTP)
  214. end
  215. 1 def test_verification_that_non_expected_request_didnt_occur
  216. 1 expected_message = Regexp.new(
  217. "The request GET #{MOCK_URL_HTTP}/ was not expected to execute but it executed 1 time\n\n" \
  218. "The following requests were made:\n\nGET #{MOCK_URL_HTTP}/ with headers .+ was made 1 time\n\n" \
  219. "============================================================"
  220. )
  221. 1 assert_raise_with_message(Minitest::Assertion, expected_message) do
  222. 1 http_request(:get, "http://www.example.com/")
  223. 1 assert_not_requested(:get, "http://www.example.com")
  224. end
  225. end
  226. 1 def test_refute_requested_alias
  227. 1 expected_message = Regexp.new(
  228. "The request GET #{MOCK_URL_HTTP}/ was not expected to execute but it executed 1 time\n\n" \
  229. "The following requests were made:\n\nGET #{MOCK_URL_HTTP}/ with headers .+ was made 1 time\n\n" \
  230. "============================================================"
  231. )
  232. 1 assert_raise_with_message(Minitest::Assertion, expected_message) do
  233. 1 http_request(:get, "#{MOCK_URL_HTTP}/")
  234. 1 refute_requested(:get, MOCK_URL_HTTP)
  235. end
  236. end
  237. 1 def test_verification_that_non_expected_stub_didnt_occur
  238. 1 expected_message = Regexp.new(
  239. "The request ANY #{MOCK_URL_HTTP}/ was not expected to execute but it executed 1 time\n\n" \
  240. "The following requests were made:\n\nGET #{MOCK_URL_HTTP}/ with headers .+ was made 1 time\n\n" \
  241. "============================================================"
  242. )
  243. 1 assert_raise_with_message(Minitest::Assertion, expected_message) do
  244. 1 http_request(:get, "#{MOCK_URL_HTTP}/")
  245. 1 assert_not_requested(@stub_http)
  246. end
  247. end
  248. 1 def test_webmock_allows_real_request
  249. 1 WebMock.allow_net_connect!
  250. 1 uri = build_uri("/get?foo=bar")
  251. 1 response = HTTPX.get(uri)
  252. 1 verify_status(response, 200)
  253. 1 verify_body_length(response)
  254. 1 assert_requested(:get, uri, query: { "foo" => "bar" })
  255. 1 assert !response.mocked?
  256. end
  257. 1 def test_webmock_allows_real_request_with_body
  258. 1 WebMock.allow_net_connect!
  259. 1 uri = build_uri("/post")
  260. 1 response = HTTPX.post(uri, form: { foo: "bar" })
  261. 1 verify_status(response, 200)
  262. 1 verify_body_length(response)
  263. 1 assert_requested(:post, uri, headers: { "Content-Type" => "application/x-www-form-urlencoded" }, body: "foo=bar")
  264. 1 assert !response.mocked?
  265. end
  266. 1 def test_webmock_allows_real_request_with_file_body
  267. 1 WebMock.allow_net_connect!
  268. 1 uri = build_uri("/post")
  269. 1 response = HTTPX.post(uri, form: { image: File.new(fixture_file_path) })
  270. 1 verify_status(response, 200)
  271. 1 verify_body_length(response)
  272. 1 body = json_body(response)
  273. 1 verify_header(body["headers"], "Content-Type", "multipart/form-data")
  274. 1 verify_uploaded_image(body, "image", "image/jpeg")
  275. 1 assert !response.mocked?
  276. # TODO: webmock does not support matching multipart request body
  277. # assert_requested(:post, uri, headers: { "Content-Type" => "multipart/form-data" }, form: { "image" => File.new(fixture_file_path) })
  278. end
  279. 1 def test_webmock_mix_mock_and_real_request
  280. 1 WebMock.allow_net_connect!
  281. 1 @stub_http.to_return(status: 200)
  282. # test webmock callback as well
  283. 1 responses = {}
  284. 1 WebMock.after_request do |request_signature, response|
  285. 2 responses[request_signature.uri.to_s] = response
  286. end
  287. # this one ain't stubbed
  288. 1 real_request_uri = build_uri("/get", "http://#{httpbin}")
  289. 1 http_request(:get, "#{MOCK_URL_HTTP}/", real_request_uri)
  290. 1 assert responses.size == 2, "received #{responses.size} instead (#{responses.keys})"
  291. 3 assert(responses.values.all? { |r| r.status.first == 200 })
  292. ensure
  293. 1 WebMock.reset_callbacks
  294. 1 WebMock.disable_net_connect!
  295. end
  296. 1 def test_webmock_disable_after_enable
  297. 1 WebMock.disable!
  298. # WebMock is disabled so this will make a real http request
  299. 1 http_request(:get, "http://#{httpbin}")
  300. # WebMock is disabled so it should not have registered the request
  301. 1 assert_not_requested(:get, "http://#{httpbin}")
  302. end
  303. 1 def test_webmock_follow_redirects_with_stream_plugin_each
  304. 1 session = HTTPX.plugin(:follow_redirects).plugin(:stream)
  305. 1 redirect_url = "#{MOCK_URL_HTTP}/redirect"
  306. 1 initial_request = stub_request(:get, MOCK_URL_HTTP).to_return(status: 302, headers: { location: redirect_url }, body: "redirecting")
  307. 1 redirect_request = stub_request(:get, redirect_url).to_return(status: 200, body: "body")
  308. 1 response = session.get(MOCK_URL_HTTP, stream: true)
  309. 1 body = "".b
  310. 1 response.each do |chunk|
  311. 2 next if (300..399).cover?(response.status)
  312. 1 body << chunk
  313. end
  314. 1 assert_equal("body", body)
  315. 1 assert_requested(initial_request)
  316. 1 assert_requested(redirect_request)
  317. end
  318. 1 def test_webmock_with_stream_plugin_each
  319. 1 session = HTTPX.plugin(:stream)
  320. 1 request = stub_request(:get, MOCK_URL_HTTP).to_return(body: "body")
  321. 1 body = "".b
  322. 1 response = session.get(MOCK_URL_HTTP, stream: true)
  323. 1 response.each do |chunk|
  324. 1 next if (300..399).cover?(response.status)
  325. 1 body << chunk
  326. end
  327. 1 assert_equal("body", body)
  328. 1 assert_requested(request)
  329. end
  330. 1 def test_webmock_with_stream_plugin_each_line
  331. 1 session = HTTPX.plugin(:stream)
  332. 1 request = stub_request(:get, MOCK_URL_HTTP).to_return(body: "First line\nSecond line")
  333. 1 response = session.get(MOCK_URL_HTTP, stream: true)
  334. 1 assert_equal(["First line", "Second line"], response.each_line.to_a)
  335. 1 assert_requested(request)
  336. end
  337. 1 private
  338. 1 def assert_raise_with_message(e, message, &block)
  339. 6 e = assert_raises(e, &block)
  340. 6 if message.is_a?(Regexp)
  341. 4 assert_match(message, e.message)
  342. else
  343. 2 assert_equal(message, e.message)
  344. end
  345. end
  346. 1 def http_request(meth, *uris, **options)
  347. 23 HTTPX.__send__(meth, *uris, **options)
  348. end
  349. 1 def scheme
  350. 3 "http://"
  351. end
  352. end

lib/httpx.rb

100.0% lines covered

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

lib/httpx/adapters/datadog.rb

81.18% lines covered

170 relevant lines. 138 lines covered and 32 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "datadog/tracing/contrib/integration"
  3. 1 require "datadog/tracing/contrib/configuration/settings"
  4. 1 require "datadog/tracing/contrib/patcher"
  5. 1 module Datadog::Tracing
  6. 1 module Contrib
  7. 1 module HTTPX
  8. 1 DATADOG_VERSION = defined?(::DDTrace) ? ::DDTrace::VERSION : ::Datadog::VERSION
  9. 1 METADATA_MODULE = Datadog::Tracing::Metadata
  10. 1 TYPE_OUTBOUND = Datadog::Tracing::Metadata::Ext::HTTP::TYPE_OUTBOUND
  11. 1 TAG_BASE_SERVICE = if Gem::Version.new(DATADOG_VERSION::STRING) < Gem::Version.new("1.15.0")
  12. "_dd.base_service"
  13. 1 elsif Gem::Version.new(DATADOG_VERSION::STRING) < Gem::Version.new("2.34.0")
  14. 1 Datadog::Tracing::Contrib::Ext::Metadata::TAG_BASE_SERVICE
  15. else
  16. Datadog::Tracing::Metadata::Ext::TAG_BASE_SERVICE
  17. end
  18. 1 TAG_PEER_HOSTNAME = Datadog::Tracing::Metadata::Ext::TAG_PEER_HOSTNAME
  19. 1 TAG_PEER_SERVICE = Datadog::Tracing::Metadata::Ext::TAG_PEER_SERVICE
  20. 1 TAG_KIND = Datadog::Tracing::Metadata::Ext::TAG_KIND
  21. 1 TAG_CLIENT = Datadog::Tracing::Metadata::Ext::SpanKind::TAG_CLIENT
  22. 1 TAG_COMPONENT = Datadog::Tracing::Metadata::Ext::TAG_COMPONENT
  23. 1 TAG_OPERATION = Datadog::Tracing::Metadata::Ext::TAG_OPERATION
  24. 1 TAG_URL = Datadog::Tracing::Metadata::Ext::HTTP::TAG_URL
  25. 1 TAG_METHOD = Datadog::Tracing::Metadata::Ext::HTTP::TAG_METHOD
  26. 1 TAG_TARGET_HOST = Datadog::Tracing::Metadata::Ext::NET::TAG_TARGET_HOST
  27. 1 TAG_TARGET_PORT = Datadog::Tracing::Metadata::Ext::NET::TAG_TARGET_PORT
  28. 1 TAG_STATUS_CODE = Datadog::Tracing::Metadata::Ext::HTTP::TAG_STATUS_CODE
  29. # HTTPX Datadog Plugin
  30. #
  31. # Enables tracing for httpx requests.
  32. #
  33. # A span will be created for each request transaction; the span is created lazily only when
  34. # buffering a request, and it is fed the start time stored inside the tracer object.
  35. #
  36. 1 module Plugin
  37. 1 module RequestTracer
  38. 1 extend Contrib::HttpAnnotationHelper
  39. 1 module_function
  40. 1 SPAN_REQUEST = "httpx.request"
  41. 1 def enabled?(request)
  42. 14 configuration(request).enabled
  43. end
  44. 1 def start(request)
  45. 16 request.datadog_span = initialize_span(request, request.init_time)
  46. end
  47. 1 def reset(request)
  48. 4 request.datadog_span = nil
  49. end
  50. 1 def finish(request, response)
  51. 16 request.datadog_span ||= initialize_span(request, request.init_time) if request.init_time
  52. 16 finish_span(response, request.datadog_span)
  53. end
  54. 1 def finish_span(response, span)
  55. 16 if response.is_a?(::HTTPX::ErrorResponse)
  56. 1 span.set_error(response.error)
  57. else
  58. 15 span.set_tag(TAG_STATUS_CODE, response.status.to_s)
  59. 15 span.set_error(::HTTPX::HTTPError.new(response)) if response.status.between?(400, 599)
  60. span.set_tags(
  61. Datadog.configuration.tracing.header_tags.response_tags(response.headers.to_h)
  62. 15 ) if Datadog.configuration.tracing.respond_to?(:header_tags)
  63. end
  64. 16 span.finish
  65. end
  66. # return a span initialized with the +@request+ state.
  67. 1 def initialize_span(request, start_time)
  68. 17 verb = request.verb
  69. 17 uri = request.uri
  70. 17 config = configuration(request)
  71. 17 span = create_span(request, config, start_time)
  72. 17 span.resource = verb
  73. # Tag original global service name if not used
  74. 17 span.set_tag(TAG_BASE_SERVICE, Datadog.configuration.service) if span.service != Datadog.configuration.service
  75. 17 span.set_tag(TAG_KIND, TAG_CLIENT)
  76. 17 span.set_tag(TAG_COMPONENT, "httpx")
  77. 17 span.set_tag(TAG_OPERATION, "request")
  78. 17 span.set_tag(TAG_URL, request.path)
  79. 17 span.set_tag(TAG_METHOD, verb)
  80. 17 span.set_tag(TAG_TARGET_HOST, uri.host)
  81. 17 span.set_tag(TAG_TARGET_PORT, uri.port)
  82. 17 span.set_tag(TAG_PEER_HOSTNAME, uri.host)
  83. # Tag as an external peer service
  84. 17 if (peer_service = config[:peer_service])
  85. span.set_tag(TAG_PEER_SERVICE, peer_service)
  86. end
  87. 17 if config[:distributed_tracing]
  88. 16 propagate_trace_http(
  89. Datadog::Tracing.active_trace,
  90. request.headers
  91. )
  92. end
  93. # Set analytics sample rate
  94. 17 if Contrib::Analytics.enabled?(config[:analytics_enabled])
  95. 2 Contrib::Analytics.set_sample_rate(span, config[:analytics_sample_rate])
  96. end
  97. span.set_tags(
  98. Datadog.configuration.tracing.header_tags.request_tags(request.headers.to_h)
  99. 17 ) if Datadog.configuration.tracing.respond_to?(:header_tags)
  100. 17 span
  101. rescue StandardError => e
  102. Datadog.logger.error("error preparing span for http request: #{e}")
  103. Datadog.logger.error(e.backtrace)
  104. end
  105. 1 def configuration(request)
  106. 31 Datadog.configuration.tracing[:httpx, request.uri.host]
  107. end
  108. 1 if Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("2.0.0")
  109. def propagate_trace_http(trace, headers)
  110. Datadog::Tracing::Contrib::HTTP.inject(trace, headers)
  111. end
  112. def create_span(request, configuration, start_time)
  113. Datadog::Tracing.trace(
  114. SPAN_REQUEST,
  115. service: service_name(request.uri.host, configuration),
  116. type: TYPE_OUTBOUND,
  117. start_time: start_time
  118. )
  119. end
  120. else
  121. 1 def propagate_trace_http(trace, headers)
  122. 16 Datadog::Tracing::Propagation::HTTP.inject!(trace.to_digest, headers)
  123. end
  124. 1 def create_span(request, configuration, start_time)
  125. 17 Datadog::Tracing.trace(
  126. SPAN_REQUEST,
  127. service: service_name(request.uri.host, configuration),
  128. span_type: TYPE_OUTBOUND,
  129. start_time: start_time
  130. )
  131. end
  132. end
  133. end
  134. 1 class << self
  135. 1 def load_dependencies(klass)
  136. 1 klass.plugin(:tracing)
  137. end
  138. 1 def extra_options(options)
  139. 1 options.merge(tracer: RequestTracer)
  140. end
  141. end
  142. 1 module RequestMethods
  143. 1 attr_accessor :datadog_span
  144. end
  145. end
  146. # patches httpx debug logs to include datadog correlation ids
  147. 1 module LoggablePatch
  148. 1 def log_identifiers
  149. "#{super} #{Datadog::Tracing.log_correlation}"
  150. end
  151. end
  152. 1 module Configuration
  153. # Default settings for httpx
  154. #
  155. 1 class Settings < Datadog::Tracing::Contrib::Configuration::Settings
  156. 1 DEFAULT_ERROR_HANDLER = lambda do |response|
  157. Datadog::Ext::HTTP::ERROR_RANGE.cover?(response.status)
  158. end
  159. 1 option :service_name, default: "httpx"
  160. 1 option :distributed_tracing, default: true
  161. 1 option :split_by_domain, default: false
  162. 1 if Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("1.13.0")
  163. 1 option :enabled do |o|
  164. 1 o.type :bool
  165. 1 o.env "DD_TRACE_HTTPX_ENABLED"
  166. 1 o.default true
  167. end
  168. 1 option :analytics_enabled do |o|
  169. 1 o.type :bool
  170. 1 o.env "DD_TRACE_HTTPX_ANALYTICS_ENABLED"
  171. 1 o.default false
  172. end
  173. 1 option :analytics_sample_rate do |o|
  174. 1 o.type :float
  175. 1 o.env "DD_TRACE_HTTPX_ANALYTICS_SAMPLE_RATE"
  176. 1 o.default 1.0
  177. end
  178. 1 option :peer_service do |o|
  179. 1 o.type :string, nilable: true
  180. 1 o.env "DD_TRACE_HTTPX_PEER_SERVICE"
  181. end
  182. else
  183. option :enabled do |o|
  184. o.default { env_to_bool("DD_TRACE_HTTPX_ENABLED", true) }
  185. o.lazy
  186. end
  187. option :analytics_enabled do |o|
  188. o.default { env_to_bool(%w[DD_TRACE_HTTPX_ANALYTICS_ENABLED DD_HTTPX_ANALYTICS_ENABLED], false) }
  189. o.lazy
  190. end
  191. option :analytics_sample_rate do |o|
  192. o.default { env_to_float(%w[DD_TRACE_HTTPX_ANALYTICS_SAMPLE_RATE DD_HTTPX_ANALYTICS_SAMPLE_RATE], 1.0) }
  193. o.lazy
  194. end
  195. option :peer_service do |o|
  196. o.default { env_to_string("DD_TRACE_HTTPX_PEER_SERVICE", nil) }
  197. o.lazy
  198. end
  199. end
  200. 1 if defined?(Datadog::Tracing::Contrib::SpanAttributeSchema)
  201. 1 option :service_name do |o|
  202. 1 o.default do
  203. 11 Datadog::Tracing::Contrib::SpanAttributeSchema.fetch_service_name(
  204. "DD_TRACE_HTTPX_SERVICE_NAME",
  205. "httpx"
  206. )
  207. end
  208. 1 o.lazy unless Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("1.13.0")
  209. end
  210. else
  211. option :service_name do |o|
  212. o.default do
  213. ENV.fetch("DD_TRACE_HTTPX_SERVICE_NAME", "httpx")
  214. end
  215. o.lazy unless Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("1.13.0")
  216. end
  217. end
  218. 1 option :distributed_tracing, default: true
  219. 1 if Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("1.15.0")
  220. 1 option :error_handler do |o|
  221. 1 o.type :proc
  222. 1 o.default_proc(&DEFAULT_ERROR_HANDLER)
  223. end
  224. elsif Gem::Version.new(DATADOG_VERSION::STRING) >= Gem::Version.new("1.13.0")
  225. option :error_handler do |o|
  226. o.type :proc
  227. o.experimental_default_proc(&DEFAULT_ERROR_HANDLER)
  228. end
  229. else
  230. option :error_handler, default: DEFAULT_ERROR_HANDLER
  231. end
  232. end
  233. end
  234. # Patcher enables patching of 'httpx' with datadog components.
  235. #
  236. 1 module Patcher
  237. 1 include Datadog::Tracing::Contrib::Patcher
  238. 1 module_function
  239. 1 def target_version
  240. 2 Integration.version
  241. end
  242. # loads a session instannce with the datadog plugin, and replaces the
  243. # base HTTPX::Session with the patched session class.
  244. 1 def patch
  245. 1 datadog_session = ::HTTPX.plugin(Plugin)
  246. 1 ::HTTPX.send(:remove_const, :Session)
  247. 1 ::HTTPX.send(:const_set, :Session, datadog_session.class)
  248. 1 ::HTTPX::Loggable.singleton_class.prepend(LoggablePatch)
  249. end
  250. end
  251. # Datadog Integration for HTTPX.
  252. #
  253. 1 class Integration
  254. 1 include Contrib::Integration
  255. 1 MINIMUM_VERSION = Gem::Version.new("0.10.2")
  256. 1 register_as :httpx
  257. 1 def self.version
  258. 41 Gem.loaded_specs["httpx"] && Gem.loaded_specs["httpx"].version
  259. end
  260. 1 def self.loaded?
  261. 13 defined?(::HTTPX::Request)
  262. end
  263. 1 def self.compatible?
  264. 13 super && version >= MINIMUM_VERSION
  265. end
  266. 1 def new_configuration
  267. 26 Configuration::Settings.new
  268. end
  269. 1 def patcher
  270. 26 Patcher
  271. end
  272. end
  273. end
  274. end
  275. end

lib/httpx/adapters/faraday.rb

63.06% lines covered

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

lib/httpx/adapters/sentry.rb

100.0% lines covered

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

lib/httpx/adapters/webmock.rb

100.0% lines covered

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

lib/httpx/altsvc.rb

29.41% lines covered

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

lib/httpx/base64.rb

13.33% lines covered

15 relevant lines. 2 lines covered and 13 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 if RUBY_VERSION < "3.3.0"
  3. 1 require "base64"
  4. elsif !defined?(Base64)
  5. module HTTPX
  6. # require "base64" will not be a default gem after ruby 3.4.0
  7. module Base64
  8. module_function
  9. def decode64(str)
  10. str.unpack1("m")
  11. end
  12. def strict_encode64(bin)
  13. [bin].pack("m0")
  14. end
  15. def urlsafe_encode64(bin, padding: true)
  16. str = strict_encode64(bin)
  17. str.chomp!("==") or str.chomp!("=") unless padding
  18. str.tr!("+/", "-_")
  19. str
  20. end
  21. end
  22. end
  23. end

lib/httpx/buffer.rb

77.78% lines covered

27 relevant lines. 21 lines covered and 6 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 module HTTPX
  4. # Internal class to abstract a string buffer, by wrapping a string and providing the
  5. # minimum possible API and functionality required.
  6. #
  7. # buffer = Buffer.new(640)
  8. # buffer.full? #=> false
  9. # buffer << "aa"
  10. # buffer.capacity #=> 638
  11. #
  12. 1 class Buffer
  13. 1 extend Forwardable
  14. 1 def_delegator :@buffer, :to_s
  15. 1 def_delegator :@buffer, :to_str
  16. 1 def_delegator :@buffer, :empty?
  17. 1 def_delegator :@buffer, :bytesize
  18. 1 def_delegator :@buffer, :clear
  19. 1 def_delegator :@buffer, :replace
  20. 1 attr_reader :limit
  21. 1 if RUBY_VERSION >= "3.4.0"
  22. def initialize(limit)
  23. @buffer = String.new("", encoding: Encoding::BINARY, capacity: limit)
  24. @limit = limit
  25. end
  26. def <<(chunk)
  27. @buffer.append_as_bytes(chunk)
  28. end
  29. else
  30. 1 def initialize(limit)
  31. 164 @buffer = "".b
  32. 164 @limit = limit
  33. end
  34. 1 def_delegator :@buffer, :<<
  35. end
  36. 1 def full?
  37. 143 @buffer.bytesize >= @limit
  38. end
  39. 1 def capacity
  40. @limit - @buffer.bytesize
  41. end
  42. 1 def shift!(fin)
  43. 52 @buffer = @buffer.byteslice(fin..-1) || "".b
  44. end
  45. end
  46. end

lib/httpx/callbacks.rb

100.0% lines covered

19 relevant lines. 19 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Callbacks
  4. 1 def on(type, &action)
  5. 901 callbacks(type) << action
  6. 901 action
  7. end
  8. 1 def once(type, &block)
  9. 378 on(type) do |*args, &callback|
  10. 187 block.call(*args, &callback)
  11. 187 :delete
  12. end
  13. end
  14. 1 def emit(type, *args)
  15. 614 log { "emit #{type.inspect} callbacks" } if respond_to?(:log)
  16. 933 callbacks(type).delete_if { |pr| :delete == pr.call(*args) } # rubocop:disable Style/YodaCondition
  17. end
  18. 1 def callbacks_for?(type)
  19. 2 @callbacks && @callbacks.key?(type) && @callbacks[type].any?
  20. end
  21. 1 protected
  22. 1 def callbacks(type = nil)
  23. 1530 return @callbacks unless type
  24. 2556 @callbacks ||= Hash.new { |h, k| h[k] = [] }
  25. 1530 @callbacks[type]
  26. end
  27. end
  28. end

lib/httpx/chainable.rb

58.54% lines covered

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

lib/httpx/connection.rb

60.14% lines covered

552 relevant lines. 332 lines covered and 220 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "resolv"
  3. 1 require "forwardable"
  4. 1 require "httpx/io"
  5. 1 require "httpx/buffer"
  6. 1 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. 1 class Connection
  29. 1 extend Forwardable
  30. 1 include Loggable
  31. 1 include Callbacks
  32. 1 using URIExtensions
  33. 1 def_delegator :@write_buffer, :empty?
  34. 1 attr_reader :type, :io, :origin, :origins, :state, :pending, :options, :ssl_session, :sibling
  35. 1 attr_writer :current_selector
  36. 1 attr_accessor :current_session, :family
  37. 1 protected :ssl_session, :sibling
  38. 1 def initialize(uri, options)
  39. @current_session = @current_selector = @max_concurrent_requests =
  40. @parser = @sibling = @coalesced_connection = @altsvc_connection =
  41. @ping_timer = @family = @io = @ssl_session =
  42. 66 @timeout = @connected_at = @response_received_at = nil
  43. 66 @exhausted = @cloned = @main_sibling = false
  44. 66 @options = Options.new(options)
  45. 66 @type = initialize_type(uri, @options)
  46. 66 @origins = [uri.origin]
  47. 66 @origin = Utils.to_uri(uri.origin)
  48. 66 @window_size = @options.window_size
  49. 66 @read_buffer = Buffer.new(@options.buffer_size)
  50. 66 @write_buffer = Buffer.new(@options.buffer_size)
  51. 66 @pending = []
  52. 66 @inflight = 0
  53. 66 @keep_alive_timeout = @options.timeout[:keep_alive_timeout]
  54. 66 @no_more_requests_counter = 0
  55. 66 if @options.io
  56. # if there's an already open IO, get its
  57. # peer address, and force-initiate the parser
  58. transition(:already_open)
  59. @io = build_socket
  60. parser
  61. else
  62. 66 transition(:idle)
  63. end
  64. 66 self.addresses = @options.addresses if @options.addresses
  65. end
  66. 1 def peer
  67. 77 @origin
  68. end
  69. # this is a semi-private method, to be used by the resolver
  70. # to initiate the io object.
  71. 1 def addresses=(addrs)
  72. 29 if @io
  73. @io.add_addresses(addrs)
  74. else
  75. 29 @io = build_socket(addrs)
  76. end
  77. end
  78. 1 def addresses
  79. 61 @io && @io.addresses
  80. end
  81. 1 def addresses?
  82. 76 @io && @io.addresses?
  83. end
  84. 1 def match?(uri, options)
  85. 11 return false if !used? && (@state == :closing || @state == :closed)
  86. 11 @origins.include?(uri.origin) &&
  87. # if there is more than one origin to match, it means that this connection
  88. # was the result of coalescing. To prevent blind trust in the case where the
  89. # origin came from an ORIGIN frame, we're going to verify the hostname with the
  90. # SSL certificate
  91. 9 (@origins.size == 1 || @origin == uri.origin || (@io.is_a?(SSL) && @io.verify_hostname(uri.host))) &&
  92. @options.connection_options_match?(options)
  93. end
  94. 1 def mergeable?(connection)
  95. 3 return false if @state == :closing || @state == :closed || !@io
  96. return false unless connection.addresses
  97. (
  98. (open? && @origin == connection.origin) ||
  99. !(@io.addresses & (connection.addresses || [])).empty?
  100. ) && @options.connection_options_match?(connection.options)
  101. end
  102. # coalesces +self+ into +connection+.
  103. 1 def coalesce!(connection)
  104. @coalesced_connection = connection
  105. close_sibling
  106. connection.merge(self)
  107. end
  108. 1 def coalesced?
  109. 38 @coalesced_connection
  110. end
  111. # coalescable connections need to be mergeable!
  112. # but internally, #mergeable? is called before #coalescable?
  113. 1 def coalescable?(connection)
  114. if @io.protocol == "h2" &&
  115. @origin.scheme == "https" &&
  116. connection.origin.scheme == "https" &&
  117. @io.can_verify_peer?
  118. @io.verify_hostname(connection.origin.host)
  119. else
  120. @origin == connection.origin
  121. end
  122. end
  123. 1 def merge(connection)
  124. @origins |= connection.instance_variable_get(:@origins)
  125. if @ssl_session.nil? && (ssl_session = connection.ssl_session)
  126. @ssl_session = ssl_session
  127. # the socket only needs the merged session if it can still resume it,
  128. # i.e. if TLS hasn't been negotiated yet.
  129. @io.ssl_session = ssl_session if @io.is_a?(SSL) && !@io.connected?
  130. end
  131. connection.purge_pending do |req|
  132. req.transition(:idle)
  133. send(req)
  134. end
  135. end
  136. 1 def purge_pending(&block)
  137. if @parser
  138. pending = @parser.pending
  139. @inflight -= pending.size
  140. pending.reject! do |req|
  141. block.call(req)
  142. true
  143. end
  144. end
  145. @pending.reject! do |req|
  146. block.call(req)
  147. true
  148. end
  149. end
  150. 1 def io_connected?
  151. return @coalesced_connection.io_connected? if @coalesced_connection
  152. @io && @io.state == :connected
  153. end
  154. 1 def connecting?
  155. 462 @state == :idle
  156. end
  157. 1 def inflight?
  158. 38 @parser && (
  159. # parser may be dealing with other requests (possibly started from a different fiber)
  160. 4 !@parser.empty? ||
  161. # connection may be doing connection termination handshake
  162. !@write_buffer.empty?
  163. )
  164. end
  165. 1 def interests
  166. # connecting
  167. 312 if connecting?
  168. 32 connect
  169. 32 return @io.interests if connecting?
  170. end
  171. 304 return @parser.interests if @parser
  172. nil
  173. rescue Error => e
  174. on_error(e)
  175. nil
  176. end
  177. 1 def to_io
  178. 69 @io.to_io
  179. end
  180. 1 def call
  181. 124 case @state
  182. when :idle
  183. 63 return if no_more_requests?
  184. 36 connect
  185. # when opening the tcp or ssl socket fails
  186. 36 return if @state == :closed
  187. 36 consume
  188. when :closed
  189. return if no_more_requests?
  190. # there are pending requests to send, restart the state machine.
  191. idling
  192. # @fiber-switch-guard
  193. # fiber may have switch after ensuring that @io is closed.
  194. return unless @state == :idle
  195. call
  196. when :closing
  197. consume
  198. transition(:closed)
  199. # @fiber-switch-guard
  200. # fiber may have switch while closing @io.
  201. return if @state == :closed &&
  202. # only remain here if there are pending requests.
  203. @pending.empty?
  204. call
  205. when :open
  206. 61 consume
  207. end
  208. nil
  209. rescue Errno::ECONNRESET,
  210. Errno::EINVAL,
  211. SocketError,
  212. IOError,
  213. TLSError => e
  214. @write_buffer.clear
  215. on_io_error(e)
  216. rescue Error => e
  217. @write_buffer.clear
  218. on_error(e)
  219. rescue Exception => e # rubocop:disable Lint/RescueException
  220. force_close(true)
  221. raise e
  222. end
  223. 1 def initial_call
  224. 55 call
  225. end
  226. 1 def close
  227. 4 transition(:active) if @state == :inactive
  228. 4 @parser.close if @parser
  229. end
  230. 1 def terminate
  231. 4 case @state
  232. when :idle
  233. purge_after_closed
  234. # @fiber-switch-guard
  235. if @io.can_disconnect? && @pending.empty?
  236. disconnect
  237. return
  238. end
  239. when :closed
  240. @connected_at = nil
  241. end
  242. 4 close
  243. end
  244. # bypasses state machine rules while setting the connection in the
  245. # :closed state.
  246. 1 def force_close(delete_pending = false)
  247. force_purge
  248. return unless @state == :closed
  249. if delete_pending
  250. @pending.clear
  251. elsif (parser = @parser)
  252. enqueue_pending_requests_from_parser(parser)
  253. end
  254. return unless @pending.empty?
  255. disconnect
  256. emit(:force_closed, delete_pending)
  257. end
  258. # bypasses the state machine to force closing of connections still connecting.
  259. # **only** used for Happy Eyeballs v2.
  260. 1 def force_reset(cloned = false)
  261. 34 @state = :closing
  262. 34 @cloned = cloned
  263. 34 transition(:closed)
  264. end
  265. 1 def reset
  266. 35 return if @state == :closing || @state == :closed
  267. # do not reset a connection which may have restarted back to :idle, such when the parser resets
  268. # (example: HTTP/1 parser disabling pipelining)
  269. 35 return if @state == :idle && @pending.any?
  270. 35 if @ping_timer
  271. @ping_timer.cancel
  272. @ping_timer = nil
  273. end
  274. 35 parser = @parser
  275. 35 if parser && parser.respond_to?(:max_concurrent_requests)
  276. # if connection being reset has at some downgraded the number of concurrent
  277. # requests, such as in the case where an attempt to use HTTP/1 pipelining failed,
  278. # keep that information around.
  279. 28 @max_concurrent_requests = parser.max_concurrent_requests
  280. end
  281. 35 transition(:closing)
  282. 35 transition(:closed)
  283. end
  284. 1 def send(request)
  285. 36 return @coalesced_connection.send(request) if @coalesced_connection
  286. 36 if @parser && !@write_buffer.full?
  287. if @response_received_at && @keep_alive_timeout &&
  288. Utils.elapsed_time(@response_received_at) > @keep_alive_timeout
  289. # when pushing a request into an existing connection, we have to check whether there
  290. # is the possibility that the connection might have extended the keep alive timeout.
  291. # for such cases, we want to ping for availability before deciding to shovel requests.
  292. log(level: 3) { "keep alive timeout expired, pinging connection..." }
  293. @pending << request
  294. transition(:active) if @state == :inactive
  295. request.ping!
  296. ping(request)
  297. return
  298. end
  299. send_request_to_parser(request)
  300. else
  301. 36 @pending << request
  302. end
  303. end
  304. 1 def timeout
  305. 71 return if @state == :closed || @state == :inactive
  306. 71 return @timeout if @timeout
  307. 33 return @options.timeout[:connect_timeout] if @state == :idle
  308. 33 @options.timeout[:operation_timeout]
  309. end
  310. 1 def idling
  311. 3 purge_after_closed
  312. 3 return unless @state == :closed
  313. 3 @write_buffer.clear
  314. 3 transition(:idle)
  315. 3 return unless @parser
  316. 3 enqueue_pending_requests_from_parser(parser)
  317. 3 @parser = nil
  318. end
  319. 1 def used?
  320. 49 @connected_at
  321. end
  322. 1 def deactivate
  323. transition(:inactive)
  324. end
  325. 1 def open?
  326. 32 @state == :open || @state == :inactive
  327. end
  328. 1 def handle_socket_timeout(interval)
  329. error = OperationTimeoutError.new(interval, "timed out while waiting on select")
  330. error.set_backtrace(caller)
  331. on_error(error)
  332. end
  333. 1 def sibling=(connection)
  334. 4 @sibling = connection
  335. 4 return unless connection
  336. @main_sibling = connection.sibling.nil?
  337. return unless @main_sibling
  338. connection.sibling = self
  339. end
  340. 1 def handle_connect_error(error)
  341. 3 return on_error(error) unless @sibling && @sibling.connecting?
  342. @sibling.merge(self)
  343. force_reset(true)
  344. end
  345. # disconnects from the current session it's attached to
  346. 1 def disconnect
  347. 68 return if @exhausted # it'll reset
  348. 68 return unless (current_session = @current_session) && (current_selector = @current_selector)
  349. 68 @current_session = @current_selector = nil
  350. 68 current_session.deselect_connection(self, current_selector, @cloned)
  351. end
  352. 1 def on_connect_error(e)
  353. # connect errors, exit gracefully
  354. error = ConnectionError.new(e.message)
  355. error.set_backtrace(e.backtrace)
  356. handle_connect_error(error) if connecting?
  357. force_close
  358. end
  359. 1 def on_io_error(e)
  360. on_error(e)
  361. # do not force close if parser resets the connection.
  362. # can happen i.e. when HTTP/1.1 pipelining is disabled.
  363. return if @state == :idle && @pending.any?
  364. force_close(true)
  365. end
  366. 1 def on_error(error, request = nil)
  367. 3 if error.is_a?(OperationTimeoutError)
  368. # inactive connections do not contribute to the select loop, therefore
  369. # they should not fail due to such errors.
  370. return if @state == :inactive
  371. if @timeout
  372. @timeout -= error.timeout
  373. return unless @timeout <= 0
  374. @timeout = nil
  375. end
  376. error = error.to_connection_error if connecting?
  377. end
  378. 3 handle_error(error, request)
  379. 3 reset
  380. end
  381. # simplecov:disable
  382. 1 def inspect
  383. "#<#{self.class}:#{object_id} " \
  384. "@origin=#{@origin} " \
  385. "@state=#{@state} " \
  386. "@pending=#{@pending.size} " \
  387. "@io=#{@io}>"
  388. end
  389. # simplecov:enable
  390. 1 private
  391. 1 def connect
  392. 68 transition(:open)
  393. end
  394. 1 def consume
  395. 101 return unless @io
  396. 101 catch(:called) do
  397. 101 epiped = false
  398. 101 loop do
  399. # connection may have
  400. 139 return if @state == :idle
  401. 111 parser.consume
  402. # we exit if there's no more requests to process
  403. #
  404. # this condition takes into account:
  405. #
  406. # * the number of pending requests
  407. # * the number of inflight requests
  408. # * whether the write buffer has bytes (i.e. for close handshake)
  409. 111 if no_more_requests? && @write_buffer.empty?
  410. 4 no_more_requests_loop_check if @parser && @parser.pending.any?
  411. # terminate if an altsvc connection has been established
  412. 4 terminate if @altsvc_connection
  413. 4 return
  414. end
  415. 107 @timeout = @current_timeout
  416. 107 read_drained = false
  417. 107 write_drained = nil
  418. #
  419. # tight read loop.
  420. #
  421. # read as much of the socket as possible.
  422. #
  423. # this tight loop reads all the data it can from the socket and pipes it to
  424. # its parser.
  425. #
  426. loop do
  427. 84 siz = @io.read(@window_size, @read_buffer)
  428. 84 log(level: 3, color: :cyan) { "IO READ: #{siz} bytes... (wsize: #{@window_size}, rbuffer: #{@read_buffer.bytesize})" }
  429. 84 unless siz
  430. @write_buffer.clear
  431. ex = EOFError.new("descriptor closed")
  432. ex.set_backtrace(caller)
  433. on_error(ex)
  434. return
  435. end
  436. # socket has been drained. mark and exit the read loop.
  437. 84 if siz.zero?
  438. 37 read_drained = @read_buffer.empty?
  439. 37 epiped = false
  440. 37 break
  441. end
  442. 47 parser << @read_buffer.to_s
  443. # continue reading if possible.
  444. 19 break if interests == :w && !epiped
  445. # exit the read loop if connection is preparing to be closed
  446. 15 break if @state == :closing || @state == :closed
  447. # exit #consume altogether if all outstanding requests have been dealt with
  448. 15 if no_more_requests? && @write_buffer.empty? # rubocop:disable Style/Next
  449. 4 no_more_requests_loop_check if @parser && @parser.pending.any?
  450. # terminate if an altsvc connection has been established
  451. 4 terminate if @altsvc_connection
  452. 4 return
  453. end
  454. 107 end unless ((ints = interests).nil? || ints == :w || @state == :closing) && !epiped
  455. #
  456. # tight write loop.
  457. #
  458. # flush as many bytes as the sockets allow.
  459. #
  460. loop do
  461. # buffer has been drained, mark and exit the write loop.
  462. 44 if @write_buffer.empty?
  463. # we only mark as drained on the first loop
  464. 2 write_drained = write_drained.nil? && @inflight.positive?
  465. 2 break
  466. end
  467. begin
  468. 42 siz = @io.write(@write_buffer)
  469. rescue Errno::EPIPE
  470. # this can happen if we still have bytes in the buffer to send to the server, but
  471. # the server wants to respond immediately with some message, or an error. An example is
  472. # when one's uploading a big file to an unintended endpoint, and the server stops the
  473. # consumption, and responds immediately with an authorization of even method not allowed error.
  474. # at this point, we have to let the connection switch to read-mode.
  475. log(level: 2) { "pipe broken, could not flush buffer..." }
  476. epiped = true
  477. read_drained = false
  478. break
  479. end
  480. 42 log(level: 3, color: :cyan) { "IO WRITE: #{siz} bytes..." }
  481. 42 unless siz
  482. @write_buffer.clear
  483. ex = EOFError.new("descriptor closed")
  484. ex.set_backtrace(caller)
  485. on_error(ex)
  486. return
  487. end
  488. # socket closed for writing. mark and exit the write loop.
  489. 42 if siz.zero?
  490. write_drained = !@write_buffer.empty?
  491. break
  492. end
  493. # exit write loop if marked to consume from peer, or is closing.
  494. 42 break if interests == :r || @state == :closing || @state == :closed
  495. 2 write_drained = false
  496. 75 end unless (ints = interests) == :r
  497. 75 send_pending if @state == :open
  498. # return if socket is drained
  499. 75 next unless (ints != :r || read_drained) && (ints != :w || write_drained)
  500. # gotta go back to the event loop. It happens when:
  501. #
  502. # * the socket is drained of bytes or it's not the interest of the conn to read;
  503. # * theres nothing more to write, or it's not in the interest of the conn to write;
  504. 37 log(level: 3) { "(#{ints}): WAITING FOR EVENTS..." }
  505. 37 return
  506. end
  507. end
  508. end
  509. 1 def send_pending
  510. 240 while !@write_buffer.full? && (request = @pending.shift)
  511. 34 send_request_to_parser(request)
  512. end
  513. end
  514. 1 def parser
  515. 227 @parser ||= build_parser
  516. end
  517. 1 def send_request_to_parser(request)
  518. 34 @inflight += 1
  519. 34 request.peer_address = @io.ip && @io.ip.address
  520. 34 set_request_timeouts(request)
  521. 34 parser.send(request)
  522. 34 return unless @state == :inactive
  523. transition(:active)
  524. # mark request as ping, as this inactive connection may have been
  525. # closed by the server, and we don't want that to influence retry
  526. # bookkeeping.
  527. request.ping!
  528. end
  529. 1 def enqueue_pending_requests_from_parser(parser)
  530. 31 parser.reset_requests # move sequential requests back to pending queue.
  531. 31 parser_pending_requests = parser.pending
  532. 31 return if parser_pending_requests.empty?
  533. # the connection will be reused, so parser requests must come
  534. # back to the pending list before the parser is reset.
  535. 1 @inflight -= parser_pending_requests.size
  536. 1 @pending.unshift(*parser_pending_requests)
  537. 1 parser.pending.clear
  538. end
  539. 1 def build_parser(protocol = @io.protocol)
  540. 32 parser = parser_type(protocol).new(@write_buffer, @options)
  541. 32 set_parser_callbacks(parser)
  542. 32 parser.max_concurrent_requests = @max_concurrent_requests if @max_concurrent_requests && parser.respond_to?(:max_concurrent_requests=)
  543. 32 parser
  544. end
  545. 1 def set_parser_callbacks(parser)
  546. 32 parser.on(:response) do |request, response|
  547. 33 AltSvc.emit(request, response) do |alt_origin, origin, alt_params|
  548. build_altsvc_connection(alt_origin, origin, alt_params)
  549. end
  550. 33 @response_received_at = Utils.now
  551. 33 @no_more_requests_counter = 0
  552. 33 @inflight -= 1
  553. 33 response.finish!
  554. 33 request.emit_response(response)
  555. end
  556. 32 parser.on(:altsvc) do |alt_origin, origin, alt_params|
  557. build_altsvc_connection(alt_origin, origin, alt_params)
  558. end
  559. 32 parser.on(:pong, &method(:pong))
  560. 32 parser.on(:promise) do |request, stream|
  561. request.emit(:promise, parser, stream)
  562. end
  563. 32 parser.on(:exhausted) do
  564. enqueue_pending_requests_from_parser(parser)
  565. @exhausted = true
  566. parser.close
  567. # @fiber-switch-guard
  568. # fiber may have switched while closing @io, check whether still in the exhausted loop.
  569. next unless @exhausted
  570. idling
  571. @exhausted = false
  572. end
  573. 32 parser.on(:origin) do |origin|
  574. @origins |= [origin]
  575. end
  576. 32 parser.on(:close) do
  577. 4 reset
  578. end
  579. 32 parser.on(:close_handshake) do
  580. consume unless @state == :closed
  581. end
  582. 32 parser.on(:reset) do
  583. 28 enqueue_pending_requests_from_parser(parser)
  584. 28 reset
  585. 28 next unless @state == :closed
  586. # :reset event only fired in http/1.1, so this guarantees
  587. # that the connection will be closed here.
  588. 28 idling unless @pending.empty?
  589. end
  590. 32 parser.on(:current_timeout) do
  591. 4 @current_timeout = @timeout = parser.timeout
  592. end
  593. 32 parser.on(:timeout) do |tout|
  594. 4 @timeout = tout
  595. end
  596. 32 parser.on(:error) do |request, error|
  597. case error
  598. when :http_1_1_required
  599. current_session = @current_session
  600. current_selector = @current_selector
  601. parser.close
  602. other_connection = current_session.find_connection(@origin, current_selector,
  603. @options.merge(ssl: { alpn_protocols: %w[http/1.1] }))
  604. other_connection.merge(self)
  605. request.transition(:idle)
  606. other_connection.send(request)
  607. next
  608. when OperationTimeoutError
  609. # request level timeouts should take precedence
  610. next unless request.active_timeouts.empty?
  611. end
  612. @inflight -= 1
  613. response = ErrorResponse.new(request, error)
  614. request.response = response
  615. request.emit_response(response)
  616. end
  617. end
  618. 1 def transition(nextstate)
  619. 241 handle_transition(nextstate)
  620. rescue Errno::ECONNABORTED,
  621. Errno::ECONNREFUSED,
  622. Errno::ECONNRESET,
  623. Errno::EADDRNOTAVAIL,
  624. Errno::EHOSTUNREACH,
  625. Errno::EINVAL,
  626. Errno::ENETUNREACH,
  627. Errno::EPIPE,
  628. Errno::ENOENT,
  629. SocketError,
  630. IOError => e
  631. on_connect_error(e)
  632. rescue TLSError, ::HTTP2::Error::ProtocolError, ::HTTP2::Error::HandshakeError => e
  633. # connect errors, exit gracefully
  634. handle_error(e)
  635. handle_connect_error(e) if connecting?
  636. force_close
  637. end
  638. 1 def handle_transition(nextstate)
  639. 241 case nextstate
  640. when :idle
  641. 69 @timeout = @current_timeout = @options.timeout[:connect_timeout]
  642. 69 @connected_at = @response_received_at = nil
  643. when :open
  644. 68 return if @state == :closed
  645. 68 @io.connect
  646. 68 close_sibling if @io.state == :connected
  647. 68 return unless @io.connected?
  648. 32 @connected_at = Utils.now
  649. 32 send_pending
  650. 32 @timeout = @current_timeout = parser.timeout
  651. 32 emit(:open)
  652. when :inactive
  653. return unless @state == :open
  654. # @type ivar @parser: HTTP1 | HTTP2
  655. # do not deactivate connection in use
  656. return if @inflight.positive? || @parser.waiting_for_ping?
  657. when :closing
  658. 35 return unless connecting? || @state == :open
  659. when :closed
  660. 69 return unless @state == :closing
  661. 69 return unless @write_buffer.empty?
  662. 69 purge_after_closed
  663. # @fiber-switch-guard
  664. 69 return unless @state == :closing && (@io.nil? || @io.can_disconnect?)
  665. when :already_open
  666. nextstate = :open
  667. # the first check for given io readiness must still use a timeout.
  668. # connect is the reasonable choice in such a case.
  669. @timeout = @options.timeout[:connect_timeout]
  670. send_pending
  671. when :active
  672. return unless @state == :inactive
  673. nextstate = :open
  674. # activate
  675. @current_session.select_connection(self, @current_selector)
  676. end
  677. 205 log(level: 3) { "#{@state} -> #{nextstate}" }
  678. 205 @state = nextstate
  679. # post state change
  680. 205 case nextstate
  681. when :inactive
  682. disconnect
  683. when :closing
  684. 35 return if @write_buffer.empty?
  685. # try flushing termination handshakes
  686. 4 consume
  687. 4 @write_buffer.clear
  688. when :closed
  689. # TODO: should this raise an error instead?
  690. 69 return unless @pending.empty?
  691. 68 disconnect
  692. end
  693. end
  694. 1 def force_purge
  695. return if @state == :closed
  696. @state = :closed
  697. @write_buffer.clear
  698. begin
  699. purge_after_closed
  700. rescue IOError
  701. # may be raised when closing the socket.
  702. # due to connection reuse / fiber scheduling, it may
  703. # have been reopened, to bail out in that case.
  704. end
  705. end
  706. 1 def close_sibling
  707. 32 sibling = @sibling
  708. 32 return unless sibling
  709. if sibling.io_connected?
  710. reset
  711. # TODO: transition connection to closed
  712. end
  713. unless sibling.state == :closed
  714. merge(sibling) unless @main_sibling
  715. sibling.force_reset(true)
  716. end
  717. @sibling = nil
  718. end
  719. 1 def purge_after_closed
  720. 72 if @io
  721. 35 @io.close
  722. # @fiber-switch-guard
  723. # due to fiber scheduler, multiple fibers may be listening on the same connection
  724. # and moving the state machine forward; in such cases, when the control flow reaches
  725. # this line, the io object may not be closed anymore.
  726. 35 return unless @io&.can_disconnect?
  727. end
  728. 72 @read_buffer.clear
  729. 72 @timeout = nil
  730. end
  731. 1 def initialize_type(uri, options)
  732. 66 options.transport || begin
  733. 66 case uri.scheme
  734. when "http"
  735. 62 "tcp"
  736. when "https"
  737. 4 "ssl"
  738. else
  739. raise UnsupportedSchemeError, "#{uri}: #{uri.scheme}: unsupported URI scheme"
  740. end
  741. end
  742. end
  743. # returns an HTTPX::Connection for the negotiated Alternative Service (or none).
  744. 1 def build_altsvc_connection(alt_origin, origin, alt_params)
  745. return if @altsvc_connection
  746. # do not allow security downgrades on altsvc negotiation
  747. return if @origin.scheme == "https" && alt_origin.scheme != "https"
  748. altsvc = AltSvc.cached_altsvc_set(origin, alt_params.merge("origin" => alt_origin))
  749. # altsvc already exists, somehow it wasn't advertised, probably noop
  750. return unless altsvc
  751. alt_options = @options.merge(ssl: @options.ssl.merge(hostname: URI(origin).host))
  752. connection = @current_session.find_connection(alt_origin, @current_selector, alt_options)
  753. # advertised altsvc is the same origin being used, ignore
  754. return if connection == self
  755. connection.extend(AltSvc::ConnectionMixin) unless connection.is_a?(AltSvc::ConnectionMixin)
  756. @altsvc_connection = connection
  757. log(level: 1) { "#{origin}: alt-svc connection##{connection.object_id} established to #{alt_origin}" }
  758. connection.merge(self)
  759. rescue UnsupportedSchemeError
  760. altsvc["noop"] = true
  761. nil
  762. end
  763. 1 def build_socket(addrs = nil)
  764. 29 case @type
  765. when "tcp"
  766. 25 TCP.new(peer, addrs, @options)
  767. when "ssl"
  768. 4 SSL.new(peer, addrs, @options) do |sock|
  769. 4 sock.ssl_session = @ssl_session
  770. 4 sock.session_new_cb do |sess|
  771. 8 @ssl_session = sess
  772. 8 sock.ssl_session = sess
  773. end
  774. end
  775. when "unix"
  776. path = Array(addrs).first
  777. path = String(path) if path
  778. UNIX.new(peer, path, @options)
  779. else
  780. raise Error, "unsupported transport (#{@type})"
  781. end
  782. end
  783. 1 def ping(_request)
  784. return if parser.waiting_for_ping?
  785. parser.ping
  786. ping_timeout = @options.timeout[:ping_timeout]
  787. @ping_timer = @current_selector.after(ping_timeout) do
  788. log(level: 3) { "ping timeout expired..." }
  789. error = PingTimeoutError.new(ping_timeout, "Timed out after #{ping_timeout} seconds")
  790. on_error(error)
  791. end
  792. call
  793. end
  794. 1 def pong
  795. @ping_timer.cancel
  796. @ping_timer = nil
  797. @response_received_at = Utils.now
  798. @no_more_requests_counter = 0
  799. send_pending
  800. end
  801. 1 def no_more_requests_loop_check
  802. log(level: 3) { "NO MORE REQUESTS..." }
  803. @no_more_requests_counter += 1
  804. return if @no_more_requests_counter < 50
  805. raise Error, "connection corrupted, aborted after looping for a while, " \
  806. "please report this https://gitlab.com/os85/httpx/-/work_items " \
  807. "along with debug logs"
  808. end
  809. # true when there are no more pending nor inflight (in parser) requests
  810. 1 def no_more_requests?
  811. 189 @pending.empty? && @inflight.zero?
  812. end
  813. # recover internal state and emit all relevant error responses when +error+ was raised.
  814. # this takes an optiona +request+ which may have already been handled and can be opted out
  815. # in the state recovery process.
  816. 1 def handle_error(error, request = nil)
  817. 3 if request
  818. @inflight -= 1
  819. response = ErrorResponse.new(request, error)
  820. request.response = response
  821. request.emit_response(response)
  822. end
  823. 3 pending = @pending
  824. 3 if (parser = @parser) && parser.respond_to?(:handle_error)
  825. # parser.handle_error may disconnect the connection
  826. pending = @pending.dup
  827. @pending = []
  828. parser.handle_error(error, request)
  829. end
  830. 9 while (req = pending.shift)
  831. 3 next if request && req == request
  832. 3 resp = ErrorResponse.new(req, error)
  833. 3 req.response = resp
  834. 3 req.emit_response(resp)
  835. end
  836. end
  837. 1 def set_request_timeouts(request)
  838. 34 request.connection = self
  839. 34 set_request_write_timeout(request)
  840. 34 set_request_read_timeout(request)
  841. 34 set_request_request_timeout(request)
  842. 34 set_request_total_request_timeout(request)
  843. end
  844. 1 def set_request_read_timeout(request)
  845. 34 read_timeout = request.read_timeout
  846. 34 return if read_timeout.nil? || read_timeout.infinite?
  847. 34 set_request_timeout(:read_timeout, request, read_timeout, :done, :response) do
  848. read_timeout_callback(request, read_timeout)
  849. end
  850. end
  851. 1 def set_request_write_timeout(request)
  852. 34 write_timeout = request.write_timeout
  853. 34 return if write_timeout.nil? || write_timeout.infinite?
  854. 34 set_request_timeout(:write_timeout, request, write_timeout, :headers, %i[done response]) do
  855. write_timeout_callback(request, write_timeout)
  856. end
  857. end
  858. 1 def set_request_request_timeout(request)
  859. 34 request_timeout = request.request_timeout
  860. 34 return if request_timeout.nil? || request_timeout.infinite?
  861. set_request_timeout(:request_timeout, request, request_timeout, :headers, :complete) do
  862. read_timeout_callback(request, request_timeout, RequestTimeoutError)
  863. end
  864. end
  865. 1 def write_timeout_callback(request, timeout)
  866. return if request.state == :done
  867. @write_buffer.clear
  868. error = WriteTimeoutError.new(request, nil, timeout)
  869. request.handle_error(error)
  870. end
  871. 1 def read_timeout_callback(request, timeout, error_type = ReadTimeoutError)
  872. response = request.response
  873. return if response && response.finished?
  874. @write_buffer.clear
  875. error = error_type.new(request, response, timeout)
  876. request.handle_error(error)
  877. end
  878. 1 def set_request_total_request_timeout(request)
  879. 34 return if request.started?
  880. 31 total_request_timeout = request.total_request_timeout
  881. 31 return if total_request_timeout.nil? || total_request_timeout.infinite?
  882. set_request_timeout(:total_request_timeout, request, total_request_timeout, :headers, :complete) do
  883. read_timeout_callback(request, total_request_timeout, TotalRequestTimeoutError)
  884. end
  885. end
  886. 1 def set_request_timeout(label, request, timeout, start_event, finish_events, &callback)
  887. 68 request.set_timeout_callback(start_event) do
  888. 68 unless (selector = @current_selector)
  889. raise Error, "request has been resend to an out-of-session connection, and this " \
  890. "should never happen!!! Please report this error! " \
  891. "(state:#{@state}, " \
  892. "parser?:#{!!@parser}, " \
  893. "bytes in write buffer?:#{!@write_buffer.empty?}, " \
  894. "cloned?:#{@cloned}, " \
  895. "sibling?:#{!!@sibling}, " \
  896. "coalesced?:#{coalesced?})"
  897. end
  898. 68 timer = selector.after(timeout, callback)
  899. 68 timer.label = label
  900. 68 request.active_timeouts << timer
  901. 68 Array(finish_events).each do |event|
  902. # clean up request timeouts if the connection errors out
  903. 102 request.set_timeout_callback(event) do
  904. 100 timer.cancel
  905. 100 request.active_timeouts.delete(timer)
  906. end
  907. end
  908. end
  909. end
  910. 1 def parser_type(protocol)
  911. 32 case protocol
  912. 4 when "h2" then @options.http2_class
  913. 28 when "http/1.1" then @options.http1_class
  914. else
  915. raise Error, "unsupported protocol (##{protocol})"
  916. end
  917. end
  918. end
  919. end

lib/httpx/connection/http1.rb

70.56% lines covered

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

lib/httpx/connection/http2.rb

62.99% lines covered

308 relevant lines. 194 lines covered and 114 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "securerandom"
  3. 1 require "http/2"
  4. 1 module HTTPX
  5. 1 class Connection::HTTP2
  6. 1 include Callbacks
  7. 1 include Loggable
  8. 1 MAX_CONCURRENT_REQUESTS = ::HTTP2::DEFAULT_MAX_CONCURRENT_STREAMS
  9. 1 class Error < Error
  10. 1 def initialize(id, error)
  11. super("stream #{id} closed with error: #{error}")
  12. end
  13. end
  14. 1 class PingError < Error
  15. 1 def initialize
  16. super(0, :ping_error)
  17. end
  18. end
  19. 1 class GoawayError < Error
  20. 1 def initialize(code = :no_error)
  21. super(0, code)
  22. end
  23. end
  24. 1 attr_reader :streams, :pending
  25. 1 def initialize(buffer, options)
  26. 4 @options = options
  27. 4 @settings = @options.http2_settings
  28. 4 @pending = []
  29. 4 @streams = {}
  30. 4 @drains = {}
  31. 4 @pings = []
  32. 4 @streams_to_close_after_receive = []
  33. 4 @buffer = buffer
  34. 4 @handshake_completed = false
  35. 4 @wait_for_handshake = @settings.key?(:wait_for_handshake) ? @settings.delete(:wait_for_handshake) : true
  36. 4 @max_concurrent_requests = @options.max_concurrent_requests || MAX_CONCURRENT_REQUESTS
  37. 4 @max_requests = @options.max_requests
  38. 4 init_connection
  39. end
  40. 1 def timeout
  41. 8 return @options.timeout[:operation_timeout] if @handshake_completed
  42. 4 @options.timeout[:settings_timeout]
  43. end
  44. 1 def interests
  45. 70 if @connection.closed?
  46. 12 return unless @handshake_completed
  47. 12 return if @buffer.empty?
  48. # HTTP/2 GOAWAY frame buffered.
  49. 8 return :w
  50. end
  51. 58 unless @connection.state == :connected && @handshake_completed
  52. # HTTP/2 in intermediate state or still completing initialization-
  53. 24 return @buffer.empty? ? :r : :rw
  54. end
  55. 34 unless @connection.send_buffer.empty?
  56. # HTTP/2 connection is buffering data chunks and failing to emit DATA frames,
  57. # most likely because the flow control window is exhausted.
  58. return :rw unless @buffer.empty?
  59. # waiting for WINDOW_UPDATE frames
  60. return :r
  61. end
  62. # there are pending bufferable requests
  63. 34 return :w if !@pending.empty? && can_buffer_more_requests?
  64. # there are pending frames from the last run
  65. 34 return :w unless @drains.empty?
  66. 34 if @buffer.empty?
  67. # skip if no more requests or pings to process
  68. 26 return if @streams.empty? && @pings.empty?
  69. 22 :r
  70. else
  71. # buffered frames
  72. 8 :w
  73. end
  74. end
  75. 1 def close
  76. 4 unless @connection.closed?
  77. 4 @connection.goaway
  78. 4 emit(:timeout, @options.timeout[:close_handshake_timeout])
  79. end
  80. 4 emit(:close)
  81. end
  82. 1 def empty?
  83. 4 @connection.closed? || @streams.empty?
  84. end
  85. 1 def exhausted?
  86. 4 !@max_requests.positive?
  87. end
  88. 1 def <<(data)
  89. 14 @connection << data
  90. 28 while (stream, request, error = @streams_to_close_after_receive.shift)
  91. # these streams were marked for cancellation due to errors found while processing the
  92. # data received by the peer.
  93. emit_stream_error(stream, request, error)
  94. end
  95. end
  96. 1 def send(request, head = false)
  97. 10 unless can_buffer_more_requests?
  98. 5 head ? @pending.unshift(request) : @pending << request
  99. 5 return false
  100. end
  101. 5 unless (stream = @streams[request])
  102. 5 stream = @connection.new_stream(**request.http2_stream_options)
  103. 5 handle_stream(stream, request)
  104. 5 @streams[request] = stream
  105. 5 @max_requests -= 1
  106. end
  107. 5 handle(request, stream)
  108. 5 true
  109. rescue ::HTTP2::Error::StreamLimitExceeded
  110. @pending.unshift(request)
  111. false
  112. rescue ::HTTP2::Error::Error, ArgumentError => e
  113. emit(:error, request, e)
  114. end
  115. 1 def consume
  116. 24 @streams.each do |request, stream|
  117. 10 next unless request.can_buffer?
  118. handle(request, stream)
  119. end
  120. end
  121. 1 def handle_error(ex, request = nil)
  122. if ex.is_a?(OperationTimeoutError) && !@handshake_completed && @connection.state != :closed
  123. @connection.goaway(:settings_timeout, "closing due to settings timeout")
  124. emit(:close_handshake)
  125. settings_ex = SettingsTimeoutError.new(ex.timeout, ex.message)
  126. settings_ex.set_backtrace(ex.backtrace)
  127. ex = settings_ex
  128. end
  129. while (req, _ = @streams.shift)
  130. next if request && request == req
  131. emit(:error, req, ex)
  132. end
  133. while (req = @pending.shift)
  134. next if request && request == req
  135. emit(:error, req, ex)
  136. end
  137. end
  138. 1 def ping
  139. ping = SecureRandom.gen_random(8)
  140. @connection.ping(ping.dup)
  141. ensure
  142. @pings << ping
  143. end
  144. 1 def waiting_for_ping?
  145. @pings.any?
  146. end
  147. 1 def reset_requests; end
  148. 1 private
  149. 1 def can_buffer_more_requests?
  150. 10 (@handshake_completed || !@wait_for_handshake) &&
  151. @streams.size < @max_concurrent_requests &&
  152. @streams.size < @max_requests
  153. end
  154. 1 def send_pending
  155. 13 while (request = @pending.shift)
  156. 5 break unless send(request, true)
  157. end
  158. end
  159. 1 def handle(request, stream)
  160. 5 catch(:buffer_full) do
  161. 5 request.transition(:headers)
  162. 5 join_headers(stream, request) if request.state == :headers
  163. 5 request.transition(:body)
  164. 5 join_body(stream, request) if request.state == :body
  165. 5 request.transition(:trailers)
  166. 5 join_trailers(stream, request) if request.state == :trailers && !request.body.empty?
  167. 5 request.transition(:done)
  168. end
  169. end
  170. 1 def init_connection
  171. 4 @connection = ::HTTP2::Client.new(@settings)
  172. 4 @connection.on(:frame, &method(:on_frame))
  173. 4 @connection.on(:frame_sent, &method(:on_frame_sent))
  174. 4 @connection.on(:frame_received, &method(:on_frame_received))
  175. 4 @connection.on(:origin, &method(:on_origin))
  176. 4 @connection.on(:promise, &method(:on_promise))
  177. 4 @connection.on(:altsvc) { |frame| on_altsvc(frame[:origin], frame) }
  178. 4 @connection.on(:settings_ack, &method(:on_settings))
  179. 4 @connection.on(:ack, &method(:on_pong))
  180. 4 @connection.on(:goaway, &method(:on_close))
  181. #
  182. # Some servers initiate HTTP/2 negotiation right away, some don't.
  183. # As such, we have to check the socket buffer. If there is something
  184. # to read, the server initiated the negotiation. If not, we have to
  185. # initiate it.
  186. #
  187. 4 @connection.send_connection_preface
  188. end
  189. 1 alias_method :reset, :init_connection
  190. 1 public :reset
  191. 1 def handle_stream(stream, request)
  192. 5 request.on(:refuse, &method(:on_stream_refuse).curry(3)[stream, request])
  193. 5 stream.on(:close, &method(:on_stream_close).curry(3)[stream, request])
  194. 10 stream.on(:half_close) { on_stream_half_close(stream, request) }
  195. 5 stream.on(:altsvc, &method(:on_altsvc).curry(2)[request.origin])
  196. 5 stream.on(:headers, &method(:on_stream_headers).curry(3)[stream, request])
  197. 5 stream.on(:data, &method(:on_stream_data).curry(3)[stream, request])
  198. end
  199. 1 def set_protocol_headers(request)
  200. {
  201. 5 ":scheme" => request.scheme,
  202. ":method" => request.verb,
  203. ":path" => request.path,
  204. ":authority" => request.authority,
  205. }
  206. end
  207. 1 def join_headers(stream, request)
  208. 5 extra_headers = set_protocol_headers(request)
  209. 5 if request.headers.key?("host")
  210. request.log { "forbidden \"host\" header found (#{log_redact_headers(request.headers["host"])}), will use it as authority..." }
  211. extra_headers[":authority"] = request.headers["host"]
  212. end
  213. 5 request.log(level: 1, color: :yellow) do
  214. "\n#{request.headers.merge(extra_headers).each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{log_redact_headers(v)}" }.join("\n")}"
  215. end
  216. 5 stream.headers(request.headers.each(extra_headers), end_stream: request.body.empty?)
  217. end
  218. 1 def join_trailers(stream, request)
  219. 1 unless request.trailers?
  220. 1 stream.data("", end_stream: true) if request.callbacks_for?(:trailers)
  221. 1 return
  222. end
  223. request.log(level: 1, color: :yellow) do
  224. request.trailers.each.map { |k, v| "#{stream.id}: -> HEADER: #{k}: #{log_redact_headers(v)}" }.join("\n")
  225. end
  226. stream.headers(request.trailers.each, end_stream: true)
  227. end
  228. 1 def join_body(stream, request)
  229. 5 return if request.body.empty?
  230. 1 chunk = @drains.delete(request) || request.drain_body
  231. 1 while chunk
  232. 1 next_chunk = request.drain_body
  233. 1 send_chunk(request, stream, chunk, next_chunk)
  234. 1 if next_chunk && (@buffer.full? || request.body.unbounded_body?)
  235. @drains[request] = next_chunk
  236. throw(:buffer_full)
  237. end
  238. 1 chunk = next_chunk
  239. end
  240. 1 return unless (error = request.drain_error)
  241. on_stream_refuse(stream, request, error)
  242. end
  243. 1 def send_chunk(request, stream, chunk, next_chunk)
  244. 1 request.log(level: 1, color: :green) { "#{stream.id}: -> DATA: #{chunk.bytesize} bytes..." }
  245. 1 request.log(level: 2, color: :green) { "#{stream.id}: -> #{log_redact_body(chunk.inspect)}" }
  246. 1 stream.data(chunk, end_stream: end_stream?(request, next_chunk))
  247. end
  248. 1 def end_stream?(request, next_chunk)
  249. 1 !(next_chunk || request.trailers? || request.callbacks_for?(:trailers))
  250. end
  251. ######
  252. # HTTP/2 Callbacks
  253. ######
  254. 1 def on_stream_headers(stream, request, h)
  255. 5 response = request.response
  256. 5 if response.is_a?(Response) && response.version == "2.0"
  257. on_stream_trailers(stream, request, response, h)
  258. return
  259. end
  260. 5 request.log(color: :yellow) do
  261. h.map { |k, v| "#{stream.id}: <- HEADER: #{k}: #{k == ":status" ? v : log_redact_headers(v)}" }.join("\n")
  262. end
  263. 5 _, status = h.shift
  264. 5 headers = request.options.headers_class.new(h)
  265. 5 raise HTTPX::Error, "maximum number of response headers exceeded" if h.size > @options.max_response_headers
  266. 5 if (max_header_value_size = @options.max_response_header_value_size)
  267. headers.each do |_, v| # rubocop:disable Style/HashEachMethods
  268. raise HTTPX::Error, "maximum header value size exceeded" if v.size > max_header_value_size
  269. end
  270. end
  271. 5 response = request.options.response_class.new(request, status, "2.0", headers)
  272. 5 if response.content_length && response.content_length > request.options.max_response_body_size
  273. raise HTTPX::Error.new, "maximum response body size exceeded"
  274. end
  275. 5 request.response = response
  276. 5 @streams[request] = stream
  277. 5 handle(request, stream) if request.expects?
  278. rescue HTTPX::Error => e
  279. @streams_to_close_after_receive << [stream, request, e]
  280. end
  281. 1 def on_stream_trailers(stream, request, response, h)
  282. request.log(color: :yellow) do
  283. h.map { |k, v| "#{stream.id}: <- HEADER: #{k}: #{log_redact_headers(v)}" }.join("\n")
  284. end
  285. response.merge_headers(h)
  286. end
  287. 1 def on_stream_data(stream, request, data)
  288. 7 request.log(level: 1, color: :green) { "#{stream.id}: <- DATA: #{data.bytesize} bytes..." }
  289. 7 request.log(level: 2, color: :green) { "#{stream.id}: <- #{log_redact_body(data.inspect)}" }
  290. 7 return unless request.response
  291. 7 request.response << data
  292. rescue HTTPX::Error => e
  293. if stream.state == :closing
  294. # there won't be any more chunks to process after this one.
  295. emit_stream_error(stream, request, e)
  296. else
  297. # defer until the last chunk from the payload is processed.
  298. @streams_to_close_after_receive << [stream, request, e]
  299. end
  300. end
  301. 1 def on_stream_refuse(stream, request, error)
  302. on_stream_close(stream, request, error)
  303. stream.close
  304. end
  305. 1 def on_stream_half_close(stream, request)
  306. 5 unless stream.send_buffer.empty?
  307. stream.send_buffer.clear
  308. stream.data("", end_stream: true)
  309. end
  310. # TODO: omit log line if response already here
  311. 5 request.log(level: 2) { "#{stream.id}: waiting for response..." }
  312. end
  313. 1 def on_stream_close(stream, request, error)
  314. 5 return if error == :stream_closed && !@streams.key?(request)
  315. 5 log(level: 2) { "#{stream.id}: closing stream" }
  316. 5 teardown(request)
  317. 5 if error
  318. case error
  319. when :http_1_1_required
  320. emit(:error, request, error)
  321. else
  322. ex = Error.new(stream.id, error)
  323. ex.set_backtrace(caller)
  324. response = ErrorResponse.new(request, ex)
  325. request.response = response
  326. emit(:response, request, response)
  327. end
  328. else
  329. 5 if (response = request.response)
  330. 5 if response.is_a?(Response) && response.status == 421
  331. emit(:error, request, :http_1_1_required)
  332. else
  333. 5 emit(:response, request, response)
  334. end
  335. end
  336. end
  337. 5 send(@pending.shift) unless @pending.empty?
  338. 5 return unless @streams.empty? && exhausted?
  339. if @pending.empty?
  340. close
  341. else
  342. emit(:exhausted)
  343. end
  344. end
  345. 1 def on_frame(bytes)
  346. 22 @buffer << bytes
  347. end
  348. 1 def on_settings(*)
  349. 4 @handshake_completed = true
  350. 4 emit(:current_timeout)
  351. 4 @max_concurrent_requests = [@max_concurrent_requests, @connection.remote_settings[:settings_max_concurrent_streams]].min
  352. 4 send_pending
  353. end
  354. 1 def on_close(_last_frame, error, _payload)
  355. is_connection_closed = @connection.closed?
  356. if error
  357. @buffer.clear if is_connection_closed
  358. case error
  359. when :http_1_1_required
  360. while (request = @pending.shift)
  361. emit(:error, request, error)
  362. end
  363. else
  364. ex = GoawayError.new(error)
  365. ex.set_backtrace(caller)
  366. handle_error(ex)
  367. teardown
  368. end
  369. end
  370. return unless is_connection_closed && @streams.empty?
  371. emit(:close) if is_connection_closed
  372. end
  373. 1 def on_frame_sent(frame)
  374. 18 log(level: 2) { "#{frame[:stream]}: frame was sent!" }
  375. 18 log(level: 2, color: :blue) { "#{frame[:stream]}: #{frame_with_extra_info(frame)}" }
  376. end
  377. 1 def on_frame_received(frame)
  378. 20 log(level: 2) { "#{frame[:stream]}: frame was received!" }
  379. 20 log(level: 2, color: :magenta) { "#{frame[:stream]}: #{frame_with_extra_info(frame)}" }
  380. end
  381. 1 def frame_with_extra_info(frame)
  382. flags_bits = frame.fetch(:flags, 0)
  383. case frame[:type]
  384. when :data
  385. flags = [] #: Array[Symbol]
  386. flags << :end_stream if flags_bits.anybits?(0b0001)
  387. flags << :padded if flags_bits.anybits?(0b1000)
  388. frame.merge(payload: frame[:payload].bytesize, flags: flags)
  389. when :push_promise, :headers
  390. flags = [] #: Array[Symbol]
  391. flags << :end_stream if flags_bits.anybits?(0b0001)
  392. flags << :priority if flags_bits.anybits?(0b0010)
  393. flags << :end_headers if flags_bits.anybits?(0b0100)
  394. flags << :padded if flags_bits.anybits?(0b1000)
  395. frame.merge(payload: log_redact_headers(frame[:payload]), flags: flags)
  396. when :ping
  397. flags = [] #: Array[Symbol]
  398. flags << :ack if flags_bits.anybits?(0b0001)
  399. frame.merge(payload: log_redact_headers(frame[:payload]), flags: flags)
  400. when :settings
  401. flags = [] #: Array[Symbol]
  402. flags << :ack if flags_bits.anybits?(0b0001)
  403. frame.merge(flags: flags)
  404. when :window_update
  405. connection_or_stream = if (id = frame[:stream]).zero?
  406. @connection
  407. else
  408. @streams.each_value.find { |s| s.id == id }
  409. end
  410. if connection_or_stream
  411. frame.merge(
  412. local_window: connection_or_stream.local_window,
  413. remote_window: connection_or_stream.remote_window,
  414. buffered_amount: connection_or_stream.buffered_amount,
  415. stream_state: connection_or_stream.state,
  416. )
  417. else
  418. frame
  419. end
  420. else
  421. frame
  422. end.merge(connection_state: @connection.state)
  423. end
  424. 1 def on_altsvc(origin, frame)
  425. log(level: 2) { "#{frame[:stream]}: altsvc frame was received" }
  426. log(level: 2) { "#{frame[:stream]}: #{log_redact_headers(frame.inspect)}" }
  427. alt_origin = URI.parse("#{frame[:proto]}://#{frame[:host]}:#{frame[:port]}")
  428. params = { "ma" => frame[:max_age] }
  429. emit(:altsvc, origin, alt_origin, origin, params)
  430. end
  431. 1 def on_promise(stream)
  432. emit(:promise, @streams.key(stream.parent), stream)
  433. end
  434. 1 def on_origin(origin)
  435. emit(:origin, origin)
  436. end
  437. 1 def on_pong(ping)
  438. raise PingError unless @pings.delete(ping.to_s)
  439. emit(:pong)
  440. end
  441. 1 def emit_stream_error(stream, request, error)
  442. teardown(request)
  443. stream.close
  444. emit(:error, request, error)
  445. end
  446. 1 def teardown(request = nil)
  447. 5 if request
  448. 5 @drains.delete(request)
  449. 5 @streams.delete(request)
  450. else
  451. @drains.clear
  452. @streams.clear
  453. end
  454. end
  455. end
  456. end

lib/httpx/domain_name.rb

27.27% lines covered

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

lib/httpx/errors.rb

82.22% lines covered

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

lib/httpx/extensions.rb

57.14% lines covered

21 relevant lines. 12 lines covered and 9 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "uri"
  3. 1 module HTTPX
  4. 1 module ArrayExtensions
  5. 1 module Intersect
  6. refine Array do
  7. # Ruby 3.1 backport
  8. def intersect?(arr)
  9. if size < arr.size
  10. smaller = self
  11. else
  12. smaller, arr = arr, self
  13. end
  14. (arr & smaller).size > 0
  15. end
  16. 1 end unless Array.method_defined?(:intersect?)
  17. end
  18. end
  19. 1 module URIExtensions
  20. # uri 0.11 backport, ships with ruby 3.1
  21. 1 refine URI::Generic do
  22. 1 def non_ascii_hostname
  23. 4 @non_ascii_hostname
  24. end
  25. 1 def non_ascii_hostname=(hostname)
  26. @non_ascii_hostname = hostname
  27. end
  28. def authority
  29. return host if port == default_port
  30. "#{host}:#{port}"
  31. 1 end unless URI::HTTP.method_defined?(:authority)
  32. def origin
  33. "#{scheme}://#{authority}"
  34. 1 end unless URI::HTTP.method_defined?(:origin)
  35. end
  36. end
  37. end

lib/httpx/headers.rb

90.41% lines covered

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

lib/httpx/io.rb

100.0% lines covered

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

lib/httpx/io/ssl.rb

77.27% lines covered

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

lib/httpx/io/tcp.rb

69.12% lines covered

136 relevant lines. 94 lines covered and 42 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "resolv"
  3. 1 module HTTPX
  4. 1 class TCP
  5. 1 include Loggable
  6. 1 using URIExtensions
  7. 1 attr_reader :ip, :port, :addresses, :state, :interests
  8. 1 alias_method :host, :ip
  9. 1 def initialize(origin, addresses, options)
  10. 29 @state = :idle
  11. 29 @keep_open = false
  12. 29 @addresses = []
  13. 29 @ip_index = -1
  14. 29 @ip = nil
  15. 29 @hostname = origin.host
  16. 29 @options = options
  17. 29 @fallback_protocol = @options.fallback_protocol
  18. 29 @port = origin.port
  19. 29 @interests = :w
  20. 29 if (io = @options.io)
  21. io =
  22. case io
  23. when Hash
  24. io[origin.authority]
  25. else
  26. io
  27. end
  28. raise Error, "Given IO objects do not match the request authority" unless io
  29. # @type var io: TCPSocket | OpenSSL::SSL::SSLSocket
  30. _, _, _, ip = io.addr
  31. @io = io
  32. @addresses << (@ip = Resolver::Entry.new(ip))
  33. @keep_open = true
  34. @state = :connected
  35. else
  36. 29 add_addresses(addresses)
  37. end
  38. 29 @ip_index = @addresses.size - 1
  39. end
  40. 1 def socket
  41. @io
  42. end
  43. 1 def add_addresses(addrs)
  44. 29 return if addrs.empty?
  45. 29 ip_index = @ip_index || (@addresses.size - 1)
  46. 29 if addrs.first.ipv6?
  47. # should be the next in line
  48. @addresses = [*@addresses[0, ip_index], *addrs, *@addresses[ip_index..-1]]
  49. else
  50. 29 @addresses.unshift(*addrs)
  51. end
  52. 29 @ip_index += addrs.size
  53. end
  54. # eliminates expired entries and returns whether there are still any left.
  55. 1 def addresses?
  56. 2 prev_addr_size = @addresses.size
  57. 2 @addresses.delete_if(&:expired?).sort! do |addr1, addr2|
  58. if addr1.ipv6?
  59. addr2.ipv6? ? 0 : 1
  60. else
  61. addr2.ipv6? ? -1 : 0
  62. end
  63. end
  64. 2 @ip_index = @addresses.size - 1 if prev_addr_size != @addresses.size
  65. 2 @addresses.any?
  66. end
  67. 1 def to_io
  68. 69 @io.to_io
  69. end
  70. 1 def protocol
  71. 28 @fallback_protocol
  72. end
  73. 1 def connect
  74. 64 return unless closed?
  75. 64 if @addresses.empty?
  76. # an idle connection trying to connect with no available addresses is a connection
  77. # out of the initial context which is back to the DNS resolution loop. This may
  78. # happen in a fiber-aware context where a connection reconnects with expired addresses,
  79. # and context is passed back to a fiber on the same connection while waiting for the
  80. # DNS answer.
  81. log { "tried connecting while resolving, skipping..." }
  82. return
  83. end
  84. 64 if !@io || @io.closed?
  85. 32 transition(:idle)
  86. 32 @io = build_socket
  87. end
  88. 64 try_connect
  89. rescue Errno::EHOSTUNREACH,
  90. Errno::ENETUNREACH => e
  91. @ip_index -= 1
  92. raise e if @ip_index.negative?
  93. log { "failed connecting to #{@ip} (#{e.message}), evict from cache and trying next..." }
  94. @options.resolver_cache.evict(@hostname, @ip)
  95. @io = build_socket
  96. retry
  97. rescue Errno::ECONNREFUSED,
  98. Errno::EADDRNOTAVAIL,
  99. SocketError,
  100. IOError => e
  101. @ip_index -= 1
  102. raise e if @ip_index.negative?
  103. log { "failed connecting to #{@ip} (#{e.message}), trying next..." }
  104. @io = build_socket
  105. retry
  106. rescue Errno::ETIMEDOUT => e
  107. @ip_index -= 1
  108. raise ConnectTimeoutError.new(@options.timeout[:connect_timeout], e.message) if @ip_index.negative?
  109. log { "failed connecting to #{@ip} (#{e.message}), trying next..." }
  110. @io = build_socket
  111. retry
  112. end
  113. 1 def try_connect
  114. 64 ret = @io.connect_nonblock(Socket.sockaddr_in(@port, @ip.to_s), exception: false)
  115. 64 log(level: 3, color: :cyan) { "TCP CONNECT: #{ret}..." }
  116. 64 case ret
  117. when :wait_readable
  118. @interests = :r
  119. return
  120. when :wait_writable
  121. 32 @interests = :w
  122. 32 return
  123. end
  124. 32 transition(:connected)
  125. 32 @interests = :w
  126. rescue Errno::EALREADY
  127. @interests = :w
  128. end
  129. 1 private :try_connect
  130. 1 def read(size, buffer)
  131. 84 ret = @io.read_nonblock(size, buffer, exception: false)
  132. 84 if ret == :wait_readable
  133. 37 buffer.clear
  134. 37 return 0
  135. end
  136. 47 return if ret.nil?
  137. 47 log { "READ: #{buffer.bytesize} bytes..." }
  138. 47 buffer.bytesize
  139. end
  140. 1 def write(buffer)
  141. 42 siz = @io.write_nonblock(buffer, exception: false)
  142. 42 return 0 if siz == :wait_writable
  143. 42 return if siz.nil?
  144. 42 log { "WRITE: #{siz} bytes..." }
  145. 42 buffer.shift!(siz)
  146. 42 siz
  147. end
  148. 1 def close
  149. 35 return if @keep_open
  150. # mark tcp as closed, so that it can be disconnected.
  151. # this bypasses the state machine API as not not allow the transition
  152. # from idle to closed in normal circumstances.
  153. 35 @state = :closed if @state == :idle
  154. 35 return if closed?
  155. begin
  156. 32 @io.close
  157. rescue IOError => e
  158. log { "error closing socket" }
  159. log { e.full_message(highlight: false) }
  160. ensure
  161. # @fiber-switch-guard
  162. # ensure that all :closed IOs don't leave dangling sockets
  163. # behind. This may happen in a fiber scheduler scenario where
  164. # connection is reused across fibers.
  165. 32 transition(:closed) if @io.closed?
  166. end
  167. end
  168. # signals that the connection that contains this IO can be checked back into the pool.
  169. # that includes sockets opened outside of the scope of the session, or closed IOs.
  170. 1 def can_disconnect?
  171. 67 @keep_open || @state == :closed
  172. end
  173. 1 def connected?
  174. 56 @state == :connected
  175. end
  176. 1 def closed?
  177. 99 @state == :idle || @state == :closed
  178. end
  179. # simplecov:disable
  180. 1 def inspect
  181. "#<#{self.class}:#{object_id} " \
  182. "#{@ip}:#{@port} " \
  183. "@state=#{@state} " \
  184. "@hostname=#{@hostname} " \
  185. "@addresses=#{@addresses} " \
  186. "@state=#{@state}>"
  187. end
  188. # simplecov:enable
  189. 1 private
  190. 1 def build_socket
  191. 32 @ip = @addresses[@ip_index]
  192. 32 Socket.new(@ip.family, :STREAM, 0)
  193. end
  194. 1 def transition(nextstate)
  195. 84 case nextstate
  196. # when :idle
  197. when :connected
  198. 28 return unless @state == :idle
  199. when :closed
  200. 28 return unless @state == :connected
  201. end
  202. 84 do_transition(nextstate)
  203. end
  204. 1 def do_transition(nextstate)
  205. 100 log(level: 1) { log_transition_state(nextstate) }
  206. 100 @state = nextstate
  207. end
  208. 1 def log_transition_state(nextstate)
  209. label = host
  210. label = "#{label}(##{@io.fileno})" if nextstate == :connected
  211. "#{label} #{@state} -> #{nextstate}"
  212. end
  213. end
  214. end

lib/httpx/io/udp.rb

85.71% lines covered

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

lib/httpx/io/unix.rb

28.95% lines covered

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

lib/httpx/loggable.rb

55.17% lines covered

29 relevant lines. 16 lines covered and 13 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "fiber" if RUBY_VERSION < "3.0.0"
  3. 1 module HTTPX
  4. 1 module Loggable
  5. 1 COLORS = {
  6. black: 30,
  7. red: 31,
  8. green: 32,
  9. yellow: 33,
  10. blue: 34,
  11. magenta: 35,
  12. cyan: 36,
  13. white: 37,
  14. }.freeze
  15. 1 USE_DEBUG_LOG = ENV.key?("HTTPX_DEBUG")
  16. 1 def self.log_identifiers
  17. "pid=#{Process.pid} " \
  18. "tid=#{Thread.current.object_id} " \
  19. "fid=#{Fiber.current.object_id}"
  20. end
  21. 1 def log(
  22. level: @options.debug_level,
  23. color: nil,
  24. debug_level: @options.debug_level,
  25. debug: @options.debug,
  26. &msg
  27. )
  28. 3083 return unless debug_level >= level
  29. 1358 debug_stream = debug || ($stderr if USE_DEBUG_LOG)
  30. 1358 return unless debug_stream
  31. klass = self.class
  32. until (class_name = klass.name)
  33. klass = klass.superclass
  34. end
  35. message = +"(time=#{Time.now.utc} " \
  36. "#{Loggable.log_identifiers} " \
  37. "self=#{class_name}##{object_id}) "
  38. message << msg.call << "\n"
  39. message = "\e[#{COLORS[color]}m#{message}\e[0m" if color && debug_stream.respond_to?(:isatty) && debug_stream.isatty
  40. debug_stream << message
  41. end
  42. 1 def log_exception(ex, level: @options.debug_level, color: nil, debug_level: @options.debug_level, debug: @options.debug)
  43. 7 log(level: level, color: color, debug_level: debug_level, debug: debug) { ex.full_message }
  44. end
  45. 1 private
  46. 1 def log_redact_headers(text)
  47. log_redact(text, @options.debug_redact == :headers)
  48. end
  49. 1 def log_redact_body(text)
  50. log_redact(text, @options.debug_redact == :body)
  51. end
  52. 1 def log_redact(text, should_redact = nil)
  53. should_redact ||= @options.debug_redact == true
  54. return text.to_s unless should_redact
  55. "[REDACTED]"
  56. end
  57. end
  58. end

lib/httpx/options.rb

82.08% lines covered

240 relevant lines. 197 lines covered and 43 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. # Contains a set of options which are passed and shared across from session to its requests or
  4. # responses.
  5. 1 class Options
  6. 1 BUFFER_SIZE = 1 << 14
  7. 1 WINDOW_SIZE = 1 << 14 # 16K
  8. 1 MAX_BODY_THRESHOLD_SIZE = (1 << 10) * 112 # 112K
  9. 1 KEEP_ALIVE_TIMEOUT = 20
  10. 1 PING_TIMEOUT = 2
  11. 1 SETTINGS_TIMEOUT = 10
  12. 1 CLOSE_HANDSHAKE_TIMEOUT = 10
  13. 1 CONNECT_TIMEOUT = READ_TIMEOUT = WRITE_TIMEOUT = 60
  14. 1 REQUEST_TIMEOUT = OPERATION_TIMEOUT = TOTAL_REQUEST_TIMEOUT = nil
  15. 1 RESOLVER_TYPES = %i[memory file].freeze
  16. # default value used for "user-agent" header, when not overridden.
  17. 1 USER_AGENT = "httpx.rb/#{VERSION}".freeze # rubocop:disable Style/RedundantFreeze
  18. 1 @options_names = []
  19. 1 class << self
  20. 1 attr_reader :options_names
  21. 1 def inherited(klass)
  22. 1 super
  23. 1 klass.instance_variable_set(:@options_names, @options_names.dup)
  24. end
  25. 1 def new(options = {})
  26. # let enhanced options go through
  27. 84 return options if self == Options && options.class < self
  28. 58 return options if options.is_a?(self)
  29. 18 super
  30. end
  31. 1 def freeze
  32. 106 @options_names.freeze
  33. 106 super
  34. end
  35. 1 def method_added(meth)
  36. 237 super
  37. 237 return unless meth =~ /^option_(.+)$/
  38. 110 optname = Regexp.last_match(1) #: String
  39. 110 if optname =~ /^(.+[^_])_+with/
  40. # ignore alias method chain generated methods.
  41. # this is the case with RBS runtime tests.
  42. # it relies on the "_with/_without" separator, which is the most used convention,
  43. # however it shouldn't be used in practice in httpx given the plugin architecture
  44. # as the main extension API.
  45. orig_name = Regexp.last_match(1) #: String
  46. return if @options_names.include?(orig_name.to_sym)
  47. end
  48. 110 optname = optname.to_sym
  49. 110 attr_reader(optname) unless method_defined?(optname)
  50. 110 @options_names << optname unless @options_names.include?(optname)
  51. end
  52. end
  53. # creates a new options instance from a given hash, which optionally define the following:
  54. #
  55. # :debug :: an object which log messages are written to (must respond to <tt><<</tt>)
  56. # :debug_level :: the log level of messages (can be 1, 2, or 3).
  57. # :debug_redact :: whether header/body payload should be redacted (defaults to <tt>false</tt>).
  58. # :ssl :: a hash of options which can be set as params of OpenSSL::SSL::SSLContext (see HTTPX::SSL)
  59. # :http2_settings :: a hash of options to be passed to a HTTP2::Connection (ex: <tt>{ max_concurrent_streams: 2 }</tt>)
  60. # :fallback_protocol :: version of HTTP protocol to use by default in the absence of protocol negotiation
  61. # like ALPN (defaults to <tt>"http/1.1"</tt>)
  62. # :supported_compression_formats :: list of compressions supported by the transcoder layer (defaults to <tt>%w[gzip deflate]</tt>).
  63. # :decompress_response_body :: whether to auto-decompress response body (defaults to <tt>true</tt>).
  64. # :compress_request_body :: whether to auto-decompress response body (defaults to <tt>true</tt>)
  65. # :timeout :: hash of timeout configurations (supports <tt>:connect_timeout</tt>, <tt>:settings_timeout</tt>,
  66. # <tt>:operation_timeout</tt>, <tt>:keep_alive_timeout</tt>, <tt>:read_timeout</tt>, <tt>:write_timeout</tt>,
  67. # <tt>:request_timeout</tt>, <tt>:total_request_timeout</tt> and <tt>:ping_timeout</tt>,
  68. # :headers :: hash of HTTP headers (ex: <tt>{ "x-custom-foo" => "bar" }</tt>)
  69. # :max_response_body_size :: maximum size (in bytes) that the response body can consume (no threshold by default), after which an
  70. # error is raised.
  71. # :max_response_headers :: maximum number of header fields that a response can receive, after which an error is raised.
  72. # :max_response_header_value_size :: maximum size (in bytes) a header value can have (no threshold by default).
  73. # for cases where the value is broken into multiple header fields (such as "cookie" or "set-cookie"),
  74. # this is the total aggregated size.
  75. # :window_size :: number of bytes to read from a socket
  76. # :buffer_size :: internal read and write buffer size in bytes
  77. # :body_threshold_size :: maximum size in bytes of response payload that is buffered in memory.
  78. # :request_class :: class used to instantiate a request
  79. # :response_class :: class used to instantiate a response
  80. # :headers_class :: class used to instantiate headers
  81. # :request_body_class :: class used to instantiate a request body
  82. # :response_body_class :: class used to instantiate a response body
  83. # :connection_class :: class used to instantiate connections
  84. # :http1_class :: class used to manage HTTP1 sessions
  85. # :http2_class :: class used to imanage HTTP2 sessions
  86. # :resolver_native_class :: class used to resolve names using pure ruby DNS implementation
  87. # :resolver_system_class :: class used to resolve names using system-based (getaddrinfo) name resolution
  88. # :resolver_https_class :: class used to resolve names using DoH
  89. # :pool_class :: class used to instantiate the session connection pool
  90. # :options_class :: class used to instantiate options
  91. # :transport :: type of transport to use (set to "unix" for UNIX sockets)
  92. # :addresses :: bucket of peer addresses (can be a list of IP addresses, a hash of domain to list of adddresses;
  93. # paths should be used for UNIX sockets instead)
  94. # :io :: open socket, or domain/ip-to-socket hash, which requests should be sent to
  95. # :persistent :: whether to persist connections in between requests (defaults to <tt>true</tt>)
  96. # :resolver_class :: which resolver to use (defaults to <tt>:native</tt>, can also be <tt>:system<tt> for
  97. # using getaddrinfo or <tt>:https</tt> for DoH resolver, or a custom class inheriting
  98. # from HTTPX::Resolver::Resolver)
  99. # :resolver_cache :: strategy to cache DNS results, ignored by the <tt>:system</tt> resolver, can be set to <tt>:memory<tt>
  100. # or an instance of a custom class inheriting from HTTPX::Resolver::Cache::Base
  101. # :resolver_options :: hash of options passed to the resolver. Accepted keys depend on the resolver type.
  102. # :pool_options :: hash of options passed to the connection pool (See Pool#initialize).
  103. # :ip_families :: which socket families are supported (system-dependent)
  104. # :origin :: HTTP origin to set on requests with relative path (ex: "https://api.serv.com")
  105. # :base_path :: path to prefix given relative paths with (ex: "/v2")
  106. # :max_concurrent_requests :: max number of requests which can be set concurrently
  107. # :max_requests :: max number of requests which can be made on socket before it reconnects.
  108. # :close_on_fork :: whether the session automatically closes when the process is fork (defaults to <tt>false</tt>).
  109. # it only works if the session is persistent (and ruby 3.1 or higher is used).
  110. #
  111. # This list of options are enhanced with each loaded plugin, see the plugin docs for details.
  112. 1 def initialize(options = EMPTY_HASH)
  113. 18 options_names = self.class.options_names
  114. defaults =
  115. 18 case options
  116. when Options
  117. 16 unknown_options = options.class.options_names - options_names
  118. 16 raise Error, "unknown option: #{unknown_options.first}" unless unknown_options.empty?
  119. 16 DEFAULT_OPTIONS.merge(options)
  120. else
  121. 2 options.each_key do |k|
  122. 37 raise Error, "unknown option: #{k}" unless options_names.include?(k)
  123. end
  124. 2 options.empty? ? DEFAULT_OPTIONS : DEFAULT_OPTIONS.merge(options)
  125. end
  126. 18 options_names.each do |k|
  127. 869 v = defaults[k]
  128. 869 if v.nil?
  129. 236 instance_variable_set(:"@#{k}", v)
  130. 236 next
  131. end
  132. 633 value = __send__(:"option_#{k}", v)
  133. 633 instance_variable_set(:"@#{k}", value)
  134. end
  135. 18 do_initialize
  136. 18 freeze
  137. end
  138. # returns the class with which to instantiate the DNS resolver.
  139. 1 def resolver_class
  140. 50 case @resolver_class
  141. when Symbol
  142. 50 public_send(:"resolver_#{@resolver_class}_class")
  143. else
  144. @resolver_class
  145. end
  146. end
  147. 1 def resolver_cache
  148. 51 cache_type = @resolver_cache
  149. 51 case cache_type
  150. when :memory
  151. 51 Resolver::Cache::Memory.cache(cache_type)
  152. when :file
  153. Resolver::Cache::File.cache(cache_type)
  154. else
  155. unless cache_type.respond_to?(:resolve) &&
  156. cache_type.respond_to?(:get) &&
  157. cache_type.respond_to?(:set) &&
  158. cache_type.respond_to?(:evict)
  159. raise TypeError, ":resolver_cache must be a compatible resolver cache and implement #get, #set and #evict"
  160. end
  161. cache_type #: Object & Resolver::_Cache
  162. end
  163. end
  164. 1 def freeze
  165. 104 self.class.options_names.each do |ivar|
  166. # avoid freezing debug option, as when it's set, it's usually an
  167. # object which cannot be frozen, like stderr or stdout. It's a
  168. # documented exception then, and still does not defeat the purpose
  169. # here, which is to make option objects shareable across ractors,
  170. # and in most cases debug should be nil, or one of the objects
  171. # which will eventually be shareable, like STDOUT or STDERR.
  172. 4815 next if ivar == :debug
  173. 4711 instance_variable_get(:"@#{ivar}").freeze
  174. end
  175. 104 super
  176. end
  177. 1 REQUEST_BODY_IVARS = %i[@headers].freeze
  178. # checks whether +other+ matches the same connection-level options
  179. 1 def connection_options_match?(other, ignore_ivars = nil)
  180. 9 return true if self == other
  181. # headers and other request options do not play a role, as they are
  182. # relevant only for the request.
  183. 1 ivars = instance_variables
  184. 51 ivars.reject! { |iv| REQUEST_BODY_IVARS.include?(iv) }
  185. 1 ivars.reject! { |iv| ignore_ivars.include?(iv) } if ignore_ivars
  186. 1 other_ivars = other.instance_variables
  187. 51 other_ivars.reject! { |iv| REQUEST_BODY_IVARS.include?(iv) }
  188. 1 other_ivars.reject! { |iv| ignore_ivars.include?(iv) } if ignore_ivars
  189. 1 return false if ivars.size != other_ivars.size
  190. 1 return false if ivars.sort != other_ivars.sort
  191. 1 ivars.all? do |ivar|
  192. 49 instance_variable_get(ivar) == other.instance_variable_get(ivar)
  193. end
  194. end
  195. 1 RESOLVER_IVARS = %i[
  196. @resolver_class @resolver_cache @resolver_options
  197. @resolver_native_class @resolver_system_class @resolver_https_class
  198. ].freeze
  199. # checks whether +other+ matches the same resolver-level options
  200. 1 def resolver_options_match?(other)
  201. self == other ||
  202. RESOLVER_IVARS.all? do |ivar|
  203. instance_variable_get(ivar) == other.instance_variable_get(ivar)
  204. end
  205. end
  206. # returns a HTTPX::Options instance resulting of the merging of +other+ with self.
  207. # it may return self if +other+ is self or equal to self.
  208. 1 def merge(other)
  209. 383 if (is_options = other.is_a?(Options))
  210. 159 return self if eql?(other)
  211. 9 opts_names = other.class.options_names
  212. 450 return self if opts_names.all? { |opt| public_send(opt) == other.public_send(opt) }
  213. other_opts = opts_names
  214. else
  215. 224 other_opts = other # : Hash[Symbol, untyped]
  216. 224 other_opts = Hash[other] unless other.is_a?(Hash)
  217. 224 return self if other_opts.empty?
  218. 138 return self if other_opts.all? { |opt, v| !respond_to?(opt) || public_send(opt) == v }
  219. end
  220. 56 opts = dup
  221. 56 other_opts.each do |opt, v|
  222. 84 next unless respond_to?(opt)
  223. 84 v = other.public_send(opt) if is_options
  224. 84 ivar = :"@#{opt}"
  225. 84 unless v
  226. 3 opts.instance_variable_set(ivar, v)
  227. 3 next
  228. end
  229. 81 v = opts.__send__(:"option_#{opt}", v)
  230. 81 orig_v = public_send(opt)
  231. 81 v = orig_v.merge(v) if orig_v.respond_to?(:merge) && v.respond_to?(:merge)
  232. 81 opts.instance_variable_set(ivar, v)
  233. end
  234. 56 opts
  235. end
  236. 1 def to_hash
  237. 17 instance_variables.each_with_object({}) do |ivar, hs|
  238. 759 val = instance_variable_get(ivar)
  239. 759 next if val.nil?
  240. 598 hs[ivar[1..-1].to_sym] = val
  241. end
  242. end
  243. 1 def extend_with_plugin_classes(pl)
  244. # extend request class
  245. 85 if defined?(pl::RequestMethods) || defined?(pl::RequestClassMethods)
  246. 30 @request_class = @request_class.dup
  247. 30 SET_TEMPORARY_NAME[@request_class, pl]
  248. 30 @request_class.__send__(:include, pl::RequestMethods) if defined?(pl::RequestMethods)
  249. 30 @request_class.extend(pl::RequestClassMethods) if defined?(pl::RequestClassMethods)
  250. end
  251. # extend response class
  252. 85 if defined?(pl::ResponseMethods) || defined?(pl::ResponseClassMethods)
  253. 60 @response_class = @response_class.dup
  254. 60 SET_TEMPORARY_NAME[@response_class, pl]
  255. 60 @response_class.__send__(:include, pl::ResponseMethods) if defined?(pl::ResponseMethods)
  256. 60 @response_class.extend(pl::ResponseClassMethods) if defined?(pl::ResponseClassMethods)
  257. end
  258. # extend headers class
  259. 85 if defined?(pl::HeadersMethods) || defined?(pl::HeadersClassMethods)
  260. @headers_class = @headers_class.dup
  261. SET_TEMPORARY_NAME[@headers_class, pl]
  262. @headers_class.__send__(:include, pl::HeadersMethods) if defined?(pl::HeadersMethods)
  263. @headers_class.extend(pl::HeadersClassMethods) if defined?(pl::HeadersClassMethods)
  264. end
  265. # extend request body class
  266. 85 if defined?(pl::RequestBodyMethods) || defined?(pl::RequestBodyClassMethods)
  267. @request_body_class = @request_body_class.dup
  268. SET_TEMPORARY_NAME[@request_body_class, pl]
  269. @request_body_class.__send__(:include, pl::RequestBodyMethods) if defined?(pl::RequestBodyMethods)
  270. @request_body_class.extend(pl::RequestBodyClassMethods) if defined?(pl::RequestBodyClassMethods)
  271. end
  272. # extend response body class
  273. 85 if defined?(pl::ResponseBodyMethods) || defined?(pl::ResponseBodyClassMethods)
  274. 40 @response_body_class = @response_body_class.dup
  275. 40 SET_TEMPORARY_NAME[@response_body_class, pl]
  276. 40 @response_body_class.__send__(:include, pl::ResponseBodyMethods) if defined?(pl::ResponseBodyMethods)
  277. 40 @response_body_class.extend(pl::ResponseBodyClassMethods) if defined?(pl::ResponseBodyClassMethods)
  278. end
  279. # extend connection pool class
  280. 85 if defined?(pl::PoolMethods)
  281. @pool_class = @pool_class.dup
  282. SET_TEMPORARY_NAME[@pool_class, pl]
  283. @pool_class.__send__(:include, pl::PoolMethods)
  284. end
  285. # extend connection class
  286. 85 if defined?(pl::ConnectionMethods)
  287. 52 @connection_class = @connection_class.dup
  288. 52 SET_TEMPORARY_NAME[@connection_class, pl]
  289. 52 @connection_class.__send__(:include, pl::ConnectionMethods)
  290. end
  291. # extend http1 class
  292. 85 if defined?(pl::HTTP1Methods)
  293. 12 @http1_class = @http1_class.dup
  294. 12 SET_TEMPORARY_NAME[@http1_class, pl]
  295. 12 @http1_class.__send__(:include, pl::HTTP1Methods)
  296. end
  297. # extend http2 class
  298. 85 if defined?(pl::HTTP2Methods)
  299. 12 @http2_class = @http2_class.dup
  300. 12 SET_TEMPORARY_NAME[@http2_class, pl]
  301. 12 @http2_class.__send__(:include, pl::HTTP2Methods)
  302. end
  303. # extend native resolver class
  304. 85 if defined?(pl::ResolverNativeMethods)
  305. 12 @resolver_native_class = @resolver_native_class.dup
  306. 12 SET_TEMPORARY_NAME[@resolver_native_class, pl]
  307. 12 @resolver_native_class.__send__(:include, pl::ResolverNativeMethods)
  308. end
  309. # extend system resolver class
  310. 85 if defined?(pl::ResolverSystemMethods)
  311. 12 @resolver_system_class = @resolver_system_class.dup
  312. 12 SET_TEMPORARY_NAME[@resolver_system_class, pl]
  313. 12 @resolver_system_class.__send__(:include, pl::ResolverSystemMethods)
  314. end
  315. # extend https resolver class
  316. 85 if defined?(pl::ResolverHTTPSMethods)
  317. @resolver_https_class = @resolver_https_class.dup
  318. SET_TEMPORARY_NAME[@resolver_https_class, pl]
  319. @resolver_https_class.__send__(:include, pl::ResolverHTTPSMethods)
  320. end
  321. 85 return unless defined?(pl::OptionsMethods)
  322. # extend option class
  323. # works around lack of initialize_dup callback
  324. 16 @options_class = @options_class.dup
  325. # (self.class.options_names)
  326. 16 @options_class.__send__(:include, pl::OptionsMethods)
  327. end
  328. 1 private
  329. # number options
  330. 1 %i[
  331. max_concurrent_requests max_requests window_size buffer_size
  332. max_response_body_size max_response_headers max_response_header_value_size
  333. body_threshold_size debug_level
  334. ].each do |option|
  335. 9 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  336. # converts +v+ into an Integer before setting the +#{option}+ option.
  337. private def option_#{option}(value) # private def option_max_requests(v)
  338. value = Integer(value) unless value.respond_to?(:infinite?) && value.infinite?
  339. raise TypeError, ":#{option} must be positive" unless value.positive? # raise TypeError, ":max_requests must be positive" unless value.positive?
  340. value
  341. end
  342. OUT
  343. end
  344. # hashable options
  345. 1 %i[ssl http2_settings resolver_options pool_options].each do |option|
  346. 4 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  347. # converts +v+ into an Hash before setting the +#{option}+ option.
  348. private def option_#{option}(value) # def option_ssl(v)
  349. Hash[value]
  350. end
  351. OUT
  352. end
  353. 1 %i[
  354. request_class response_class headers_class request_body_class
  355. response_body_class connection_class http1_class http2_class
  356. resolver_native_class resolver_system_class resolver_https_class options_class pool_class
  357. io fallback_protocol debug debug_redact
  358. compress_request_body decompress_response_body
  359. persistent close_on_fork
  360. ].each do |method_name|
  361. 21 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  362. # sets +v+ as the value of the +#{method_name}+ option
  363. private def option_#{method_name}(v); v; end # private def option_smth(v); v; end
  364. OUT
  365. end
  366. 1 def option_origin(value)
  367. URI(value)
  368. end
  369. 1 def option_base_path(value)
  370. String(value)
  371. end
  372. 1 def option_headers(value)
  373. 18 value = value.dup if value.frozen?
  374. 18 headers_class.new(value)
  375. end
  376. 1 def option_timeout(value)
  377. 42 timeout_hash = Hash[value]
  378. 42 default_timeouts = DEFAULT_OPTIONS[:timeout]
  379. # Validate keys and values
  380. 42 timeout_hash.each do |key, val|
  381. 192 raise TypeError, "invalid timeout: :#{key}" unless default_timeouts.key?(key)
  382. 192 next if val.nil?
  383. 139 raise TypeError, ":#{key} must be numeric" unless val.is_a?(Numeric)
  384. end
  385. 42 timeout_hash
  386. end
  387. 1 def option_supported_compression_formats(value)
  388. 18 Array(value).map(&:to_s)
  389. end
  390. 1 def option_transport(value)
  391. transport = value.to_s
  392. raise TypeError, "#{transport} is an unsupported transport type" unless %w[unix].include?(transport)
  393. transport
  394. end
  395. 1 def option_addresses(value)
  396. Array(value).map { |entry| Resolver::Entry.convert(entry) }
  397. end
  398. 1 def option_ip_families(value)
  399. Array(value)
  400. end
  401. 1 def option_resolver_class(resolver_type)
  402. 18 case resolver_type
  403. when Symbol
  404. 18 meth = :"resolver_#{resolver_type}_class"
  405. 18 raise TypeError, ":resolver_class must be a supported type" unless respond_to?(meth)
  406. 18 resolver_type
  407. when Class
  408. raise TypeError, ":resolver_class must be a subclass of `#{Resolver::Resolver}`" unless resolver_type < Resolver::Resolver
  409. resolver_type
  410. else
  411. raise TypeError, ":resolver_class must be a supported type"
  412. end
  413. end
  414. 1 def option_resolver_cache(cache_type)
  415. 18 if cache_type.is_a?(Symbol)
  416. 18 raise TypeError, ":resolver_cache: #{cache_type} is invalid" unless RESOLVER_TYPES.include?(cache_type)
  417. 18 require "httpx/resolver/cache/file" if cache_type == :file
  418. else
  419. unless cache_type.respond_to?(:resolve) &&
  420. cache_type.respond_to?(:get) &&
  421. cache_type.respond_to?(:set) &&
  422. cache_type.respond_to?(:evict)
  423. raise TypeError, ":resolver_cache must be a compatible resolver cache and implement #resolve, #get, #set and #evict"
  424. end
  425. end
  426. 18 cache_type
  427. end
  428. # called after all options are initialized
  429. 1 def do_initialize
  430. 18 hs = @headers
  431. # initialized default request headers
  432. 18 hs["user-agent"] = USER_AGENT unless hs.key?("user-agent")
  433. 18 hs["accept"] = "*/*" unless hs.key?("accept")
  434. 18 if hs.key?("range")
  435. hs.delete("accept-encoding")
  436. else
  437. 18 hs["accept-encoding"] = supported_compression_formats unless hs.key?("accept-encoding")
  438. end
  439. end
  440. 1 def access_option(obj, k, ivar_map)
  441. case obj
  442. when Hash
  443. obj[ivar_map[k]]
  444. else
  445. obj.instance_variable_get(k)
  446. end
  447. end
  448. # rubocop:disable Lint/UselessConstantScoping
  449. # these really need to be defined at the end of the class
  450. 1 SET_TEMPORARY_NAME = ->(klass, pl = nil) do
  451. 246 if klass.respond_to?(:set_temporary_name) # ruby 3.4 only
  452. name = klass.name || "#{klass.superclass.name}(plugin)"
  453. name = "#{name}/#{pl}" if pl
  454. klass.set_temporary_name(name)
  455. end
  456. end
  457. DEFAULT_OPTIONS = {
  458. 1 :max_requests => Float::INFINITY,
  459. :debug => nil,
  460. 1 :debug_level => (ENV["HTTPX_DEBUG"] || 1).to_i,
  461. :debug_redact => ENV.key?("HTTPX_DEBUG_REDACT"),
  462. :ssl => EMPTY_HASH,
  463. :http2_settings => { settings_enable_push: 0 }.freeze,
  464. :fallback_protocol => "http/1.1",
  465. :supported_compression_formats => %w[gzip deflate],
  466. :decompress_response_body => true,
  467. :compress_request_body => true,
  468. :max_response_headers => 1000,
  469. :max_response_header_value_size => nil,
  470. :max_response_body_size => Float::INFINITY,
  471. :timeout => {
  472. connect_timeout: CONNECT_TIMEOUT,
  473. settings_timeout: SETTINGS_TIMEOUT,
  474. close_handshake_timeout: CLOSE_HANDSHAKE_TIMEOUT,
  475. operation_timeout: OPERATION_TIMEOUT,
  476. keep_alive_timeout: KEEP_ALIVE_TIMEOUT,
  477. ping_timeout: PING_TIMEOUT,
  478. read_timeout: READ_TIMEOUT,
  479. write_timeout: WRITE_TIMEOUT,
  480. request_timeout: REQUEST_TIMEOUT,
  481. total_request_timeout: TOTAL_REQUEST_TIMEOUT,
  482. }.freeze,
  483. :headers_class => Class.new(Headers, &SET_TEMPORARY_NAME),
  484. :headers => EMPTY_HASH,
  485. :window_size => WINDOW_SIZE,
  486. :buffer_size => BUFFER_SIZE,
  487. :body_threshold_size => MAX_BODY_THRESHOLD_SIZE,
  488. :request_class => Class.new(Request, &SET_TEMPORARY_NAME),
  489. :response_class => Class.new(Response, &SET_TEMPORARY_NAME),
  490. :request_body_class => Class.new(Request::Body, &SET_TEMPORARY_NAME),
  491. :response_body_class => Class.new(Response::Body, &SET_TEMPORARY_NAME),
  492. :pool_class => Class.new(Pool, &SET_TEMPORARY_NAME),
  493. :connection_class => Class.new(Connection, &SET_TEMPORARY_NAME),
  494. :http1_class => Class.new(Connection::HTTP1, &SET_TEMPORARY_NAME),
  495. :http2_class => Class.new(Connection::HTTP2, &SET_TEMPORARY_NAME),
  496. :resolver_native_class => Class.new(Resolver::Native, &SET_TEMPORARY_NAME),
  497. :resolver_system_class => Class.new(Resolver::System, &SET_TEMPORARY_NAME),
  498. :resolver_https_class => Class.new(Resolver::HTTPS, &SET_TEMPORARY_NAME),
  499. :options_class => Class.new(self, &SET_TEMPORARY_NAME),
  500. :transport => nil,
  501. :addresses => nil,
  502. :persistent => false,
  503. 1 :resolver_class => (ENV["HTTPX_RESOLVER"] || :native).to_sym,
  504. 1 :resolver_cache => (ENV["HTTPX_RESOLVER_CACHE"] || :memory).to_sym,
  505. :resolver_options => { cache: true }.freeze,
  506. :pool_options => EMPTY_HASH,
  507. :ip_families => nil,
  508. :close_on_fork => false,
  509. }.each_value(&:freeze).freeze
  510. # rubocop:enable Lint/UselessConstantScoping
  511. end
  512. end

lib/httpx/parser/http1.rb

84.48% lines covered

116 relevant lines. 98 lines covered and 18 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Parser
  4. 1 class Error < Error; end
  5. 1 class HTTP1
  6. 1 VERSIONS = %w[1.0 1.1].freeze
  7. 1 attr_reader :status_code, :http_version, :headers
  8. 1 def initialize(observer, max_headers, max_header_value_size)
  9. 28 @observer = observer
  10. 28 @state = :idle
  11. 28 @buffer = "".b
  12. 28 @headers = {}
  13. 28 @max_headers = max_headers
  14. 28 @max_header_value_size = max_header_value_size
  15. 28 @content_length = nil
  16. 28 @_has_trailers = @upgrade = false
  17. end
  18. 1 def <<(chunk)
  19. 33 @buffer << chunk
  20. 33 parse
  21. end
  22. 1 def reset!
  23. 56 @state = :idle
  24. 56 @headers = {}
  25. 56 @content_length = nil
  26. 56 @_has_trailers = @upgrade = false
  27. 56 @buffer = @buffer.to_s
  28. end
  29. 1 def upgrade?
  30. 28 @upgrade
  31. end
  32. 1 def upgrade_data
  33. @buffer.to_s
  34. end
  35. 1 private
  36. 1 def parse
  37. 33 loop do
  38. 75 state = @state
  39. 75 case @state
  40. when :idle
  41. 28 parse_headline
  42. when :headers, :trailers
  43. 28 parse_headers
  44. when :data
  45. 19 parse_data
  46. end
  47. 47 return if @buffer.empty? || state == @state
  48. end
  49. end
  50. 1 def parse_headline
  51. #: @type ivar @buffer: String
  52. 28 idx = @buffer.index("\n")
  53. 28 return unless idx
  54. 28 (m = %r{\AHTTP(?:/(\d+\.\d+))?\s+(\d\d\d)(?:\s+(.*))?}in.match(@buffer)) ||
  55. raise(Error, "wrong head line format")
  56. 28 version, code, _ = m.captures
  57. 28 raise(Error, "unsupported HTTP version (HTTP/#{version})") unless version && VERSIONS.include?(version)
  58. 28 @http_version = version.split(".").map(&:to_i)
  59. 28 @status_code = code.to_i
  60. 28 raise(Error, "wrong status code (#{@status_code})") unless (100..599).cover?(@status_code)
  61. 28 @buffer = @buffer.byteslice((idx + 1)..-1)
  62. 28 nextstate(:headers)
  63. end
  64. 1 def parse_headers
  65. 28 headers = @headers
  66. 28 buffer = @buffer
  67. #: @type var buffer: String
  68. 280 while (idx = buffer.index("\n"))
  69. # @type var line: String
  70. 252 line = buffer.byteslice(0..idx)
  71. 252 raise Error, "wrong header format" if line.start_with?("\s", "\t")
  72. 252 line.lstrip!
  73. 252 buffer = @buffer = buffer.byteslice((idx + 1)..-1)
  74. 252 if line.empty?
  75. 28 case @state
  76. when :headers
  77. 28 prepare_data(headers)
  78. 28 @observer.on_headers(headers)
  79. 15 return unless @state == :headers
  80. # state might have been reset
  81. # in the :headers callback
  82. 15 nextstate(:data)
  83. 15 headers.clear
  84. when :trailers
  85. @observer.on_trailers(headers)
  86. headers.clear
  87. nextstate(:complete)
  88. end
  89. 15 return
  90. end
  91. 224 separator_index = line.index(":")
  92. 224 raise Error, "wrong header format" unless separator_index
  93. # @type var key: String
  94. 224 key = line.byteslice(0..(separator_index - 1))
  95. 224 key.rstrip! # was lstripped previously!
  96. # @type var value: String
  97. 224 value = line.byteslice((separator_index + 1)..-1)
  98. 224 value.strip!
  99. 224 raise Error, "wrong header format" if value.nil?
  100. 224 values = (headers[key.downcase] ||= []) << value
  101. 224 raise Error, "maximum header value size exceeded" if @max_header_value_size && (values.sum(&:size) > @max_header_value_size)
  102. 224 raise Error, "maximum number of response headers exceeded" if headers.size > @max_headers
  103. end
  104. end
  105. 1 def parse_data
  106. 19 if @buffer.respond_to?(:each)
  107. # @type ivar @buffer: Transcoder::Chunker::Decoder
  108. @buffer.each do |chunk|
  109. @observer.on_data(chunk)
  110. end
  111. 19 elsif @content_length
  112. # @type ivar @buffer: String
  113. 19 data = @buffer.byteslice(0, @content_length)
  114. # @type var data: String
  115. 19 @buffer = @buffer.byteslice(@content_length..-1) || "".b
  116. 19 @content_length -= data.bytesize
  117. 19 @observer.on_data(data)
  118. 19 data.clear
  119. else
  120. # @type ivar @buffer: String
  121. @observer.on_data(@buffer)
  122. @buffer.clear
  123. end
  124. 19 return unless no_more_data?
  125. 15 @buffer = @buffer.to_s
  126. 15 if @_has_trailers
  127. nextstate(:trailers)
  128. else
  129. 15 nextstate(:complete)
  130. end
  131. end
  132. 1 def prepare_data(headers)
  133. 28 @upgrade = headers.key?("upgrade")
  134. 28 @_has_trailers = headers.key?("trailer")
  135. 28 if (tr_encodings = headers["transfer-encoding"])
  136. tr_encodings.reverse_each do |tr_encoding|
  137. tr_encoding.split(/ *, */).each do |encoding|
  138. case encoding
  139. when "chunked"
  140. @buffer = Transcoder::Chunker::Decoder.new(@buffer.to_s, @_has_trailers)
  141. end
  142. end
  143. end
  144. else
  145. 28 @content_length = headers["content-length"][0].to_i if headers.key?("content-length")
  146. end
  147. end
  148. 1 def no_more_data?
  149. 19 if @content_length
  150. 19 @content_length <= 0
  151. elsif @buffer.respond_to?(:finished?)
  152. # @type ivar @buffer: Transcoder::Chunker::Decoder
  153. @buffer.finished?
  154. else
  155. false
  156. end
  157. end
  158. 1 def nextstate(state)
  159. 58 @state = state
  160. 58 case state
  161. when :headers
  162. 28 @observer.on_start
  163. when :complete
  164. 15 @observer.on_complete
  165. reset!
  166. nextstate(:idle) unless @buffer.empty?
  167. end
  168. end
  169. end
  170. end
  171. end

lib/httpx/plugins/callbacks.rb

31.94% lines covered

72 relevant lines. 23 lines covered and 49 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. #
  5. # This plugin adds suppoort for callbacks around the request/response lifecycle.
  6. #
  7. # https://gitlab.com/os85/httpx/-/wikis/Events
  8. #
  9. 1 module Callbacks
  10. 1 CALLBACKS = %i[
  11. connection_opened connection_closed
  12. request_error
  13. request_started request_body_chunk request_completed
  14. response_started response_body_chunk response_completed
  15. ].freeze
  16. # connection closed user-space errors happen after errors can be surfaced to requests,
  17. # so they need to pierce through the scheduler, which is only possible by simulating an
  18. # interrupt.
  19. 1 class CallbackError < Exception; end # rubocop:disable Lint/InheritException
  20. 1 module InstanceMethods
  21. 1 include HTTPX::Callbacks
  22. 1 CALLBACKS.each do |meth|
  23. 9 class_eval(<<-MOD, __FILE__, __LINE__ + 1)
  24. def on_#{meth}(&blk) # def on_connection_opened(&blk)
  25. on(:#{meth}, &blk) # on(:connection_opened, &blk)
  26. self # self
  27. end # end
  28. MOD
  29. end
  30. 1 def plugin(*args, &blk)
  31. super(*args).tap do |sess|
  32. CALLBACKS.each do |cb|
  33. next unless callbacks_for?(cb)
  34. sess.callbacks(cb).concat(callbacks(cb))
  35. end
  36. sess.wrap(&blk) if blk
  37. end
  38. end
  39. 1 private
  40. 1 def branch(options, &blk)
  41. super(options).tap do |sess|
  42. CALLBACKS.each do |cb|
  43. next unless callbacks_for?(cb)
  44. sess.callbacks(cb).concat(callbacks(cb))
  45. end
  46. sess.wrap(&blk) if blk
  47. end
  48. end
  49. 1 def do_init_connection(connection, selector)
  50. super
  51. connection.on(:open) do
  52. next unless connection.current_session == self
  53. emit_or_callback_error(:connection_opened, connection.origin, connection.io.socket)
  54. end
  55. connection.on(:callback_connection_closed) do
  56. next unless connection.current_session == self
  57. emit_or_callback_error(:connection_closed, connection.origin) if connection.used?
  58. end
  59. connection
  60. end
  61. 1 def set_request_callbacks(request)
  62. super
  63. request.on(:headers) do
  64. emit_or_callback_error(:request_started, request)
  65. end
  66. request.on(:body_chunk) do |chunk|
  67. emit_or_callback_error(:request_body_chunk, request, chunk)
  68. end
  69. request.on(:done) do
  70. emit_or_callback_error(:request_completed, request)
  71. end
  72. request.on(:response_started) do |res|
  73. if res.is_a?(Response)
  74. emit_or_callback_error(:response_started, request, res)
  75. res.on(:chunk_received) do |chunk|
  76. emit_or_callback_error(:response_body_chunk, request, res, chunk)
  77. end
  78. else
  79. emit_or_callback_error(:request_error, request, res.error)
  80. end
  81. end
  82. request.on(:response) do |res|
  83. emit_or_callback_error(:response_completed, request, res) if res.is_a?(Response)
  84. end
  85. end
  86. 1 def emit_or_callback_error(*args)
  87. emit(*args)
  88. rescue StandardError => e
  89. ex = CallbackError.new(e.message)
  90. ex.set_backtrace(e.backtrace)
  91. raise ex
  92. end
  93. 1 def receive_requests(*)
  94. super
  95. rescue CallbackError => e
  96. raise e.cause
  97. end
  98. 1 def close(*)
  99. super
  100. rescue CallbackError => e
  101. raise e.cause
  102. end
  103. end
  104. 1 module RequestMethods
  105. 1 def drain_body
  106. super.tap do |chunk|
  107. emit(:body_chunk, chunk) if chunk
  108. rescue StandardError => e
  109. # in case an error occurs in callback code
  110. @drain_error = e
  111. nil
  112. end
  113. end
  114. end
  115. 1 module ConnectionMethods
  116. 1 private
  117. 1 def disconnect
  118. return if @exhausted
  119. return unless @current_session && @current_selector
  120. emit(:callback_connection_closed)
  121. super
  122. end
  123. end
  124. end
  125. 1 register_plugin :callbacks, Callbacks
  126. end
  127. end

lib/httpx/plugins/fiber_concurrency.rb

63.57% lines covered

129 relevant lines. 82 lines covered and 47 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. # This plugin makes a session reuse the same selector across all fibers in a given thread.
  5. #
  6. # This enables integration with fiber scheduler implementations such as [async](https://github.com/async).
  7. #
  8. # # https://gitlab.com/os85/httpx/wikis/Fiber-Concurrency
  9. #
  10. 1 module FiberConcurrency
  11. 1 def self.subplugins
  12. {
  13. 30 h2c: FiberConcurrencyH2C,
  14. stream: FiberConcurrencyStream,
  15. }
  16. end
  17. 1 module InstanceMethods
  18. 1 private
  19. 1 def send_request(request, *)
  20. 15 request.set_context!
  21. 15 super
  22. end
  23. 1 def get_current_selector
  24. 15 super(&nil) || begin
  25. 12 return unless block_given?
  26. 12 default = yield
  27. 12 set_current_selector(default)
  28. 12 default
  29. end
  30. end
  31. end
  32. 1 module RequestMethods
  33. # the execution context (fiber) this request was sent on.
  34. 1 attr_reader :context
  35. 1 def initialize(*)
  36. 15 super
  37. 15 @context = nil
  38. end
  39. # sets the execution context for this request. the default is the current fiber.
  40. 1 def set_context!
  41. 15 @context ||= Fiber.current # rubocop:disable Naming/MemoizedInstanceVariableName
  42. end
  43. # checks whether the current execution context is the one where the request was created.
  44. 1 def current_context?
  45. 106 @context == Fiber.current
  46. end
  47. 1 def complete!(response = @response)
  48. 9 @context = nil
  49. 9 super
  50. end
  51. end
  52. 1 module ConnectionMethods
  53. 1 def current_context?
  54. 41 @pending.any?(&:current_context?) || (
  55. 6 @sibling && @sibling.pending.any?(&:current_context?)
  56. )
  57. end
  58. 1 def initial_call
  59. 13 return unless current_context?
  60. 7 super
  61. end
  62. 1 def interests
  63. 64 return if connecting? && @pending.none?(&:current_context?)
  64. 64 super
  65. end
  66. 1 def on_io_error(e)
  67. return super unless e.is_a?(IOError) && e.message.include?("stream closed in another thread")
  68. # @fiber-switch-guard
  69. # sockets closed during fiber scheduler switches are raised in separate fibers than the fiber the
  70. # socket may be used in. this check verifies that this is actually about this socket.
  71. return unless to_io.closed?
  72. if @state == :closing
  73. # @fiber-switch-guard
  74. # if the connection is reused across fibers, the socket may have been closed in the other fiber
  75. # and switched here during the process, so continue what it was doing and transition to closed
  76. # via #call.
  77. call
  78. elsif !backlog?
  79. super
  80. end
  81. end
  82. 1 def on_connect_error(e)
  83. return super unless e.is_a?(IOError) && e.message.include?("stream closed in another thread")
  84. # @fiber-switch-guard
  85. # sockets closed during fiber scheduler switches are raised in separate fibers than the fiber the
  86. # socket may be used in. this check verifies that this is actually about this socket.
  87. return unless to_io.closed? && !backlog?
  88. super
  89. end
  90. 1 private
  91. # checks whether the connection has any pending request (which the connection itself may
  92. # have stored, or it may be somewhere in the parser).
  93. 1 def backlog?
  94. @pending.any? ||
  95. (@parser && (@parser.pending.any? || @parser.requests.any?))
  96. end
  97. end
  98. 1 module HTTP1Methods
  99. 1 def interests
  100. 63 request = @request || @requests.first
  101. 63 return unless request
  102. 63 return unless request.current_context? || @requests.any?(&:current_context?) || @pending.any?(&:current_context?)
  103. 63 super
  104. end
  105. end
  106. 1 module HTTP2Methods
  107. 1 def initialize(*)
  108. super
  109. @contexts = Hash.new { |hs, k| hs[k] = Set.new }
  110. end
  111. 1 def interests
  112. if @connection.state == :connected && @handshake_completed && !@contexts.key?(Fiber.current)
  113. return :w unless @pings.empty?
  114. return
  115. end
  116. super
  117. end
  118. 1 def send(request, *)
  119. add_to_context(request)
  120. super
  121. end
  122. 1 private
  123. 1 def on_close(_, error, _)
  124. if error == :http_1_1_required
  125. # remove all pending requests context
  126. @pending.each do |req|
  127. clear_from_context(req)
  128. end
  129. end
  130. super
  131. end
  132. 1 def on_stream_close(_, request, error)
  133. clear_from_context(request) if error != :stream_closed && @streams.key?(request)
  134. super
  135. end
  136. 1 def teardown(request = nil)
  137. super
  138. if request
  139. clear_from_context(request)
  140. else
  141. @contexts.clear
  142. end
  143. end
  144. 1 def add_to_context(request)
  145. @contexts[request.context] << request
  146. end
  147. 1 def clear_from_context(request)
  148. requests = @contexts[request.context]
  149. requests.delete(request)
  150. @contexts.delete(request.context) if requests.empty?
  151. end
  152. end
  153. 1 module ResolverNativeMethods
  154. 1 def initial_call
  155. 2 return unless @queries.values.any?(&:current_context?) || @connections.any?(&:current_context?)
  156. 2 super
  157. end
  158. 1 def calculate_interests
  159. 28 return if @queries.empty?
  160. 26 return unless @queries.values.any?(&:current_context?) || @connections.any?(&:current_context?)
  161. 26 super
  162. end
  163. 1 def disconnect
  164. 2 return unless @connections.all?(&:current_context?)
  165. 2 super
  166. end
  167. 1 def on_io_error(e)
  168. # TODO: return super if this is not stream clsed in another thread
  169. log { "IO Erroring: #{e.message}, current:#{@name}, queries:#{@queries.size}" }
  170. return unless @name
  171. super
  172. end
  173. end
  174. 1 module ResolverSystemMethods
  175. 1 def initial_call
  176. return unless current_context?
  177. super
  178. end
  179. 1 def interests
  180. return unless current_context?
  181. super
  182. end
  183. 1 private
  184. 1 def current_context?
  185. @queries.any? { |_, conn| conn.current_context? }
  186. end
  187. end
  188. 1 module FiberConcurrencyH2C
  189. 1 module HTTP2Methods
  190. 1 def upgrade(request, *)
  191. @contexts[request.context] << request
  192. super
  193. end
  194. end
  195. end
  196. 1 module FiberConcurrencyStream
  197. 1 module StreamResponseMethods
  198. 1 def close
  199. unless @request.current_context?
  200. @request.close
  201. return
  202. end
  203. super
  204. end
  205. end
  206. end
  207. end
  208. 1 register_plugin :fiber_concurrency, FiberConcurrency
  209. end
  210. end

lib/httpx/plugins/follow_redirects.rb

68.1% lines covered

116 relevant lines. 79 lines covered and 37 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 class InsecureRedirectError < Error
  4. end
  5. 1 module Plugins
  6. #
  7. # This plugin adds support for automatically following redirect (status 30X) responses.
  8. #
  9. # It has a default upper bound of followed redirects (see *MAX_REDIRECTS* and the *max_redirects* option),
  10. # after which it will return the last redirect response. It will **not** raise an exception.
  11. #
  12. # It doesn't follow insecure redirects (https -> http) by default (see *follow_insecure_redirects*).
  13. #
  14. # It doesn't propagate authorization related headers to requests redirecting to different origins
  15. # (see *allow_auth_to_other_origins*) to override.
  16. #
  17. # It allows customization of when to redirect via the *redirect_on* callback option).
  18. #
  19. # https://gitlab.com/os85/httpx/wikis/Follow-Redirects
  20. #
  21. 1 module FollowRedirects
  22. 1 MAX_REDIRECTS = 3
  23. 1 REDIRECT_STATUS = (300..399).freeze
  24. 1 REQUEST_BODY_HEADERS = %w[transfer-encoding content-encoding content-type content-length content-language content-md5 trailer].freeze
  25. 1 using URIExtensions
  26. # adds support for the following options:
  27. #
  28. # :max_redirects :: max number of times a request will be redirected (defaults to <tt>3</tt>).
  29. # :follow_insecure_redirects :: whether redirects to an "http://" URI, when coming from an "https//", are allowed
  30. # (defaults to <tt>false</tt>).
  31. # :allow_auth_to_other_origins :: whether auth-related headers, such as "Authorization", are propagated on redirection
  32. # (defaults to <tt>false</tt>).
  33. # :redirect_on :: optional callback which receives the redirect location and can halt the redirect chain if it returns <tt>false</tt>.
  34. 1 module OptionsMethods
  35. 1 private
  36. 1 def option_max_redirects(value)
  37. 1 num = Integer(value)
  38. 1 raise TypeError, ":max_redirects must be positive" if num.negative?
  39. 1 num
  40. end
  41. 1 def option_follow_insecure_redirects(value)
  42. value
  43. end
  44. 1 def option_allow_auth_to_other_origins(value)
  45. value
  46. end
  47. 1 def option_redirect_on(value)
  48. raise TypeError, ":redirect_on must be callable" unless value.respond_to?(:call)
  49. value
  50. end
  51. end
  52. 1 module InstanceMethods
  53. # returns a session with the *max_redirects* option set to +n+
  54. 1 def max_redirects(n)
  55. with(max_redirects: n.to_i)
  56. end
  57. 1 private
  58. 1 def fetch_response(request, selector, options)
  59. 2 redirect_request = request.redirect_request
  60. 2 response = super(redirect_request, selector, options)
  61. 2 return unless response
  62. 2 max_redirects = redirect_request.max_redirects
  63. 2 return response unless response.is_a?(Response)
  64. 2 return response unless REDIRECT_STATUS.include?(response.status) && response.headers.key?("location")
  65. 1 return response unless max_redirects.positive?
  66. 1 redirect_uri = __get_location_from_response(response)
  67. 1 if options.redirect_on
  68. redirect_allowed = options.redirect_on.call(redirect_uri)
  69. return response unless redirect_allowed
  70. end
  71. # build redirect request
  72. 1 request_body = redirect_request.body
  73. 1 redirect_method = "GET"
  74. 1 redirect_params = {}
  75. 1 if response.status == 305 && options.respond_to?(:proxy)
  76. request_body.rewind
  77. # The requested resource MUST be accessed through the proxy given by
  78. # the Location field. The Location field gives the URI of the proxy.
  79. redirect_options = options.merge(headers: redirect_request.headers,
  80. proxy: { uri: redirect_uri },
  81. max_redirects: max_redirects - 1)
  82. redirect_params[:body] = request_body
  83. redirect_uri = redirect_request.uri
  84. options = redirect_options
  85. else
  86. 1 redirect_headers = redirect_request_headers(redirect_request.uri, redirect_uri, request.headers, options)
  87. 1 redirect_opts = Hash[options]
  88. 1 redirect_params[:max_redirects] = max_redirects - 1
  89. 1 unless request_body.empty?
  90. if response.status == 307
  91. # The method and the body of the original request are reused to perform the redirected request.
  92. redirect_method = redirect_request.verb
  93. request_body.rewind
  94. redirect_params[:body] = request_body
  95. else
  96. # redirects are **ALWAYS** GET, so remove body-related headers
  97. REQUEST_BODY_HEADERS.each do |h|
  98. redirect_headers.delete(h)
  99. end
  100. redirect_params[:body] = nil
  101. end
  102. end
  103. 1 options = options.class.new(redirect_opts.merge(headers: redirect_headers.to_h))
  104. end
  105. 1 redirect_uri = Utils.to_uri(redirect_uri)
  106. 1 if !options.follow_insecure_redirects &&
  107. response.uri.scheme == "https" &&
  108. redirect_uri.scheme == "http"
  109. error = InsecureRedirectError.new(redirect_uri.to_s)
  110. error.set_backtrace(caller)
  111. return ErrorResponse.new(request, error)
  112. end
  113. 1 retry_request = build_request(redirect_method, redirect_uri, redirect_params, options)
  114. 1 request.redirect_request = retry_request
  115. 1 redirect_after = response.headers["retry-after"]
  116. 1 if redirect_after
  117. # Servers send the "Retry-After" header field to indicate how long the
  118. # user agent ought to wait before making a follow-up request.
  119. # When sent with any 3xx (Redirection) response, Retry-After indicates
  120. # the minimum time that the user agent is asked to wait before issuing
  121. # the redirected request.
  122. #
  123. redirect_after = Utils.parse_retry_after(redirect_after)
  124. retry_start = Utils.now
  125. log { "redirecting after #{redirect_after} secs..." }
  126. selector.after(redirect_after) do
  127. if (response = request.response)
  128. response.finish!
  129. retry_request.response = response
  130. # request has terminated abruptly meanwhile
  131. retry_request.emit_response(response)
  132. else
  133. log { "redirecting (elapsed time: #{Utils.elapsed_time(retry_start)})!!" }
  134. send_request(retry_request, selector, options)
  135. end
  136. end
  137. else
  138. 1 send_request(retry_request, selector, options)
  139. # recalling itself, in case an error was triggered by the above, and we can
  140. # verify retriability again.
  141. 1 return fetch_response(request, selector, options)
  142. end
  143. nil
  144. end
  145. # :nodoc:
  146. 1 def redirect_request_headers(original_uri, redirect_uri, headers, options)
  147. 1 headers = headers.dup
  148. 1 return headers if options.allow_auth_to_other_origins
  149. 1 return headers unless headers.key?("authorization")
  150. return headers if original_uri.origin == redirect_uri.origin
  151. headers.delete("authorization")
  152. headers
  153. end
  154. # :nodoc:
  155. 1 def __get_location_from_response(response)
  156. # @type var location_uri: http_uri
  157. 1 location_uri = URI(response.headers["location"])
  158. 1 location_uri = response.uri.merge(location_uri) if location_uri.relative?
  159. 1 location_uri
  160. end
  161. end
  162. 1 module RequestMethods
  163. # returns the top-most original HTTPX::Request from the redirect chain
  164. 1 attr_accessor :root_request
  165. 1 def initialize(*)
  166. 2 super
  167. 2 @redirect_request = nil
  168. end
  169. 1 def on_response_arrived=(cb)
  170. 1 @redirect_request.on_response_arrived = cb if @redirect_request
  171. 1 super
  172. end
  173. # returns the follow-up redirect request, or itself
  174. 1 def redirect_request
  175. 2 @redirect_request || self
  176. end
  177. # sets the follow-up redirect request
  178. 1 def redirect_request=(req)
  179. 1 @redirect_request = req
  180. 1 req.root_request = @root_request || self
  181. 1 req.on_response_arrived = @on_response_arrived
  182. 1 @response = nil
  183. end
  184. 1 def response
  185. 8 return super unless @redirect_request && @response.nil?
  186. 2 @redirect_request.response
  187. end
  188. 1 def max_redirects
  189. 2 @options.max_redirects || MAX_REDIRECTS
  190. end
  191. end
  192. 1 module ConnectionMethods
  193. 1 private
  194. 1 def set_request_request_timeout(request)
  195. return unless request.root_request.nil?
  196. super
  197. end
  198. end
  199. end
  200. 1 register_plugin :follow_redirects, FollowRedirects
  201. end
  202. end

lib/httpx/plugins/h2c.rb

38.6% lines covered

57 relevant lines. 22 lines covered and 35 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. #
  5. # This plugin adds support for upgrading a plaintext HTTP/1.1 connection to HTTP/2
  6. # (https://datatracker.ietf.org/doc/html/rfc7540#section-3.2)
  7. #
  8. # https://gitlab.com/os85/httpx/wikis/Connection-Upgrade#h2c
  9. #
  10. 1 module H2C
  11. 1 VALID_H2C_VERBS = %w[GET OPTIONS HEAD].freeze
  12. 1 class << self
  13. 1 def load_dependencies(klass)
  14. klass.plugin(:upgrade)
  15. end
  16. 1 def call(connection, request, response)
  17. connection.upgrade_to_h2c(request, response)
  18. end
  19. 1 def extra_options(options)
  20. options.merge(
  21. h2c_class: Class.new(options.http2_class) { include(H2CParser) },
  22. max_concurrent_requests: 1,
  23. upgrade_handlers: options.upgrade_handlers.merge("h2c" => self),
  24. )
  25. end
  26. end
  27. 1 module OptionsMethods
  28. 1 def option_h2c_class(value)
  29. value
  30. end
  31. end
  32. 1 module RequestMethods
  33. 1 def valid_h2c_verb?
  34. VALID_H2C_VERBS.include?(@verb)
  35. end
  36. end
  37. 1 module ConnectionMethods
  38. 1 using URIExtensions
  39. 1 def initialize(*)
  40. super
  41. @h2c_handshake = false
  42. end
  43. 1 def send(request)
  44. return super if @h2c_handshake
  45. return super unless request.valid_h2c_verb? && request.scheme == "http"
  46. return super if @upgrade_protocol == "h2c"
  47. @h2c_handshake = true
  48. # build upgrade request
  49. request.headers.add("connection", "upgrade")
  50. request.headers.add("connection", "http2-settings")
  51. request.headers["upgrade"] = "h2c"
  52. request.headers["http2-settings"] = ::HTTP2::Client.settings_header(request.options.http2_settings)
  53. super
  54. end
  55. 1 def upgrade_to_h2c(request, response)
  56. enqueue_pending_requests_from_parser(@parser)
  57. @parser = request.options.h2c_class.new(@write_buffer, @options)
  58. set_parser_callbacks(@parser)
  59. @inflight += 1 # request is being completed below
  60. @parser.upgrade(request, response)
  61. @upgrade_protocol = "h2c"
  62. end
  63. 1 private
  64. 1 def send_request_to_parser(request)
  65. super
  66. return unless request.headers["upgrade"] == "h2c" && parser.is_a?(Connection::HTTP1)
  67. max_concurrent_requests = parser.max_concurrent_requests
  68. return if max_concurrent_requests == 1
  69. parser.max_concurrent_requests = 1
  70. request.once(:response) do
  71. parser.max_concurrent_requests = max_concurrent_requests
  72. end
  73. end
  74. end
  75. 1 module H2CParser
  76. 1 def upgrade(request, response)
  77. # skip checks, it is assumed that this is the first
  78. # request in the connection
  79. stream = @connection.upgrade
  80. # on_settings
  81. handle_stream(stream, request)
  82. @streams[request] = stream
  83. # clean up data left behind in the buffer, if the server started
  84. # sending frames
  85. data = response.read
  86. @connection << data
  87. end
  88. end
  89. end
  90. 1 register_plugin(:h2c, H2C)
  91. end
  92. end

lib/httpx/plugins/persistent.rb

75.68% lines covered

37 relevant lines. 28 lines covered and 9 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. # This plugin implements a session that persists connections over the duration of the process.
  5. #
  6. # This will improve connection reuse in a long-running process.
  7. #
  8. # One important caveat to note is, although this session might not close connections,
  9. # other sessions from the same process that don't have this plugin turned on might.
  10. #
  11. # This session will still be able to work with it, as if, when expecting a connection
  12. # terminated by a different session, it will just retry on a new one and keep it open.
  13. #
  14. # This plugin is also not recommendable when connecting to >9000 (like, a lot) different origins.
  15. # So when you use this, make sure that you don't fall into this trap.
  16. #
  17. # https://gitlab.com/os85/httpx/wikis/Persistent
  18. #
  19. 1 module Persistent
  20. 1 class << self
  21. 1 def load_dependencies(klass)
  22. 9 klass.plugin(:fiber_concurrency)
  23. 9 max_retries = if klass.default_options.respond_to?(:max_retries)
  24. [klass.default_options.max_retries, 1].max
  25. else
  26. 9 1
  27. end
  28. 9 klass.plugin(:retries, max_retries: max_retries)
  29. end
  30. end
  31. 1 def self.extra_options(options)
  32. 9 options.merge(persistent: true)
  33. end
  34. 1 module InstanceMethods
  35. 1 def close(*)
  36. 9 super
  37. # traverse other threads and unlink respective selector
  38. # WARNING: this is not thread safe, make sure that the session isn't being
  39. # used anymore, or all non-main threads are stopped.
  40. 9 Thread.list.each do |th|
  41. 72 store = thread_selector_store(th)
  42. 72 next unless store && store.key?(self)
  43. 9 selector = store.delete(self)
  44. 9 selector_close(selector)
  45. end
  46. end
  47. 1 private
  48. 1 def reconnectable_error?(error)
  49. Retries::RECONNECTABLE_ERRORS.any? { |klass| error.is_a?(klass) }
  50. end
  51. 1 def when_to_retry(request, response, *)
  52. return super unless response.is_a?(ErrorResponse)
  53. error = response.error
  54. # allow request to be retried immediately if the request failed right after the keep alive timeout.
  55. # the chances are, the request failed because the connect has been dropped by the peer server, so it's
  56. # fine to reopen.
  57. return if request.ping? && reconnectable_error?(error)
  58. super
  59. end
  60. 1 def retryable_request?(request, response, *)
  61. 9 super || begin
  62. 1 return false unless response.is_a?(ErrorResponse)
  63. error = response.error
  64. reconnectable_error?(error)
  65. end
  66. end
  67. 1 def retryable_error?(ex, options)
  68. 1 super &&
  69. # under the persistent plugin rules, requests are only retried for connection related errors,
  70. # which do not include request timeout related errors. This only gets overriden if the end user
  71. # manually changed +:max_retries+ to something else, which means it is aware of the
  72. # consequences.
  73. (!ex.is_a?(RequestTimeoutError) || options.max_retries != 1)
  74. end
  75. end
  76. end
  77. 1 register_plugin :persistent, Persistent
  78. end
  79. end

lib/httpx/plugins/retries.rb

61.02% lines covered

118 relevant lines. 72 lines covered and 46 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Plugins
  4. #
  5. # This plugin adds support for retrying requests when errors happen.
  6. #
  7. # It has a default max number of retries (see *MAX_RETRIES* and the *max_retries* option),
  8. # after which it will return the last response, error or not. It will **not** raise an exception.
  9. #
  10. # It does not retry which are not considered idempotent (see *retry_change_requests* to override).
  11. #
  12. # https://gitlab.com/os85/httpx/wikis/Retries
  13. #
  14. 1 module Retries
  15. 1 MAX_RETRIES = 3
  16. # TODO: pass max_retries in a configure/load block
  17. 1 IDEMPOTENT_METHODS = %w[GET OPTIONS HEAD PUT DELETE].freeze
  18. # subset of retryable errors which are safe to retry when reconnecting
  19. RECONNECTABLE_ERRORS = [
  20. 1 IOError,
  21. EOFError,
  22. Errno::ECONNRESET,
  23. Errno::ECONNABORTED,
  24. Errno::EPIPE,
  25. Errno::EINVAL,
  26. Errno::ETIMEDOUT,
  27. ConnectionError,
  28. TLSError,
  29. Connection::HTTP2::Error,
  30. ].freeze
  31. 1 RETRYABLE_ERRORS = (RECONNECTABLE_ERRORS + [
  32. Parser::Error,
  33. TimeoutError,
  34. ]).freeze
  35. 1 DEFAULT_JITTER = ->(interval) { interval * ((rand + 1) * 0.5) }.freeze
  36. # list of supported backoff algorithms
  37. 1 BACKOFF_ALGORITHMS = %i[exponential_backoff polynomial_backoff].freeze
  38. 1 class << self
  39. 1 if ENV.key?("HTTPX_NO_JITTER")
  40. 1 def extra_options(options)
  41. 11 options.merge(max_retries: MAX_RETRIES)
  42. end
  43. else
  44. def extra_options(options)
  45. options.merge(max_retries: MAX_RETRIES, retry_jitter: DEFAULT_JITTER)
  46. end
  47. end
  48. # returns the time to wait before resending +request+ as per the polynomial backoff retry strategy,
  49. # where base is 1 and exponent is 2.
  50. 1 def retry_after_polynomial_backoff(request, _)
  51. offset = request.options.max_retries - request.retries
  52. 1 * ((offset - 1)**2)
  53. end
  54. # returns the time to wait before resending +request+ as per the exponential backoff retry strategy,
  55. # where base is 2
  56. 1 def retry_after_exponential_backoff(request, _)
  57. offset = request.options.max_retries - request.retries
  58. 2**(offset - 1)
  59. end
  60. end
  61. # adds support for the following options:
  62. #
  63. # :max_retries :: max number of times a request will be retried (defaults to <tt>3</tt>).
  64. # :retry_change_requests :: whether idempotent requests are retried (defaults to <tt>false</tt>).
  65. # :retry_after:: seconds after which a request is retried; can also be a callable object (i.e. <tt>->(req, res) { ... } </tt>)
  66. # or the name of a supported backoff algorithm (i.e. <tt>:exponential_backoff</tt>).
  67. # :retry_jitter :: number of seconds applied to *:retry_after* (must be a callable, i.e. <tt>->(retry_after) { ... } </tt>).
  68. # :retry_on :: callable which alternatively defines a different rule for when a response is to be retried
  69. # (i.e. <tt>->(res) { ... }</tt>).
  70. 1 module OptionsMethods
  71. 1 private
  72. 1 def option_retry_after(value)
  73. if value.respond_to?(:call)
  74. value1 = value
  75. value1 = value1.method(:call) unless value1.respond_to?(:arity)
  76. # allow ->(*) arity as well, which is < 0
  77. raise TypeError, "`:retry_after` proc has invalid number of parameters" unless value1.arity.negative? || value1.arity.between?(
  78. 1, 2
  79. )
  80. else
  81. case value
  82. when Symbol
  83. raise TypeError, "`retry_after`: `#{value}` is not a supported backoff algorithm" unless BACKOFF_ALGORITHMS.include?(value)
  84. value = Retries.method(:"retry_after_#{value}")
  85. else
  86. value = Float(value)
  87. raise TypeError, "`:retry_after` must be positive" unless value.positive?
  88. end
  89. end
  90. value
  91. end
  92. 1 def option_retry_jitter(value)
  93. # return early if callable
  94. raise TypeError, ":retry_jitter must be callable" unless value.respond_to?(:call)
  95. value
  96. end
  97. 1 def option_max_retries(value)
  98. 21 num = Integer(value)
  99. 21 raise TypeError, ":max_retries must be positive" unless num >= 0
  100. 21 num
  101. end
  102. 1 def option_retry_change_requests(v)
  103. v
  104. end
  105. 1 def option_retry_on(value)
  106. 1 raise TypeError, ":retry_on must be called with the response" unless value.respond_to?(:call)
  107. 1 value
  108. end
  109. end
  110. 1 module InstanceMethods
  111. # returns a `:retries` plugin enabled session with +n+ maximum retries per request setting.
  112. 1 def max_retries(n)
  113. with(max_retries: n)
  114. end
  115. 1 private
  116. 1 def fetch_response(request, selector, options)
  117. 27 response = super
  118. 27 if response &&
  119. request.retries.positive? &&
  120. retryable_request?(request, response, options) &&
  121. retryable_response?(response, options)
  122. 4 try_partial_retry(request, response)
  123. 4 log { "failed to get response, #{request.retries} tries to go..." }
  124. 4 prepare_to_retry(request, response)
  125. 4 if (retry_after = when_to_retry(request, response, options)) && retry_after.positive?
  126. retry_start = Utils.now
  127. log { "retrying after #{retry_after} secs..." }
  128. selector.after(retry_after) do
  129. if (response = request.response)
  130. response.finish!
  131. # request has terminated abruptly meanwhile
  132. request.emit_response(response)
  133. else
  134. log { "retrying (elapsed time: #{Utils.elapsed_time(retry_start)})!!" }
  135. send_request(request, selector, options)
  136. end
  137. end
  138. return
  139. else
  140. 4 send_request(request, selector, options)
  141. # recalling itself, in case an error was triggered by the above, and we can
  142. # verify retriability again.
  143. 4 return fetch_response(request, selector, options)
  144. end
  145. end
  146. 23 response
  147. end
  148. # returns whether +request+ can be retried.
  149. 1 def retryable_request?(request, _, options)
  150. 14 IDEMPOTENT_METHODS.include?(request.verb) || options.retry_change_requests
  151. end
  152. 1 def retryable_response?(response, options)
  153. 13 (response.is_a?(ErrorResponse) && retryable_error?(response.error, options)) || options.retry_on&.call(response)
  154. end
  155. # returns whether the +ex+ exception happend for a retriable request.
  156. 1 def retryable_error?(ex, _)
  157. 39 RETRYABLE_ERRORS.any? { |klass| ex.is_a?(klass) } && !ex.is_a?(TotalRequestTimeoutError)
  158. end
  159. 1 def proxy_error?(request, response, _)
  160. super && !request.retries.positive?
  161. end
  162. 1 def prepare_to_retry(request, _response)
  163. 4 request.retries -= 1 unless request.ping? # do not exhaust retries on connection liveness probes
  164. 4 request.transition(:idle)
  165. end
  166. 1 def when_to_retry(request, response, options)
  167. 4 retry_after = options.retry_after
  168. 4 return unless retry_after
  169. retry_after = retry_after.call(request, response) if retry_after.respond_to?(:call)
  170. return unless retry_after
  171. # apply jitter
  172. if (jitter = request.options.retry_jitter)
  173. retry_after = jitter.call(retry_after)
  174. end
  175. retry_after
  176. end
  177. #
  178. # Attempt to set the request to perform a partial range request.
  179. # This happens if the peer server accepts byte-range requests, and
  180. # the last response contains some body payload.
  181. #
  182. 1 def try_partial_retry(request, response)
  183. 4 response = response.response if response.is_a?(ErrorResponse)
  184. 4 return unless response
  185. 2 unless response.headers.key?("accept-ranges") &&
  186. response.headers["accept-ranges"] == "bytes" && # there's nothing else supported though...
  187. (original_body = response.body)
  188. 2 response.body.close
  189. 2 return
  190. end
  191. request.partial_response = response
  192. size = original_body.bytesize
  193. request.headers["range"] = "bytes=#{size}-"
  194. end
  195. end
  196. 1 module RequestMethods
  197. # number of retries left.
  198. 1 attr_accessor :retries
  199. # a response partially received before.
  200. 1 attr_writer :partial_response
  201. # initializes the request instance, sets the number of retries for the request.
  202. 1 def initialize(*args)
  203. 11 super
  204. 11 @retries = @options.max_retries
  205. 11 @partial_response = nil
  206. end
  207. 1 def response=(response)
  208. 15 if (partial_response = @partial_response)
  209. if response.is_a?(Response) && response.status == 206
  210. response.from_partial_response(partial_response)
  211. else
  212. partial_response.close
  213. end
  214. @partial_response = nil
  215. end
  216. 15 super
  217. end
  218. end
  219. 1 module ResponseMethods
  220. 1 def from_partial_response(response)
  221. @status = response.status
  222. @headers = response.headers
  223. @body = response.body
  224. end
  225. end
  226. end
  227. 1 register_plugin :retries, Retries
  228. end
  229. end

lib/httpx/plugins/stream.rb

75.44% lines covered

114 relevant lines. 86 lines covered and 28 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 class StreamResponse
  4. 1 attr_reader :request
  5. 1 def initialize(request, session)
  6. 3 @request = request
  7. 3 @options = @request.options
  8. 3 @session = session
  9. 3 @response_enum = nil
  10. 3 @buffered_chunks = []
  11. end
  12. 1 def each(&block)
  13. 3 return enum_for(__method__) unless block
  14. 3 if (response_enum = @response_enum)
  15. @response_enum = nil
  16. # streaming already started, let's finish it
  17. while (chunk = @buffered_chunks.shift)
  18. block.call(chunk)
  19. end
  20. # consume enum til the end
  21. begin
  22. while (chunk = response_enum.next)
  23. block.call(chunk)
  24. end
  25. rescue StopIteration
  26. return
  27. end
  28. end
  29. 3 @request.stream = self
  30. begin
  31. 3 @on_chunk = block
  32. 3 response = @session.request(@request)
  33. 3 response.raise_for_status
  34. ensure
  35. 3 @on_chunk = nil
  36. end
  37. end
  38. 1 def each_line(&block)
  39. 2 return enum_for(__method__) unless block
  40. 1 line = "".b
  41. 1 each do |chunk|
  42. 1 line << chunk
  43. 3 while (idx = line.index("\n"))
  44. 1 if idx.zero?
  45. yield ""
  46. else
  47. 1 yield line.byteslice(0..(idx - 1))
  48. end
  49. 1 line = line.byteslice((idx + 1)..-1)
  50. end
  51. end
  52. 1 yield line unless line.empty?
  53. end
  54. # This is a ghost method. It's to be used ONLY internally, when processing streams
  55. 1 def on_chunk(chunk)
  56. 4 raise NoMethodError unless @on_chunk
  57. 4 @on_chunk.call(chunk)
  58. end
  59. # simplecov:disable
  60. 1 def inspect
  61. "#<#{self.class}:#{object_id}>"
  62. end
  63. # simplecov:enable
  64. 1 def to_s
  65. if @request.response
  66. @request.response.to_s
  67. else
  68. @buffered_chunks.join
  69. end
  70. end
  71. 1 private
  72. 1 def response
  73. 6 @request.response || begin
  74. response_enum = each
  75. while (chunk = response_enum.next)
  76. @buffered_chunks << chunk
  77. break if @request.response
  78. end
  79. @response_enum = response_enum
  80. @request.response
  81. end
  82. end
  83. 1 def respond_to_missing?(meth, include_private)
  84. if (response = @request.response)
  85. response.respond_to_missing?(meth, include_private)
  86. else
  87. @options.response_class.method_defined?(meth) || (include_private && @options.response_class.private_method_defined?(meth))
  88. end || super
  89. end
  90. 1 def method_missing(meth, *args, **kwargs, &block)
  91. 3 return super unless response.respond_to?(meth)
  92. 3 response.__send__(meth, *args, **kwargs, &block)
  93. end
  94. end
  95. 1 module Plugins
  96. #
  97. # This plugin adds support for streaming a response (useful for i.e. "text/event-stream" payloads).
  98. #
  99. # https://gitlab.com/os85/httpx/wikis/Stream
  100. #
  101. 1 module Stream
  102. 1 STREAM_REQUEST_OPTIONS = { timeout: { read_timeout: Float::INFINITY, operation_timeout: 60 }.freeze }.freeze
  103. 1 def self.extra_options(options)
  104. 3 options.merge(
  105. stream: false,
  106. timeout: { read_timeout: Float::INFINITY, operation_timeout: 60 },
  107. stream_response_class: Class.new(StreamResponse, &Options::SET_TEMPORARY_NAME).freeze
  108. )
  109. end
  110. # adds support for the following options:
  111. #
  112. # :stream :: whether the request to process should be handled as a stream (defaults to <tt>false</tt>).
  113. # :stream_response_class :: Class used to build the stream response object.
  114. 1 module OptionsMethods
  115. 1 def option_stream(val)
  116. 4 val
  117. end
  118. 1 def option_stream_response_class(value)
  119. 4 value
  120. end
  121. 1 def extend_with_plugin_classes(pl)
  122. return super unless defined?(pl::StreamResponseMethods)
  123. @stream_response_class = @stream_response_class.dup
  124. Options::SET_TEMPORARY_NAME[@stream_response_class, pl]
  125. @stream_response_class.__send__(:include, pl::StreamResponseMethods) if defined?(pl::StreamResponseMethods)
  126. super
  127. end
  128. end
  129. 1 module InstanceMethods
  130. 1 def request(*args, **options)
  131. 6 if args.first.is_a?(Request)
  132. 3 requests = args
  133. 3 request = requests.first
  134. 3 unless request.options.stream && !request.stream
  135. 3 if options[:stream]
  136. warn "passing `stream: true` with a request object is not supported anymore. " \
  137. "You can instead build the request object with `stream :true`"
  138. end
  139. 3 return super
  140. end
  141. else
  142. 3 return super unless options[:stream]
  143. 3 requests = build_requests(*args, options)
  144. 3 request = requests.first
  145. end
  146. 3 raise Error, "only 1 response at a time is supported for streaming requests" unless requests.size == 1
  147. 3 @options.stream_response_class.new(request, self)
  148. end
  149. 1 def build_request(verb, uri, params = EMPTY_HASH, options = @options)
  150. 4 return super unless params[:stream]
  151. 3 super(verb, uri, params, options.merge(STREAM_REQUEST_OPTIONS.merge(stream: true)))
  152. end
  153. end
  154. 1 module RequestMethods
  155. 1 attr_accessor :stream
  156. end
  157. 1 module ResponseMethods
  158. 1 def stream
  159. 4 request = @request.root_request if @request.respond_to?(:root_request)
  160. 4 request ||= @request
  161. 4 request.stream
  162. end
  163. end
  164. 1 module ResponseBodyMethods
  165. 1 def initialize(*)
  166. 4 super
  167. 4 @stream = @response.stream
  168. end
  169. 1 def write(chunk)
  170. 4 return super unless @stream
  171. 4 return 0 if chunk.empty?
  172. 4 chunk = decode_chunk(chunk)
  173. 4 @stream.on_chunk(chunk.dup)
  174. 4 chunk.bytesize
  175. end
  176. 1 private
  177. 1 def transition(*)
  178. return if @stream
  179. super
  180. end
  181. end
  182. end
  183. 1 register_plugin :stream, Stream
  184. end
  185. end

lib/httpx/plugins/tracing.rb

82.46% lines covered

57 relevant lines. 47 lines covered and 10 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX::Plugins
  3. #
  4. # This plugin adds a simple interface to integrate request tracing SDKs.
  5. #
  6. # An example of such an integration is the datadog adapter.
  7. #
  8. # https://gitlab.com/os85/httpx/wikis/Tracing
  9. #
  10. 1 module Tracing
  11. 1 class Wrapper
  12. 1 attr_reader :tracers
  13. 1 protected :tracers
  14. 1 def initialize(*tracers)
  15. @tracers = tracers.flat_map do |tracer|
  16. case tracer
  17. when Wrapper
  18. tracer.tracers
  19. else
  20. tracer
  21. end
  22. end.uniq
  23. end
  24. 1 def merge(tracer)
  25. Wrapper.new(*@tracers, *tracer.tracers)
  26. end
  27. 1 def freeze
  28. @tracers.each(&:freeze).freeze
  29. super
  30. end
  31. 1 %i[start finish reset enabled?].each do |callback|
  32. 4 class_eval(<<-OUT, __FILE__, __LINE__ + 1)
  33. # proxies ##{callback} calls to wrapper tracers.
  34. def #{callback}(*args) # def start(*args)
  35. @tracers.each { |t| t.#{callback}(*args) } # @tracers.each { |t| t.start(*args) }
  36. end # end
  37. OUT
  38. end
  39. end
  40. # adds support for the following options:
  41. #
  42. # :tracer :: object which responds to #start, #finish and #reset.
  43. 1 module OptionsMethods
  44. 1 private
  45. 1 def option_tracer(tracer)
  46. 2 unless tracer.respond_to?(:start) &&
  47. tracer.respond_to?(:finish) &&
  48. tracer.respond_to?(:reset) &&
  49. tracer.respond_to?(:enabled?)
  50. raise TypeError, "#{tracer} must to respond to `#start(r)`, `#finish` and `#reset` and `#enabled?"
  51. end
  52. 2 tracer = Wrapper.new(@tracer, tracer) if @tracer
  53. 2 tracer
  54. end
  55. end
  56. 1 module RequestMethods
  57. 1 attr_accessor :init_time
  58. # intercepts request initialization to inject the tracing logic.
  59. 1 def initialize(*)
  60. 14 super
  61. 14 @init_time = nil
  62. 14 tracer = @options.tracer
  63. 14 return unless tracer && tracer.enabled?(self)
  64. 14 on(:idle) do
  65. 4 tracer.reset(self)
  66. # request is reset when it's retried.
  67. 4 @init_time = nil
  68. end
  69. 14 on(:headers) do
  70. # the usual request init time (when not including the connection handshake)
  71. # should be the time the request is buffered the first time.
  72. 16 @init_time ||= ::Time.now.utc
  73. 16 tracer.start(self)
  74. end
  75. 30 on(:response) { |response| tracer.finish(self, response) }
  76. end
  77. 1 def response=(*)
  78. # init_time should be set when it's send to a connection.
  79. # However, there are situations where connection initialization fails.
  80. # Example is the :ssrf_filter plugin, which raises an error on
  81. # initialize if the host is an IP which matches against the known set.
  82. # in such cases, we'll just set here right here.
  83. 16 @init_time ||= ::Time.now.utc
  84. 16 super
  85. end
  86. end
  87. # Connection mixin
  88. 1 module ConnectionMethods
  89. 1 def initialize(*)
  90. 13 super
  91. 13 @init_time = ::Time.now.utc
  92. end
  93. 1 def send_request_to_parser(request)
  94. 16 if connecting?
  95. # request span timeframe should include the time it took to connect.
  96. 16 request.init_time ||= @init_time
  97. end
  98. 16 super
  99. end
  100. 1 def idling
  101. 3 super
  102. # time of initial request(s) is accounted from the moment
  103. # the connection is back to :idle, and ready to connect again.
  104. 3 @init_time = ::Time.now.utc
  105. end
  106. 1 private
  107. 1 def ping(request)
  108. # if a connection is probed for liveness, the request timeframe should include
  109. # it too.
  110. request.init_time ||= ::Time.now.utc
  111. super
  112. end
  113. end
  114. end
  115. 1 register_plugin :tracing, Tracing
  116. end

lib/httpx/pmatch_extensions.rb

70.59% lines covered

17 relevant lines. 12 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module ResponsePatternMatchExtensions
  4. 1 def deconstruct
  5. [@status, @headers, @body]
  6. end
  7. 1 def deconstruct_keys(_keys)
  8. { status: @status, headers: @headers, body: @body }
  9. end
  10. end
  11. 1 module ErrorResponsePatternMatchExtensions
  12. 1 def deconstruct
  13. [@error]
  14. end
  15. 1 def deconstruct_keys(_keys)
  16. { error: @error }
  17. end
  18. end
  19. 1 module HeadersPatternMatchExtensions
  20. 1 def deconstruct
  21. to_a
  22. end
  23. end
  24. 1 Headers.include HeadersPatternMatchExtensions
  25. 1 Response.include ResponsePatternMatchExtensions
  26. 1 ErrorResponse.include ErrorResponsePatternMatchExtensions
  27. end

lib/httpx/pool.rb

80.0% lines covered

110 relevant lines. 88 lines covered and 22 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "httpx/selector"
  3. 1 require "httpx/connection"
  4. 1 require "httpx/connection/http2"
  5. 1 require "httpx/connection/http1"
  6. 1 require "httpx/resolver"
  7. 1 module HTTPX
  8. 1 class Pool
  9. 1 using URIExtensions
  10. 1 POOL_TIMEOUT = 5
  11. # Sets up the connection pool with the given +options+, which can be the following:
  12. #
  13. # :max_connections:: the maximum number of connections held in the pool.
  14. # :max_connections_per_origin :: the maximum number of connections held in the pool pointing to a given origin.
  15. # :pool_timeout :: the number of seconds to wait for a connection to a given origin (before raising HTTPX::PoolTimeoutError)
  16. #
  17. 1 def initialize(options)
  18. 159 @max_connections = options.fetch(:max_connections, Float::INFINITY)
  19. 159 @max_connections_per_origin = options.fetch(:max_connections_per_origin, Float::INFINITY)
  20. 159 @pool_timeout = options.fetch(:pool_timeout, POOL_TIMEOUT)
  21. 191 @resolvers = Hash.new { |hs, resolver_type| hs[resolver_type] = [] }
  22. 159 @resolver_mtx = Thread::Mutex.new
  23. 159 @connections = []
  24. 159 @connection_mtx = Thread::Mutex.new
  25. 159 @connections_counter = 0
  26. 159 @max_connections_cond = ConditionVariable.new
  27. 159 @origin_counters = Hash.new(0)
  28. 191 @origin_conds = Hash.new { |hs, orig| hs[orig] = ConditionVariable.new }
  29. end
  30. # connections returned by this function are not expected to return to the connection pool.
  31. 1 def pop_connection
  32. 92 @connection_mtx.synchronize do
  33. 92 drop_connection
  34. end
  35. end
  36. # opens a connection to the IP reachable through +uri+.
  37. # Many hostnames are reachable through the same IP, so we try to
  38. # maximize pipelining by opening as few connections as possible.
  39. #
  40. 1 def checkout_connection(uri, options)
  41. 68 return checkout_new_connection(uri, options) if options.io
  42. 68 @connection_mtx.synchronize do
  43. 68 acquire_connection(uri, options) || begin
  44. 66 if @connections_counter == @max_connections
  45. # this takes precedence over per-origin
  46. expires_at = Utils.now + @pool_timeout
  47. loop do
  48. @max_connections_cond.wait(@connection_mtx, @pool_timeout)
  49. if (conn = acquire_connection(uri, options))
  50. return conn
  51. end
  52. # if one can afford to create a new connection, do it
  53. break unless @connections_counter == @max_connections
  54. # if no matching usable connection was found, the pool will make room and drop a closed connection.
  55. if (conn = @connections.find { |c| c.state == :closed })
  56. drop_connection(conn)
  57. break
  58. end
  59. # happens when a condition was signalled, but another thread snatched the available connection before
  60. # context was passed back here.
  61. next if Utils.now < expires_at
  62. raise PoolTimeoutError.new(@pool_timeout,
  63. "Timed out after #{@pool_timeout} seconds while waiting for a connection")
  64. end
  65. end
  66. 66 if @origin_counters[uri.origin] == @max_connections_per_origin
  67. expires_at = Utils.now + @pool_timeout
  68. loop do
  69. @origin_conds[uri.origin].wait(@connection_mtx, @pool_timeout)
  70. if (conn = acquire_connection(uri, options))
  71. return conn
  72. end
  73. # happens when a condition was signalled, but another thread snatched the available connection before
  74. # context was passed back here.
  75. next if Utils.now < expires_at
  76. raise(PoolTimeoutError.new(@pool_timeout,
  77. "Timed out after #{@pool_timeout} seconds while waiting for a connection to #{uri.origin}"))
  78. end
  79. end
  80. 66 @connections_counter += 1
  81. 66 @origin_counters[uri.origin] += 1
  82. 66 checkout_new_connection(uri, options)
  83. end
  84. end
  85. end
  86. 1 def checkin_connection(connection)
  87. 38 return if connection.options.io
  88. 38 @connection_mtx.synchronize do
  89. 38 if connection.coalesced? || connection.state == :idle
  90. # when connections coalesce
  91. 4 drop_connection(connection)
  92. 4 return
  93. end
  94. 34 @connections << connection
  95. 34 @max_connections_cond.signal
  96. 34 @origin_conds[connection.origin.to_s].signal
  97. end
  98. end
  99. 1 def checkout_mergeable_connection(connection)
  100. 67 return if connection.options.io
  101. 67 @connection_mtx.synchronize do
  102. 67 idx = @connections.find_index do |ch|
  103. ch != connection && ch.mergeable?(connection)
  104. end
  105. 67 @connections.delete_at(idx) if idx
  106. end
  107. end
  108. 1 def reset_resolvers
  109. 128 @resolver_mtx.synchronize { @resolvers.clear }
  110. end
  111. 1 def checkout_resolver(options)
  112. 32 resolver_type = options.resolver_class
  113. 32 @resolver_mtx.synchronize do
  114. 32 resolvers = @resolvers[resolver_type]
  115. 32 idx = resolvers.find_index do |res|
  116. res.options.resolver_options_match?(options)
  117. end
  118. 32 resolvers.delete_at(idx) if idx
  119. end || checkout_new_resolver(resolver_type, options)
  120. end
  121. 1 def checkin_resolver(resolver)
  122. 32 if resolver.is_a?(Resolver::Multi)
  123. 28 resolver_class = resolver.resolvers.first.class
  124. else
  125. 4 resolver_class = resolver.class
  126. 4 resolver = resolver.multi
  127. end
  128. # a multi requires all sub-resolvers being closed in order to be
  129. # correctly checked back in.
  130. 32 return unless resolver.closed?
  131. 32 @resolver_mtx.synchronize do
  132. 32 resolvers = @resolvers[resolver_class]
  133. 32 resolvers << resolver unless resolvers.include?(resolver)
  134. end
  135. end
  136. # simplecov:disable
  137. 1 def inspect
  138. "#<#{self.class}:#{object_id} " \
  139. "@max_connections=#{@max_connections} " \
  140. "@max_connections_per_origin=#{@max_connections_per_origin} " \
  141. "@pool_timeout=#{@pool_timeout} " \
  142. "@connections=#{@connections.size}>"
  143. end
  144. # simplecov:enable
  145. 1 private
  146. 1 def acquire_connection(uri, options)
  147. 68 idx = @connections.find_index do |connection|
  148. 2 connection.match?(uri, options)
  149. end
  150. 68 return unless idx
  151. 2 @connections.delete_at(idx)
  152. end
  153. 1 def checkout_new_connection(uri, options)
  154. 66 connection = options.connection_class.new(uri, options)
  155. 66 connection.log(level: 2) { "created connection##{connection.object_id} in pool##{object_id}" }
  156. 66 connection
  157. end
  158. 1 def checkout_new_resolver(resolver_type, options)
  159. 32 resolver = if resolver_type.multi?
  160. 32 Resolver::Multi.new(resolver_type, options)
  161. else
  162. resolver_type.new(options)
  163. end
  164. 32 resolver.log(level: 2) { "created resolver##{resolver.object_id} in pool##{object_id}" }
  165. 32 resolver
  166. end
  167. # drops and returns the +connection+ from the connection pool; if +connection+ is <tt>nil</tt> (default),
  168. # the first available connection from the pool will be dropped.
  169. 1 def drop_connection(connection = nil)
  170. 96 if connection
  171. 4 @connections.delete(connection)
  172. else
  173. 92 connection = @connections.shift
  174. 92 return unless connection
  175. end
  176. 32 @connections_counter -= 1
  177. 32 @origin_conds.delete(connection.origin) if (@origin_counters[connection.origin.to_s] -= 1).zero?
  178. 32 connection
  179. end
  180. end
  181. end

lib/httpx/punycode.rb

55.56% lines covered

9 relevant lines. 5 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Punycode
  4. 1 module_function
  5. begin
  6. 1 require "idnx"
  7. 1 def encode_hostname(hostname)
  8. Idnx.to_punycode(hostname)
  9. end
  10. rescue LoadError
  11. def encode_hostname(hostname)
  12. warn "#{hostname} cannot be converted to punycode. Install the " \
  13. "\"idnx\" gem: https://github.com/HoneyryderChuck/idnx"
  14. hostname
  15. end
  16. end
  17. end
  18. end

lib/httpx/request.rb

84.57% lines covered

162 relevant lines. 137 lines covered and 25 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "delegate"
  3. 1 require "forwardable"
  4. 1 module HTTPX
  5. # Defines how an HTTP request is handled internally, both in terms of making attributes accessible,
  6. # as well as maintaining the state machine which manages streaming the request onto the wire.
  7. 1 class Request
  8. 1 extend Forwardable
  9. 1 include Loggable
  10. 1 include Callbacks
  11. 1 using URIExtensions
  12. 1 ALLOWED_URI_SCHEMES = %w[https http].freeze
  13. # the upcased string HTTP verb for this request.
  14. 1 attr_reader :verb
  15. # the absolute URI object for this request.
  16. 1 attr_reader :uri
  17. # an HTTPX::Headers object containing the request HTTP headers.
  18. 1 attr_reader :headers
  19. # an HTTPX::Request::Body object containing the request body payload (or +nil+, whenn there is none).
  20. 1 attr_reader :body
  21. # a symbol describing which frame is currently being flushed.
  22. 1 attr_reader :state
  23. # an HTTPX::Options object containing request options.
  24. 1 attr_reader :options
  25. # the corresponding HTTPX::Response object, when there is one.
  26. 1 attr_reader :response
  27. # Exception raised during enumerable body writes.
  28. 1 attr_reader :drain_error
  29. # when this request is sent via HTTP/2, it'll use this hash of options to set the priority of the
  30. # respective HTTP/2 frame.
  31. 1 attr_reader :http2_stream_options
  32. # The IP address from the peer server.
  33. 1 attr_accessor :peer_address
  34. # the connection the request is currently being sent to (none if before or after transaction)
  35. 1 attr_writer :connection
  36. # callback triggered when a response (which may not be the final response) was assigned to the request.
  37. 1 attr_writer :on_response_arrived
  38. 1 attr_writer :persistent
  39. 1 attr_reader :active_timeouts
  40. # will be +true+ when request body has been completely flushed.
  41. 1 def_delegator :@body, :empty?
  42. # closes the body
  43. 1 def_delegator :@body, :close
  44. # initializes the instance with the given +verb+ (an upppercase String, ex. 'GEt'),
  45. # an absolute or relative +uri+ (either as String or URI::HTTP object), the
  46. # request +options+ (instance of HTTPX::Options) and an optional Hash of +params+.
  47. #
  48. # Besides any of the options documented in HTTPX::Options (which would override or merge with what
  49. # +options+ sets), it accepts also the following:
  50. #
  51. # :params :: hash or array of key-values which will be encoded and set in the query string of request uris.
  52. # :body :: to be encoded in the request body payload. can be a String, an IO object (i.e. a File), or an Enumerable.
  53. # :form :: hash of array of key-values which will be form-urlencoded- or multipart-encoded in requests body payload.
  54. # :json :: hash of array of key-values which will be JSON-encoded in requests body payload.
  55. # :xml :: Nokogiri XML nodes which will be encoded in requests body payload.
  56. # :http2_stream_options :: hash of options to be used to set the HTTP/2 priority by sending an initial PRIORITY frame.
  57. #
  58. # :body, :form, :json and :xml are all mutually exclusive, i.e. only one of them gets picked up.
  59. 1 def initialize(verb, uri, options, params = EMPTY_HASH)
  60. 71 @verb = verb.to_s.upcase
  61. 71 @uri = Utils.to_uri(uri)
  62. 71 @headers = options.headers.dup
  63. 71 merge_headers(params.delete(:headers)) if params.key?(:headers)
  64. 71 @query_params = params.delete(:params) if params.key?(:params)
  65. 71 @http2_stream_options = params.key?(:http2_stream_options) ? params.delete(:http2_stream_options) : EMPTY_HASH
  66. 71 @body = options.request_body_class.new(@headers, options, **params)
  67. 71 @options = @body.options
  68. 71 if @uri.relative? || @uri.host.nil?
  69. origin = @options.origin
  70. raise(Error, "invalid URI: #{@uri}") unless origin
  71. base_path = @options.base_path
  72. @uri = origin.merge("#{base_path}#{@uri}")
  73. end
  74. 71 raise UnsupportedSchemeError, "#{@uri}: #{@uri.scheme}: unsupported URI scheme" unless ALLOWED_URI_SCHEMES.include?(@uri.scheme)
  75. 71 @state = :idle
  76. @connection = @response =
  77. @drainer = @peer_address =
  78. 71 @informational_status = @on_response_arrived = nil
  79. 71 @ping = @started = false
  80. 71 @persistent = @options.persistent
  81. 71 @active_timeouts = []
  82. end
  83. # dupped initialization
  84. 1 def initialize_dup(orig)
  85. super
  86. @uri = orig.instance_variable_get(:@uri).dup
  87. @headers = orig.instance_variable_get(:@headers).dup
  88. @body = orig.instance_variable_get(:@body).dup
  89. end
  90. 1 def complete!(response = @response)
  91. 34 emit(:complete, response)
  92. 34 reset_timers(true)
  93. end
  94. # whether request has been buffered with a ping
  95. 1 def ping?
  96. 4 @ping
  97. end
  98. # marks the request as having been buffered with a ping
  99. 1 def ping!
  100. @ping = true
  101. end
  102. # the read timeout defined for this request.
  103. 1 def read_timeout
  104. 34 @options.timeout[:read_timeout]
  105. end
  106. # the write timeout defined for this request.
  107. 1 def write_timeout
  108. 34 @options.timeout[:write_timeout]
  109. end
  110. # the request timeout defined for this request.
  111. 1 def request_timeout
  112. 34 @options.timeout[:request_timeout]
  113. end
  114. # the total request timeout defined for this request.
  115. 1 def total_request_timeout
  116. 31 @options.timeout[:total_request_timeout]
  117. end
  118. 1 def persistent?
  119. 29 @persistent
  120. end
  121. # if the request contains trailer headers
  122. 1 def trailers?
  123. 2 defined?(@trailers)
  124. end
  125. # returns an instance of HTTPX::Headers containing the trailer headers
  126. 1 def trailers
  127. @trailers ||= @options.headers_class.new
  128. end
  129. # returns +:r+ or +:w+, depending on whether the request is waiting for a response or flushing.
  130. 1 def interests
  131. 234 return :r if @state == :done || @state == :expect
  132. 30 :w
  133. end
  134. 1 def can_buffer?
  135. 100 @state != :done
  136. end
  137. 1 def started?
  138. 34 @started
  139. end
  140. # merges +h+ into the instance of HTTPX::Headers of the request.
  141. 1 def merge_headers(h)
  142. 10 @headers = @headers.merge(h)
  143. 10 return unless @headers.key?("range")
  144. @headers.delete("accept-encoding")
  145. end
  146. # the URI scheme of the request +uri+.
  147. 1 def scheme
  148. 5 @uri.scheme
  149. end
  150. # sets the +response+ on this request.
  151. 1 def response=(response)
  152. 74 return unless response
  153. 74 case response
  154. when Response
  155. 67 if response.status < 200
  156. # deal with informational responses
  157. if response.status == 100 && @headers.key?("expect")
  158. @informational_status = response.status
  159. return
  160. end
  161. # 103 Early Hints advertises resources in document to browsers.
  162. # not very relevant for an HTTP client, discard.
  163. return if response.status >= 103
  164. end
  165. when ErrorResponse
  166. 7 response.error.connection = nil if response.error.respond_to?(:connection=)
  167. end
  168. 74 @response = response
  169. 74 emit(:response_started, response)
  170. end
  171. # returnns the URI path of the request +uri+.
  172. 1 def path
  173. 51 path = uri.path.dup
  174. 51 path = +"" if path.nil?
  175. 51 path << "/" if path.empty?
  176. 51 path << "?#{query}" unless query.empty?
  177. 51 path
  178. end
  179. # returs the URI authority of the request.
  180. #
  181. # session.build_request("GET", "https://google.com/query").authority #=> "google.com"
  182. # session.build_request("GET", "http://internal:3182/a").authority #=> "internal:3182"
  183. 1 def authority
  184. 34 @uri.authority
  185. end
  186. # returs the URI origin of the request.
  187. #
  188. # session.build_request("GET", "https://google.com/query").authority #=> "https://google.com"
  189. # session.build_request("GET", "http://internal:3182/a").authority #=> "http://internal:3182"
  190. 1 def origin
  191. 5 @uri.origin
  192. end
  193. # returs the URI query string of the request (when available).
  194. #
  195. # session.build_request("GET", "https://search.com").query #=> ""
  196. # session.build_request("GET", "https://search.com?q=a").query #=> "q=a"
  197. # session.build_request("GET", "https://search.com", params: { q: "a"}).query #=> "q=a"
  198. # session.build_request("GET", "https://search.com?q=a", params: { foo: "bar"}).query #=> "q=a&foo&bar"
  199. 1 def query
  200. 103 return @query if defined?(@query)
  201. 69 query = []
  202. 69 if (q = @query_params) && !q.empty?
  203. 4 query << Transcoder::Form.encode(q)
  204. end
  205. 69 query << @uri.query if @uri.query
  206. 69 @query = query.join("&")
  207. end
  208. # consumes and returns the next available chunk of request body that can be sent
  209. 1 def drain_body
  210. 12 return if @body.nil?
  211. 12 @drainer ||= @body.each
  212. 12 @drainer.next.dup
  213. rescue StopIteration
  214. 5 nil
  215. rescue StandardError => e
  216. # in case an error occurs while emitting body chunks
  217. @drain_error = e
  218. nil
  219. end
  220. # simplecov:disable
  221. 1 def inspect
  222. 1 "#<#{self.class}:#{object_id} " \
  223. "#{@verb} " \
  224. "#{uri} " \
  225. "@headers=#{@headers} " \
  226. "@body=#{@body}>"
  227. end
  228. # simplecov:enable
  229. # moves on to the +nextstate+ of the request state machine (when all preconditions are met)
  230. 1 def transition(nextstate)
  231. 298 case nextstate
  232. when :idle
  233. 6 @body.rewind
  234. 6 @ping = false
  235. 6 @response = @drainer = nil
  236. # request may be sent to a different connection and will be
  237. # reassigned a new set of timers.
  238. 6 reset_timers(false)
  239. when :headers
  240. 74 return unless @state == :idle
  241. 72 @started = true
  242. when :body
  243. 74 return unless @state == :headers ||
  244. @state == :expect
  245. 72 if @headers.key?("expect")
  246. if @informational_status && @informational_status == 100
  247. # check for 100 Continue response, and deallocate the var
  248. # if @informational_status == 100
  249. # @response = nil
  250. # end
  251. else
  252. return if @state == :expect # do not re-set it
  253. nextstate = :expect
  254. end
  255. end
  256. when :trailers
  257. 72 return unless @state == :body
  258. when :done
  259. 72 return if @state == :expect
  260. end
  261. 294 log(level: 3) { "#{@state} -> #{nextstate}" }
  262. 294 @state = nextstate
  263. 294 emit(@state, self)
  264. nil
  265. end
  266. # whether the request supports the 100-continue handshake and already processed the 100 response.
  267. 1 def expects?
  268. 33 @headers["expect"] == "100-continue" && @informational_status == 100 && !@response
  269. end
  270. 1 def set_timeout_callback(event, &callback)
  271. 170 clb = once(event, &callback)
  272. # reset timeout callbacks when requests get rerouted to a different connection
  273. 170 once(:idle) do
  274. 15 callbacks(event).delete(clb)
  275. end
  276. end
  277. 1 def handle_error(error)
  278. if (connection = @connection)
  279. connection.on_error(error, self)
  280. else
  281. response = ErrorResponse.new(self, error)
  282. self.response = response
  283. emit_response(response)
  284. end
  285. end
  286. 1 def emit_response(response)
  287. 74 emit(:response, response)
  288. 74 return unless @on_response_arrived
  289. 36 @on_response_arrived.call
  290. end
  291. 1 private
  292. 1 def reset_timers(reset_total_request_timers)
  293. 40 timers = @active_timeouts
  294. 81 until (timer = timers.shift).nil?
  295. 1 next if !reset_total_request_timers && timer.label == :total_request_timeout
  296. # cancel active timers.
  297. 1 timer.cancel
  298. end
  299. end
  300. end
  301. end
  302. 1 require_relative "request/body"

lib/httpx/request/body.rb

75.71% lines covered

70 relevant lines. 53 lines covered and 17 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. # Implementation of the HTTP Request body as a delegator which iterates (responds to +each+) payload chunks.
  4. 1 class Request::Body < SimpleDelegator
  5. 1 class << self
  6. 1 def new(_, options, body: nil, **params)
  7. 71 if body.is_a?(self)
  8. # request derives its options from body
  9. body.options = options.merge(params)
  10. return body
  11. end
  12. 71 super
  13. end
  14. end
  15. 1 attr_accessor :options
  16. # inits the instance with the request +headers+, +options+ and +params+, which contain the payload definition.
  17. # it wraps the given body with the appropriate encoder on initialization.
  18. #
  19. # ..., json: { foo: "bar" }) #=> json encoder
  20. # ..., form: { foo: "bar" }) #=> form urlencoded encoder
  21. # ..., form: { foo: Pathname.open("path/to/file") }) #=> multipart urlencoded encoder
  22. # ..., form: { foo: File.open("path/to/file") }) #=> multipart urlencoded encoder
  23. # ..., form: { body: "bla") }) #=> raw data encoder
  24. 1 def initialize(h, options, **params)
  25. 71 @headers = h
  26. 71 @body = self.class.initialize_body(params)
  27. 71 @options = options.merge(params)
  28. 71 if @body
  29. 9 if @options.compress_request_body && @headers.key?("content-encoding")
  30. @headers.get("content-encoding").each do |encoding|
  31. @body = self.class.initialize_deflater_body(@body, encoding)
  32. end
  33. end
  34. 9 @headers["content-type"] ||= @body.content_type
  35. 9 @headers["content-length"] = @body.bytesize unless unbounded_body?
  36. end
  37. 71 super(@body)
  38. end
  39. # consumes and yields the request payload in chunks.
  40. 1 def each(&block)
  41. 10 return enum_for(__method__) unless block
  42. 5 return if @body.nil?
  43. 5 body = stream(@body)
  44. 5 if body.respond_to?(:read)
  45. 5 while (chunk = body.read(16_384))
  46. 3 block.call(chunk)
  47. end
  48. # TODO: use copy_stream once bug is resolved: https://bugs.ruby-lang.org/issues/21131
  49. # IO.copy_stream(body, ProcIO.new(block))
  50. 4 elsif body.respond_to?(:each)
  51. body.each(&block)
  52. else
  53. 4 block[body.to_s]
  54. end
  55. end
  56. 1 def close
  57. @body.close if @body.respond_to?(:close)
  58. end
  59. # if the +@body+ is rewindable, it rewinnds it.
  60. 1 def rewind
  61. 6 return if empty?
  62. @body.rewind if @body.respond_to?(:rewind)
  63. end
  64. # return +true+ if the +body+ has been fully drained (or does nnot exist).
  65. 1 def empty?
  66. 57 return true if @body.nil?
  67. 11 return false if chunked?
  68. 11 @body.bytesize.zero?
  69. end
  70. # returns the +@body+ payload size in bytes.
  71. 1 def bytesize
  72. 25 return 0 if @body.nil?
  73. @body.bytesize
  74. end
  75. # sets the body to yield using chunked trannsfer encoding format.
  76. 1 def stream(body)
  77. 5 return body unless chunked?
  78. Transcoder::Chunker.encode(body.enum_for(:each))
  79. end
  80. # returns whether the body yields infinitely.
  81. 1 def unbounded_body?
  82. 11 return @unbounded_body if defined?(@unbounded_body)
  83. 9 @unbounded_body = !@body.nil? && (chunked? || @body.bytesize == Float::INFINITY)
  84. end
  85. # returns whether the chunked transfer encoding header is set.
  86. 1 def chunked?
  87. 54 @headers["transfer-encoding"] == "chunked"
  88. end
  89. # sets the chunked transfer encoding header.
  90. 1 def chunk!
  91. @headers.add("transfer-encoding", "chunked")
  92. end
  93. # simplecov:disable
  94. 1 def inspect
  95. "#<#{self.class}:#{object_id} " \
  96. "#{unbounded_body? ? "stream" : "@bytesize=#{bytesize}"}>"
  97. end
  98. # simplecov:enable
  99. 1 class << self
  100. 1 def initialize_body(params)
  101. 71 if (body = params.delete(:body))
  102. # @type var body: bodyIO
  103. 3 Transcoder::Body.encode(body)
  104. 68 elsif (form = params.delete(:form))
  105. 6 if Transcoder::Multipart.multipart?(form)
  106. # @type var form: Transcoder::Multipart::multipart_input
  107. 3 Transcoder::Multipart.encode(form)
  108. else
  109. # @type var form: Transcoder::urlencoded_input
  110. 3 Transcoder::Form.encode(form)
  111. end
  112. 62 elsif (json = params.delete(:json))
  113. # @type var body: _ToJson
  114. Transcoder::JSON.encode(json)
  115. end
  116. end
  117. # returns the +body+ wrapped with the correct deflater accordinng to the given +encodisng+.
  118. 1 def initialize_deflater_body(body, encoding)
  119. case encoding
  120. when "gzip"
  121. Transcoder::GZIP.encode(body)
  122. when "deflate"
  123. Transcoder::Deflate.encode(body)
  124. when "identity"
  125. body
  126. else
  127. body
  128. end
  129. end
  130. end
  131. end
  132. end

lib/httpx/resolver.rb

80.0% lines covered

55 relevant lines. 44 lines covered and 11 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "socket"
  3. 1 require "resolv"
  4. 1 module HTTPX
  5. 1 module Resolver
  6. 1 extend self
  7. 1 RESOLVE_TIMEOUT = [2, 3].freeze
  8. 1 require "httpx/resolver/entry"
  9. 1 require "httpx/resolver/cache"
  10. 1 require "httpx/resolver/resolver"
  11. 1 require "httpx/resolver/system"
  12. 1 require "httpx/resolver/native"
  13. 1 require "httpx/resolver/https"
  14. 1 require "httpx/resolver/multi"
  15. 1 @identifier_mutex = Thread::Mutex.new
  16. 1 @identifier = 1
  17. 1 def supported_ip_families
  18. 33 if Utils.in_ractor?
  19. Ractor.store_if_absent(:httpx_supported_ip_families) { find_supported_ip_families }
  20. else
  21. 33 @supported_ip_families ||= find_supported_ip_families
  22. end
  23. end
  24. 1 def generate_id
  25. 10 if Utils.in_ractor?
  26. identifier = Ractor.store_if_absent(:httpx_resolver_identifier) { -1 }
  27. Ractor.current[:httpx_resolver_identifier] = (identifier + 1) & 0xFFFF
  28. else
  29. 20 id_synchronize { @identifier = (@identifier + 1) & 0xFFFF }
  30. end
  31. end
  32. 1 def encode_dns_query(hostname, type: Resolv::DNS::Resource::IN::A, message_id: generate_id)
  33. 10 Resolv::DNS::Message.new(message_id).tap do |query|
  34. 10 query.rd = 1
  35. 10 query.add_question(hostname, type)
  36. end.encode
  37. end
  38. 1 def decode_dns_answer(payload)
  39. begin
  40. 10 message = Resolv::DNS::Message.decode(payload)
  41. rescue Resolv::DNS::DecodeError => e
  42. return :decode_error, e
  43. end
  44. # no domain was found
  45. 10 return :no_domain_found if message.rcode == Resolv::DNS::RCode::NXDomain
  46. 1 return :message_truncated if message.tc == 1
  47. 1 if message.rcode != Resolv::DNS::RCode::NoError
  48. case message.rcode
  49. when Resolv::DNS::RCode::ServFail
  50. return :retriable_error, message.rcode
  51. else
  52. return :dns_error, message.rcode
  53. end
  54. end
  55. 1 addresses = []
  56. 1 now = Utils.now
  57. 1 message.each_answer do |question, _, value|
  58. 1 case value
  59. when Resolv::DNS::Resource::IN::CNAME
  60. addresses << {
  61. "name" => question.to_s,
  62. "TTL" => (now + value.ttl),
  63. "alias" => value.name.to_s,
  64. }
  65. when Resolv::DNS::Resource::IN::A,
  66. Resolv::DNS::Resource::IN::AAAA
  67. 1 addresses << {
  68. "name" => question.to_s,
  69. 1 "TTL" => (now + value.ttl),
  70. "data" => value.address.to_s,
  71. }
  72. end
  73. end
  74. 1 [:ok, addresses]
  75. end
  76. 1 private
  77. 1 def id_synchronize(&block)
  78. 10 @identifier_mutex.synchronize(&block)
  79. end
  80. 1 def find_supported_ip_families
  81. 1 list = Socket.ip_address_list
  82. begin
  83. 4 if list.any? { |a| a.ipv6? && !a.ipv6_loopback? && !a.ipv6_linklocal? }
  84. [Socket::AF_INET6, Socket::AF_INET]
  85. else
  86. 1 [Socket::AF_INET]
  87. end
  88. rescue NotImplementedError
  89. [Socket::AF_INET]
  90. end.freeze
  91. end
  92. end
  93. end

lib/httpx/resolver/cache.rb

100.0% lines covered

4 relevant lines. 4 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "httpx/resolver/cache/base"
  3. 1 require "httpx/resolver/cache/memory"
  4. 1 module HTTPX::Resolver
  5. # The internal resolvers cache adapters are defined under this namespace.
  6. #
  7. # Adapters must comply with the Resolver Cache Adapter API and implement the following methods:
  8. #
  9. # * #resolve: (String hostname) -> Array[HTTPX::Entry]? => resolves hostname to a list of cached IPs (if found in cache or system)
  10. # * #get: (String hostname) -> Array[HTTPX::Entry]? => resolves hostname to a list of cached IPs (if found in cache)
  11. # * #set: (String hostname, Integer ip_family, Array[dns_result]) -> void => stores the set of results in the cache indexes for
  12. # the hostname and the IP family
  13. # * #evict: (String hostname, _ToS ip) -> void => evicts the ip for the hostname from the cache (usually done when no longer reachable)
  14. 1 module Cache
  15. end
  16. end

lib/httpx/resolver/cache/base.rb

75.0% lines covered

60 relevant lines. 45 lines covered and 15 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "resolv"
  3. 1 module HTTPX
  4. 1 module Resolver::Cache
  5. # Base class of the Resolver Cache adapter implementations.
  6. #
  7. # While resolver caches are not required to inherit from this class, it nevertheless provides
  8. # common useful functions for desired functionality, such as singleton object ractor-safe access,
  9. # or a default #resolve implementation which deals with IPs and the system hosts file.
  10. #
  11. 1 class Base
  12. 1 MAX_CACHE_SIZE = 512
  13. 1 CACHE_MUTEX = Thread::Mutex.new
  14. 1 HOSTS = Resolv::Hosts.new
  15. 1 @cache = nil
  16. 1 class << self
  17. 1 attr_reader :hosts_resolver
  18. # returns the singleton instance to be used within the current ractor.
  19. 1 def cache(label)
  20. 51 return Ractor.store_if_absent(:"httpx_resolver_cache_#{label}") { new } if Utils.in_ractor?
  21. 51 @cache ||= CACHE_MUTEX.synchronize do
  22. 1 @cache || new
  23. end
  24. end
  25. end
  26. # resolves +hostname+ into an instance of HTTPX::Resolver::Entry if +hostname+ is an IP,
  27. # or can be found in the cache, or can be found in the system hosts file.
  28. 1 def resolve(hostname)
  29. 32 ip_resolve(hostname) || get(hostname) || hosts_resolve(hostname)
  30. end
  31. 1 private
  32. # tries to convert +hostname+ into an IPAddr, returns <tt>nil</tt> otherwise.
  33. 1 def ip_resolve(hostname)
  34. 32 [Resolver::Entry.new(hostname)]
  35. rescue ArgumentError
  36. end
  37. # matches +hostname+ to entries in the hosts file, returns <tt>nil</nil> if none is
  38. # found, or there is no hosts file.
  39. 1 def hosts_resolve(hostname)
  40. 4 ips = if Utils.in_ractor?
  41. Ractor.store_if_absent(:httpx_hosts_resolver) { Resolv::Hosts.new }
  42. else
  43. 4 HOSTS
  44. end.getaddresses(hostname)
  45. 4 return if ips.empty?
  46. ips.map { |ip| Resolver::Entry.new(ip) }
  47. rescue IOError
  48. end
  49. # not to be used directly!
  50. 1 def _get(hostname, lookups, hostnames, ttl)
  51. 32 return unless lookups.key?(hostname)
  52. 28 entries = lookups[hostname]
  53. 28 return unless entries
  54. 28 entries.delete_if do |address|
  55. 28 address["TTL"] < ttl
  56. end
  57. 28 if entries.empty?
  58. lookups.delete(hostname)
  59. hostnames.delete(hostname)
  60. end
  61. 28 ips = entries.flat_map do |address|
  62. 28 if (als = address["alias"])
  63. _get(als, lookups, hostnames, ttl)
  64. else
  65. 28 Resolver::Entry.new(address["data"], address["TTL"])
  66. end
  67. end.compact
  68. 28 ips unless ips.empty?
  69. end
  70. 1 def _set(hostname, family, entries, lookups, hostnames)
  71. # lru cleanup
  72. 2 while lookups.size >= MAX_CACHE_SIZE
  73. hs = hostnames.shift
  74. lookups.delete(hs)
  75. end
  76. 2 hostnames << hostname
  77. 2 lookups[hostname] ||= [] # when there's no default proc
  78. 2 case family
  79. when Socket::AF_INET6
  80. lookups[hostname].concat(entries)
  81. when Socket::AF_INET
  82. 2 lookups[hostname].unshift(*entries)
  83. end
  84. 2 entries.each do |entry|
  85. 2 name = entry["name"]
  86. 2 next unless name != hostname
  87. 1 _set(name, family, [entry], lookups, hostnames)
  88. end
  89. end
  90. 1 def _evict(hostname, ip, lookups, hostnames)
  91. return unless lookups.key?(hostname)
  92. entries = lookups[hostname]
  93. return unless entries
  94. entries.delete_if { |entry| entry["data"] == ip }
  95. return unless entries.empty?
  96. lookups.delete(hostname)
  97. hostnames.delete(hostname)
  98. end
  99. end
  100. end
  101. end

lib/httpx/resolver/cache/memory.rb

86.36% lines covered

22 relevant lines. 19 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Resolver::Cache
  4. # Implementation of a thread-safe in-memory LRU resolver cache.
  5. 1 class Memory < Base
  6. 1 def initialize
  7. 1 super
  8. 1 @hostnames = []
  9. 3 @lookups = Hash.new { |h, k| h[k] = [] }
  10. 1 @lookup_mutex = Thread::Mutex.new
  11. end
  12. 1 def get(hostname)
  13. 32 now = Utils.now
  14. 32 synchronize do |lookups, hostnames|
  15. 32 _get(hostname, lookups, hostnames, now)
  16. end
  17. end
  18. 1 def set(hostname, family, entries)
  19. 1 synchronize do |lookups, hostnames|
  20. 1 _set(hostname, family, entries, lookups, hostnames)
  21. end
  22. end
  23. 1 def evict(hostname, ip)
  24. ip = ip.to_s
  25. synchronize do |lookups, hostnames|
  26. _evict(hostname, ip, lookups, hostnames)
  27. end
  28. end
  29. 1 private
  30. 1 def synchronize
  31. 66 @lookup_mutex.synchronize { yield(@lookups, @hostnames) }
  32. end
  33. end
  34. end
  35. end

lib/httpx/resolver/entry.rb

81.25% lines covered

16 relevant lines. 13 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "ipaddr"
  3. 1 module HTTPX
  4. 1 module Resolver
  5. 1 class Entry < SimpleDelegator
  6. 1 attr_reader :address
  7. 1 def self.convert(address)
  8. new(address, rescue_on_convert: true)
  9. end
  10. 1 def initialize(address, expires_in = Float::INFINITY, rescue_on_convert: false)
  11. 61 @expires_in = expires_in
  12. 61 @address = address.is_a?(IPAddr) ? address : IPAddr.new(address.to_s)
  13. 29 super(@address)
  14. rescue IPAddr::InvalidAddressError
  15. 32 raise unless rescue_on_convert
  16. @address = address.to_s
  17. super(@address)
  18. end
  19. 1 def expired?
  20. 2 @expires_in < Utils.now
  21. end
  22. end
  23. end
  24. end

lib/httpx/resolver/https.rb

16.13% lines covered

186 relevant lines. 30 lines covered and 156 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "resolv"
  3. 1 require "uri"
  4. 1 require "forwardable"
  5. 1 require "httpx/base64"
  6. 1 module HTTPX
  7. # Implementation of a DoH name resolver (https://www.youtube.com/watch?v=unMXvnY2FNM).
  8. # It wraps an HTTPX::Connection object which integrates with the main session in the
  9. # same manner as other performed HTTP requests.
  10. #
  11. 1 class Resolver::HTTPS < Resolver::Resolver
  12. 1 extend Forwardable
  13. 1 using URIExtensions
  14. 1 module DNSExtensions
  15. 1 refine Resolv::DNS do
  16. 1 def generate_candidates(name)
  17. @config.generate_candidates(name)
  18. end
  19. end
  20. end
  21. 1 using DNSExtensions
  22. 1 NAMESERVER = "https://1.1.1.1/dns-query"
  23. DEFAULTS = {
  24. 1 uri: NAMESERVER,
  25. use_get: false,
  26. }.freeze
  27. 1 def_delegators :@resolver_connection, :connecting?, :to_io, :call, :close,
  28. :closed?, :deactivate, :terminate, :inflight?, :handle_socket_timeout
  29. 1 def initialize(_, options)
  30. super
  31. @resolver_options = DEFAULTS.merge(@options.resolver_options)
  32. @queries = {}
  33. @requests = {}
  34. @_timeouts = Array(@resolver_options[:timeouts])
  35. @timeouts = Hash.new { |timeouts, host| timeouts[host] = @_timeouts.dup }
  36. @uri = URI(@resolver_options[:uri])
  37. @name = @uri_addresses = nil
  38. @resolver = Resolv::DNS.new
  39. @resolver.timeouts = @_timeouts.empty? ? Resolver::RESOLVE_TIMEOUT : @_timeouts
  40. @resolver.lazy_initialize
  41. end
  42. 1 def state
  43. @resolver_connection ? @resolver_connection.state : :idle
  44. end
  45. 1 def <<(connection)
  46. return if @uri.origin == connection.peer.to_s
  47. @uri_addresses ||= @options.resolver_cache.resolve(@uri.host) || @resolver.getaddresses(@uri.host)
  48. if @uri_addresses.empty?
  49. ex = ResolveError.new("Can't resolve DNS server #{@uri.host}")
  50. ex.set_backtrace(caller)
  51. connection.force_close
  52. throw(:resolve_error, ex)
  53. end
  54. resolve(connection)
  55. end
  56. 1 def resolver_connection
  57. # TODO: leaks connection object into the pool
  58. @resolver_connection ||=
  59. @current_session.find_connection(
  60. @uri,
  61. @current_selector,
  62. @options.merge(resolver_class: :system, ssl: { alpn_protocols: %w[h2] })
  63. ).tap do |conn|
  64. emit_addresses(conn, @family, @uri_addresses) unless conn.addresses
  65. conn.on(:force_closed, &method(:force_close))
  66. end
  67. end
  68. 1 private
  69. 1 def resolve(connection = nil, hostname = nil)
  70. @connections.shift until @connections.empty? || @connections.first.state != :closed
  71. connection ||= @connections.first
  72. return unless connection
  73. hostname ||= @queries.key(connection)
  74. if hostname.nil?
  75. hostname = connection.peer.host
  76. log do
  77. "resolver #{FAMILY_TYPES[@record_type]}: resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}"
  78. end if connection.peer.non_ascii_hostname
  79. hostname = @resolver.generate_candidates(hostname).each do |name|
  80. @queries[name.to_s] = connection
  81. end.first.to_s
  82. else
  83. @queries[hostname] = connection
  84. end
  85. @name = hostname
  86. log { "resolver #{FAMILY_TYPES[@record_type]}: query for #{hostname}" }
  87. send_request(hostname, connection)
  88. end
  89. 1 def send_request(hostname, connection)
  90. request = build_request(hostname)
  91. request.on(:response, &method(:on_response).curry(2)[request])
  92. request.on(:promise, &method(:on_promise))
  93. @requests[request] = hostname
  94. resolver_connection.send(request)
  95. @connections << connection
  96. rescue ResolveError, Resolv::DNS::EncodeError => e
  97. reset_hostname(hostname)
  98. throw(:resolve_error, e) if connection.pending.empty?
  99. emit_resolve_error(connection, connection.peer.host, e)
  100. close_or_resolve
  101. end
  102. 1 def on_response(request, response)
  103. if (e = response.error)
  104. hostname = @requests.delete(request)
  105. connection = reset_hostname(hostname)
  106. emit_resolve_error(connection, connection.peer.host, e)
  107. close_or_resolve
  108. else
  109. # @type var response: HTTPX::Response
  110. if response.status.between?(300, 399) && response.headers.key?("location")
  111. hostname = @requests[request]
  112. connection = @queries[hostname]
  113. location_uri = URI(response.headers["location"])
  114. location_uri = response.uri.merge(location_uri) if location_uri.relative?
  115. # we assume that the DNS server URI changed permanently and move on
  116. @uri = location_uri
  117. send_request(hostname, connection)
  118. return
  119. end
  120. parse(request, response)
  121. end
  122. ensure
  123. @requests.delete(request)
  124. end
  125. 1 def on_promise(_, stream)
  126. log(level: 2) { "#{stream.id}: refusing stream!" }
  127. stream.refuse
  128. end
  129. 1 def parse(request, response)
  130. hostname = @name
  131. @name = nil
  132. code, result = decode_response_body(response)
  133. case code
  134. when :ok
  135. parse_addresses(result, request)
  136. when :no_domain_found
  137. # Indicates no such domain was found.
  138. host = @requests.delete(request)
  139. connection = reset_hostname(host, reset_candidates: false)
  140. unless @queries.value?(connection)
  141. emit_resolve_error(connection)
  142. close_or_resolve
  143. return
  144. end
  145. resolve
  146. when :retriable_error
  147. timeouts = @timeouts[hostname]
  148. unless timeouts.empty?
  149. log { "resolver #{FAMILY_TYPES[@record_type]}: failed, but will retry..." }
  150. connection = @queries[hostname]
  151. resolve(connection, hostname)
  152. return
  153. end
  154. host = @requests.delete(request)
  155. connection = reset_hostname(host)
  156. emit_resolve_error(connection)
  157. close_or_resolve
  158. when :dns_error
  159. host = @requests.delete(request)
  160. connection = reset_hostname(host)
  161. emit_resolve_error(connection)
  162. close_or_resolve
  163. when :decode_error
  164. host = @requests.delete(request)
  165. connection = reset_hostname(host)
  166. emit_resolve_error(connection, connection.peer.host, result)
  167. close_or_resolve
  168. end
  169. end
  170. 1 def parse_addresses(answers, request)
  171. if answers.empty?
  172. # no address found, eliminate candidates
  173. host = @requests.delete(request)
  174. connection = reset_hostname(host)
  175. emit_resolve_error(connection)
  176. close_or_resolve
  177. return
  178. else
  179. answers = answers.group_by { |answer| answer["name"] }
  180. answers.each do |hostname, addresses|
  181. addresses = addresses.flat_map do |address|
  182. if address.key?("alias")
  183. alias_address = answers[address["alias"]]
  184. if alias_address.nil?
  185. reset_hostname(address["name"])
  186. if early_resolve(connection, hostname: address["alias"])
  187. @connections.delete(connection)
  188. else
  189. resolve(connection, address["alias"])
  190. return # rubocop:disable Lint/NonLocalExitFromIterator
  191. end
  192. else
  193. alias_address
  194. end
  195. else
  196. address
  197. end
  198. end.compact
  199. next if addresses.empty?
  200. hostname.delete_suffix!(".") if hostname.end_with?(".")
  201. connection = reset_hostname(hostname, reset_candidates: false)
  202. next unless connection # probably a retried query for which there's an answer
  203. @connections.delete(connection)
  204. # eliminate other candidates
  205. @queries.delete_if { |_, conn| connection == conn }
  206. @options.resolver_cache.set(hostname, @family, addresses) if @resolver_options[:cache]
  207. catch(:coalesced) { emit_addresses(connection, @family, addresses.map { |a| Resolver::Entry.new(a["data"], a["TTL"]) }) }
  208. end
  209. end
  210. close_or_resolve(true)
  211. end
  212. 1 def build_request(hostname)
  213. uri = @uri.dup
  214. rklass = @options.request_class
  215. payload = Resolver.encode_dns_query(hostname, type: @record_type)
  216. timeouts = @timeouts[hostname]
  217. request_timeout = timeouts.shift
  218. options = @options.merge(timeout: { request_timeout: request_timeout })
  219. if @resolver_options[:use_get]
  220. params = URI.decode_www_form(uri.query.to_s)
  221. params << ["type", FAMILY_TYPES[@record_type]]
  222. params << ["dns", Base64.urlsafe_encode64(payload, padding: false)]
  223. uri.query = URI.encode_www_form(params)
  224. request = rklass.new("GET", uri, options)
  225. else
  226. request = rklass.new("POST", uri, options, body: [payload])
  227. request.headers["content-type"] = "application/dns-message"
  228. end
  229. request.headers["accept"] = "application/dns-message"
  230. request
  231. end
  232. 1 def decode_response_body(response)
  233. case response.headers["content-type"]
  234. when "application/dns-udpwireformat",
  235. "application/dns-message"
  236. Resolver.decode_dns_answer(response.to_s)
  237. else
  238. raise Error, "unsupported DNS mime-type (#{response.headers["content-type"]})"
  239. end
  240. end
  241. 1 def reset_hostname(hostname, reset_candidates: true)
  242. @timeouts.delete(hostname)
  243. connection = @queries.delete(hostname)
  244. return connection unless connection && reset_candidates
  245. # eliminate other candidates
  246. candidates = @queries.select { |_, conn| connection == conn }.keys
  247. @queries.delete_if { |h, _| candidates.include?(h) }
  248. # reset timeouts
  249. @timeouts.delete_if { |h, _| candidates.include?(h) }
  250. connection
  251. end
  252. 1 def close_or_resolve(should_deactivate = false)
  253. # drop already closed connections
  254. @connections.shift until @connections.empty? || @connections.first.state != :closed
  255. if (@connections - @queries.values).empty?
  256. # the same resolver connection may be serving different https resolvers (AAAA and A).
  257. return if inflight?
  258. if should_deactivate
  259. deactivate
  260. else
  261. disconnect
  262. end
  263. else
  264. resolve
  265. end
  266. end
  267. end
  268. end

lib/httpx/resolver/multi.rb

97.78% lines covered

45 relevant lines. 44 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 require "resolv"
  4. 1 module HTTPX
  5. 1 class Resolver::Multi
  6. 1 attr_reader :resolvers, :options
  7. 1 def initialize(resolver_type, options)
  8. 32 @current_selector = @current_session = nil
  9. 32 @options = options
  10. 32 @resolver_options = @options.resolver_options
  11. 32 ip_families = options.ip_families || Resolver.supported_ip_families
  12. 32 @resolvers = ip_families.map do |ip_family|
  13. 32 resolver = resolver_type.new(ip_family, options)
  14. 32 resolver.multi = self
  15. 32 resolver
  16. end
  17. end
  18. 1 def state
  19. @resolvers.map(&:state).uniq.join(",")
  20. end
  21. 1 def current_selector=(s)
  22. 32 @current_selector = s
  23. 64 @resolvers.each { |r| r.current_selector = s }
  24. end
  25. 1 def current_session=(s)
  26. 32 @current_session = s
  27. 64 @resolvers.each { |r| r.current_session = s }
  28. end
  29. 1 def log(*args, **kwargs, &blk)
  30. 128 @resolvers.each { |r| r.log(*args, **kwargs, &blk) }
  31. end
  32. 1 def closed?
  33. 32 @resolvers.all?(&:closed?)
  34. end
  35. 1 def early_resolve(connection)
  36. 32 hostname = connection.peer.host
  37. 32 addresses = @resolver_options[:cache] && (connection.addresses || nolookup_resolve(hostname, connection.options))
  38. 32 return false unless addresses
  39. 28 ip_families = connection.options.ip_families
  40. 28 resolved = false
  41. 28 addresses.group_by(&:family).sort { |(f1, _), (f2, _)| f2 <=> f1 }.each do |family, addrs|
  42. 28 next unless ip_families.nil? || ip_families.include?(family)
  43. # try to match the resolver by family. However, there are cases where that's not possible, as when
  44. # the system does not have IPv6 connectivity, but it does support IPv6 via loopback/link-local.
  45. 56 resolver = @resolvers.find { |r| r.family == family } || @resolvers.first
  46. 28 next unless resolver # this should ever happen
  47. # it does not matter which resolver it is, as early-resolve code is shared.
  48. 28 resolver.emit_addresses(connection, family, addrs, true)
  49. 28 resolved = true
  50. end
  51. 28 resolved
  52. end
  53. 1 def lazy_resolve(connection)
  54. 4 @resolvers.each do |resolver|
  55. 4 resolver.lazy_resolve(connection)
  56. end
  57. end
  58. 1 private
  59. 1 def nolookup_resolve(hostname, options)
  60. 32 options.resolver_cache.resolve(hostname)
  61. end
  62. end
  63. end

lib/httpx/resolver/native.rb

58.36% lines covered

341 relevant lines. 199 lines covered and 142 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 require "resolv"
  4. 1 module HTTPX
  5. # Implements a pure ruby name resolver, which abides by the Selectable API.
  6. # It delegates DNS payload encoding/decoding to the +resolv+ stlid gem.
  7. #
  8. 1 class Resolver::Native < Resolver::Resolver
  9. 1 extend Forwardable
  10. 1 using URIExtensions
  11. DEFAULTS = {
  12. 1 nameserver: nil,
  13. **Resolv::DNS::Config.default_config_hash,
  14. packet_size: 512,
  15. timeouts: Resolver::RESOLVE_TIMEOUT,
  16. }.freeze
  17. 1 DNS_PORT = 53
  18. 1 def_delegator :@connections, :empty?
  19. 1 attr_reader :state
  20. 1 def initialize(family, options)
  21. 32 super
  22. 32 @ns_index = 0
  23. 32 @resolver_options = DEFAULTS.merge(@options.resolver_options)
  24. 32 @socket_type = @resolver_options.fetch(:socket_type, :udp)
  25. 32 @nameserver = if (nameserver = @resolver_options[:nameserver])
  26. 32 nameserver = nameserver[family] if nameserver.is_a?(Hash)
  27. 32 Array(nameserver)
  28. end
  29. 32 @ndots = @resolver_options.fetch(:ndots, 1)
  30. 96 @search = Array(@resolver_options[:search]).map { |srch| srch.scan(/[^.]+/) }
  31. 32 @_timeouts = Array(@resolver_options[:timeouts])
  32. 42 @timeouts = Hash.new { |timeouts, host| timeouts[host] = @_timeouts.dup }
  33. 32 @name = nil
  34. 32 @queries = {}
  35. 32 @read_buffer = "".b
  36. 32 @write_buffer = Buffer.new(@resolver_options[:packet_size])
  37. 32 @state = :idle
  38. 32 @timer = nil
  39. end
  40. 1 def close
  41. 4 transition(:closed)
  42. end
  43. 1 def force_close(*)
  44. @timer.cancel if @timer
  45. @timer = @name = nil
  46. @queries.clear
  47. @timeouts.clear
  48. close
  49. super
  50. ensure
  51. terminate
  52. end
  53. 1 def terminate
  54. disconnect
  55. end
  56. 1 def closed?
  57. 36 @state == :idle || @state == :closed
  58. end
  59. 1 def to_io
  60. 10 @io.to_io
  61. end
  62. 1 def call
  63. 14 case @state
  64. when :idle, :closed
  65. 4 return if @connections.empty?
  66. 4 transition(:idle) if @state == :closed
  67. 4 transition(:open)
  68. 4 consume if @state == :open
  69. when :open
  70. 10 consume
  71. end
  72. end
  73. 1 def interests
  74. 10 case @state
  75. when :idle
  76. transition(:open)
  77. when :closed
  78. transition(:idle)
  79. transition(:open)
  80. end
  81. 10 calculate_interests
  82. end
  83. 1 def <<(connection)
  84. 4 if @nameserver.nil?
  85. ex = ResolveError.new("No available nameserver")
  86. ex.set_backtrace(caller)
  87. connection.force_close
  88. throw(:resolve_error, ex)
  89. else
  90. 4 @connections << connection
  91. 4 resolve
  92. end
  93. end
  94. 1 def timeout
  95. 10 return unless @name
  96. 10 @start_timeout = Utils.now
  97. 10 timeouts = @timeouts[@name]
  98. 10 return if timeouts.empty?
  99. 10 log(level: 2) { "resolver #{FAMILY_TYPES[@record_type]}: next timeout #{timeouts.first} secs... (#{timeouts.size - 1} left)" }
  100. 10 timeouts.first
  101. end
  102. 1 def handle_socket_timeout(interval); end
  103. 1 def handle_error(error)
  104. if error.respond_to?(:connection) &&
  105. error.respond_to?(:host)
  106. reset_hostname(error.host, connection: error.connection)
  107. else
  108. @queries.each do |host, connection|
  109. reset_hostname(host, connection: connection)
  110. end
  111. end
  112. super
  113. end
  114. 1 private
  115. 1 def calculate_interests
  116. 66 if @queries.empty?
  117. 2 return @io.interests if (@socket_type == :tcp) && (@state == :idle)
  118. 2 return
  119. end
  120. 64 return :r if @write_buffer.empty?
  121. 14 :w
  122. end
  123. 1 def consume
  124. 14 loop do
  125. 24 dread if calculate_interests == :r
  126. 24 break unless calculate_interests == :w
  127. 10 dwrite
  128. 10 break unless calculate_interests == :r
  129. end
  130. rescue Errno::EHOSTUNREACH => e
  131. @ns_index += 1
  132. nameserver = @nameserver
  133. if nameserver && @ns_index < nameserver.size
  134. log { "resolver #{FAMILY_TYPES[@record_type]}: failed resolving on nameserver #{@nameserver[@ns_index - 1]} (#{e.message})" }
  135. transition(:idle)
  136. @timeouts.clear
  137. retry
  138. else
  139. handle_error(e)
  140. disconnect
  141. end
  142. rescue NativeResolveError => e
  143. handle_error(e)
  144. close_or_resolve
  145. retry unless closed?
  146. end
  147. 1 def schedule_retry
  148. 10 h = @name
  149. 10 return unless h
  150. 10 connection = @queries[h]
  151. 10 timeouts = @timeouts[h]
  152. 10 timeout = timeouts.shift
  153. 10 @timer = @current_selector.after(timeout) do
  154. next unless @connections.include?(connection)
  155. @timer = @name = nil
  156. do_retry(h, connection, timeout)
  157. end
  158. end
  159. 1 def do_retry(h, connection, interval)
  160. timeouts = @timeouts[h]
  161. if !timeouts.empty?
  162. log { "resolver #{FAMILY_TYPES[@record_type]}: timeout after #{interval}s, retry (with #{timeouts.first}s) #{h}..." }
  163. # must downgrade to tcp AND retry on same host as last
  164. downgrade_socket
  165. resolve(connection, h)
  166. elsif @ns_index + 1 < @nameserver.size
  167. # try on the next nameserver
  168. @ns_index += 1
  169. log do
  170. "resolver #{FAMILY_TYPES[@record_type]}: failed resolving #{h} on nameserver #{@nameserver[@ns_index - 1]} (timeout error)"
  171. end
  172. transition(:idle)
  173. @timeouts.clear
  174. resolve(connection, h)
  175. else
  176. reset_hostname(h, reset_candidates: false)
  177. unless @queries.empty?
  178. resolve(connection)
  179. return
  180. end
  181. @connections.delete(connection)
  182. host = connection.peer.host
  183. # This loop_time passed to the exception is bogus. Ideally we would pass the total
  184. # resolve timeout, including from the previous retries.
  185. ex = ResolveTimeoutError.new(interval, "Timed out while resolving #{host}")
  186. ex.set_backtrace(ex ? ex.backtrace : caller)
  187. emit_resolve_error(connection, host, ex)
  188. close_or_resolve
  189. end
  190. end
  191. 1 def dread(wsize = @resolver_options[:packet_size])
  192. 20 loop do
  193. 20 wsize = @large_packet.capacity if @large_packet
  194. 20 siz = @io.read(wsize, @read_buffer)
  195. 20 unless siz
  196. ex = EOFError.new("descriptor closed")
  197. ex.set_backtrace(caller)
  198. raise ex
  199. end
  200. 20 return unless siz.positive?
  201. 10 if @socket_type == :tcp
  202. # packet may be incomplete, need to keep draining from the socket
  203. if @large_packet
  204. # large packet buffer already exists, continue pumping
  205. @large_packet << @read_buffer
  206. next unless @large_packet.full?
  207. parse(@large_packet.to_s)
  208. @large_packet = nil
  209. # downgrade to udp again
  210. downgrade_socket
  211. return
  212. else
  213. size = @read_buffer[0, 2].unpack1("n")
  214. buffer = @read_buffer.byteslice(2..-1)
  215. if size > @read_buffer.bytesize
  216. # only do buffer logic if it's worth it, and the whole packet isn't here already
  217. @large_packet = Buffer.new(size)
  218. @large_packet << buffer
  219. next
  220. else
  221. parse(buffer)
  222. end
  223. end
  224. else # udp
  225. 10 parse(@read_buffer)
  226. end
  227. 10 return if @state == :closed || !@write_buffer.empty?
  228. end
  229. end
  230. 1 def dwrite
  231. 10 loop do
  232. 20 return if @write_buffer.empty?
  233. 10 siz = @io.write(@write_buffer)
  234. 10 unless siz
  235. ex = EOFError.new("descriptor closed")
  236. ex.set_backtrace(caller)
  237. raise ex
  238. end
  239. 10 return unless siz.positive?
  240. 10 schedule_retry if @write_buffer.empty?
  241. 10 return if @state == :closed
  242. end
  243. end
  244. 1 def parse(buffer)
  245. 10 code, result = Resolver.decode_dns_answer(buffer)
  246. 10 case code
  247. when :ok
  248. 1 reset_query
  249. 1 parse_addresses(result)
  250. when :no_domain_found
  251. 9 reset_query
  252. # Indicates no such domain was found.
  253. 9 hostname, connection = @queries.first
  254. 9 reset_hostname(hostname, reset_candidates: false)
  255. 15 other_candidate, _ = @queries.find { |_, conn| conn == connection }
  256. 9 if other_candidate
  257. 6 resolve(connection, other_candidate)
  258. else
  259. 3 @connections.delete(connection)
  260. 3 ex = NativeResolveError.new(connection, connection.peer.host, "name or service not known")
  261. 3 ex.set_backtrace(ex ? ex.backtrace : caller)
  262. 3 emit_resolve_error(connection, connection.peer.host, ex)
  263. 3 close_or_resolve
  264. end
  265. when :message_truncated
  266. reset_query
  267. # TODO: what to do if it's already tcp??
  268. return if @socket_type == :tcp
  269. @socket_type = :tcp
  270. hostname, _ = @queries.first
  271. reset_hostname(hostname)
  272. transition(:closed)
  273. when :retriable_error
  274. if @name && @timer
  275. log { "resolver #{FAMILY_TYPES[@record_type]}: failed, but will retry..." }
  276. return
  277. end
  278. # retry now!
  279. # connection = @queries[@name].shift
  280. # @timer.fire
  281. reset_query
  282. hostname, connection = @queries.first
  283. reset_hostname(hostname)
  284. @connections.delete(connection)
  285. ex = NativeResolveError.new(connection, connection.peer.host, "unknown DNS error (error code #{result})")
  286. raise ex
  287. when :dns_error
  288. reset_query
  289. hostname, connection = @queries.first
  290. reset_hostname(hostname)
  291. @connections.delete(connection)
  292. ex = NativeResolveError.new(connection, connection.peer.host, "unknown DNS error (error code #{result})")
  293. raise ex
  294. when :decode_error
  295. reset_query
  296. hostname, connection = @queries.first
  297. reset_hostname(hostname)
  298. @connections.delete(connection)
  299. ex = NativeResolveError.new(connection, connection.peer.host, result.message)
  300. ex.set_backtrace(result.backtrace)
  301. raise ex
  302. end
  303. end
  304. 1 def parse_addresses(addresses)
  305. 1 if addresses.empty?
  306. # no address found, eliminate candidates
  307. hostname, connection = @queries.first
  308. reset_hostname(hostname)
  309. @connections.delete(connection)
  310. raise NativeResolveError.new(connection, connection.peer.host)
  311. else
  312. 1 address = addresses.first
  313. 1 name = address["name"]
  314. 1 connection = @queries.delete(name)
  315. 1 unless connection
  316. 1 orig_name = name
  317. # absolute name
  318. 1 name_labels = Resolv::DNS::Name.create(name).to_a
  319. 1 name = @queries.each_key.first { |hname| name_labels == Resolv::DNS::Name.create(hname).to_a }
  320. # probably a retried query for which there's an answer
  321. 1 unless name
  322. @timeouts.delete(orig_name)
  323. return
  324. end
  325. 1 address["name"] = name
  326. 1 connection = @queries.delete(name)
  327. end
  328. 2 alias_addresses, addresses = addresses.partition { |addr| addr.key?("alias") }
  329. 1 if addresses.empty? && !alias_addresses.empty? # CNAME
  330. hostname_alias = alias_addresses.first["alias"]
  331. # clean up intermediate queries
  332. @timeouts.delete(name) unless connection.peer.host == name
  333. if early_resolve(connection, hostname: hostname_alias)
  334. @connections.delete(connection)
  335. else
  336. if @socket_type == :tcp
  337. # must downgrade to udp if tcp
  338. @socket_type = @resolver_options.fetch(:socket_type, :udp)
  339. transition(:idle)
  340. transition(:open)
  341. end
  342. log { "resolver #{FAMILY_TYPES[@record_type]}: ALIAS #{hostname_alias} for #{name}" }
  343. resolve(connection, hostname_alias)
  344. return
  345. end
  346. else
  347. 1 reset_hostname(name, connection: connection)
  348. 1 @timeouts.delete(connection.peer.host)
  349. 1 @connections.delete(connection)
  350. 1 @options.resolver_cache.set(connection.peer.host, @family, addresses) if @resolver_options[:cache]
  351. 1 catch(:coalesced) do
  352. 2 emit_addresses(connection, @family, addresses.map { |a| Resolver::Entry.new(a["data"], a["TTL"]) })
  353. end
  354. end
  355. end
  356. 1 close_or_resolve
  357. end
  358. 1 def resolve(connection = nil, hostname = nil)
  359. 10 @connections.shift until @connections.empty? || @connections.first.state != :closed
  360. 14 connection ||= @connections.find { |c| !@queries.value?(c) }
  361. 10 raise Error, "no URI to resolve" unless connection
  362. # do not buffer query if previous is still in the buffer or awaiting reply/retry
  363. 10 return unless @write_buffer.empty? && @timer.nil?
  364. 10 hostname ||= @queries.key(connection)
  365. 10 if hostname.nil?
  366. 4 hostname = connection.peer.host
  367. 4 if connection.peer.non_ascii_hostname
  368. log { "resolver #{FAMILY_TYPES[@record_type]}: resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}" }
  369. end
  370. 4 hostname = generate_candidates(hostname).each do |name|
  371. 12 @queries[name] = connection
  372. end.first
  373. else
  374. 6 @queries[hostname] = connection
  375. end
  376. 10 @name = hostname
  377. 10 log { "resolver #{FAMILY_TYPES[@record_type]}: query for #{hostname}" }
  378. begin
  379. 10 @write_buffer << encode_dns_query(hostname)
  380. rescue Resolv::DNS::EncodeError => e
  381. reset_hostname(hostname, connection: connection)
  382. @connections.delete(connection)
  383. emit_resolve_error(connection, hostname, e)
  384. close_or_resolve
  385. end
  386. end
  387. 1 def encode_dns_query(hostname)
  388. 10 message_id = Resolver.generate_id
  389. 10 msg = Resolver.encode_dns_query(hostname, type: @record_type, message_id: message_id)
  390. 10 msg[0, 2] = [msg.size, message_id].pack("nn") if @socket_type == :tcp
  391. 10 msg
  392. end
  393. 1 def generate_candidates(name)
  394. 4 return [name] if name.end_with?(".")
  395. 4 name_parts = name.scan(/[^.]+/)
  396. 12 candidates = @search.map { |domain| [*name_parts, *domain].join(".") }
  397. 4 fname = "#{name}."
  398. 4 if @ndots <= name_parts.size - 1
  399. 4 candidates.unshift(fname)
  400. else
  401. candidates << fname
  402. end
  403. 4 candidates
  404. end
  405. 1 def build_socket
  406. 4 ip, port = @nameserver[@ns_index]
  407. 4 port ||= DNS_PORT
  408. 4 case @socket_type
  409. when :udp
  410. 4 log { "resolver #{FAMILY_TYPES[@record_type]}: server: udp://#{ip}:#{port}..." }
  411. 4 UDP.new(ip, port, @options)
  412. when :tcp
  413. log { "resolver #{FAMILY_TYPES[@record_type]}: server: tcp://#{ip}:#{port}..." }
  414. origin = URI("tcp://#{ip}:#{port}")
  415. TCP.new(origin, [Resolver::Entry.new(ip)], @options)
  416. end
  417. end
  418. 1 def downgrade_socket
  419. return unless @socket_type == :tcp
  420. @socket_type = @resolver_options.fetch(:socket_type, :udp)
  421. transition(:idle)
  422. transition(:open)
  423. end
  424. # moves the resolver state machine to the +nextstate+ state (if all conditions are met)-
  425. 1 def transition(nextstate)
  426. 8 case nextstate
  427. when :idle
  428. if (io = @io)
  429. @io = nil
  430. io.close
  431. # @fiber-switch-guard
  432. return if @io
  433. end
  434. when :open
  435. 4 return unless @state == :idle
  436. 4 @io ||= build_socket
  437. 4 @io.connect
  438. 4 return unless @io.connected?
  439. 4 resolve if @queries.empty? && !@connections.empty?
  440. when :closed
  441. 4 return if @state == :closed
  442. 4 if (io = @io)
  443. 4 @io = nil
  444. 4 io.close
  445. # @fiber-switch-guard
  446. 4 return if @io
  447. end
  448. 4 @start_timeout = nil
  449. 4 @write_buffer.clear
  450. 4 @read_buffer.clear
  451. end
  452. 8 log(level: 3) { "#{@state} -> #{nextstate}" }
  453. 8 @state = nextstate
  454. rescue Errno::ECONNREFUSED,
  455. Errno::EADDRNOTAVAIL,
  456. Errno::EHOSTUNREACH,
  457. SocketError,
  458. IOError,
  459. ConnectTimeoutError => e
  460. # these errors may happen during TCP handshake
  461. # treat them as resolve errors.
  462. on_error(e)
  463. end
  464. 1 def reset_query
  465. 10 @timer.cancel
  466. 10 @timer = @name = nil
  467. end
  468. 1 def reset_hostname(hostname, connection: @queries.delete(hostname), reset_candidates: true)
  469. 10 @timeouts.delete(hostname)
  470. 10 return unless connection && reset_candidates
  471. # eliminate other candidates
  472. 3 candidates = @queries.select { |_, conn| connection == conn }.keys
  473. 3 @queries.delete_if { |h, _| candidates.include?(h) }
  474. # reset timeouts
  475. 1 @timeouts.delete_if { |h, _| candidates.include?(h) }
  476. end
  477. 1 def close_or_resolve
  478. # drop already closed connections
  479. 4 @connections.shift until @connections.empty? || @connections.first.state != :closed
  480. 4 if (@connections - @queries.values).empty?
  481. 4 disconnect
  482. else
  483. resolve
  484. end
  485. end
  486. end
  487. end

lib/httpx/resolver/resolver.rb

65.35% lines covered

101 relevant lines. 66 lines covered and 35 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "resolv"
  3. 1 module HTTPX
  4. # Base class for all internal internet name resolvers. It handles basic blocks
  5. # from the Selectable API.
  6. #
  7. 1 class Resolver::Resolver
  8. 1 include Loggable
  9. 1 using ArrayExtensions::Intersect
  10. RECORD_TYPES = {
  11. 1 Socket::AF_INET6 => Resolv::DNS::Resource::IN::AAAA,
  12. Socket::AF_INET => Resolv::DNS::Resource::IN::A,
  13. }.freeze
  14. FAMILY_TYPES = {
  15. 1 Resolv::DNS::Resource::IN::AAAA => "AAAA",
  16. Resolv::DNS::Resource::IN::A => "A",
  17. }.freeze
  18. 1 class << self
  19. 1 def multi?
  20. 32 true
  21. end
  22. end
  23. 1 attr_reader :family, :options
  24. 1 attr_writer :current_selector, :current_session
  25. 1 attr_accessor :multi
  26. 1 def initialize(family, options)
  27. 32 @family = family
  28. 32 @record_type = RECORD_TYPES[family]
  29. 32 @options = options
  30. 32 @connections = []
  31. end
  32. 1 def each_connection(&block)
  33. 1 enum_for(__method__) unless block
  34. 1 return unless @connections
  35. 1 @connections.each(&block)
  36. end
  37. 1 def close; end
  38. 1 alias_method :terminate, :close
  39. 1 def initial_call
  40. 4 call
  41. end
  42. 1 def force_close(*args)
  43. while (connection = @connections.shift)
  44. connection.force_close(*args)
  45. end
  46. end
  47. 1 def closed?
  48. true
  49. end
  50. 1 def empty?
  51. true
  52. end
  53. 1 def inflight?
  54. false
  55. end
  56. 1 def emit_addresses(connection, family, addresses, early_resolve = false)
  57. 58 addresses.map! { |address| address.is_a?(Resolver::Entry) ? address : Resolver::Entry.new(address) }
  58. # double emission check, but allow early resolution to work
  59. 29 conn_addrs = connection.addresses
  60. 29 return if !early_resolve && conn_addrs && !conn_addrs.empty? && !addresses.intersect?(conn_addrs)
  61. 29 log do
  62. "resolver #{FAMILY_TYPES[RECORD_TYPES[family]]}: " \
  63. "answer #{connection.peer.host}: #{addresses.inspect} (early resolve: #{early_resolve})"
  64. end
  65. # do not apply resolution delay for non-dns name resolution
  66. 29 if !early_resolve &&
  67. # just in case...
  68. @current_selector &&
  69. # resolution delay only applies to IPv4
  70. family == Socket::AF_INET &&
  71. # connection already has addresses and initiated/ended handshake
  72. !connection.io &&
  73. # no need to delay if not supporting dual stack / multi-homed IP
  74. 1 (connection.options.ip_families || Resolver.supported_ip_families).size > 1 &&
  75. # connection URL host is already the IP (early resolve included perhaps?)
  76. addresses.first.to_s != connection.peer.host.to_s
  77. log { "resolver #{FAMILY_TYPES[RECORD_TYPES[family]]}: applying resolution delay..." }
  78. @current_selector.after(0.05) do
  79. # double emission check
  80. unless connection.addresses && addresses.intersect?(connection.addresses)
  81. emit_resolved_connection(connection, addresses, early_resolve)
  82. end
  83. end
  84. else
  85. 29 emit_resolved_connection(connection, addresses, early_resolve)
  86. end
  87. end
  88. 1 def handle_error(error)
  89. if error.respond_to?(:connection) &&
  90. error.respond_to?(:host)
  91. @connections.delete(error.connection)
  92. emit_resolve_error(error.connection, error.host, error)
  93. else
  94. while (connection = @connections.shift)
  95. emit_resolve_error(connection, connection.peer.host, error)
  96. end
  97. end
  98. end
  99. 1 def on_io_error(e)
  100. on_error(e)
  101. force_close(true)
  102. end
  103. 1 def on_error(error)
  104. handle_error(error)
  105. disconnect
  106. end
  107. 1 def early_resolve(connection, hostname: connection.peer.host) # rubocop:disable Naming/PredicateMethod
  108. addresses = @resolver_options[:cache] && (connection.addresses || @options.resolver_cache.resolve(hostname))
  109. return false unless addresses
  110. addresses = addresses.select { |addr| addr.family == @family }
  111. return false if addresses.empty?
  112. emit_addresses(connection, @family, addresses, true)
  113. true
  114. end
  115. 1 def lazy_resolve(connection)
  116. 4 return unless @current_session && @current_selector
  117. 4 conn_to_resolve = @current_session.try_clone_connection(connection, @current_selector, @family)
  118. 4 self << conn_to_resolve
  119. 4 return if empty?
  120. # both the resolver and the connection it's resolving must be pinned to the session
  121. 4 @current_session.pin(conn_to_resolve, @current_selector)
  122. 4 @current_session.select_resolver(self, @current_selector)
  123. end
  124. 1 private
  125. 1 def emit_resolved_connection(connection, addresses, early_resolve)
  126. begin
  127. 29 connection.addresses = addresses
  128. 29 return if connection.state == :closed
  129. 29 resolve_connection(connection)
  130. rescue StandardError => e
  131. if early_resolve
  132. connection.force_close
  133. throw(:resolve_error, e)
  134. else
  135. emit_connection_error(connection, e)
  136. end
  137. end
  138. end
  139. 1 def emit_resolve_error(connection, hostname = connection.peer.host, ex = nil)
  140. 3 emit_connection_error(connection, resolve_error(hostname, ex))
  141. end
  142. 1 def resolve_error(hostname, ex = nil)
  143. 3 return ex if ex.is_a?(ResolveError) || ex.is_a?(ResolveTimeoutError)
  144. message = ex ? ex.message : "Can't resolve #{hostname}"
  145. error = ResolveError.new(message)
  146. error.set_backtrace(ex ? ex.backtrace : caller)
  147. error
  148. end
  149. 1 def resolve_connection(connection)
  150. 29 @current_session.__send__(:on_resolver_connection, connection, @current_selector)
  151. end
  152. 1 def emit_connection_error(connection, error)
  153. 3 return connection.handle_connect_error(error) if connection.connecting?
  154. connection.on_error(error)
  155. end
  156. 1 def disconnect
  157. 4 close
  158. 4 return unless closed?
  159. 4 @current_session.deselect_resolver(self, @current_selector)
  160. end
  161. end
  162. end

lib/httpx/resolver/system.rb

20.78% lines covered

154 relevant lines. 32 lines covered and 122 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "resolv"
  3. 1 module HTTPX
  4. # Implementation of a synchronous name resolver which relies on the system resolver,
  5. # which is lib'c getaddrinfo function (abstracted in ruby via Addrinfo.getaddrinfo).
  6. #
  7. # Its main advantage is relying on the reference implementation for name resolution
  8. # across most/all OSs which deploy ruby (it's what TCPSocket also uses), its main
  9. # disadvantage is the inability to set timeouts / check socket for readiness events,
  10. # hence why it relies on using the Timeout module, which poses a lot of problems for
  11. # the selector loop, specially when network is unstable.
  12. #
  13. 1 class Resolver::System < Resolver::Resolver
  14. 1 using URIExtensions
  15. 1 RESOLV_ERRORS = [Resolv::ResolvError,
  16. Resolv::DNS::Requester::RequestError,
  17. Resolv::DNS::EncodeError,
  18. Resolv::DNS::DecodeError].freeze
  19. 1 DONE = 1
  20. 1 ERROR = 2
  21. 1 class AddrinfoTimeoutError < StandardError
  22. end
  23. 1 class << self
  24. 1 def multi?
  25. false
  26. end
  27. end
  28. 1 attr_reader :state
  29. 1 def initialize(options)
  30. super(0, options)
  31. @resolver_options = @options.resolver_options
  32. resolv_options = @resolver_options.dup
  33. timeouts = resolv_options.delete(:timeouts) || Resolver::RESOLVE_TIMEOUT
  34. @_timeouts = Array(timeouts)
  35. @timeouts = Hash.new { |tims, host| tims[host] = @_timeouts.dup }
  36. resolv_options.delete(:cache)
  37. @queries = []
  38. @ips = []
  39. @pipe_mutex = Thread::Mutex.new
  40. @state = :idle
  41. end
  42. 1 def resolvers
  43. return enum_for(__method__) unless block_given?
  44. yield self
  45. end
  46. 1 def multi
  47. self
  48. end
  49. 1 def empty?
  50. @connections.empty?
  51. end
  52. 1 def close
  53. transition(:closed)
  54. end
  55. 1 def force_close(*)
  56. close
  57. @queries.clear
  58. @timeouts.clear
  59. @ips.clear
  60. super
  61. end
  62. 1 def closed?
  63. @state == :closed
  64. end
  65. 1 def to_io
  66. @pipe_read.to_io
  67. end
  68. 1 def call
  69. case @state
  70. when :open
  71. consume
  72. end
  73. nil
  74. end
  75. 1 def interests
  76. return if @queries.empty?
  77. :r
  78. end
  79. 1 def timeout
  80. _, connection = @queries.first
  81. return unless connection
  82. timeouts = @timeouts[connection.peer.host]
  83. return if timeouts.empty?
  84. log(level: 2) { "resolver #{FAMILY_TYPES[@record_type]}: next timeout #{timeouts.first} secs... (#{timeouts.size - 1} left)" }
  85. timeouts.first
  86. end
  87. 1 def lazy_resolve(connection)
  88. @connections << connection
  89. resolve
  90. return if empty?
  91. @current_session.select_resolver(self, @current_selector)
  92. end
  93. 1 def early_resolve(_, **) # rubocop:disable Naming/PredicateMethod
  94. false
  95. end
  96. 1 def handle_socket_timeout(interval)
  97. error = HTTPX::ResolveTimeoutError.new(interval, "timed out while waiting on select")
  98. error.set_backtrace(caller)
  99. @queries.each do |_, connection| # rubocop:disable Style/HashEachMethods
  100. emit_resolve_error(connection, connection.peer.host, error) if @connections.delete(connection)
  101. end
  102. while (connection = @connections.shift)
  103. emit_resolve_error(connection, connection.peer.host, error)
  104. end
  105. close_or_resolve
  106. end
  107. 1 private
  108. 1 def transition(nextstate)
  109. case nextstate
  110. when :idle
  111. @timeouts.clear
  112. when :open
  113. return unless @state == :idle
  114. @pipe_read, @pipe_write = IO.pipe
  115. when :closed
  116. return unless @state == :open
  117. @pipe_write.close
  118. @pipe_read.close
  119. end
  120. @state = nextstate
  121. end
  122. 1 def consume
  123. return if @connections.empty?
  124. event = @pipe_read.read_nonblock(1, exception: false)
  125. return if event == :wait_readable
  126. raise ResolveError, "socket pipe closed unexpectedly" if event.nil?
  127. case event.unpack1("C")
  128. when DONE
  129. *pair, addrs = @pipe_mutex.synchronize { @ips.pop }
  130. if pair
  131. @queries.delete(pair)
  132. family, connection = pair
  133. @connections.delete(connection)
  134. catch(:coalesced) { emit_addresses(connection, family, addrs) }
  135. end
  136. when ERROR
  137. *pair, error = @pipe_mutex.synchronize { @ips.pop }
  138. if pair && error
  139. @queries.delete(pair)
  140. _, connection = pair
  141. @connections.delete(connection)
  142. emit_resolve_error(connection, connection.peer.host, error)
  143. end
  144. end
  145. return disconnect if @connections.empty?
  146. resolve
  147. rescue StandardError => e
  148. on_error(e)
  149. end
  150. 1 def resolve(connection = nil, hostname = nil)
  151. @connections.shift until @connections.empty? || @connections.first.state != :closed
  152. connection ||= @connections.first
  153. raise Error, "no URI to resolve" unless connection
  154. return unless @queries.empty?
  155. hostname ||= connection.peer.host
  156. scheme = connection.origin.scheme
  157. log do
  158. "resolver: resolve IDN #{connection.peer.non_ascii_hostname} as #{hostname}"
  159. end if connection.peer.non_ascii_hostname
  160. transition(:open)
  161. ip_families = connection.options.ip_families || Resolver.supported_ip_families
  162. ip_families.each do |family|
  163. @queries << [family, connection]
  164. end
  165. async_resolve(connection, hostname, scheme)
  166. consume
  167. end
  168. 1 def async_resolve(connection, hostname, scheme)
  169. families = connection.options.ip_families || Resolver.supported_ip_families
  170. log { "resolver: query for #{hostname}" }
  171. timeouts = @timeouts[connection.peer.host]
  172. resolve_timeout = timeouts.first
  173. Thread.start do
  174. Thread.current.report_on_exception = false
  175. begin
  176. addrs = if resolve_timeout
  177. Timeout.timeout(resolve_timeout, AddrinfoTimeoutError) do
  178. __addrinfo_resolve(hostname, scheme)
  179. end
  180. else
  181. __addrinfo_resolve(hostname, scheme)
  182. end
  183. addrs = addrs.sort_by(&:afamily).group_by(&:afamily)
  184. families.each do |family|
  185. addresses = addrs[family]
  186. next unless addresses
  187. addresses.map!(&:ip_address)
  188. addresses.uniq!
  189. @pipe_mutex.synchronize do
  190. @ips.unshift([family, connection, addresses])
  191. @pipe_write.putc(DONE) unless @pipe_write.closed?
  192. end
  193. end
  194. rescue StandardError => e
  195. if e.is_a?(AddrinfoTimeoutError)
  196. timeouts.shift
  197. retry unless timeouts.empty?
  198. e = ResolveTimeoutError.new(resolve_timeout, e.message)
  199. e.set_backtrace(e.backtrace)
  200. end
  201. @pipe_mutex.synchronize do
  202. families.each do |family|
  203. @ips.unshift([family, connection, e])
  204. @pipe_write.putc(ERROR) unless @pipe_write.closed?
  205. end
  206. end
  207. end
  208. end
  209. Thread.pass
  210. end
  211. 1 def close_or_resolve
  212. # drop already closed connections
  213. @connections.shift until @connections.empty? || @connections.first.state != :closed
  214. if (@connections - @queries.map(&:last)).empty?
  215. disconnect
  216. else
  217. resolve
  218. end
  219. end
  220. 1 def __addrinfo_resolve(host, scheme)
  221. Addrinfo.getaddrinfo(host, scheme, Socket::AF_UNSPEC, Socket::SOCK_STREAM)
  222. end
  223. end
  224. end

lib/httpx/response.rb

80.15% lines covered

131 relevant lines. 105 lines covered and 26 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "objspace"
  3. 1 require "stringio"
  4. 1 require "tempfile"
  5. 1 require "fileutils"
  6. 1 require "forwardable"
  7. 1 module HTTPX
  8. # Defines a HTTP response is handled internally, with a few properties exposed as attributes.
  9. #
  10. # It delegates the following methods to the corresponding HTTPX::Request:
  11. #
  12. # * HTTPX::Request#uri
  13. # * HTTPX::Request#peer_address
  14. #
  15. # It implements (indirectly, via the +body+) the IO write protocol to internally buffer payloads.
  16. #
  17. # It implements the IO reader protocol in order for users to buffer/stream it, acts as an enumerable
  18. # (of payload chunks).
  19. #
  20. 1 class Response
  21. 1 extend Forwardable
  22. 1 include Callbacks
  23. # the HTTP response status code
  24. 1 attr_reader :status
  25. # an HTTPX::Headers object containing the response HTTP headers.
  26. 1 attr_reader :headers
  27. # a HTTPX::Response::Body object wrapping the response body. The following methods are delegated to it:
  28. #
  29. # * HTTPX::Response::Body#to_s
  30. # * HTTPX::Response::Body#to_str
  31. # * HTTPX::Response::Body#read
  32. # * HTTPX::Response::Body#copy_to
  33. # * HTTPX::Response::Body#close
  34. 1 attr_reader :body
  35. # The HTTP protocol version used to fetch the response.
  36. 1 attr_reader :version
  37. # returns the response body buffered in a string.
  38. 1 def_delegator :@body, :to_s
  39. 1 def_delegator :@body, :to_str
  40. # implements the IO reader +#read+ interface.
  41. 1 def_delegator :@body, :read
  42. # copies the response body to a different location.
  43. 1 def_delegator :@body, :copy_to
  44. # the corresponding request uri.
  45. 1 def_delegator :@request, :uri
  46. # the IP address of the peer server.
  47. 1 def_delegator :@request, :peer_address
  48. # inits the instance with the corresponding +request+ to this response, an the
  49. # response HTTP +status+, +version+ and HTTPX::Headers instance of +headers+.
  50. 1 def initialize(request, status, version, headers)
  51. 67 @request = request
  52. 67 @options = request.options
  53. 67 @version = version
  54. 67 @status = Integer(status)
  55. 67 @headers = @options.headers_class.new(headers)
  56. 67 @body = @options.response_body_class.new(self, @options)
  57. 67 @finished = complete?
  58. 67 @content_type = @content_length = nil
  59. end
  60. # dupped initialization
  61. 1 def initialize_dup(orig)
  62. super
  63. # if a response gets dupped, the body handle must also get dupped to prevent
  64. # two responses from using the same file handle to read.
  65. @body = orig.body.dup
  66. end
  67. # closes the respective +@request+ and +@body+.
  68. 1 def close
  69. @request.close
  70. @body.close
  71. end
  72. # merges headers defined in +h+ into the response headers.
  73. 1 def merge_headers(h)
  74. @headers = @headers.merge(h)
  75. @content_type = @content_length = nil
  76. end
  77. # writes +data+ chunk into the response body.
  78. 1 def <<(data)
  79. 60 @body.write(data)
  80. end
  81. # returns the HTTPX::ContentType for the response, as per what's declared in the content-type header.
  82. #
  83. # response.content_type #=> #<HTTPX::ContentType:xxx @header_value="text/plain">
  84. # response.content_type.mime_type #=> "text/plain"
  85. 1 def content_type
  86. 67 @content_type ||= ContentType.new(@headers["content-type"])
  87. end
  88. # returns the response content length as advertised in the HTTP Content-Length header value.
  89. 1 def content_length
  90. 33 return @content_length if defined?(@content_length)
  91. @content_length = @headers["content-length"]&.to_i
  92. end
  93. # returns whether the response has been fully fetched.
  94. 1 def finished?
  95. 95 @finished
  96. end
  97. # marks the response as finished, freezes the headers.
  98. 1 def finish!
  99. 67 @finished = true
  100. 67 @headers.freeze
  101. 67 @request.connection = nil
  102. end
  103. # returns whether the response contains body payload.
  104. 1 def bodyless?
  105. 67 @request.verb == "HEAD" ||
  106. @status < 200 || # informational response
  107. @status == 204 ||
  108. @status == 205 ||
  109. @status == 304 || begin
  110. 67 content_length = @headers["content-length"]
  111. 67 return false if content_length.nil?
  112. 33 content_length == "0"
  113. end
  114. end
  115. 1 def complete?
  116. 67 bodyless? || (@request.verb == "CONNECT" && @status == 200)
  117. end
  118. # simplecov:disable
  119. 1 def inspect
  120. "#<#{self.class}:#{object_id} " \
  121. "HTTP/#{version} " \
  122. "@status=#{@status} " \
  123. "@headers=#{@headers} " \
  124. "@body=#{@body.bytesize}>"
  125. end
  126. # simplecov:enable
  127. # returns an instance of HTTPX::HTTPError if the response has a 4xx or 5xx
  128. # status code, or nothing.
  129. #
  130. # ok_response.error #=> nil
  131. # not_found_response.error #=> HTTPX::HTTPError instance, status 404
  132. 1 def error
  133. 3 return if @status < 400
  134. HTTPError.new(self)
  135. end
  136. # it raises the exception returned by +error+, or itself otherwise.
  137. #
  138. # ok_response.raise_for_status #=> ok_response
  139. # not_found_response.raise_for_status #=> raises HTTPX::HTTPError exception
  140. 1 def raise_for_status
  141. 3 return self unless (err = error)
  142. raise err
  143. end
  144. # decodes the response payload into a ruby object **if** the payload is valid json.
  145. #
  146. # response.json #≈> { "foo" => "bar" } for "{\"foo\":\"bar\"}" payload
  147. # response.json(symbolize_names: true) #≈> { foo: "bar" } for "{\"foo\":\"bar\"}" payload
  148. 1 def json(*args)
  149. decode(Transcoder::JSON, *args)
  150. end
  151. # decodes the response payload into a ruby object **if** the payload is valid
  152. # "application/x-www-urlencoded" or "multipart/form-data".
  153. 1 def form
  154. decode(Transcoder::Form)
  155. end
  156. 1 def xml
  157. # TODO: remove at next major version.
  158. warn "DEPRECATION WARNING: calling `.#{__method__}` on plain HTTPX responses is deprecated. " \
  159. "Use HTTPX.plugin(:xml) sessions and call `.#{__method__}` in its responses instead."
  160. require "httpx/plugins/xml"
  161. decode(Plugins::XML::Transcoder)
  162. end
  163. 1 private
  164. # decodes the response payload using the given +transcoder+, which implements the decoding logic.
  165. #
  166. # +transcoder+ must implement the internal transcoder API, i.e. respond to <tt>decode(HTTPX::Response response)</tt>,
  167. # which returns a decoder which responds to <tt>call(HTTPX::Response response, **kwargs)</tt>
  168. 1 def decode(transcoder, *args)
  169. # TODO: check if content-type is a valid format, i.e. "application/json" for json parsing
  170. decoder = transcoder.decode(self)
  171. raise Error, "no decoder available for \"#{transcoder}\"" unless decoder
  172. @body.rewind
  173. decoder.call(self, *args)
  174. end
  175. end
  176. # Helper class which decodes the HTTP "content-type" header.
  177. 1 class ContentType
  178. 1 MIME_TYPE_RE = %r{^([^/]+/[^;]+)(?:$|;)}.freeze
  179. 1 CHARSET_RE = /;\s*charset=([^;]+)/i.freeze
  180. 1 def initialize(header_value)
  181. 67 @header_value = header_value
  182. 67 @mime_type = @charset = nil
  183. 67 @initialized = false
  184. end
  185. # returns the mime type declared in the header.
  186. #
  187. # ContentType.new("application/json; charset=utf-8").mime_type #=> "application/json"
  188. 1 def mime_type
  189. return @mime_type if @initialized
  190. load
  191. @mime_type
  192. end
  193. # returns the charset declared in the header.
  194. #
  195. # ContentType.new("application/json; charset=utf-8").charset #=> "utf-8"
  196. # ContentType.new("text/plain").charset #=> nil
  197. 1 def charset
  198. 67 return @charset if @initialized
  199. 67 load
  200. 67 @charset
  201. end
  202. 1 private
  203. 1 def load
  204. 67 m = @header_value.to_s[MIME_TYPE_RE, 1]
  205. 67 m && @mime_type = m.strip.downcase
  206. 67 c = @header_value.to_s[CHARSET_RE, 1]
  207. 67 c && @charset = c.strip.delete('"')
  208. 67 @initialized = true
  209. end
  210. end
  211. # Wraps an error which has happened while processing an HTTP Request. It has partial
  212. # public API parity with HTTPX::Response, so users should rely on it to infer whether
  213. # the returned response is one or the other.
  214. #
  215. # response = HTTPX.get("https://some-domain/path") #=> response is HTTPX::Response or HTTPX::ErrorResponse
  216. # response.raise_for_status #=> raises if it wraps an error
  217. 1 class ErrorResponse
  218. 1 extend Forwardable
  219. # the corresponding HTTPX::Request instance.
  220. 1 attr_reader :request
  221. # the HTTPX::Response instance, when there is one (i.e. error happens fetching the response).
  222. 1 attr_reader :response
  223. # the wrapped exception.
  224. 1 attr_reader :error
  225. # the request uri
  226. 1 def_delegator :@request, :uri
  227. # the IP address of the peer server.
  228. 1 def_delegator :@request, :peer_address
  229. 1 def initialize(request, error)
  230. 7 @request = request
  231. 7 @response = request.response if request.response.is_a?(Response)
  232. 7 @error = error
  233. 7 @options = request.options
  234. 7 @request.log_exception(@error)
  235. 7 finish!
  236. end
  237. # returns the exception full message.
  238. 1 def to_s
  239. @error.full_message(highlight: false)
  240. end
  241. # closes the error resources.
  242. 1 def close
  243. @response.close if @response
  244. end
  245. # always true for error responses.
  246. 1 def finished?
  247. 7 true
  248. end
  249. 1 def finish!
  250. 11 @request.connection = nil
  251. end
  252. # raises the wrapped exception.
  253. 1 def raise_for_status
  254. 1 raise @error
  255. end
  256. # buffers lost chunks to error response
  257. 1 def <<(data)
  258. return unless @response
  259. @response << data
  260. end
  261. end
  262. end
  263. 1 require_relative "response/body"
  264. 1 require_relative "response/buffer"
  265. 1 require_relative "pmatch_extensions" if RUBY_VERSION >= "2.7.0"

lib/httpx/response/body.rb

67.89% lines covered

109 relevant lines. 74 lines covered and 35 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. # Implementation of the HTTP Response body as a buffer which implements the IO writer protocol
  4. # (for buffering the response payload), the IO reader protocol (for consuming the response payload),
  5. # and can be iterated over (via #each, which yields the payload in chunks).
  6. 1 class Response::Body
  7. # the payload encoding (i.e. "utf-8", "ASCII-8BIT")
  8. 1 attr_reader :encoding
  9. # Array of encodings contained in the response "content-encoding" header.
  10. 1 attr_reader :encodings
  11. 1 attr_reader :buffer
  12. 1 protected :buffer
  13. # initialized with the corresponding HTTPX::Response +response+ and HTTPX::Options +options+.
  14. 1 def initialize(response, options)
  15. 67 @response = response
  16. 67 @headers = response.headers
  17. 67 @options = options
  18. 67 @window_size = options.window_size
  19. 67 @max_response_body_size = options.max_response_body_size
  20. 67 @encodings = []
  21. 67 @length = 0
  22. 67 @buffer = @reader = nil
  23. 67 @state = :idle
  24. # initialize response encoding
  25. 67 @encoding = if (enc = response.content_type.charset)
  26. begin
  27. 16 Encoding.find(enc)
  28. rescue ArgumentError
  29. Encoding::BINARY
  30. end
  31. else
  32. 51 Encoding::BINARY
  33. end
  34. 67 initialize_inflaters
  35. end
  36. 1 def initialize_dup(other)
  37. super
  38. @buffer = other.instance_variable_get(:@buffer).dup
  39. end
  40. 1 def closed?
  41. @state == :closed
  42. end
  43. # write the response payload +chunk+ into the buffer. Inflates the chunk when required
  44. # and supported.
  45. 1 def write(chunk)
  46. 56 return if @state == :closed
  47. 56 return 0 if chunk.empty?
  48. 29 chunk = decode_chunk(chunk)
  49. 29 raise Error, "maximum response body size exceeded" if @max_response_body_size < @length
  50. 29 transition(:open)
  51. 29 @buffer.write(chunk)
  52. 29 @response.emit(:chunk_received, chunk)
  53. 29 chunk.bytesize
  54. end
  55. # reads a chunk from the payload (implementation of the IO reader protocol).
  56. 1 def read(*args)
  57. return unless @buffer
  58. unless @reader
  59. rewind
  60. @reader = @buffer
  61. end
  62. @reader.read(*args)
  63. end
  64. # size of the decoded response payload. May differ from "content-length" header if
  65. # response was encoded over-the-wire.
  66. 1 def bytesize
  67. @length
  68. end
  69. # yields the payload in chunks.
  70. 1 def each
  71. return enum_for(__method__) unless block_given?
  72. begin
  73. if @buffer
  74. rewind
  75. while (chunk = @buffer.read(@window_size))
  76. yield(chunk.force_encoding(@encoding))
  77. end
  78. end
  79. ensure
  80. close
  81. end
  82. end
  83. # returns the declared filename in the "contennt-disposition" header, when present.
  84. 1 def filename
  85. return unless @headers.key?("content-disposition")
  86. Utils.get_filename(@headers["content-disposition"])
  87. end
  88. # returns the full response payload as a string.
  89. 1 def to_s
  90. 31 return "".b unless @buffer
  91. 18 @buffer.to_s
  92. end
  93. 1 alias_method :to_str, :to_s
  94. # whether the payload is empty.
  95. 1 def empty?
  96. 1 @length.zero?
  97. end
  98. # copies the payload to +dest+.
  99. #
  100. # body.copy_to("path/to/file")
  101. # body.copy_to(Pathname.new("path/to/file"))
  102. # body.copy_to(File.new("path/to/file"))
  103. 1 def copy_to(dest)
  104. return unless @buffer
  105. rewind
  106. if dest.respond_to?(:path) && @buffer.respond_to?(:path)
  107. FileUtils.mv(@buffer.path, dest.path)
  108. else
  109. IO.copy_stream(@buffer, dest)
  110. end
  111. ensure
  112. close
  113. end
  114. # closes/cleans the buffer, resets everything
  115. 1 def close
  116. 2 if @buffer
  117. @buffer.close
  118. @buffer = nil
  119. end
  120. 2 @length = 0
  121. 2 transition(:closed)
  122. end
  123. 1 def ==(other)
  124. super || case other
  125. when Response::Body
  126. @buffer == other.buffer
  127. else
  128. @buffer = other
  129. end
  130. end
  131. # simplecov:disable
  132. 1 def inspect
  133. "#<#{self.class}:#{object_id} " \
  134. "@state=#{@state} " \
  135. "@length=#{@length}>"
  136. end
  137. # simplecov:enable
  138. # rewinds the response payload buffer.
  139. 1 def rewind
  140. return unless @buffer
  141. # in case there's some reading going on
  142. @reader = nil
  143. @buffer.rewind
  144. end
  145. 1 private
  146. # prepares inflaters for the advertised encodings in "content-encoding" header.
  147. 1 def initialize_inflaters
  148. 67 @inflaters = nil
  149. 67 return unless @headers.key?("content-encoding")
  150. 1 return unless @options.decompress_response_body
  151. 1 @inflaters = @headers.get("content-encoding").filter_map do |encoding|
  152. 1 next if encoding == "identity"
  153. 1 inflater = self.class.initialize_inflater_by_encoding(encoding, @response)
  154. # do not uncompress if there is no decoder available. In fact, we can't reliably
  155. # continue decompressing beyond that, so ignore.
  156. 1 break unless inflater
  157. 1 @encodings << encoding
  158. 1 inflater
  159. end
  160. end
  161. # passes the +chunk+ through all inflaters to decode it.
  162. 1 def decode_chunk(chunk)
  163. @inflaters.reverse_each do |inflater|
  164. chunk = inflater.call(chunk)
  165. 33 end if @inflaters
  166. 33 @length += chunk.bytesize
  167. 33 chunk
  168. end
  169. # tries transitioning the body STM to the +nextstate+.
  170. 1 def transition(nextstate)
  171. 31 case nextstate
  172. when :open
  173. 29 return unless @state == :idle
  174. 23 @buffer = Response::Buffer.new(
  175. threshold_size: @options.body_threshold_size,
  176. bytesize: @length,
  177. encoding: @encoding
  178. )
  179. when :closed
  180. 2 return if @state == :closed
  181. end
  182. 25 @state = nextstate
  183. end
  184. 1 class << self
  185. 1 def initialize_inflater_by_encoding(encoding, response, **kwargs) # :nodoc:
  186. 1 case encoding
  187. when "gzip"
  188. 1 Transcoder::GZIP.decode(response, **kwargs)
  189. when "deflate"
  190. Transcoder::Deflate.decode(response, **kwargs)
  191. end
  192. end
  193. end
  194. end
  195. end

lib/httpx/response/buffer.rb

45.76% lines covered

59 relevant lines. 27 lines covered and 32 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "delegate"
  3. 1 require "stringio"
  4. 1 require "tempfile"
  5. 1 module HTTPX
  6. # wraps and delegates to an internal buffer, which can be a StringIO or a Tempfile.
  7. 1 class Response::Buffer < SimpleDelegator
  8. 1 attr_reader :buffer
  9. 1 protected :buffer
  10. # initializes buffer with the +threshold_size+ over which the payload gets buffer to a tempfile,
  11. # the initial +bytesize+, and the +encoding+.
  12. 1 def initialize(threshold_size:, bytesize: 0, encoding: Encoding::BINARY)
  13. 23 @threshold_size = threshold_size
  14. 23 @bytesize = bytesize
  15. 23 @encoding = encoding
  16. 23 @buffer = StringIO.new("".b)
  17. 23 super(@buffer)
  18. end
  19. 1 def initialize_dup(other)
  20. super
  21. # create new descriptor in READ-ONLY mode
  22. @buffer =
  23. case other.buffer
  24. when StringIO
  25. StringIO.new(other.buffer.string, mode: File::RDONLY)
  26. else
  27. other.buffer.class.new(other.buffer.path, encoding: Encoding::BINARY, mode: File::RDONLY).tap do |temp|
  28. FileUtils.copy_file(other.buffer.path, temp.path)
  29. end
  30. end
  31. end
  32. # size in bytes of the buffered content.
  33. 1 def size
  34. @bytesize
  35. end
  36. # writes the +chunk+ into the buffer.
  37. 1 def write(chunk)
  38. 29 @bytesize += chunk.bytesize
  39. 29 try_upgrade_buffer
  40. 29 @buffer.write(chunk)
  41. end
  42. # returns the buffered content as a string.
  43. 1 def to_s
  44. 18 case @buffer
  45. when StringIO
  46. begin
  47. 18 @buffer.string.force_encoding(@encoding)
  48. rescue ArgumentError
  49. @buffer.string
  50. end
  51. when Tempfile
  52. rewind
  53. content = @buffer.read
  54. begin
  55. content.force_encoding(@encoding)
  56. rescue ArgumentError # ex: unknown encoding name - utf
  57. content
  58. end
  59. end
  60. end
  61. # closes the buffer.
  62. 1 def close
  63. @buffer.close
  64. @buffer.unlink if @buffer.respond_to?(:unlink)
  65. end
  66. 1 def ==(other)
  67. super || begin
  68. return false unless other.is_a?(Response::Buffer)
  69. buffer_pos = @buffer.pos
  70. other_pos = other.buffer.pos
  71. @buffer.rewind
  72. other.buffer.rewind
  73. begin
  74. FileUtils.compare_stream(@buffer, other.buffer)
  75. ensure
  76. @buffer.pos = buffer_pos
  77. other.buffer.pos = other_pos
  78. end
  79. end
  80. end
  81. 1 private
  82. # initializes the buffer into a StringIO, or turns it into a Tempfile when the threshold
  83. # has been reached.
  84. 1 def try_upgrade_buffer
  85. 29 return unless @bytesize > @threshold_size
  86. return if @buffer.is_a?(Tempfile)
  87. aux = @buffer
  88. @buffer = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
  89. if aux
  90. aux.rewind
  91. IO.copy_stream(aux, @buffer)
  92. aux.close
  93. end
  94. __setobj__(@buffer)
  95. end
  96. end
  97. end

lib/httpx/selector.rb

62.76% lines covered

145 relevant lines. 91 lines covered and 54 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "io/wait"
  3. 1 module HTTPX
  4. #
  5. # Implements the selector loop, where it registers and monitors "Selectable" objects.
  6. #
  7. # A Selectable object is an object which can calculate the **interests** (<tt>:r</tt>, <tt>:w</tt> or <tt>:rw</tt>,
  8. # respectively "read", "write" or "read-write") it wants to monitor for, and returns (via <tt>to_io</tt> method) an
  9. # IO object which can be passed to functions such as IO.select . More exhaustively, a Selectable **must** implement
  10. # the following methods:
  11. #
  12. # state :: returns the state as a Symbol, must return <tt>:closed</tt> when disposed of resources.
  13. # to_io :: returns the IO object.
  14. # call :: gets called when the IO is ready.
  15. # interests :: returns the current interests to monitor for, as described above.
  16. # timeout :: returns nil or an integer, representing how long to wait for interests.
  17. # handle_socket_timeout(Numeric) :: called when waiting for interest times out.
  18. #
  19. 1 class Selector
  20. 1 extend Forwardable
  21. 1 READABLE = %i[rw r].freeze
  22. 1 WRITABLE = %i[rw w].freeze
  23. 1 private_constant :READABLE
  24. 1 private_constant :WRITABLE
  25. 1 def_delegator :@timers, :after
  26. 1 def_delegator :@selectables, :each
  27. 1 def initialize
  28. 70 @timers = Timers.new
  29. 70 @selectables = []
  30. 70 @is_timer_interval = false
  31. end
  32. 1 def empty?
  33. 79 @selectables.empty? && @timers.empty?
  34. end
  35. # first time the registered selectables are added, there's probably work to do.
  36. 1 def initial_call
  37. 63 @selectables.each(&:initial_call)
  38. end
  39. 1 def next_tick
  40. 79 catch(:jump_tick) do
  41. 79 timeout = next_timeout
  42. 79 if timeout && timeout.negative?
  43. @timers.fire
  44. throw(:jump_tick)
  45. end
  46. begin
  47. 79 select(timeout) do |c|
  48. 79 c.log(level: 2) { "[#{c.state}] selected from selector##{object_id} #{" after #{timeout} secs" unless timeout.nil?}..." }
  49. 79 c.call
  50. end
  51. 79 @timers.fire
  52. rescue TimeoutError => e
  53. @timers.fire(e)
  54. end
  55. end
  56. end
  57. 1 def terminate
  58. # array may change during iteration
  59. 73 selectables = @selectables.reject(&:inflight?)
  60. 73 selectables.delete_if do |sel|
  61. 38 sel.terminate
  62. 38 sel.state == :closed
  63. end
  64. 73 until selectables.empty?
  65. next_tick
  66. selectables &= @selectables
  67. end
  68. end
  69. 1 def find_resolver(options)
  70. 32 res = @selectables.find do |c|
  71. 1 c.is_a?(Resolver::Resolver) &&
  72. options.resolver_options_match?(c.options)
  73. end
  74. 32 res.multi if res
  75. end
  76. 1 def each_connection(&block)
  77. 302 return enum_for(__method__) unless block
  78. 151 @selectables.each do |c|
  79. 13 case c
  80. when Resolver::Resolver
  81. 1 c.each_connection(&block)
  82. when Connection
  83. 12 yield c
  84. end
  85. end
  86. end
  87. 1 def find_connection(request_uri, options)
  88. 75 each_connection.find do |connection|
  89. 9 connection.match?(request_uri, options)
  90. end
  91. end
  92. 1 def find_mergeable_connection(connection)
  93. 67 each_connection.find do |ch|
  94. 3 ch != connection && ch.mergeable?(connection)
  95. end
  96. end
  97. # deregisters +io+ from selectables.
  98. 1 def deregister(io)
  99. 76 @selectables.delete(io)
  100. end
  101. # register +io+.
  102. 1 def register(io)
  103. 73 return if @selectables.include?(io)
  104. 73 @selectables << io
  105. end
  106. 1 private
  107. 1 def select(interval, &block)
  108. # do not cause an infinite loop here.
  109. #
  110. # this may happen if timeout calculation actually triggered an error which causes
  111. # the connections to be reaped (such as the total timeout error) before #select
  112. # gets called.
  113. 79 if @selectables.empty?
  114. begin
  115. sleep(interval)
  116. rescue IOError
  117. # @fiber-switch-guard
  118. # in a fiber scheduler scenario, IOs may be closed by the scheduler and raised in a separate fiber
  119. # on wakeup, which includes a sleep call.
  120. end if interval
  121. return
  122. end
  123. # @type var r: (selectable | Array[selectable])?
  124. # @type var w: (selectable | Array[selectable])?
  125. 79 r, w = nil
  126. 79 @selectables.delete_if do |io|
  127. 81 interests = io.interests
  128. 81 is_closed = io.state == :closed
  129. 81 if is_closed
  130. # the process by which io was closed may have already triggered the on_close callback,
  131. # which already deregistered the io. this check prevents it from deleting the wrong io,
  132. # because of https://bugs.ruby-lang.org/issues/22021 .
  133. next(@selectables.include?(io))
  134. end
  135. 81 if interests
  136. 79 io.log(level: 2) do
  137. "[#{io.state}] registering in selector##{object_id} for select (#{interests})#{" for #{interval} seconds" unless interval.nil?}"
  138. end
  139. 79 if READABLE.include?(interests)
  140. 51 r = r.nil? ? io : (Array(r) << io)
  141. end
  142. 79 if WRITABLE.include?(interests)
  143. 28 w = w.nil? ? io : (Array(w) << io)
  144. end
  145. end
  146. 81 is_closed
  147. end
  148. 79 case r
  149. when Array
  150. w = Array(w) unless w.nil?
  151. select_many(r, w, interval, &block)
  152. when nil
  153. 28 case w
  154. when Array
  155. select_many(r, w, interval, &block)
  156. when nil
  157. return unless interval && @selectables.any?
  158. # no selectables
  159. # TODO: replace with sleep?
  160. select_many(r, w, interval, &block)
  161. else
  162. 28 select_one(w, :w, interval, &block)
  163. end
  164. else
  165. 51 case w
  166. when Array
  167. select_many(Array(r), w, interval, &block)
  168. when nil
  169. 51 select_one(r, :r, interval, &block)
  170. else
  171. if r == w
  172. select_one(r, :rw, interval, &block)
  173. else
  174. select_many(Array(r), Array(w), interval, &block)
  175. end
  176. end
  177. end
  178. end
  179. 1 def select_many(r, w, interval, &block)
  180. begin
  181. readers, writers = ::IO.select(r, w, nil, interval)
  182. rescue IOError => e
  183. (Array(r) + Array(w)).each do |sel|
  184. # TODO: is there a way to cheaply find the IO associated with the error?
  185. sel.on_io_error(e)
  186. end
  187. rescue StandardError => e
  188. (Array(r) + Array(w)).each do |sel|
  189. sel.on_error(e)
  190. end
  191. return
  192. rescue Exception => e # rubocop:disable Lint/RescueException
  193. (Array(r) + Array(w)).each do |sel|
  194. sel.force_close(true)
  195. end
  196. raise e
  197. end
  198. if readers.nil? && writers.nil? && interval
  199. [*r, *w].each { |io| io.handle_socket_timeout(interval) }
  200. return
  201. end
  202. if writers
  203. readers.each do |io|
  204. yield io
  205. # so that we don't yield 2 times
  206. writers.delete(io)
  207. end if readers
  208. writers.each(&block)
  209. else
  210. readers.each(&block) if readers
  211. end
  212. end
  213. 1 def select_one(io, interests, interval)
  214. begin
  215. result =
  216. 79 case interests
  217. 51 when :r then io.to_io.wait_readable(interval)
  218. 28 when :w then io.to_io.wait_writable(interval)
  219. when :rw then rw_wait(io, interval)
  220. end
  221. rescue IOError => e
  222. io.on_io_error(e)
  223. return
  224. rescue StandardError => e
  225. io.on_error(e)
  226. return
  227. rescue Exception => e # rubocop:disable Lint/RescueException
  228. io.force_close(true)
  229. raise e
  230. end
  231. 79 unless result || interval.nil?
  232. io.handle_socket_timeout(interval) unless @is_timer_interval
  233. return
  234. end
  235. 79 yield io
  236. end
  237. 1 def next_timeout
  238. 79 @is_timer_interval = false
  239. 79 timer_interval = @timers.wait_interval
  240. 79 connection_interval = @selectables.filter_map(&:timeout).min
  241. 79 return connection_interval unless timer_interval
  242. 43 if connection_interval.nil? || timer_interval <= connection_interval
  243. 43 @is_timer_interval = true
  244. 43 return timer_interval
  245. end
  246. connection_interval
  247. end
  248. 1 if RUBY_ENGINE == "jruby"
  249. def rw_wait(io, interval)
  250. io.to_io.wait(interval, :read_write)
  251. end
  252. 1 elsif IO.const_defined?(:READABLE)
  253. 1 def rw_wait(io, interval)
  254. io.to_io.wait(IO::READABLE | IO::WRITABLE, interval)
  255. end
  256. else
  257. def rw_wait(io, interval)
  258. if interval
  259. io.to_io.wait(interval, :read_write)
  260. else
  261. io.to_io.wait(:read_write)
  262. end
  263. end
  264. end
  265. end
  266. end

lib/httpx/session.rb

77.89% lines covered

294 relevant lines. 229 lines covered and 65 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. # Class implementing the APIs being used publicly.
  4. #
  5. # HTTPX.get(..) #=> delegating to an internal HTTPX::Session object.
  6. # HTTPX.plugin(..).get(..) #=> creating an intermediate HTTPX::Session with plugin, then sending the GET request
  7. 1 class Session
  8. 1 include Loggable
  9. 1 include Chainable
  10. # initializes the session with a set of +options+, which will be shared by all
  11. # requests sent from it.
  12. #
  13. # When pass a block, it'll yield itself to it, then closes after the block is evaluated.
  14. 1 def initialize(options = EMPTY_HASH, &blk)
  15. 159 @options = self.class.default_options.merge(options)
  16. 159 @persistent = @options.persistent
  17. 159 @pool = @options.pool_class.new(@options.pool_options)
  18. 159 @wrapped = false
  19. 159 @closing = false
  20. 159 INSTANCES[self] = self if @persistent && @options.close_on_fork && INSTANCES
  21. 159 wrap(&blk) if blk
  22. end
  23. # Yields itself the block, then closes it after the block is evaluated.
  24. #
  25. # session.wrap do |http|
  26. # http.get("https://wikipedia.com")
  27. # end # wikipedia connection closes here
  28. 1 def wrap
  29. prev_wrapped = @wrapped
  30. @wrapped = true
  31. was_initialized = false
  32. current_selector = get_current_selector do
  33. selector = Selector.new
  34. set_current_selector(selector)
  35. was_initialized = true
  36. selector
  37. end
  38. begin
  39. yield self
  40. ensure
  41. unless prev_wrapped
  42. if @persistent
  43. deactivate(current_selector)
  44. else
  45. close(current_selector)
  46. end
  47. end
  48. @wrapped = prev_wrapped
  49. set_current_selector(nil) if was_initialized
  50. end
  51. end
  52. # closes all the active connections from the session.
  53. #
  54. # when called directly without specifying +selector+, all available connections
  55. # will be picked up from the connection pool and closed. Connections in use
  56. # by other sessions, or same session in a different thread, will not be reaped.
  57. 1 def close(selector = Selector.new)
  58. # throw resolvers away from the pool
  59. 64 @pool.reset_resolvers
  60. # preparing to throw away connections
  61. 156 while (connection = @pool.pop_connection)
  62. 28 next if connection.state == :closed
  63. select_connection(connection, selector)
  64. end
  65. 64 selector_close(selector)
  66. end
  67. # performs one, or multple requests; it accepts:
  68. #
  69. # 1. one or multiple HTTPX::Request objects;
  70. # 2. an HTTP verb, then a sequence of URIs or URI/options tuples;
  71. # 3. one or multiple HTTP verb / uri / (optional) options tuples;
  72. #
  73. # when present, the set of +options+ kwargs is applied to all of the
  74. # sent requests.
  75. #
  76. # respectively returns a single HTTPX::Response response, or all of them in an Array, in the same order.
  77. #
  78. # resp1 = session.request(req1)
  79. # resp1, resp2 = session.request(req1, req2)
  80. # resp1 = session.request("GET", "https://server.org/a")
  81. # resp1, resp2 = session.request("GET", ["https://server.org/a", "https://server.org/b"])
  82. # resp1, resp2 = session.request(["GET", "https://server.org/a"], ["GET", "https://server.org/b"])
  83. # resp1 = session.request("POST", "https://server.org/a", form: { "foo" => "bar" })
  84. # resp1, resp2 = session.request(["POST", "https://server.org/a", form: { "foo" => "bar" }], ["GET", "https://server.org/b"])
  85. # resp1, resp2 = session.request("GET", ["https://server.org/a", "https://server.org/b"], headers: { "x-api-token" => "TOKEN" })
  86. #
  87. 1 def request(*args, **params)
  88. 64 raise ArgumentError, "must perform at least one request" if args.empty?
  89. 64 requests = args.first.is_a?(Request) ? args : build_requests(*args, params)
  90. 64 responses = send_requests(*requests)
  91. 63 return responses.first if responses.size == 1
  92. 6 responses
  93. end
  94. # returns a HTTP::Request instance built from the HTTP +verb+, the request +uri+, and
  95. # the optional set of request-specific +options+. This request **must** be sent through
  96. # the same session it was built from.
  97. #
  98. # req = session.build_request("GET", "https://server.com")
  99. # resp = session.request(req)
  100. 1 def build_request(verb, uri, params = EMPTY_HASH, options = @options)
  101. 71 rklass = options.request_class
  102. 71 request = rklass.new(verb, uri, options, params)
  103. 71 request.persistent = @persistent
  104. 71 set_request_callbacks(request)
  105. 71 request
  106. end
  107. 1 def select_connection(connection, selector)
  108. 73 pin(connection, selector)
  109. 73 connection.log(level: 2) do
  110. "registering into selector##{selector.object_id}"
  111. end
  112. 73 selector.register(connection)
  113. end
  114. 1 def pin(conn_or_resolver, selector)
  115. 141 conn_or_resolver.current_session = self
  116. 141 conn_or_resolver.current_selector = selector
  117. end
  118. 1 alias_method :select_resolver, :select_connection
  119. 1 def deselect_connection(connection, selector, cloned = false)
  120. 72 connection.log(level: 2) do
  121. "deregistering connection##{connection.object_id}(#{connection.state}) from selector##{selector.object_id}"
  122. end
  123. 72 selector.deregister(connection)
  124. # do not check-in connections only created for Happy Eyeballs
  125. 72 return if cloned
  126. 72 return if @closing && connection.state == :closed && !connection.used?
  127. 38 connection.log(level: 2) { "check-in connection##{connection.object_id}(#{connection.state}) in pool##{@pool.object_id}" }
  128. 38 @pool.checkin_connection(connection)
  129. end
  130. 1 def deselect_resolver(resolver, selector)
  131. 4 resolver.log(level: 2) do
  132. "deregistering resolver##{resolver.object_id}(#{resolver.state}) from selector##{selector.object_id}"
  133. end
  134. 4 selector.deregister(resolver)
  135. 4 return if @closing && resolver.closed?
  136. 4 resolver.log(level: 2) { "check-in resolver##{resolver.object_id}(#{resolver.state}) in pool##{@pool.object_id}" }
  137. 4 @pool.checkin_resolver(resolver)
  138. end
  139. 1 def try_clone_connection(connection, selector, family)
  140. 4 connection.family ||= family
  141. 4 return connection if connection.family == family
  142. new_connection = connection.class.new(connection.origin, connection.options)
  143. new_connection.family = family
  144. connection.sibling = new_connection
  145. do_init_connection(new_connection, selector)
  146. new_connection
  147. end
  148. # returns the HTTPX::Connection through which the +request+ should be sent through.
  149. 1 def find_connection(request_uri, selector, options)
  150. 75 if (connection = selector.find_connection(request_uri, options))
  151. 7 connection.idling if connection.state == :closed
  152. 7 log(level: 2) { "found connection##{connection.object_id}(#{connection.state}) in selector##{selector.object_id}" }
  153. 7 return connection
  154. end
  155. 68 connection = @pool.checkout_connection(request_uri, options)
  156. 68 log(level: 2) { "found connection##{connection.object_id}(#{connection.state}) in pool##{@pool.object_id}" }
  157. 68 case connection.state
  158. when :idle
  159. 66 do_init_connection(connection, selector)
  160. when :open
  161. # external io
  162. select_connection(connection, selector)
  163. when :closing, :closed
  164. 2 connection.idling
  165. 2 if connection.addresses?
  166. 2 select_connection(connection, selector)
  167. else
  168. # if addresses expired, resolve again
  169. resolve_connection(connection, selector)
  170. end
  171. else
  172. pin(connection, selector)
  173. end
  174. 68 connection
  175. end
  176. 1 private
  177. 1 def selector_close(selector)
  178. begin
  179. 73 @closing = true
  180. 73 selector.terminate
  181. ensure
  182. 73 @closing = false
  183. end
  184. end
  185. # tries deactivating connections in the +selector+, deregistering the ones that have been deactivated.
  186. 1 def deactivate(selector)
  187. 9 selector.each_connection.to_a.each(&:deactivate)
  188. end
  189. # callback executed when an HTTP/2 promise frame has been received.
  190. 1 def on_promise(_, stream)
  191. log(level: 2) { "#{stream.id}: refusing stream!" }
  192. stream.refuse
  193. end
  194. # returns the corresponding HTTP::Response to the given +request+ if it has been received.
  195. 1 def fetch_response(request, _selector, _options)
  196. 110 response = request.response
  197. 110 return unless response && response.finished?
  198. 74 log(level: 2) { "response##{response.object_id} fetched" }
  199. 74 response
  200. end
  201. # sends the +request+ to the corresponding HTTPX::Connection
  202. 1 def send_request(request, selector, options = request.options)
  203. error = begin
  204. 75 catch(:resolve_error) do
  205. 75 log(level: 2) { "finding connection for request##{request.object_id}..." }
  206. 75 connection = find_connection(request.uri, selector, options)
  207. 75 connection.send(request)
  208. end
  209. rescue StandardError => e
  210. e
  211. end
  212. 74 return unless error && error.is_a?(Exception)
  213. raise error unless error.is_a?(Error)
  214. response = ErrorResponse.new(request, error)
  215. request.response = response
  216. request.emit_response(response)
  217. end
  218. # returns a set of HTTPX::Request objects built from the given +args+ and +options+.
  219. 1 def build_requests(*args, params)
  220. 55 requests = if args.size == 1
  221. 1 reqs = args.first
  222. 1 reqs.map do |verb, uri, ps = EMPTY_HASH|
  223. 2 request_params = params
  224. 2 request_params = request_params.merge(ps) unless ps.empty?
  225. 2 build_request(verb, uri, request_params)
  226. end
  227. else
  228. 54 verb, uris = args
  229. 54 if uris.respond_to?(:each)
  230. 54 uris.enum_for(:each).map do |uri, ps = EMPTY_HASH|
  231. 59 request_params = params
  232. 59 request_params = request_params.merge(ps) unless ps.empty?
  233. 59 build_request(verb, uri, request_params)
  234. end
  235. else
  236. [build_request(verb, uris, params)]
  237. end
  238. end
  239. 55 raise ArgumentError, "wrong number of URIs (given 0, expect 1..+1)" if requests.empty?
  240. 55 requests
  241. end
  242. 1 def set_request_callbacks(request)
  243. 71 request.on(:promise, &method(:on_promise))
  244. end
  245. 1 def do_init_connection(connection, selector)
  246. 66 resolve_connection(connection, selector) unless connection.family
  247. end
  248. # sends an array of HTTPX::Request +requests+, returns the respective array of HTTPX::Response objects.
  249. 1 def send_requests(*requests)
  250. 125 selector = get_current_selector { Selector.new }
  251. begin
  252. 64 receive_requests(requests, selector)
  253. ensure
  254. 64 unless @wrapped
  255. 64 if @persistent
  256. 9 deactivate(selector)
  257. else
  258. 55 close(selector)
  259. end
  260. end
  261. end
  262. end
  263. # returns the array of HTTPX::Response objects corresponding to the array of HTTPX::Request +requests+.
  264. 1 def receive_requests(requests, selector)
  265. 64 pending_idxs = [] #: Array[Integer]
  266. 64 pending = 0
  267. 64 waiting = false
  268. 64 requests.each do |request|
  269. 70 send_request(request, selector)
  270. end
  271. # do work first
  272. 63 selector.initial_call
  273. 63 responses = requests.each_with_index.map do |request, idx|
  274. 69 fetch_response(request, selector, request.options).tap do |response|
  275. 69 if response.nil?
  276. 34 pending += 1
  277. 34 request.on_response_arrived = lambda do
  278. 36 pending_idxs << idx if waiting
  279. end
  280. end
  281. end
  282. end
  283. 63 until pending.zero? || selector.empty?
  284. # loop on selector until at least one response has been received.
  285. 79 waiting = true
  286. 158 catch(:coalesced) { selector.next_tick }
  287. 79 waiting = false
  288. 194 while (idx = pending_idxs.shift)
  289. 36 request = requests[idx]
  290. 36 response = fetch_response(request, selector, request.options)
  291. # stop on first pending response. this avoids traversing pending idxs all the way
  292. # (which is more expensive in the beginning, when the array is larger and N) while
  293. # making the next loop cheaper (because we're dropping).
  294. 36 next unless response
  295. 34 request.complete!(response)
  296. 34 responses[idx] = response
  297. 34 request.on_response_arrived = nil
  298. 34 pending -= 1
  299. end
  300. end
  301. 63 raise Error, "something went wrong, responses not found and requests not resent" unless pending.zero?
  302. 63 responses
  303. end
  304. 1 def resolve_connection(connection, selector)
  305. 70 if connection.addresses? || connection.open?
  306. #
  307. # there are two cases in which we want to activate initialization of
  308. # connection immediately:
  309. #
  310. # 1. when the connection already has addresses, i.e. it doesn't need to
  311. # resolve a name (not the same as name being an IP, yet)
  312. # 2. when the connection is initialized with an external already open IO.
  313. #
  314. 38 on_resolver_connection(connection, selector)
  315. 38 return
  316. end
  317. 32 resolver = find_resolver_for(connection, selector)
  318. 32 pin(connection, selector)
  319. 32 if early_resolve(resolver, connection)
  320. 28 @pool.checkin_resolver(resolver)
  321. else
  322. 4 resolver.lazy_resolve(connection)
  323. end
  324. end
  325. 1 def early_resolve(resolver, connection)
  326. 32 resolver.early_resolve(connection)
  327. end
  328. 1 def on_resolver_connection(connection, selector)
  329. 67 from_pool = false
  330. 67 found_connection = selector.find_mergeable_connection(connection) || begin
  331. 67 from_pool = true
  332. 67 connection.log(level: 2) do
  333. "try finding a mergeable connection in pool##{@pool.object_id}"
  334. end
  335. 67 @pool.checkout_mergeable_connection(connection)
  336. end
  337. 67 return select_connection(connection, selector) unless found_connection
  338. connection.log(level: 2) do
  339. "try coalescing from #{from_pool ? "pool##{@pool.object_id}" : "selector##{selector.object_id}"} " \
  340. "(connection##{found_connection.object_id}[#{found_connection.origin}])"
  341. end
  342. coalesce_connections(found_connection, connection, selector, from_pool)
  343. end
  344. 1 def find_resolver_for(connection, selector)
  345. 32 if (resolver = selector.find_resolver(connection.options))
  346. resolver.log(level: 2) { "found resolver##{resolver.object_id}(#{resolver.state}) in selector##{selector.object_id}" }
  347. return resolver
  348. end
  349. 32 resolver = @pool.checkout_resolver(connection.options)
  350. 32 resolver.log(level: 2) { "found resolver##{resolver.object_id}(#{resolver.state}) in pool##{@pool.object_id}" }
  351. 32 pin(resolver, selector)
  352. 32 resolver
  353. end
  354. # coalesces +conn2+ into +conn1+. if +conn1+ was loaded from the connection pool
  355. # (it is known via +from_pool+), then it adds its to the +selector+.
  356. 1 def coalesce_connections(conn1, conn2, selector, from_pool)
  357. unless conn1.coalescable?(conn2)
  358. conn2.log(level: 2) { "not coalescing with conn##{conn1.object_id}[#{conn1.origin}])" }
  359. select_connection(conn2, selector)
  360. if from_pool
  361. conn1.log(level: 2) { "check-in connection##{conn1.object_id}(#{conn1.state}) in pool##{@pool.object_id}" }
  362. @pool.checkin_connection(conn1)
  363. end
  364. return
  365. end
  366. conn2.log(level: 2) { "coalescing with connection##{conn1.object_id}[#{conn1.origin}])" }
  367. select_connection(conn1, selector) if from_pool
  368. conn2.coalesce!(conn1)
  369. conn2.disconnect
  370. end
  371. 1 def get_current_selector
  372. 64 selector_store[self] || (yield if block_given?)
  373. end
  374. 1 def set_current_selector(selector)
  375. 12 if selector
  376. 12 selector_store[self] = selector
  377. else
  378. selector_store.delete(self)
  379. end
  380. end
  381. 1 def selector_store
  382. 76 th_current = Thread.current
  383. 76 thread_selector_store(th_current) || begin
  384. 4 {}.compare_by_identity.tap do |store|
  385. 4 th_current.thread_variable_set(:httpx_persistent_selector_store, store)
  386. end
  387. end
  388. end
  389. 1 def thread_selector_store(th)
  390. 148 th.thread_variable_get(:httpx_persistent_selector_store)
  391. end
  392. 1 Options.freeze
  393. 1 @default_options = Options.new
  394. 1 @default_options.freeze
  395. 1 @plugins = []
  396. 1 class << self
  397. 1 attr_reader :default_options
  398. 1 def inherited(klass)
  399. 106 super
  400. 106 klass.instance_variable_set(:@default_options, @default_options)
  401. 106 klass.instance_variable_set(:@plugins, @plugins.dup)
  402. 106 klass.instance_variable_set(:@callbacks, @callbacks.dup)
  403. end
  404. # returns a new HTTPX::Session instance, with the plugin pointed by +pl+ loaded.
  405. #
  406. # session_with_retries = session.plugin(:retries)
  407. # session_with_custom = session.plugin(CustomPlugin)
  408. #
  409. 1 def plugin(pl, options = nil, &block)
  410. 125 label = pl
  411. 125 pl = Plugins.load_plugin(pl) if pl.is_a?(Symbol)
  412. 125 raise ArgumentError, "Invalid plugin type: #{pl.class.inspect}" unless pl.is_a?(Module)
  413. 125 if !@plugins.include?(pl)
  414. 85 @plugins << pl
  415. 85 pl.load_dependencies(self, &block) if pl.respond_to?(:load_dependencies)
  416. 85 @default_options = @default_options.dup
  417. 85 include(pl::InstanceMethods) if defined?(pl::InstanceMethods)
  418. 85 extend(pl::ClassMethods) if defined?(pl::ClassMethods)
  419. 85 opts = @default_options
  420. 85 opts.extend_with_plugin_classes(pl)
  421. 85 if defined?(pl::OptionsMethods)
  422. # when a class gets dup'ed, the #initialize_dup callbacks isn't triggered.
  423. # moreover, and because #method_added does not get triggered on mixin include,
  424. # the callback is also forcefully manually called here.
  425. 16 opts.options_class.instance_variable_set(:@options_names, opts.options_class.options_names.dup)
  426. 16 (pl::OptionsMethods.instance_methods + pl::OptionsMethods.private_instance_methods - Object.instance_methods).each do |meth|
  427. 69 opts.options_class.method_added(meth)
  428. end
  429. 16 @default_options = opts.options_class.new(opts)
  430. end
  431. 85 @default_options = pl.extra_options(@default_options) if pl.respond_to?(:extra_options)
  432. 85 @default_options = @default_options.merge(options) if options
  433. 85 if pl.respond_to?(:subplugins)
  434. 12 pl.subplugins.transform_keys(&Plugins.method(:load_plugin)).each do |main_pl, sub_pl|
  435. # in case the main plugin has already been loaded, then apply subplugin functionality
  436. # immediately
  437. 24 next unless @plugins.include?(main_pl)
  438. plugin(sub_pl, options, &block)
  439. end
  440. end
  441. 85 pl.configure(self, &block) if pl.respond_to?(:configure)
  442. 85 if label.is_a?(Symbol)
  443. # in case an already-loaded plugin complements functionality of
  444. # the plugin currently being loaded, loaded it now
  445. 37 @plugins.each do |registered_pl|
  446. 96 next if registered_pl == pl
  447. 59 next unless registered_pl.respond_to?(:subplugins)
  448. 18 sub_pl = registered_pl.subplugins[label]
  449. 18 next unless sub_pl
  450. plugin(sub_pl, options, &block)
  451. end
  452. end
  453. 85 @default_options.freeze
  454. 85 set_temporary_name("#{superclass}/#{pl}") if respond_to?(:set_temporary_name) # ruby 3.4 only
  455. 40 elsif options
  456. # this can happen when two plugins are loaded, an one of them calls the other under the hood,
  457. # albeit changing some default.
  458. @default_options = pl.extra_options(@default_options) if pl.respond_to?(:extra_options)
  459. @default_options = @default_options.merge(options) if options
  460. @default_options.freeze
  461. end
  462. 125 self
  463. end
  464. end
  465. # setup of the support for close_on_fork sessions.
  466. # adapted from https://github.com/mperham/connection_pool/blob/main/lib/connection_pool.rb#L48
  467. 1 if Process.respond_to?(:fork)
  468. 1 INSTANCES = ObjectSpace::WeakMap.new
  469. 1 private_constant :INSTANCES
  470. 1 def self.after_fork
  471. INSTANCES.each_value(&:close)
  472. nil
  473. end
  474. 1 if ::Process.respond_to?(:_fork)
  475. 1 module ForkTracker
  476. 1 def _fork
  477. pid = super
  478. Session.after_fork if pid.zero?
  479. pid
  480. end
  481. end
  482. 1 Process.singleton_class.prepend(ForkTracker)
  483. end
  484. else
  485. INSTANCES = nil
  486. private_constant :INSTANCES
  487. def self.after_fork
  488. # noop
  489. end
  490. end
  491. end
  492. # session may be overridden by certain adapters.
  493. 1 S = Session
  494. end

lib/httpx/session_extensions.rb

15.79% lines covered

19 relevant lines. 3 lines covered and 16 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 unless ENV.keys.grep(/\Ahttps?_proxy\z/i).empty?
  4. proxy_session = plugin(:proxy)
  5. remove_const(:Session)
  6. const_set(:Session, proxy_session.class)
  7. # redefine the default options static var, which needs to
  8. # refresh options_class
  9. options = proxy_session.class.default_options.to_hash
  10. original_verbosity = $VERBOSE
  11. $VERBOSE = nil
  12. new_options_class = proxy_session.class.default_options.options_class.dup
  13. const_set(:Options, new_options_class)
  14. options[:options_class] = Class.new(new_options_class).freeze
  15. options.freeze
  16. Options.send(:const_set, :DEFAULT_OPTIONS, options)
  17. Session.instance_variable_set(:@default_options, Options.new(options))
  18. $VERBOSE = original_verbosity
  19. end
  20. # simplecov:disable
  21. 1 if Session.default_options.debug_level > 2
  22. proxy_session = plugin(:internal_telemetry)
  23. remove_const(:Session)
  24. const_set(:Session, proxy_session.class)
  25. end
  26. # simplecov:enable
  27. end

lib/httpx/timers.rb

88.06% lines covered

67 relevant lines. 59 lines covered and 8 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 class Timers
  4. 1 def initialize
  5. 70 @intervals = []
  6. end
  7. 1 def empty?
  8. @intervals.empty?
  9. end
  10. 1 def after(interval_in_secs, cb = nil, &blk)
  11. 78 callback = cb || blk
  12. 78 raise Error, "timer must have a callback" unless callback
  13. # I'm assuming here that most requests will have the same
  14. # request timeout, as in most cases they share common set of
  15. # options. A user setting different request timeouts for 100s of
  16. # requests will already have a hard time dealing with that.
  17. 120 unless (interval = @intervals.bsearch { |t| t.interval == interval_in_secs })
  18. 36 interval = Interval.new(interval_in_secs)
  19. 36 @intervals << interval
  20. 36 @intervals.sort!
  21. end
  22. 78 interval << callback
  23. 78 @next_interval_at = nil
  24. 78 Timer.new(interval, callback)
  25. end
  26. 1 def wait_interval
  27. 79 return if @intervals.empty?
  28. 43 first_interval = @intervals.first
  29. 43 drop_elapsed!(0) if first_interval.elapsed?(0)
  30. 43 @next_interval_at = Utils.now
  31. 43 first_interval.interval
  32. end
  33. 1 def fire(error = nil)
  34. 79 raise error if error && error.timeout != @intervals.first
  35. 79 return if @intervals.empty? || !@next_interval_at
  36. 37 elapsed_time = Utils.elapsed_time(@next_interval_at)
  37. 37 drop_elapsed!(elapsed_time)
  38. 37 @next_interval_at = nil if @intervals.empty?
  39. end
  40. 1 private
  41. 1 def drop_elapsed!(elapsed_time)
  42. 74 @intervals = @intervals.drop_while { |interval| interval.elapse(elapsed_time) <= 0 }
  43. end
  44. 1 class Timer
  45. # simpler helper which allows classification
  46. 1 attr_accessor :label
  47. 1 def initialize(interval, callback)
  48. 78 @interval = interval
  49. 78 @callback = callback
  50. end
  51. 1 def cancel
  52. 111 @interval.delete(@callback)
  53. end
  54. end
  55. 1 class Interval
  56. 1 include Comparable
  57. 1 attr_reader :interval
  58. 1 def initialize(interval)
  59. 36 @interval = interval
  60. 36 @callbacks = []
  61. end
  62. 1 def <=>(other)
  63. @interval <=> other.interval
  64. end
  65. 1 def ==(other)
  66. return @interval == other if other.is_a?(Numeric)
  67. @interval == other.to_f # rubocop:disable Lint/FloatComparison
  68. end
  69. 1 def to_f
  70. Float(@interval)
  71. end
  72. 1 def <<(callback)
  73. 78 @callbacks << callback
  74. end
  75. 1 def delete(callback)
  76. 111 @callbacks.delete(callback)
  77. end
  78. 1 def no_callbacks?
  79. @callbacks.empty?
  80. end
  81. 1 def elapsed?(elapsed = 0)
  82. 43 (@interval - elapsed) <= 0 || @callbacks.empty?
  83. end
  84. 1 def elapse(elapsed)
  85. # same as elapsing
  86. 37 return 0 if @callbacks.empty?
  87. 1 @interval -= elapsed
  88. 1 if @interval <= 0
  89. cb = @callbacks.dup
  90. cb.each(&:call)
  91. end
  92. 1 @interval
  93. end
  94. end
  95. 1 private_constant :Interval
  96. end
  97. end

lib/httpx/transcoder.rb

36.0% lines covered

50 relevant lines. 18 lines covered and 32 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Transcoder
  4. 1 module_function
  5. 1 def normalize_keys(key, value, transcoder = self, &block)
  6. 9 if value.respond_to?(:to_ary)
  7. 1 if value.empty?
  8. block.call("#{key}[]")
  9. else
  10. 1 value.to_ary.each do |element|
  11. 2 transcoder.normalize_keys("#{key}[]", element, transcoder, &block)
  12. end
  13. end
  14. 8 elsif value.respond_to?(:to_hash)
  15. value.to_hash.each do |child_key, child_value|
  16. transcoder.normalize_keys("#{key}[#{child_key}]", child_value, transcoder, &block)
  17. end
  18. else
  19. 8 block.call(key.to_s, value)
  20. end
  21. end
  22. # based on https://github.com/rack/rack/blob/d15dd728440710cfc35ed155d66a98dc2c07ae42/lib/rack/query_parser.rb#L82
  23. 1 def normalize_query(params, name, v, depth)
  24. raise Error, "params depth surpasses what's supported" if depth <= 0
  25. name =~ /\A[\[\]]*([^\[\]]+)\]*/
  26. k = Regexp.last_match(1) || ""
  27. after = Regexp.last_match ? Regexp.last_match.post_match : ""
  28. if k.empty?
  29. return Array(v) if !v.empty? && name == "[]"
  30. return
  31. end
  32. case after
  33. when ""
  34. params[k] = v
  35. when "["
  36. params[name] = v
  37. when "[]"
  38. params[k] ||= []
  39. raise Error, "expected Array (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Array)
  40. params[k] << v
  41. when /^\[\]\[([^\[\]]+)\]$/, /^\[\](.+)$/
  42. child_key = Regexp.last_match(1)
  43. params[k] ||= []
  44. raise Error, "expected Array (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Array)
  45. if params[k].last.is_a?(Hash) && !params_hash_has_key?(params[k].last, child_key)
  46. normalize_query(params[k].last, child_key, v, depth - 1)
  47. else
  48. params[k] << normalize_query({}, child_key, v, depth - 1)
  49. end
  50. else
  51. params[k] ||= {}
  52. raise Error, "expected Hash (got #{params[k].class}) for param '#{k}'" unless params[k].is_a?(Hash)
  53. params[k] = normalize_query(params[k], after, v, depth - 1)
  54. end
  55. params
  56. end
  57. 1 def params_hash_has_key?(hash, key)
  58. return false if key.include?("[]")
  59. key.split(/[\[\]]+/).inject(hash) do |h, part|
  60. next h if part == ""
  61. return false unless h.is_a?(Hash) && h.key?(part)
  62. h[part]
  63. end
  64. true
  65. end
  66. end
  67. end
  68. 1 require "httpx/transcoder/body"
  69. 1 require "httpx/transcoder/form"
  70. 1 require "httpx/transcoder/json"
  71. 1 require "httpx/transcoder/chunker"
  72. 1 require "httpx/transcoder/deflate"
  73. 1 require "httpx/transcoder/gzip"

lib/httpx/transcoder/body.rb

65.38% lines covered

26 relevant lines. 17 lines covered and 9 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "delegate"
  3. 1 module HTTPX::Transcoder
  4. 1 module Body
  5. 1 class Error < HTTPX::Error; end
  6. 1 module_function
  7. 1 class Encoder < SimpleDelegator
  8. 1 def initialize(body)
  9. 3 body = body.open(File::RDONLY, encoding: Encoding::BINARY) if Object.const_defined?(:Pathname) && body.is_a?(Pathname)
  10. 3 @body = body
  11. 3 super
  12. end
  13. 1 def bytesize
  14. 8 if @body.respond_to?(:bytesize)
  15. 8 @body.bytesize
  16. elsif @body.respond_to?(:to_ary)
  17. @body.sum(&:bytesize)
  18. elsif @body.respond_to?(:size)
  19. @body.size || Float::INFINITY
  20. elsif @body.respond_to?(:length)
  21. @body.length || Float::INFINITY
  22. elsif @body.respond_to?(:each)
  23. Float::INFINITY
  24. else
  25. raise Error, "cannot determine size of body: #{@body.inspect}"
  26. end
  27. end
  28. 1 def content_type
  29. 2 "application/octet-stream"
  30. end
  31. end
  32. 1 def encode(body)
  33. 3 Encoder.new(body)
  34. end
  35. end
  36. end

lib/httpx/transcoder/chunker.rb

34.85% lines covered

66 relevant lines. 23 lines covered and 43 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 module HTTPX::Transcoder
  4. 1 module Chunker
  5. 1 class Error < HTTPX::Error; end
  6. 1 CRLF = "\r\n".b
  7. 1 class Encoder
  8. 1 extend Forwardable
  9. 1 def initialize(body)
  10. @raw = body
  11. end
  12. 1 def each
  13. return enum_for(__method__) unless block_given?
  14. @raw.each do |chunk|
  15. yield "#{chunk.bytesize.to_s(16)}#{CRLF}#{chunk}#{CRLF}"
  16. end
  17. yield "0#{CRLF}"
  18. end
  19. 1 def respond_to_missing?(meth, *args)
  20. @raw.respond_to?(meth, *args) || super
  21. end
  22. end
  23. 1 class Decoder
  24. 1 extend Forwardable
  25. 1 def_delegator :@buffer, :empty?
  26. 1 def_delegator :@buffer, :<<
  27. 1 def_delegator :@buffer, :clear
  28. 1 def initialize(buffer, trailers = false)
  29. @buffer = buffer
  30. @chunk_buffer = "".b
  31. @finished = false
  32. @state = :length
  33. @trailers = trailers
  34. end
  35. 1 def to_s
  36. @buffer
  37. end
  38. 1 def each
  39. loop do
  40. case @state
  41. when :length
  42. index = @buffer.index(CRLF)
  43. return unless index && index.positive?
  44. # Read hex-length
  45. hexlen = @buffer.byteslice(0, index)
  46. @buffer = @buffer.byteslice(index..-1) || "".b
  47. hexlen[/\h/] || raise(Error, "wrong chunk size line: #{hexlen}")
  48. @chunk_length = hexlen.hex
  49. # check if is last chunk
  50. @finished = @chunk_length.zero?
  51. nextstate(:crlf)
  52. when :crlf
  53. crlf_size = @finished && !@trailers ? 4 : 2
  54. # consume CRLF
  55. return if @buffer.bytesize < crlf_size
  56. raise Error, "wrong chunked encoding format" unless @buffer.start_with?(CRLF * (crlf_size / 2))
  57. @buffer = @buffer.byteslice(crlf_size..-1)
  58. if @chunk_length.nil?
  59. nextstate(:length)
  60. else
  61. return if @finished
  62. nextstate(:data)
  63. end
  64. when :data
  65. chunk = @buffer.byteslice(0, @chunk_length)
  66. @buffer = @buffer.byteslice(@chunk_length..-1) || "".b
  67. @chunk_buffer << chunk
  68. @chunk_length -= chunk.bytesize
  69. if @chunk_length.zero?
  70. yield @chunk_buffer unless @chunk_buffer.empty?
  71. @chunk_buffer.clear
  72. @chunk_length = nil
  73. nextstate(:crlf)
  74. end
  75. end
  76. break if @buffer.empty?
  77. end
  78. end
  79. 1 def finished?
  80. @finished
  81. end
  82. 1 private
  83. 1 def nextstate(state)
  84. @state = state
  85. end
  86. end
  87. 1 module_function
  88. 1 def encode(chunks)
  89. Encoder.new(chunks)
  90. end
  91. end
  92. end

lib/httpx/transcoder/deflate.rb

47.62% lines covered

21 relevant lines. 10 lines covered and 11 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "zlib"
  3. 1 require_relative "utils/deflater"
  4. 1 module HTTPX
  5. 1 module Transcoder
  6. 1 module Deflate
  7. 1 class Deflater < Transcoder::Deflater
  8. 1 def deflate(chunk)
  9. @deflater ||= Zlib::Deflate.new
  10. unless chunk.nil?
  11. chunk = @deflater.deflate(chunk)
  12. # deflate call may return nil, while still
  13. # retaining the last chunk in the deflater.
  14. return chunk unless chunk.empty?
  15. end
  16. return if @deflater.closed?
  17. last = @deflater.finish
  18. @deflater.close
  19. last unless last.empty?
  20. end
  21. end
  22. 1 module_function
  23. 1 def encode(body)
  24. Deflater.new(body)
  25. end
  26. 1 def decode(response, bytesize: nil)
  27. bytesize ||= response.headers.key?("content-length") ? response.headers["content-length"].to_i : Float::INFINITY
  28. GZIP::Inflater.new(bytesize)
  29. end
  30. end
  31. end
  32. end

lib/httpx/transcoder/form.rb

80.0% lines covered

35 relevant lines. 28 lines covered and 7 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 require "uri"
  4. 1 require_relative "multipart"
  5. 1 module HTTPX
  6. 1 module Transcoder
  7. 1 module Form
  8. 1 module_function
  9. 1 PARAM_DEPTH_LIMIT = 32
  10. 1 class Encoder
  11. 1 extend Forwardable
  12. 1 def_delegator :@raw, :to_s
  13. 1 def_delegator :@raw, :to_str
  14. 1 def_delegator :@raw, :bytesize
  15. 1 def_delegator :@raw, :==
  16. 1 def initialize(form)
  17. 7 @raw = form.each_with_object("".b) do |(key, val), buf|
  18. 7 HTTPX::Transcoder.normalize_keys(key, val) do |k, v|
  19. 8 buf << "&" unless buf.empty?
  20. 8 buf << URI.encode_www_form_component(k)
  21. 8 buf << "=#{URI.encode_www_form_component(v.to_s)}" unless v.nil?
  22. end
  23. end
  24. end
  25. 1 def content_type
  26. 3 "application/x-www-form-urlencoded"
  27. end
  28. end
  29. 1 module Decoder
  30. 1 module_function
  31. 1 def call(response, *)
  32. URI.decode_www_form(response.to_s).each_with_object({}) do |(field, value), params|
  33. HTTPX::Transcoder.normalize_query(params, field, value, PARAM_DEPTH_LIMIT)
  34. end
  35. end
  36. end
  37. 1 def encode(form)
  38. 7 Encoder.new(form)
  39. end
  40. 1 def decode(response)
  41. content_type = response.content_type.mime_type
  42. case content_type
  43. when "application/x-www-form-urlencoded"
  44. Decoder
  45. when "multipart/form-data"
  46. Multipart::Decoder.new(response)
  47. else
  48. raise Error, "invalid form mime type (#{content_type})"
  49. end
  50. end
  51. end
  52. end
  53. end

lib/httpx/transcoder/gzip.rb

45.45% lines covered

44 relevant lines. 20 lines covered and 24 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "zlib"
  3. 1 module HTTPX
  4. 1 module Transcoder
  5. 1 module GZIP
  6. 1 class Deflater < Transcoder::Deflater
  7. 1 def initialize(body)
  8. @compressed_chunk = "".b
  9. @deflater = nil
  10. super
  11. end
  12. 1 def deflate(chunk)
  13. @deflater ||= Zlib::GzipWriter.new(self)
  14. if chunk.nil?
  15. unless @deflater.closed?
  16. @deflater.flush
  17. @deflater.close
  18. compressed_chunk
  19. end
  20. else
  21. @deflater.write(chunk)
  22. compressed_chunk
  23. end
  24. end
  25. 1 private
  26. 1 def write(*chunks)
  27. chunks.sum do |chunk|
  28. chunk = chunk.to_s
  29. @compressed_chunk << chunk
  30. chunk.bytesize
  31. end
  32. end
  33. 1 def compressed_chunk
  34. @compressed_chunk.dup
  35. ensure
  36. @compressed_chunk.clear
  37. end
  38. end
  39. 1 class Inflater
  40. 1 def initialize(bytesize)
  41. 1 @inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 32)
  42. 1 @bytesize = bytesize
  43. end
  44. 1 def call(chunk)
  45. buffer = @inflater.inflate(chunk)
  46. @bytesize -= chunk.bytesize
  47. if @bytesize <= 0
  48. buffer << @inflater.finish
  49. @inflater.close
  50. end
  51. buffer
  52. end
  53. end
  54. 1 module_function
  55. 1 def encode(body)
  56. Deflater.new(body)
  57. end
  58. 1 def decode(response, bytesize: nil)
  59. 1 bytesize ||= response.headers.key?("content-length") ? response.headers["content-length"].to_i : Float::INFINITY
  60. 1 Inflater.new(bytesize)
  61. end
  62. end
  63. end
  64. end

lib/httpx/transcoder/json.rb

60.61% lines covered

33 relevant lines. 20 lines covered and 13 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "forwardable"
  3. 1 module HTTPX::Transcoder
  4. 1 module JSON
  5. 1 module_function
  6. 1 JSON_REGEX = %r{
  7. \b
  8. application/
  9. # optional vendor specific type
  10. (?:
  11. # token as per https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6
  12. [!#$%&'*+\-.^_`|~0-9a-z]+
  13. # literal plus sign
  14. \+
  15. )?
  16. json
  17. \b
  18. }ix.freeze
  19. 1 class Encoder
  20. 1 extend Forwardable
  21. 1 def_delegator :@raw, :to_s
  22. 1 def_delegator :@raw, :bytesize
  23. 1 def_delegator :@raw, :==
  24. 1 def initialize(json)
  25. @raw = JSON.json_dump(json)
  26. @charset = @raw.encoding.name.downcase
  27. end
  28. 1 def content_type
  29. "application/json; charset=#{@charset}"
  30. end
  31. end
  32. 1 def encode(json)
  33. Encoder.new(json)
  34. end
  35. 1 def decode(response)
  36. content_type = response.content_type.mime_type
  37. raise HTTPX::Error, "invalid json mime type (#{content_type})" unless JSON_REGEX.match?(content_type)
  38. method(:json_load)
  39. end
  40. # rubocop:disable Style/SingleLineMethods
  41. 1 if defined?(MultiJson)
  42. def json_load(*args); MultiJson.load(*args); end
  43. def json_dump(*args); MultiJson.dump(*args); end
  44. 1 elsif defined?(Oj)
  45. def json_load(response, *args); Oj.load(response.to_s, *args); end
  46. def json_dump(obj, options = {}); Oj.dump(obj, { mode: :compat }.merge(options)); end
  47. 1 elsif defined?(Yajl)
  48. def json_load(response, *args); Yajl::Parser.new(*args).parse(response.to_s); end
  49. def json_dump(*args); Yajl::Encoder.encode(*args); end
  50. else
  51. 1 require "json"
  52. 1 def json_load(*args); ::JSON.parse(*args); end
  53. 1 def json_dump(*args); ::JSON.generate(*args); end
  54. end
  55. # rubocop:enable Style/SingleLineMethods
  56. end
  57. end

lib/httpx/transcoder/multipart.rb

90.91% lines covered

22 relevant lines. 20 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "multipart/encoder"
  3. 1 require_relative "multipart/decoder"
  4. 1 require_relative "multipart/part"
  5. 1 require_relative "multipart/mime_type_detector"
  6. 1 module HTTPX::Transcoder
  7. 1 module Multipart
  8. 1 module_function
  9. 1 def multipart?(form_data)
  10. 6 form_data.any? do |_, v|
  11. 6 multipart_value?(v) ||
  12. 3 (v.respond_to?(:to_ary) && v.to_ary.any? { |av| multipart_value?(av) }) ||
  13. 3 (v.respond_to?(:to_hash) && v.to_hash.any? { |_, e| multipart_value?(e) })
  14. end
  15. end
  16. 1 def multipart_value?(value)
  17. 12 value.respond_to?(:read) ||
  18. 3 (value.is_a?(Hash) &&
  19. value.key?(:body) &&
  20. (value.key?(:filename) || value.key?(:content_type)))
  21. end
  22. 1 def normalize_keys(key, value, transcoder = self, &block)
  23. 6 if multipart_value?(value)
  24. 6 block.call(key.to_s, value)
  25. else
  26. HTTPX::Transcoder.normalize_keys(key, value, transcoder, &block)
  27. end
  28. end
  29. 1 def encode(form_data)
  30. 3 Encoder.new(form_data)
  31. end
  32. end
  33. end

lib/httpx/transcoder/multipart/decoder.rb

24.69% lines covered

81 relevant lines. 20 lines covered and 61 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "tempfile"
  3. 1 require "delegate"
  4. 1 module HTTPX
  5. 1 module Transcoder
  6. 1 module Multipart
  7. 1 class FilePart < SimpleDelegator
  8. 1 attr_reader :original_filename, :content_type
  9. 1 def initialize(filename, content_type)
  10. @original_filename = filename
  11. @content_type = content_type
  12. @file = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
  13. super(@file)
  14. end
  15. end
  16. 1 class Decoder
  17. 1 include HTTPX::Utils
  18. 1 CRLF = "\r\n"
  19. 1 BOUNDARY_RE = /;\s*boundary=([^;]+)/i.freeze
  20. 1 MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{CRLF}/ni.freeze
  21. 1 MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:.*;\s*name=(#{VALUE})/ni.freeze
  22. 1 MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{CRLF}]*)/ni.freeze
  23. 1 WINDOW_SIZE = 2 << 14
  24. 1 def initialize(response)
  25. @boundary = begin
  26. m = response.headers["content-type"].to_s[BOUNDARY_RE, 1]
  27. raise Error, "no boundary declared in content-type header" unless m
  28. m.strip
  29. end
  30. @buffer = "".b
  31. @parts = {}
  32. @intermediate_boundary = "--#{@boundary}"
  33. @state = :idle
  34. @current = nil
  35. end
  36. 1 def call(response, *)
  37. response.body.each do |chunk|
  38. @buffer << chunk
  39. parse
  40. end
  41. raise Error, "invalid or unsupported multipart format" unless @buffer.empty?
  42. @parts
  43. end
  44. 1 private
  45. 1 def parse
  46. case @state
  47. when :idle
  48. raise Error, "payload does not start with boundary" unless @buffer.start_with?("#{@intermediate_boundary}#{CRLF}")
  49. @buffer = @buffer.byteslice((@intermediate_boundary.bytesize + 2)..-1)
  50. @state = :part_header
  51. when :part_header
  52. idx = @buffer.index("#{CRLF}#{CRLF}")
  53. # raise Error, "couldn't parse part headers" unless idx
  54. return unless idx
  55. # @type var head: String
  56. head = @buffer.byteslice(0..(idx + 4 - 1))
  57. @buffer = @buffer.byteslice(head.bytesize..-1)
  58. content_type = head[MULTIPART_CONTENT_TYPE, 1] || "text/plain"
  59. if (name = head[MULTIPART_CONTENT_DISPOSITION, 1])
  60. name = /\A"(.*)"\Z/ =~ name ? Regexp.last_match(1) : name.dup
  61. name.gsub!(/\\(.)/, "\\1")
  62. name
  63. else
  64. name = head[MULTIPART_CONTENT_ID, 1]
  65. end
  66. filename = HTTPX::Utils.get_filename(head)
  67. name = filename || +"#{content_type}[]" if name.nil? || name.empty?
  68. @current = name
  69. @parts[name] = if filename
  70. FilePart.new(filename, content_type)
  71. else
  72. "".b
  73. end
  74. @state = :part_body
  75. when :part_body
  76. part = @parts[@current]
  77. body_separator = if part.is_a?(FilePart)
  78. "#{CRLF}#{CRLF}"
  79. else
  80. CRLF
  81. end
  82. idx = @buffer.index(body_separator)
  83. if idx
  84. payload = @buffer.byteslice(0..(idx - 1))
  85. @buffer = @buffer.byteslice((idx + body_separator.bytesize)..-1)
  86. part << payload
  87. part.rewind if part.respond_to?(:rewind)
  88. @state = :parse_boundary
  89. else
  90. part << @buffer
  91. @buffer.clear
  92. end
  93. when :parse_boundary
  94. raise Error, "payload does not start with boundary" unless @buffer.start_with?(@intermediate_boundary)
  95. @buffer = @buffer.byteslice(@intermediate_boundary.bytesize..-1)
  96. if @buffer == "--"
  97. @buffer.clear
  98. @state = :done
  99. return
  100. elsif @buffer.start_with?(CRLF)
  101. @buffer = @buffer.byteslice(2..-1)
  102. @state = :part_header
  103. else
  104. return
  105. end
  106. when :done
  107. raise Error, "parsing should have been over by now"
  108. end until @buffer.empty?
  109. end
  110. end
  111. end
  112. end
  113. end

lib/httpx/transcoder/multipart/encoder.rb

100.0% lines covered

69 relevant lines. 69 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Transcoder::Multipart
  4. 1 class Encoder
  5. 1 attr_reader :bytesize
  6. 1 def initialize(form)
  7. 3 @boundary = ("-" * 21) << SecureRandom.hex(21)
  8. 3 @part_index = 0
  9. 3 @buffer = "".b
  10. 3 @form = form
  11. 3 @bytesize = 0
  12. 3 @parts = to_parts(form)
  13. end
  14. 1 def content_type
  15. 3 "multipart/form-data; boundary=#{@boundary}"
  16. end
  17. 1 def to_s
  18. 3 read || ""
  19. ensure
  20. 3 rewind
  21. end
  22. 1 def read(length = nil, outbuf = nil)
  23. 7 data = String(outbuf).clear.force_encoding(Encoding::BINARY) if outbuf
  24. 7 data ||= "".b
  25. 7 read_chunks(data, length)
  26. 7 data unless length && data.empty?
  27. end
  28. 1 def rewind
  29. 3 form = @form.each_with_object([]) do |(key, val), aux|
  30. 3 if val.respond_to?(:path) && val.respond_to?(:reopen) && val.respond_to?(:closed?) && val.closed?
  31. # @type var val: File
  32. 3 val = val.reopen(val.path, File::RDONLY)
  33. end
  34. 3 val.rewind if val.respond_to?(:rewind)
  35. 3 aux << [key, val]
  36. end
  37. 3 @form = form
  38. 3 @bytesize = 0
  39. 3 @parts = to_parts(form)
  40. 3 @part_index = 0
  41. end
  42. 1 private
  43. 1 def to_parts(form)
  44. 6 params = form.each_with_object([]) do |(key, val), aux|
  45. 6 Transcoder::Multipart.normalize_keys(key, val) do |k, v|
  46. 6 next if v.nil?
  47. 6 value, content_type, filename = Part.call(v)
  48. 6 header = header_part(k, content_type, filename)
  49. 6 @bytesize += header.size
  50. 6 aux << header
  51. 6 @bytesize += value.size
  52. 6 aux << value
  53. 6 delimiter = StringIO.new("\r\n")
  54. 6 @bytesize += delimiter.size
  55. 6 aux << delimiter
  56. end
  57. end
  58. 6 final_delimiter = StringIO.new("--#{@boundary}--\r\n")
  59. 6 @bytesize += final_delimiter.size
  60. 6 params << final_delimiter
  61. 6 params
  62. end
  63. 1 def header_part(key, content_type, filename)
  64. 6 header = "--#{@boundary}\r\n".b
  65. 6 header << "Content-Disposition: form-data; name=#{key.inspect}".b
  66. 6 header << "; filename=#{filename.inspect}" if filename
  67. 6 header << "\r\nContent-Type: #{content_type}\r\n\r\n"
  68. 6 StringIO.new(header)
  69. end
  70. 1 def read_chunks(buffer, length = nil)
  71. 7 while @part_index < @parts.size
  72. 33 chunk = read_from_part(length)
  73. 33 next unless chunk
  74. 17 buffer << chunk.force_encoding(Encoding::BINARY)
  75. 17 next unless length
  76. 6 length -= chunk.bytesize
  77. 6 break if length.zero?
  78. end
  79. end
  80. # if there's a current part to read from, tries to read a chunk.
  81. 1 def read_from_part(max_length = nil)
  82. 33 part = @parts[@part_index]
  83. 33 chunk = part.read(max_length, @buffer)
  84. 33 return chunk if chunk && !chunk.empty?
  85. 16 part.close if part.respond_to?(:close)
  86. 16 @part_index += 1
  87. nil
  88. end
  89. end
  90. end
  91. end

lib/httpx/transcoder/multipart/mime_type_detector.rb

56.76% lines covered

37 relevant lines. 21 lines covered and 16 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Transcoder::Multipart
  4. 1 module MimeTypeDetector
  5. 1 module_function
  6. 1 DEFAULT_MIMETYPE = "application/octet-stream"
  7. # inspired by https://github.com/shrinerb/shrine/blob/master/lib/shrine/plugins/determine_mime_type.rb
  8. 1 if defined?(FileMagic)
  9. MAGIC_NUMBER = 256 * 1024
  10. def call(file, _)
  11. return nil if file.eof? # FileMagic returns "application/x-empty" for empty files
  12. mime = FileMagic.open(FileMagic::MAGIC_MIME_TYPE) do |filemagic|
  13. filemagic.buffer(file.read(MAGIC_NUMBER))
  14. end
  15. file.rewind
  16. mime
  17. end
  18. 1 elsif defined?(Marcel)
  19. def call(file, filename)
  20. return nil if file.eof? # marcel returns "application/octet-stream" for empty files
  21. Marcel::MimeType.for(file, name: filename)
  22. end
  23. 1 elsif defined?(MimeMagic)
  24. def call(file, _)
  25. mime = MimeMagic.by_magic(file)
  26. mime.type if mime
  27. end
  28. 1 elsif system("which file", out: File::NULL)
  29. 1 require "open3"
  30. 1 def call(file, _)
  31. 6 return if file.eof? # file command returns "application/x-empty" for empty files
  32. 4 Open3.popen3(*%w[file --mime-type --brief -]) do |stdin, stdout, stderr, thread|
  33. begin
  34. 4 IO.copy_stream(file, stdin.binmode)
  35. rescue Errno::EPIPE
  36. end
  37. 4 file.rewind
  38. 4 stdin.close
  39. 4 status = thread.value
  40. # call to file command failed
  41. 4 if status.nil? || !status.success?
  42. $stderr.print(stderr.read)
  43. else
  44. 4 output = stdout.read.strip
  45. 4 if output.include?("cannot open")
  46. $stderr.print(output)
  47. else
  48. 4 output
  49. end
  50. end
  51. end
  52. end
  53. else
  54. def call(_, _); end
  55. end
  56. end
  57. end
  58. end

lib/httpx/transcoder/multipart/part.rb

72.22% lines covered

18 relevant lines. 13 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Transcoder::Multipart
  4. 1 module Part
  5. 1 module_function
  6. 1 def call(value)
  7. # take out specialized objects of the way
  8. 6 if value.respond_to?(:filename) && value.respond_to?(:content_type) && value.respond_to?(:read)
  9. return value, value.content_type, value.filename
  10. end
  11. 6 content_type = filename = nil
  12. 6 if value.is_a?(Hash)
  13. content_type = value[:content_type]
  14. filename = value[:filename]
  15. value = value[:body]
  16. end
  17. 6 value = value.open(File::RDONLY, encoding: Encoding::BINARY) if Object.const_defined?(:Pathname) && value.is_a?(Pathname)
  18. 6 if value.respond_to?(:path) && value.respond_to?(:read)
  19. # either a File, a Tempfile, or something else which has to quack like a file
  20. 6 filename ||= File.basename(value.path)
  21. 6 content_type ||= MimeTypeDetector.call(value, filename) || "application/octet-stream"
  22. 6 [value, content_type, filename]
  23. else
  24. [StringIO.new(value.to_s), content_type || "text/plain", filename]
  25. end
  26. end
  27. end
  28. end
  29. end

lib/httpx/transcoder/utils/body_reader.rb

33.33% lines covered

24 relevant lines. 8 lines covered and 16 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "stringio"
  3. 1 module HTTPX
  4. 1 module Transcoder
  5. 1 class BodyReader
  6. 1 def initialize(body)
  7. @body = if body.respond_to?(:read)
  8. body.rewind if body.respond_to?(:rewind)
  9. body
  10. elsif body.respond_to?(:each)
  11. body.enum_for(:each)
  12. else
  13. StringIO.new(body.to_s)
  14. end
  15. end
  16. 1 def bytesize
  17. return @body.bytesize if @body.respond_to?(:bytesize)
  18. Float::INFINITY
  19. end
  20. 1 def read(length = nil, outbuf = nil)
  21. return @body.read(length, outbuf) if @body.respond_to?(:read)
  22. begin
  23. chunk = @body.next
  24. if outbuf
  25. outbuf.replace(chunk)
  26. else
  27. outbuf = chunk
  28. end
  29. outbuf unless length && outbuf.empty?
  30. rescue StopIteration
  31. end
  32. end
  33. 1 def close
  34. @body.close if @body.respond_to?(:close)
  35. end
  36. end
  37. end
  38. end

lib/httpx/transcoder/utils/deflater.rb

33.33% lines covered

36 relevant lines. 12 lines covered and 24 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "body_reader"
  3. 1 module HTTPX
  4. 1 module Transcoder
  5. 1 class Deflater
  6. 1 attr_reader :content_type
  7. 1 def initialize(body)
  8. @content_type = body.content_type
  9. @body = BodyReader.new(body)
  10. @closed = false
  11. end
  12. 1 def bytesize
  13. buffer_deflate!
  14. @buffer.size
  15. end
  16. 1 def read(length = nil, outbuf = nil)
  17. return @buffer.read(length, outbuf) if @buffer
  18. return if @closed
  19. chunk = @body.read(length)
  20. compressed_chunk = deflate(chunk)
  21. return unless compressed_chunk
  22. if outbuf
  23. outbuf.replace(compressed_chunk)
  24. else
  25. compressed_chunk
  26. end
  27. end
  28. 1 def close
  29. return if @closed
  30. @buffer.close if @buffer
  31. @body.close
  32. @closed = true
  33. end
  34. 1 def rewind
  35. return unless @buffer
  36. @buffer.rewind
  37. end
  38. 1 private
  39. # rubocop:disable Naming/MemoizedInstanceVariableName
  40. 1 def buffer_deflate!
  41. return @buffer if defined?(@buffer)
  42. buffer = Response::Buffer.new(
  43. threshold_size: Options::MAX_BODY_THRESHOLD_SIZE
  44. )
  45. IO.copy_stream(self, buffer)
  46. buffer.rewind if buffer.respond_to?(:rewind)
  47. @buffer = buffer
  48. end
  49. # rubocop:enable Naming/MemoizedInstanceVariableName
  50. end
  51. end
  52. end

lib/httpx/utils.rb

45.45% lines covered

44 relevant lines. 20 lines covered and 24 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module HTTPX
  3. 1 module Utils
  4. 1 using URIExtensions
  5. 1 TOKEN = %r{[^\s()<>,;:\\"/\[\]?=]+}.freeze
  6. 1 VALUE = /"(?:\\"|[^"])*"|#{TOKEN}/.freeze
  7. 1 FILENAME_REGEX = /\s*filename=(#{VALUE})/.freeze
  8. 1 FILENAME_EXTENSION_REGEX = /\s*filename\*=(#{VALUE})/.freeze
  9. 1 module_function
  10. 1 def now
  11. 153 Process.clock_gettime(Process::CLOCK_MONOTONIC)
  12. end
  13. 1 def elapsed_time(monotonic_timestamp)
  14. 37 Process.clock_gettime(Process::CLOCK_MONOTONIC) - monotonic_timestamp
  15. end
  16. # The value of this field can be either an HTTP-date or a number of
  17. # seconds to delay after the response is received.
  18. 1 def parse_retry_after(retry_after)
  19. # first: bet on it being an integer
  20. Integer(retry_after)
  21. rescue ArgumentError
  22. # Then it's a datetime
  23. time = Time.httpdate(retry_after)
  24. time - Time.now
  25. end
  26. 1 def get_filename(header, _prefix_regex = nil)
  27. filename = nil
  28. case header
  29. when FILENAME_REGEX
  30. filename = Regexp.last_match(1)
  31. filename = Regexp.last_match(1) if filename =~ /^"(.*)"$/
  32. when FILENAME_EXTENSION_REGEX
  33. filename = Regexp.last_match(1)
  34. encoding, _, filename = filename.split("'", 3)
  35. end
  36. return unless filename
  37. filename = URI::DEFAULT_PARSER.unescape(filename) if filename.scan(/%.?.?/).all? { |s| /%[0-9a-fA-F]{2}/.match?(s) }
  38. filename.scrub!
  39. filename = filename.gsub(/\\(.)/, '\1') unless /\\[^\\"]/.match?(filename)
  40. filename.force_encoding ::Encoding.find(encoding) if encoding
  41. filename
  42. end
  43. 1 URIParser = URI::RFC2396_Parser.new
  44. 1 def to_uri(uri)
  45. 138 return URI(uri) unless uri.is_a?(String) && !uri.ascii_only?
  46. uri = URI(URIParser.escape(uri))
  47. non_ascii_hostname = URIParser.unescape(uri.host)
  48. non_ascii_hostname.force_encoding(Encoding::UTF_8)
  49. idna_hostname = Punycode.encode_hostname(non_ascii_hostname)
  50. uri.host = idna_hostname
  51. uri.non_ascii_hostname = non_ascii_hostname
  52. uri
  53. end
  54. 1 if defined?(Ractor) &&
  55. # no ractor support for 3.0
  56. RUBY_VERSION >= "3.1.0"
  57. 1 def in_ractor?
  58. 98 Ractor.main != Ractor.current
  59. end
  60. else
  61. def in_ractor?
  62. false
  63. end
  64. end
  65. end
  66. end

test/support/assertion_helpers.rb

58.18% lines covered

55 relevant lines. 32 lines covered and 23 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ResponseHelpers
  3. 1 private
  4. 1 if RUBY_ENGINE == "jruby" || RUBY_ENGINE == "truffleruby" || defined?(RBS::Test)
  5. def can_run_ractor_tests?
  6. false
  7. end
  8. else
  9. 1 def can_run_ractor_tests?
  10. defined?(Ractor) && Ractor.method_defined?(:value)
  11. end
  12. end
  13. 1 def verify_status(response, expect)
  14. 27 raise response.error if response.is_a?(HTTPX::ErrorResponse)
  15. 27 assert response.status == expect, "status assertion failed: #{response.status} (expected: #{expect})"
  16. end
  17. 1 %w[header param].each do |meth|
  18. 2 class_eval <<-DEFINE, __FILE__, __LINE__ + 1
  19. def verify_#{meth}(#{meth}s, key, expect) # def verify_header(headers, key, expect)
  20. assert #{meth}s.key?(key), "#{meth}s don't contain the given key ('\#{key}', headers: \#{#{meth}s})" # assert headers.key?(key), "headers ...
  21. value = #{meth}s[key] # value = headers[key]
  22. if value.respond_to?(:start_with?) # if value.respond_to?(:start_with?)
  23. assert value.start_with?(expect), "#{meth} assertion failed: \#{key}=\#{value} (expected: \#{expect}})" # assert value.start_with?(expect), "headers assertion failed: ...
  24. else # else
  25. assert value == expect, "#{meth} assertion failed: \#{key}=\#{value.inspect} (expected: \#{expect.to_s})" # assert value == expect, "headers assertion failed: ...
  26. end # end
  27. end # end
  28. def verify_no_#{meth}(#{meth}s, key) # def verify_no_header(headers, key)
  29. assert !#{meth}s.key?(key), "#{meth}s contains the given key (" + key + ": \#{#{meth}s[key].inspect})" # assert !headers.key?(key), "headers contains ...
  30. end # end
  31. DEFINE
  32. end
  33. 1 def verify_body_length(response, expect = response.headers["content-length"].to_i)
  34. 3 len = response.body.to_s.bytesize
  35. 3 assert len == expect, "length assertion failed: #{len} (expected: #{expect})"
  36. end
  37. 1 def verify_execution_delta(expected, actual, delta = 0)
  38. delta += 3 # because of jitter
  39. delta += if RUBY_ENGINE == "truffleruby"
  40. # truffleruby has a hard time complying reliably with this delta when running in parallel. Therefore,
  41. # we give it a bit of leeway.
  42. 20
  43. else
  44. # delta checks become very innacurate under multi-thread mode, and elapsed time. we give it some leeway too.
  45. 3
  46. end
  47. assert_in_delta expected, actual, delta, "expected to have executed in #{expected} secs (actual: #{actual} secs)"
  48. end
  49. 1 def data_base64(path)
  50. "data:application/octet-stream;base64" \
  51. ",#{Base64.strict_encode64(File.read(path))}"
  52. end
  53. 1 def verify_uploaded(body, type, expect)
  54. assert body[type] == expect, "#{type} is unexpected: #{body[type]} (expected: #{expect})"
  55. end
  56. 1 def verify_error_response(response, expectation = nil)
  57. 1 assert response.is_a?(HTTPX::ErrorResponse), "expected an error response (instead got: #{response.inspect})"
  58. 1 return unless expectation
  59. 1 case expectation
  60. when Regexp
  61. 1 assert response.error.message =~ expectation,
  62. "expected to match /#{expectation}/ in \"#{response.error.message}\""
  63. when String
  64. assert response.error.message.include?(expectation),
  65. "expected \"#{response.error.message}\" to include \"#{expectation}\""
  66. when Class
  67. assert response.error.is_a?(expectation) || response.error.cause.is_a?(expectation),
  68. "expected #{response.error} to be a #{expectation}"
  69. else
  70. raise "unexpected expectation (#{expectation})"
  71. end
  72. end
  73. # test files
  74. 1 def verify_uploaded_image(body, key, mime_type, skip_verify_data: false)
  75. 1 assert body.key?("files"), "there were no files uploaded"
  76. 1 assert body["files"].key?(key), "there is no image in the file"
  77. # checking mime-type is a bit leaky, as httpbin displays the base64-encoded data
  78. 1 return if skip_verify_data
  79. 1 assert body["files"][key].start_with?("data:#{mime_type}"), "data was wrongly encoded (#{body["files"][key][0..64]})"
  80. end
  81. 1 def fixture
  82. File.read(fixture_file_path, encoding: Encoding::BINARY)
  83. end
  84. 1 def fixture_name
  85. File.basename(fixture_file_path)
  86. end
  87. 1 def fixture_file_name
  88. 2 "image.jpg"
  89. end
  90. 1 def fixture_file_path
  91. 2 File.join("test", "support", "fixtures", fixture_file_name)
  92. end
  93. 1 def start_test_servlet(servlet_class, *args, **kwargs)
  94. server = servlet_class.new(*args, **kwargs)
  95. th = Thread.new { server.start }
  96. begin
  97. yield server
  98. ensure
  99. if server.respond_to?(:shutdown)
  100. server.shutdown
  101. begin
  102. Timeout.timeout(3) { th.join }
  103. rescue Timeout::Error
  104. th.kill
  105. end
  106. else
  107. th.kill
  108. end
  109. end
  110. end
  111. end

test/support/connect_timeout_helpers.rb

36.36% lines covered

11 relevant lines. 4 lines covered and 7 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ConnectTimeoutHelpers
  3. # 9090 drops SYN packets for connect timeout tests, make sure there's a server binding there.
  4. 1 CONNECT_TIMEOUT_PORT_MUTEX = Thread::Mutex.new
  5. 1 CONNECT_TIMEOUT_PORT = ENV.fetch("CONNECT_TIMEOUT_PORT", 9090).to_i
  6. 1 def start_connect_timeout_tcp_server
  7. CONNECT_TIMEOUT_PORT_MUTEX.synchronize do
  8. i = 3
  9. begin
  10. server = TCPServer.new("127.0.0.1", CONNECT_TIMEOUT_PORT)
  11. rescue Errno::EADDRINUSE
  12. retry unless (i -= 1).zero?
  13. raise
  14. end
  15. begin
  16. yield "127.0.0.1:#{CONNECT_TIMEOUT_PORT}"
  17. ensure
  18. server.close
  19. end
  20. end
  21. end
  22. end

test/support/faraday_helpers.rb

100.0% lines covered

27 relevant lines. 27 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module FaradayHelpers
  3. 1 private
  4. # extra options to pass when building the adapter
  5. 1 def adapter_options
  6. 9 []
  7. end
  8. 1 def faraday_connection(options = {}, &optional_connection_config_blk)
  9. 9 return @faraday_connection if defined?(@faraday_connection)
  10. 9 builder_block = proc do |b|
  11. 9 b.request :url_encoded
  12. 9 b.adapter :httpx, *adapter_options, &optional_connection_config_blk
  13. end
  14. 9 options[:ssl] ||= {}
  15. 9 options[:ssl][:ca_file] ||= ENV["SSL_FILE"]
  16. 9 server = options.delete(:server_uri) || URI("https://#{httpbin}")
  17. 9 @faraday_connection = Faraday::Connection.new(server.to_s, options, &builder_block).tap do |conn|
  18. 9 conn.headers["X-Faraday-Adapter"] = "httpx"
  19. end
  20. end
  21. 1 def request_headers(response)
  22. 6 if response.is_a?(Hash)
  23. 2 response[:request][:headers]
  24. else
  25. 4 response.env.request_headers
  26. end.transform_keys(&:downcase)
  27. end
  28. 1 def verify_http_error_span(span, status, error)
  29. 2 assert span.get_tag("http.status_code") == status.to_s
  30. 2 if status >= 500 || Gem::Version.new(DatadogHelpers::DATADOG_VERSION::STRING) >= Gem::Version.new("2.0.0")
  31. 1 assert span.get_tag("error.type") == error
  32. 1 assert span.status == 1
  33. else
  34. 1 assert span.status.zero?
  35. end
  36. end
  37. 1 def teardown
  38. 9 super
  39. 9 @faraday_connection.close if defined?(@faraday_connection)
  40. end
  41. end

test/support/grpc_helpers.rb

31.53% lines covered

111 relevant lines. 35 lines covered and 76 lines missed.
    
  1. # frozen_string_literal: true
  2. begin
  3. 1 require "grpc"
  4. 1 require "logging"
  5. # A test message
  6. 1 class EchoMsg
  7. 1 attr_reader :msg
  8. 1 def initialize(msg: "")
  9. @msg = msg
  10. end
  11. 1 def self.marshal(o)
  12. o.msg
  13. end
  14. 1 def self.unmarshal(msg)
  15. EchoMsg.new(msg: msg)
  16. end
  17. end
  18. # a test service that checks the cert of its peer
  19. 1 class TestService
  20. 1 include GRPC::GenericService
  21. 1 rpc :an_rpc, EchoMsg, EchoMsg
  22. 1 rpc :a_cancellable_rpc, EchoMsg, EchoMsg
  23. 1 rpc :a_client_streaming_rpc, stream(EchoMsg), EchoMsg
  24. 1 rpc :a_server_streaming_rpc, EchoMsg, stream(EchoMsg)
  25. 1 rpc :a_bidi_rpc, stream(EchoMsg), stream(EchoMsg)
  26. 1 def check_peer_cert(call)
  27. # error_msg = "want:\n#{client_cert}\n\ngot:\n#{call.peer_cert}"
  28. # fail(error_msg) unless call.peer_cert == client_cert
  29. end
  30. 1 def an_rpc(req, call)
  31. check_peer_cert(call)
  32. req
  33. end
  34. 1 def a_cancellable_rpc(_req, call)
  35. check_peer_cert(call)
  36. raise GRPC::Cancelled, "dump"
  37. end
  38. 1 def a_client_streaming_rpc(call)
  39. check_peer_cert(call)
  40. call.each_remote_read.each { |r| GRPC.logger.info(r) }
  41. EchoMsg.new(msg: "client stream pong")
  42. end
  43. 1 def a_server_streaming_rpc(_, call)
  44. check_peer_cert(call)
  45. call.send_initial_metadata
  46. [EchoMsg.new(msg: "server stream pong"), EchoMsg.new(msg: "server stream pong")]
  47. end
  48. 1 def a_bidi_rpc(requests, call)
  49. check_peer_cert(call)
  50. requests.each { |r| GRPC.logger.info(r) }
  51. call.send_initial_metadata
  52. [EchoMsg.new(msg: "bidi pong"), EchoMsg.new(msg: "bidi pong")]
  53. end
  54. end
  55. 1 if ENV.key?("HTTPX_DEBUG")
  56. log_level = ENV["HTTPX_DEBUG"].to_i
  57. log_level = log_level > 1 ? :debug : :info
  58. module GRPC
  59. extend Logging.globally
  60. end
  61. Logging.logger.root.appenders = Logging.appenders.stdout
  62. Logging.logger.root.level = log_level
  63. Logging.logger["GRPC"].level = log_level
  64. Logging.logger["GRPC::ActiveCall"].level = log_level
  65. Logging.logger["GRPC::BidiCall"].level = log_level
  66. end
  67. 1 module GRPCHelpers
  68. 1 include ::GRPC::Core::StatusCodes
  69. 1 include ::GRPC::Core::TimeConsts
  70. 1 include ::GRPC::Core::CallOps
  71. 1 private
  72. 1 def teardown
  73. super
  74. if @grpc_server
  75. @grpc_server.shutdown_and_notify(from_relative_time(2))
  76. @grpc_server.close
  77. @grpc_server_th.join if @grpc_server_th
  78. end
  79. return unless @rpc_server && !@rpc_server.stopped?
  80. @rpc_server.stop
  81. @rpc_server_th.join
  82. end
  83. 1 def grpc_plugin
  84. grpc = HTTPX.plugin(:grpc)
  85. grpc = grpc.with_channel_credentials(*channel_credentials_paths, hostname: "foo.test.google.fr") if origin.start_with?("https")
  86. grpc
  87. end
  88. 1 def run_rpc(service, server_args: {})
  89. @rpc_server = ::GRPC::RpcServer.new(server_args: server_args.merge("grpc.so_reuseport" => 0))
  90. cred = origin.start_with?("https") ? server_credentials : :this_port_is_insecure
  91. server_port = @rpc_server.add_http2_port("localhost:0", cred)
  92. @rpc_server.handle(service)
  93. @rpc_server_th = Thread.new { @rpc_server.run }
  94. @rpc_server.wait_till_running
  95. server_port
  96. end
  97. 1 def run_request_response(resp, status, marshal: nil, server_args: {}, server_initial_md: {}, server_trailing_md: {})
  98. @grpc_server = ::GRPC::Core::Server.new(server_args.merge("grpc.so_reuseport" => 0))
  99. cred = origin.start_with?("https") ? server_credentials : :this_port_is_insecure
  100. server_port = @grpc_server.add_http2_port("localhost:0", cred)
  101. @grpc_server_th = wakey_thread do |notifier|
  102. c = expect_server_to_be_invoked(notifier, metadata_to_send: server_initial_md, marshal: marshal)
  103. begin
  104. yield c
  105. ensure
  106. c.remote_send(resp)
  107. c.send_status(status, status == OK ? "OK" : "NOK", true, metadata: server_trailing_md)
  108. c.send(:set_input_stream_done)
  109. c.send(:set_output_stream_done)
  110. end
  111. end
  112. server_port
  113. end
  114. 1 def wakey_thread(&blk)
  115. n = ::GRPC::Notifier.new
  116. t = Thread.new do
  117. begin
  118. blk.call(n)
  119. rescue GRPC::Core::CallError
  120. end
  121. end
  122. t.abort_on_exception = true
  123. n.wait
  124. t
  125. end
  126. 1 def expect_server_to_be_invoked(notifier, metadata_to_send: nil, marshal: nil)
  127. @grpc_server.start
  128. notifier.notify(nil)
  129. recvd_rpc = @grpc_server.request_call
  130. recvd_call = recvd_rpc.call
  131. recvd_call.metadata = recvd_rpc.metadata
  132. recvd_call.run_batch(SEND_INITIAL_METADATA => metadata_to_send)
  133. ::GRPC::ActiveCall.new(recvd_call, marshal, marshal, INFINITE_FUTURE, metadata_received: true)
  134. end
  135. 1 def server_credentials
  136. creds = ["ca.pem", "server1.key", "server1.pem"]
  137. .map { |path| File.join(grpc_testdata_path, path) }
  138. .map(&File.method(:read))
  139. GRPC::Core::ServerCredentials.new(
  140. creds[0],
  141. [{ private_key: creds[1], cert_chain: creds[2] }],
  142. true
  143. ) # force client auth
  144. end
  145. 1 def channel_credentials_paths
  146. ["ca.pem", "client.key", "client.pem"]
  147. .map { |path| File.join(grpc_testdata_path, path) }
  148. # .map(&File.method(:read))
  149. # GRPC::Core::ChannelCredentials.new(*creds)
  150. end
  151. 1 def grpc_testdata_path
  152. grpc_path = Gem::Specification.find_by_path("grpc").full_gem_path
  153. File.join(grpc_path, "src", "ruby", "spec", "testdata")
  154. end
  155. end
  156. rescue LoadError
  157. module GRPCHelpers
  158. end
  159. end

test/support/http_helpers.rb

76.19% lines covered

21 relevant lines. 16 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require_relative "assertion_helpers"
  3. 1 module HTTPHelpers
  4. 1 include ResponseHelpers
  5. 1 private
  6. 1 def build_uri(suffix = "/", uri_origin = origin)
  7. 30 "#{uri_origin}#{suffix || "/"}"
  8. end
  9. 1 def json_body(response)
  10. 9 raise response.error if response.is_a?(HTTPX::ErrorResponse)
  11. 9 JSON.parse(response.body.to_s)
  12. end
  13. 1 def httpbin
  14. 41 ENV.fetch("HTTPBIN_HOST", "nghttp2.org/httpbin")
  15. end
  16. 1 def httpbin_no_proxy
  17. URI(ENV.fetch("HTTPBIN_NO_PROXY_HOST", "#{scheme}httpbin.org"))
  18. end
  19. 1 def origin(orig = httpbin)
  20. 3 "#{scheme}#{orig}"
  21. end
  22. 1 def next_available_port
  23. server = TCPServer.new("localhost", 0)
  24. server.addr[1]
  25. ensure
  26. server.close
  27. end
  28. 1 def tls?
  29. scheme == "https://"
  30. end
  31. end

test/support/minitest_extensions.rb

72.22% lines covered

36 relevant lines. 26 lines covered and 10 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module MinitestExtensions
  3. 1 module TimeoutForTest
  4. # our own subclass so we never confused different timeouts
  5. 1 class TestTimeout < Timeout::Error
  6. end
  7. 1 def run(*)
  8. 126 ::Timeout.timeout(60 * 5, TestTimeout) { super }
  9. end
  10. end
  11. 1 module FirstFailedTestInThread
  12. 1 def self.prepended(*)
  13. 1 super
  14. 1 HTTPX::Connection.include ConnectionExtensions
  15. end
  16. 1 def setup
  17. 63 super
  18. 63 extend(OnTheFly)
  19. end
  20. 1 module ConnectionExtensions
  21. 1 def send(request)
  22. request.instance_variable_set(:@connection, connection)
  23. super
  24. end
  25. end
  26. 1 def run(*)
  27. 63 (Thread.current[:passed_tests] ||= []) << "#{self.class.name}##{name}"
  28. 63 super
  29. ensure
  30. 63 if !skipped? && !Thread.current[:tests_already_failed] && !failures.empty?
  31. Thread.current[:tests_already_failed] = true
  32. puts "first test failed: #{Thread.current[:passed_tests].pop}\n"
  33. puts "this thread also executed: #{Thread.current[:passed_tests].join(", ")}" unless Thread.current[:passed_tests].empty?
  34. end
  35. end
  36. 1 module OnTheFly
  37. 1 def verify_status(response, expect)
  38. 27 if response.is_a?(HTTPX::ErrorResponse) && response.error.message.include?("execution expired")
  39. connection = response.request.instance_variable_get(:@connection)
  40. puts connection.inspect
  41. end
  42. 27 super
  43. end
  44. end
  45. end
  46. 1 module TestName
  47. 1 def run(*)
  48. print "#{self.class.name}##{name}: "
  49. super
  50. ensure
  51. puts " "
  52. end
  53. end
  54. end
  55. 1 Minitest::Test.prepend(MinitestExtensions::TimeoutForTest) unless ENV.key?("HTTPX_DEBUG")
  56. 1 Minitest::Test.prepend(MinitestExtensions::FirstFailedTestInThread)
  57. # Minitest::Test.prepend(MinitestExtensions::TestName)

test/support/proxy_helper.rb

33.93% lines covered

56 relevant lines. 19 lines covered and 37 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "uri"
  3. 1 require "net/http"
  4. 1 require "oga"
  5. 1 module ProxyHelper
  6. 1 private
  7. 1 def socks4_proxy
  8. Array(ENV["HTTPX_SOCKS4_PROXY"] || begin
  9. socks_proxies_list.select { |_, _, version, https| version == "Socks4" && https }
  10. .map { |ip, port, _, _| "socks4://#{ip}:#{port}" }
  11. end)
  12. end
  13. 1 def socks4a_proxy
  14. Array(ENV["HTTPX_SOCKS4A_PROXY"] || begin
  15. socks_proxies_list.select { |_, _, version, https| version == "Socks4" && https }
  16. .map { |ip, port, _, _| "socks4a://#{ip}:#{port}" }
  17. end)
  18. end
  19. 1 def socks5_proxy
  20. Array(ENV["HTTPX_SOCKS5_PROXY"] || begin
  21. socks_proxies_list.select { |_, _, version, https| version == "Socks5" && https }
  22. .map { |ip, port, _, _| "socks5://#{ip}:#{port}" }
  23. end)
  24. end
  25. 1 def http_proxy
  26. Array(ENV["HTTPX_HTTP_PROXY"] || begin
  27. http_proxies_list.map do |ip, port, _|
  28. "http://#{ip}:#{port}"
  29. end
  30. end)
  31. end
  32. 1 def http2_proxy
  33. Array(ENV["HTTPX_HTTP2_PROXY"])
  34. end
  35. 1 def https_proxy
  36. Array(ENV["HTTPX_HTTPS_PROXY"] || begin
  37. http_proxies_list.select { |_, _, https| https }.map do |ip, port, _|
  38. "http://#{ip}:#{port}"
  39. end
  40. end)
  41. end
  42. 1 def ssh_proxy
  43. Array(ENV["HTTPX_SSH_PROXY"] || begin
  44. http_proxies_list.select { |_, _, https| https }.map do |ip, port, _|
  45. "ssh://#{ip}:#{port}"
  46. end
  47. end)
  48. end
  49. 1 def http_proxies_list
  50. proxies_list(parse_http_proxies)
  51. .map do |line|
  52. ip, port, _, _, _, _, https, _ = line.css("td").map(&:text)
  53. [ip, port, https == "yes"]
  54. end.select { |ip, port, _| ip && port } # rubocop:disable Style/MultilineBlockChain
  55. end
  56. 1 def socks_proxies_list
  57. proxies_list(parse_socks_proxies)
  58. .map do |line|
  59. ip, port, _, _, version, _, https, _ = line.css("td").map(&:text)
  60. [ip, port, version, https == "Yes"]
  61. end.select { |ip, port, _, _| ip && port } # rubocop:disable Style/MultilineBlockChain
  62. end
  63. 1 def proxies_list(document)
  64. row = document.enum_for(:each_node).find do |node|
  65. next unless node.is_a?(Oga::XML::Element)
  66. id = node.attribute("id")
  67. next unless id
  68. id.value == "list"
  69. end
  70. row ? row.css("tr") : []
  71. end
  72. 1 def parse_http_proxies
  73. @parse_http_proxies ||= Oga.parse_html(fetch_http_proxies)
  74. end
  75. 1 def fetch_http_proxies
  76. Net::HTTP.get_response(URI("https://free-proxy-list.net/ssl-proxy.html")).body
  77. end
  78. 1 def parse_socks_proxies
  79. @parse_socks_proxies ||= Oga.parse_html(fetch_socks_proxies)
  80. end
  81. 1 def fetch_socks_proxies
  82. Net::HTTP.get_response(URI("https://free-proxy-list.net/socks-proxy.html")).body
  83. end
  84. end

test/support/proxy_response_detector.rb

61.54% lines covered

13 relevant lines. 8 lines covered and 5 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ProxyResponseDetector
  3. 1 module RequestMethods
  4. 1 attr_writer :proxied
  5. 1 def proxied?
  6. @proxied
  7. end
  8. end
  9. 1 module ResponseMethods
  10. 1 def proxied?
  11. @request.proxied?
  12. end
  13. end
  14. 1 module ConnectionMethods
  15. 1 def send(request)
  16. return super unless @options.respond_to?(:proxy) && @options.proxy
  17. request.proxied = true
  18. super
  19. end
  20. end
  21. end

test/support/proxy_retry.rb

36.36% lines covered

11 relevant lines. 4 lines covered and 7 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ProxyRetry
  3. 1 def run(*)
  4. 63 return super unless name.include?("_proxy")
  5. result = nil
  6. 3.times.each do |_i|
  7. result = super
  8. break if result.passed?
  9. self.failures = []
  10. self.assertions = 0
  11. end
  12. result
  13. end
  14. end
  15. 1 Minitest::Test.prepend(ProxyRetry) unless ENV.key?("HTTPX_DEBUG")

test/support/request_inspector.rb

41.67% lines covered

24 relevant lines. 10 lines covered and 14 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module RequestInspector
  3. 1 module InstanceMethods
  4. 1 attr_reader :calls, :total_requests, :total_responses
  5. 1 def initialize(*args)
  6. super
  7. # we're comparing against max-retries + 1, because the calls increment will happen
  8. # also in the last call, where the request is not going to be retried.
  9. @calls = -1
  10. @total_requests = []
  11. @total_responses = []
  12. end
  13. 1 def reset
  14. @calls = -1
  15. @total_requests.clear
  16. @total_responses.clear
  17. end
  18. 1 private
  19. 1 def send_request(request, *)
  20. @total_requests << request.dup
  21. super
  22. end
  23. 1 def fetch_response(*)
  24. response = super
  25. if response
  26. @calls += 1
  27. @total_responses << response
  28. end
  29. response
  30. end
  31. end
  32. 1 module ResponseMethods
  33. 1 attr_reader :request
  34. end
  35. end

test/support/requests/altsvc.rb

20.0% lines covered

15 relevant lines. 3 lines covered and 12 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module AltSvc
  4. 1 def test_altsvc_get
  5. altsvc_host = ENV["HTTPBIN_ALTSVC_HOST"]
  6. altsvc_origin = origin(altsvc_host)
  7. HTTPX.plugin(SessionWithPool).wrap do |http|
  8. altsvc_uri = build_uri("/get", altsvc_origin)
  9. res1, res2 = http.get(altsvc_uri, altsvc_uri)
  10. verify_status(res1, 200)
  11. verify_header(res1.headers, "alt-svc", "h2=\"nghttp2:443\"")
  12. verify_status(res2, 200)
  13. verify_header(res2.headers, "alt-svc", "h2=\"nghttp2:443\"")
  14. res3 = http.get(altsvc_uri)
  15. verify_status(res3, 200)
  16. verify_no_header(res3.headers, "alt-svc")
  17. # introspection time
  18. end
  19. end
  20. end
  21. end

test/support/requests/callbacks.rb

16.48% lines covered

91 relevant lines. 15 lines covered and 76 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "resolv"
  3. 1 require "httpx/plugins/callbacks"
  4. 1 module Requests
  5. 1 using HTTPX::URIExtensions
  6. 1 module Callbacks
  7. 1 def test_callbacks_connection_opened
  8. uri = URI(build_uri("/get"))
  9. origin = ip = nil
  10. opened = 0
  11. response = HTTPX.plugin(SessionWithPool).plugin(:callbacks).on_connection_opened do |o, sock|
  12. origin = o
  13. ip = sock.to_io.remote_address.ip_address
  14. opened += 1
  15. end.get(uri)
  16. verify_status(response, 200)
  17. assert !origin.nil?
  18. assert origin.to_s == uri.origin
  19. assert !ip.nil?
  20. assert opened == 1
  21. assert Resolv.getaddresses(uri.host).include?(ip)
  22. end
  23. 1 def test_callbacks_connection_closed
  24. uri = URI(build_uri("/get"))
  25. origin = nil
  26. closed = 0
  27. response = HTTPX.plugin(SessionWithPool).plugin(:callbacks).on_connection_closed do |o|
  28. origin = o
  29. closed += 1
  30. end.get(uri)
  31. verify_status(response, 200)
  32. assert !origin.nil?
  33. assert origin.to_s == uri.origin
  34. assert closed == 1
  35. end
  36. 1 def test_callbacks_request_error
  37. uri = URI(build_uri("/get"))
  38. error = nil
  39. http = HTTPX.plugin(:callbacks).on_request_error { |_, err| error = err }
  40. response = http.get(uri)
  41. verify_status(response, 200)
  42. assert error.nil?
  43. unavailable_host = URI(origin("localhost"))
  44. unavailable_host.port = next_available_port
  45. response = http.get(unavailable_host.to_s)
  46. verify_error_response(response, /Connection refused| not available/)
  47. assert !error.nil?
  48. assert error == response.error
  49. end
  50. 1 def test_callbacks_request_error_allow_reraise_in_bock
  51. URI(build_uri("/get"))
  52. err_type = Class.new(StandardError)
  53. ex = assert_raises(err_type) do
  54. http = HTTPX.plugin(:callbacks).on_request_error do |_, err|
  55. raise err_type, err.message
  56. end
  57. # unavailable_host = URI(origin("localhost"))
  58. # unavailable_host.port = next_available_port
  59. # http.get(unavailable_host.to_s)
  60. http.get("http://unknownhost")
  61. end
  62. assert ex.message == "name or service not known"
  63. end
  64. 1 def test_callbacks_request
  65. uri = URI(build_uri("/post"))
  66. started = completed = false
  67. chunks = 0
  68. http = HTTPX.plugin(:callbacks)
  69. .on_request_started { |_| started = true }
  70. .on_request_body_chunk { |_, _chunk| chunks += 1 }
  71. .on_request_completed { |_| completed = true }
  72. response = http.post(uri, body: "data")
  73. verify_status(response, 200)
  74. assert started
  75. assert completed
  76. assert chunks.positive?
  77. end
  78. 1 def test_callbacks_response
  79. uri = URI(build_uri("/get"))
  80. started = completed = false
  81. chunks = 0
  82. http = HTTPX.plugin(:callbacks)
  83. .on_response_started { |_, _| started = true }
  84. .on_response_body_chunk { |_, _, _chunk| chunks += 1 }
  85. .on_response_completed { |_, _| completed = true }
  86. response = http.get(uri)
  87. verify_status(response, 200)
  88. assert started
  89. assert completed
  90. assert chunks.positive?
  91. end
  92. 1 def test_callbacks_keeps_callbacks_when_building_new_sessions
  93. http = HTTPX.plugin(:callbacks).on_request_started { puts "test" }
  94. http.singleton_class.class_eval do
  95. public :callbacks_for?
  96. end
  97. assert http.callbacks_for?(:request_started)
  98. http = http.with(headers: { a: 1 })
  99. assert http.callbacks_for?(:request_started)
  100. end
  101. 1 %i[
  102. connection_opened connection_closed
  103. request_started request_completed
  104. response_started response_body_chunk response_completed
  105. ].each do |callback|
  106. 7 define_method :"test_callbacks_bug_inside_#{callback}_callback" do
  107. assert_raises(NameError) do
  108. HTTPX.plugin(SessionWithPool).plugin(:callbacks).send(:"on_#{callback}") { i_dont_exist }.get(build_uri("/get"))
  109. end
  110. end
  111. end
  112. 1 def test_callbacks_can_compose_with
  113. http = HTTPX.plugin(:callbacks).with(persistent: true)
  114. assert http.instance_variable_get(:@persistent)
  115. end
  116. end
  117. end

test/support/requests/chunked_get.rb

19.05% lines covered

21 relevant lines. 4 lines covered and 17 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module ChunkedGet
  4. 1 def test_http_chunked_get
  5. uri = build_uri("/stream-bytes/30?chunk_size=5")
  6. response = HTTPX.get(uri)
  7. verify_status(response, 200)
  8. verify_header(response.headers, "transfer-encoding", "chunked")
  9. verify_body_length(response, 30)
  10. end
  11. 1 def test_http_head_chunked_to_next_request
  12. start_test_servlet(KeepAliveServer) do |server|
  13. chunked_uri = "#{server.origin}/chunk"
  14. HTTPX.with(persistent: true) do |http|
  15. response = http.head(chunked_uri)
  16. verify_status(response, 200)
  17. verify_header(response.headers, "transfer-encoding", "chunked")
  18. verify_body_length(response, 0)
  19. response = http.get(chunked_uri)
  20. verify_status(response, 200)
  21. verify_header(response.headers, "transfer-encoding", "chunked")
  22. body = json_body(response)
  23. assert body["chunked"] == true
  24. end
  25. end
  26. end
  27. end
  28. end

test/support/requests/coalescing.rb

7.02% lines covered

57 relevant lines. 4 lines covered and 53 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Coalescing
  4. 1 def test_connection_coalescing
  5. coalesced_origin = "https://#{ENV["HTTPBIN_COALESCING_HOST"]}"
  6. HTTPX.plugin(SessionWithPool).wrap do |http|
  7. response1 = http.get(origin)
  8. verify_status(response1, 200)
  9. response2 = http.get(coalesced_origin)
  10. verify_status(response2, 200)
  11. # introspection time
  12. connections = http.connections
  13. assert connections.size == 2
  14. origins = connections.map(&:origins)
  15. assert origins.any? { |orgs| orgs.sort == [origin, coalesced_origin].sort },
  16. "connections for #{[origin, coalesced_origin]} didn't coalesce (expected connection with both origins (#{origins}))"
  17. assert http.pool.connections_counter == 1, "coalesced connection should not have been accounted for in the pool"
  18. unsafe_origin = URI(origin)
  19. unsafe_origin.scheme = "http"
  20. response3 = http.get(unsafe_origin)
  21. verify_status(response3, 200)
  22. # introspection time
  23. connections = http.connections
  24. assert connections.size == 3
  25. origins = connections.map(&:origins)
  26. refute origins.any?([origin]),
  27. "connection coalesced inexpectedly (expected connection with both origins (#{origins}))"
  28. end
  29. end
  30. 1 def test_coalesce_should_not_leak_across_threads
  31. # https://gitlab.com/os85/httpx/-/issues/365
  32. uri = URI(build_uri("/get", "https://#{httpbin}"))
  33. coalesced_uri = URI(build_uri("/get", "https://#{ENV["HTTPBIN_COALESCING_HOST"]}"))
  34. q = Queue.new
  35. http = HTTPX.plugin(SessionWithPool).plugin(:persistent)
  36. registered_conns = Set[]
  37. http.define_singleton_method(:select_connection) do |conn, selector|
  38. registered_conns << [conn, selector]
  39. super(conn, selector)
  40. end
  41. http.singleton_class.class_eval do
  42. public(:get_current_selector)
  43. end
  44. th1 = Thread.start do
  45. q.pop
  46. res = http.get(coalesced_uri)
  47. verify_status(res, 200)
  48. http.get_current_selector
  49. end
  50. th2 = Thread.start do
  51. res = http.get(uri)
  52. verify_status(res, 200)
  53. sel = http.get_current_selector
  54. q << :done
  55. sel
  56. end
  57. th2_selector = th2.value
  58. th1_selector = th1.value
  59. conns = http.connections.select(&:open?)
  60. assert conns.size == 1
  61. conn = conns.first
  62. assert conn.current_session.nil?, "connection should have reset its session already"
  63. assert conn.current_selector.nil?, "connection should have reset its selector already"
  64. assert http.pool.connections_counter == 1, "connection"
  65. assert http.pool.connections.include?(conn)
  66. assert registered_conns.size == 2
  67. assert registered_conns.include?([conn, th1_selector])
  68. assert registered_conns.include?([conn, th2_selector])
  69. ensure
  70. http.close if defined?(http)
  71. end
  72. end
  73. end

test/support/requests/compression.rb

15.05% lines covered

93 relevant lines. 14 lines covered and 79 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Compression
  4. 1 def test_compression_accepts
  5. url = "https://github.com"
  6. response = HTTPX.get(url)
  7. skip if response == 429
  8. verify_status(response, 200)
  9. assert response.body.encodings == %w[gzip], "response should be sent with gzip encoding"
  10. response.close
  11. end
  12. 1 def test_compression_identity_post
  13. uri = build_uri("/post")
  14. response = HTTPX.with_headers("content-encoding" => "identity")
  15. .post(uri, body: "a" * 8012)
  16. verify_status(response, 200)
  17. body = json_body(response)
  18. verify_header(body["headers"], "Content-Type", "application/octet-stream")
  19. compressed_data = body["data"]
  20. assert compressed_data.bytesize == 8012, "body shouldn't have been compressed"
  21. end
  22. 1 def test_compression_gzip
  23. uri = build_uri("/gzip")
  24. response = HTTPX.get(uri)
  25. verify_status(response, 200)
  26. assert response.headers["content-length"].to_i != response.body.bytesize
  27. body = json_body(response)
  28. assert body["gzipped"], "response should be gzipped"
  29. end
  30. 1 def test_compression_gzip_do_not_decompress
  31. uri = build_uri("/gzip")
  32. response = HTTPX.get(uri, decompress_response_body: false)
  33. verify_status(response, 200)
  34. assert response.headers["content-length"].to_i == response.body.bytesize
  35. end
  36. 1 def test_compression_gzip_post
  37. uri = build_uri("/post")
  38. response = HTTPX.with_headers("content-encoding" => "gzip")
  39. .post(uri, body: "a" * 8012)
  40. verify_status(response, 200)
  41. body = json_body(response)
  42. verify_header(body["headers"], "Content-Type", "application/octet-stream")
  43. compressed_data = body["data"]
  44. compressed_data = compressed_data.delete_prefix("data:application/octet-stream;base64,")
  45. compressed_data = Base64.decode64(compressed_data)
  46. assert compressed_data.bytesize < 8012, "body hasn't been compressed"
  47. assert inflate_test_data(compressed_data) == "a" * 8012
  48. end
  49. 1 def test_compression_gzip_post_already_compressed
  50. uri = build_uri("/post")
  51. gzip_body = Zlib.gzip("a" * 8012)
  52. response = HTTPX.with(
  53. compress_request_body: false,
  54. headers: { "content-encoding" => "gzip" }
  55. ).post(uri, body: gzip_body)
  56. verify_status(response, 200)
  57. body = json_body(response)
  58. verify_header(body["headers"], "Content-Type", "application/octet-stream")
  59. compressed_data = body["data"]
  60. compressed_data = compressed_data.delete_prefix("data:application/octet-stream;base64,")
  61. compressed_data = Base64.decode64(compressed_data)
  62. assert compressed_data.bytesize < 8012, "body hasn't been compressed"
  63. assert inflate_test_data(compressed_data) == "a" * 8012
  64. end
  65. 1 def test_compression_deflate
  66. uri = build_uri("/deflate")
  67. response = HTTPX.get(uri)
  68. verify_status(response, 200)
  69. body = json_body(response)
  70. assert body["deflated"], "response should be deflated"
  71. end
  72. 1 def test_compression_deflate_post
  73. uri = build_uri("/post")
  74. response = HTTPX.with_headers("content-encoding" => "deflate")
  75. .post(uri, body: "a" * 8012)
  76. verify_status(response, 200)
  77. body = json_body(response)
  78. verify_header(body["headers"], "Content-Type", "application/octet-stream")
  79. compressed_data = body["data"]
  80. compressed_data = compressed_data.delete_prefix("data:application/octet-stream;base64,")
  81. compressed_data = Base64.decode64(compressed_data)
  82. assert compressed_data.bytesize < 8012, "body hasn't been compressed"
  83. assert inflate_test_data(compressed_data) == "a" * 8012
  84. end
  85. # regression test
  86. 1 def test_compression_no_content_length
  87. # run this only for http/1.1 mode, as this is a local test server
  88. return unless origin.start_with?("http://")
  89. start_test_servlet(NoContentLengthServer) do |server|
  90. uri = build_uri("/", server.origin)
  91. response = HTTPX.get(uri)
  92. verify_status(response, 200)
  93. body = response.body.to_s
  94. assert body == "helloworld"
  95. end
  96. end
  97. 1 def test_compression_ignore_encoding_on_range
  98. uri = build_uri("/get")
  99. response = HTTPX.get(uri)
  100. verify_status(response, 200)
  101. body = json_body(response)
  102. assert body["headers"].key?("Accept-Encoding")
  103. response = HTTPX.get(uri, headers: { "range" => "bytes=100-200" })
  104. body = json_body(response)
  105. assert !body["headers"].key?("Accept-Encoding")
  106. end
  107. 1 private
  108. 1 def inflate_test_data(string)
  109. zstream = Zlib::Inflate.new(Zlib::MAX_WBITS + 32)
  110. buf = zstream.inflate(string)
  111. zstream.finish
  112. zstream.close
  113. buf
  114. end
  115. end
  116. end

test/support/requests/errors.rb

32.89% lines covered

76 relevant lines. 25 lines covered and 51 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Errors
  4. 1 def test_errors_invalid_uri
  5. exc = assert_raises { HTTPX.get("/get") }
  6. assert exc.message.include?("invalid URI: /get")
  7. exc = assert_raises { HTTPX.get("http:/smth/get") }
  8. assert exc.message.include?("invalid URI: http:/smth/get")
  9. end
  10. 1 def test_errors_invalid_scheme
  11. assert_raises(HTTPX::UnsupportedSchemeError) { HTTPX.get("foo://example.com") }
  12. end
  13. 1 def test_errors_connection_refused
  14. unavailable_host = URI(origin("localhost"))
  15. unavailable_host.port = next_available_port
  16. response = HTTPX.get(unavailable_host.to_s)
  17. verify_error_response(response, /Connection refused| not available/)
  18. end
  19. 1 def test_errors_log_error
  20. log = StringIO.new
  21. unavailable_host = URI(origin("localhost"))
  22. unavailable_host.port = next_available_port
  23. response = HTTPX.plugin(SessionWithPool).get(unavailable_host.to_s, debug: log, debug_level: 3)
  24. output = log.string
  25. assert output.include?(response.error.message)
  26. end
  27. 1 def test_errors_host_unreachable
  28. uri = URI(origin("localhost")).to_s
  29. return unless uri.start_with?("http://")
  30. response = HTTPX.get(uri, addresses: [EHOSTUNREACH_HOST] * 2)
  31. verify_error_response(response, /No route to host/)
  32. end
  33. # TODO: reset this test once it's possible to test ETIMEDOUT again
  34. # the new iptables crapped out on me
  35. # def test_errors_host_etimedout
  36. # uri = URI(origin("etimedout:#{ETIMEDOUT_PORT}")).to_s
  37. # return unless uri.start_with?("http://")
  38. # server = TCPServer.new("127.0.0.1", ETIMEDOUT_PORT)
  39. # begin
  40. # response = HTTPX.get(uri, addresses: %w[127.0.0.1] * 2)
  41. # verify_error_response(response, Errno::ETIMEDOUT)
  42. # ensure
  43. # server.close
  44. # end
  45. # end
  46. 1 SocketErrorPlugin = Module.new do
  47. 1 self::ResolverNativeMethods = Module.new do
  48. 1 define_method :call do
  49. end
  50. 1 define_method :to_io do
  51. raise "socket error here"
  52. end
  53. end
  54. end
  55. 1 SocketExceptionPlugin = Module.new do
  56. 1 self::SocketException = Class.new(Exception) # rubocop:disable Style/EmptyClassDefinition
  57. 1 self::ResolverNativeMethods = Module.new do
  58. 1 define_method :call do
  59. end
  60. 1 define_method :to_io do
  61. raise SocketExceptionPlugin::SocketException, "socket exception here"
  62. end
  63. end
  64. 1 self::ResolverHTTPSMethods = Module.new do
  65. 1 def resolver_connection
  66. super.tap do |conn|
  67. def conn.to_io
  68. raise SocketExceptionPlugin::SocketException, "socket exception here"
  69. end
  70. end
  71. end
  72. end
  73. 1 self::ResolverSystemMethods = Module.new do
  74. 1 def __addrinfo_resolve(*)
  75. sleep(0.1)
  76. super
  77. end
  78. 1 define_method :to_io do
  79. raise SocketExceptionPlugin::SocketException, "socket exception here"
  80. end
  81. end
  82. end
  83. 1 def test_errors_native_resolver_error_mid_dns_query_io_wait
  84. uri = URI(build_uri("/get"))
  85. HTTPX
  86. .plugin(SessionWithPool)
  87. .plugin(SocketErrorPlugin)
  88. .with(resolver_class: :native, resolver_options: { cache: false }) do |http|
  89. response = http.get(uri)
  90. verify_error_response(response, /socket error here/)
  91. pool = http.pool
  92. assert pool.connections_counter.nonzero?
  93. assert pool.connections_counter == pool.connections.size
  94. assert(pool.connections.all? { |conn| conn.state == :closed })
  95. assert http.resolvers.size == 1
  96. resolver = http.resolvers.first
  97. resolver = resolver.resolvers.first # because it's a multi
  98. assert resolver.state == :closed
  99. assert resolver.connections.empty?
  100. end
  101. end
  102. {
  103. 1 single: [Socket::AF_INET],
  104. multihomed: [Socket::AF_INET6, Socket::AF_INET],
  105. }.each do |type, ip_families|
  106. 2 %i[native system https].each do |resolver_class|
  107. 6 define_method :"test_errors_#{type}_#{resolver_class}_resolver_exception_mid_dns_query_io_wait" do
  108. uri = URI(build_uri("/get"))
  109. HTTPX
  110. .plugin(SessionWithPool)
  111. .plugin(SocketExceptionPlugin)
  112. .with(resolver_class: resolver_class, resolver_options: { cache: false }, ip_families: ip_families) do |http|
  113. assert_raises(SocketExceptionPlugin::SocketException) do
  114. http.get(uri)
  115. end
  116. # some state is going to be corrupted in the face of an Exception,
  117. # the only thing we care about is whether all used sockets are closed.
  118. connections = http.connections
  119. assert connections.size >= 1
  120. assert(connections.all? { |conn| conn.state == :closed })
  121. # https resolver will also need to resolve its resolver connection
  122. assert http.resolvers.size == (resolver_class == :https ? 2 : 1)
  123. resolver = http.resolvers.first
  124. resolver = resolver.resolvers.first # because it's a multi
  125. assert resolver.state == :closed
  126. end
  127. end
  128. end
  129. end
  130. end
  131. end

test/support/requests/get.rb

17.44% lines covered

86 relevant lines. 15 lines covered and 71 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "time"
  3. 1 module Requests
  4. 1 using HTTPX::URIExtensions
  5. 1 module Get
  6. 1 def test_http_get
  7. uri = build_uri("/get")
  8. response = HTTPX.get(uri)
  9. verify_status(response, 200)
  10. verify_body_length(response)
  11. return unless can_run_ractor_tests?
  12. response2 = Ractor.new(uri) do |uri|
  13. HTTPX.get(uri)
  14. end.value
  15. verify_status(response2, 200)
  16. verify_body_length(response2)
  17. end
  18. 1 def test_http_get_option_origin
  19. uri = URI(build_uri("/get"))
  20. response = HTTPX.with(origin: uri.origin).get(uri.path)
  21. verify_status(response, 200)
  22. verify_body_length(response)
  23. end
  24. 1 def test_http_get_option_origin_base_path
  25. status_uri = URI(build_uri("/status"))
  26. http = HTTPX.with(origin: status_uri.origin, base_path: status_uri.request_uri)
  27. response = http.get("/200")
  28. verify_status(response, 200)
  29. assert response.uri.request_uri == "#{status_uri.request_uri}/200"
  30. end
  31. 1 def test_http_get_request
  32. uri = build_uri("/get")
  33. response = HTTPX.request("GET", uri)
  34. verify_status(response, 200)
  35. verify_body_length(response)
  36. end
  37. 1 def test_http_get_build_request
  38. uri = build_uri("/get")
  39. HTTPX.wrap do |http|
  40. request = http.build_request("GET", uri)
  41. response = http.request(request)
  42. verify_status(response, 200)
  43. verify_body_length(response)
  44. end
  45. end
  46. 1 def test_get_multiple_same_origin
  47. uri = build_uri("/delay/2")
  48. session = HTTPX.plugin(SessionWithPool)
  49. response1, response2 = session.get(uri, uri)
  50. verify_status(response1, 200)
  51. verify_body_length(response1)
  52. verify_status(response2, 200)
  53. verify_body_length(response2)
  54. assert session.resolvers.size == 1
  55. end
  56. 1 def test_get_multiple_different_origin
  57. session = HTTPX.plugin(SessionWithPool)
  58. req1 = ["/delay/2", { origin: origin(httpbin) }]
  59. req2 = ["/delay/2", { origin: httpbin_no_proxy }]
  60. response1, response2 = session.get(req1, req2)
  61. verify_status(response1, 200)
  62. verify_body_length(response1)
  63. verify_status(response2, 200)
  64. verify_body_length(response2)
  65. num_resolvers = session.resolvers.size
  66. assert num_resolvers == 1, "should have only had 1 resolver, was #{num_resolvers}"
  67. end
  68. 1 def test_get_multiple_no_concurrency
  69. uri = build_uri("/delay/2")
  70. response1, response2 = HTTPX.plugin(:persistent).get(uri, uri, max_concurrent_requests: 1)
  71. verify_status(response1, 200)
  72. verify_body_length(response1)
  73. verify_status(response2, 200)
  74. verify_body_length(response2)
  75. assert response1.to_s == response2.to_s, "request should have been the same"
  76. date1 = Time.parse(response1.headers["date"])
  77. date2 = Time.parse(response2.headers["date"])
  78. # I test for greater than 2 due to the concurrent test, which affect the times.
  79. # However, most important is, it takes certainly more than 2 seconds.
  80. time_it_took = (date2 - date1).abs
  81. assert time_it_took >= 2, "time between requests took < 2 secs (actual: #{time_it_took} secs)"
  82. end
  83. 1 def test_get_http_accept
  84. uri = build_uri("/get")
  85. response = HTTPX.accept("text/html").get(uri)
  86. verify_status(response, 200)
  87. request = response.instance_variable_get(:@request)
  88. verify_header(request.headers, "accept", "text/html")
  89. response.close
  90. end
  91. 1 def test_get_idn
  92. response = HTTPX.get("http://bücher.ch")
  93. verify_status(response, 301)
  94. verify_header(response.headers, "location", "https://www.buecher.de")
  95. response.close
  96. assert response.instance_variable_get(:@request).authority == "xn--bcher-kva.ch"
  97. end
  98. 1 def test_get_non_ascii
  99. response = HTTPX.get(build_uri("/get?q=ã"))
  100. verify_status(response, 200)
  101. response.close
  102. end
  103. end
  104. end

test/support/requests/head.rb

42.86% lines covered

7 relevant lines. 3 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Head
  4. 1 def test_http_head
  5. uri = build_uri("/get")
  6. response = HTTPX.head(uri)
  7. verify_status(response, 200)
  8. verify_body_length(response, 0)
  9. end
  10. end
  11. end

test/support/requests/headers.rb

25.0% lines covered

16 relevant lines. 4 lines covered and 12 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Headers
  4. 1 def test_http_headers
  5. uri = build_uri("/headers")
  6. response = HTTPX.get(uri)
  7. body = json_body(response)
  8. assert body.key?("headers"), "no headers"
  9. assert body["headers"]["Accept"] == "*/*", "unexpected accept"
  10. response = HTTPX.with_headers("accept" => "text/css").get(uri)
  11. body = json_body(response)
  12. verify_header(body["headers"], "Accept", "text/css")
  13. end
  14. 1 def test_http_user_agent
  15. uri = build_uri("/user-agent")
  16. response = HTTPX.get(uri)
  17. body = json_body(response)
  18. verify_header(body, "user-agent", "httpx.rb/#{HTTPX::VERSION}")
  19. end
  20. end
  21. end

test/support/requests/io.rb

21.88% lines covered

32 relevant lines. 7 lines covered and 25 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module IO
  4. 1 using HTTPX::URIExtensions
  5. 1 def test_http_io
  6. io = origin_io
  7. uri = build_uri("/get")
  8. response = HTTPX.get(uri, io: io)
  9. verify_status(response, 200)
  10. verify_body_length(response)
  11. assert !io.closed?, "io should have been left open"
  12. ensure
  13. io.close if io
  14. end
  15. 1 def test_http_io_hash
  16. io = origin_io
  17. uri = build_uri("/get")
  18. response = HTTPX.get(uri, io: { URI(origin).authority => io })
  19. verify_status(response, 200)
  20. verify_body_length(response)
  21. assert !io.closed?, "io should have been left open"
  22. ensure
  23. io.close if io
  24. end
  25. end
  26. 1 private
  27. 1 def origin_io
  28. uri = URI(origin)
  29. case uri.scheme
  30. when "http"
  31. TCPSocket.new(uri.host, uri.port)
  32. when "https"
  33. ctx = OpenSSL::SSL::SSLContext.new
  34. ctx.alpn_protocols = %w[h2 http/1.1]
  35. sock = OpenSSL::SSL::SSLSocket.new(TCPSocket.new(uri.host, uri.port), ctx)
  36. sock.hostname = uri.host
  37. sock.sync_close = true
  38. sock.connect
  39. sock
  40. else
  41. raise "#{uri.scheme}: unsupported scheme"
  42. end
  43. end
  44. end

test/support/requests/limits.rb

18.75% lines covered

32 relevant lines. 6 lines covered and 26 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "time"
  3. 1 module Requests
  4. 1 module Limits
  5. 1 def test_limits_max_response_body_size
  6. uri = build_uri("/get")
  7. response = HTTPX.get(uri, max_response_body_size: 200)
  8. verify_error_response(response)
  9. verify_error_response(response, /maximum response body size exceeded/)
  10. chunked_uri = build_uri("/stream-bytes/30?chunk_size=5")
  11. chunked_response = HTTPX.get(chunked_uri, max_response_body_size: 25)
  12. verify_error_response(chunked_response)
  13. verify_error_response(chunked_response, /maximum response body size exceeded/)
  14. end
  15. 1 def test_limits_max_response_headers
  16. frontend_headers = 7 # date, content-type, etc always there
  17. frontend_headers += 1 if scheme == "http://" # connection header for http/1
  18. uri = build_uri("/response-headers?h1=v1&h2=v2&h3=v3&v4=v4")
  19. response = HTTPX.get(uri, max_response_headers: 4 + frontend_headers)
  20. verify_status(response, 200)
  21. response = HTTPX.get(uri, max_response_headers: 3 + frontend_headers)
  22. verify_error_response(response)
  23. verify_error_response(response, /maximum number of response headers exceeded/)
  24. end
  25. 1 def test_limits_max_header_value_size
  26. uri = build_uri("/response-headers?cookie=asdfasdfasdf")
  27. response = HTTPX.get(uri, max_response_header_value_size: 200)
  28. verify_status(response, 200)
  29. response = HTTPX.get(uri, max_response_header_value_size: 10)
  30. verify_error_response(response)
  31. verify_error_response(response, /maximum header value size exceeded/)
  32. uri2 = build_uri("/response-headers?cookie=asdf&cookie=asdf")
  33. response = HTTPX.get(uri2, max_response_header_value_size: 4)
  34. verify_error_response(response)
  35. verify_error_response(response, /maximum header value size exceeded/)
  36. end
  37. end
  38. end

test/support/requests/multipart.rb

13.22% lines covered

174 relevant lines. 23 lines covered and 151 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "http/form_data"
  3. 1 module Requests
  4. 1 module Multipart
  5. 1 %w[post put patch delete].each do |meth|
  6. 4 define_method :"test_multipart_urlencoded_#{meth}" do
  7. uri = build_uri("/#{meth}")
  8. response = HTTPX.send(meth, uri, form: { "foo" => "bar" })
  9. verify_status(response, 200)
  10. body = json_body(response)
  11. verify_header(body["headers"], "Content-Type", "application/x-www-form-urlencoded")
  12. verify_uploaded(body, "form", "foo" => "bar")
  13. end
  14. 4 define_method :"test_multipart_nested_urlencoded_#{meth}" do
  15. uri = build_uri("/#{meth}")
  16. response = HTTPX.send(meth, uri, form: { "q" => { "a" => "z" }, "a" => %w[1 2] })
  17. verify_status(response, 200)
  18. body = json_body(response)
  19. verify_header(body["headers"], "Content-Type", "application/x-www-form-urlencoded")
  20. verify_uploaded(body, "form", "q[a]" => "z", "a[]" => %w[1 2])
  21. end
  22. 4 define_method :"test_multipart_repeated_field_urlencoded_#{meth}" do
  23. uri = build_uri("/#{meth}")
  24. response = HTTPX.send(meth, uri, form: [%w[foo bar1], %w[foo bar2]])
  25. verify_status(response, 200)
  26. body = json_body(response)
  27. verify_header(body["headers"], "Content-Type", "application/x-www-form-urlencoded")
  28. verify_uploaded(body, "form", "foo" => %w[bar1 bar2])
  29. end
  30. 4 define_method :"test_multipart_hash_#{meth}" do
  31. uri = build_uri("/#{meth}")
  32. req_body = JSON.dump({ a: 1 }).freeze
  33. response = HTTPX.send(meth, uri, form: { metadata: { content_type: "application/json", body: req_body } })
  34. verify_status(response, 200)
  35. body = json_body(response)
  36. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  37. assert JSON.parse(body["form"]["metadata"], symbolize_names: true) == { a: 1 }
  38. return unless can_run_ractor_tests?
  39. response2 = Ractor.new(meth, uri, req_body) do |meth, uri, req_body|
  40. HTTPX.send(meth, uri, form: { metadata: { content_type: "application/json", body: req_body } })
  41. end.value
  42. verify_status(response2, 200)
  43. body2 = json_body(response2)
  44. verify_header(body2["headers"], "Content-Type", "multipart/form-data")
  45. assert JSON.parse(body2["form"]["metadata"], symbolize_names: true) == { a: 1 }
  46. end
  47. 4 define_method :"test_multipart_nested_hash_#{meth}" do
  48. uri = build_uri("/#{meth}")
  49. response = HTTPX.send(meth, uri, form: { q: { metadata: { content_type: "application/json", body: JSON.dump({ a: 1 }) } } })
  50. verify_status(response, 200)
  51. body = json_body(response)
  52. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  53. assert JSON.parse(body["form"]["q[metadata]"], symbolize_names: true) == { a: 1 }
  54. end
  55. 4 define_method :"test_multipart_nested_array_#{meth}" do
  56. uri = build_uri("/#{meth}")
  57. response = HTTPX.send(meth, uri, form: { q: [{ content_type: "application/json", body: JSON.dump({ a: 1 }) }] })
  58. verify_status(response, 200)
  59. body = json_body(response)
  60. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  61. assert JSON.parse(body["form"]["q[]"], symbolize_names: true) == { a: 1 }
  62. end
  63. 4 define_method :"test_multipart_file_#{meth}" do
  64. uri = build_uri("/#{meth}")
  65. image_path = fixture_file_path
  66. response = HTTPX.send(meth, uri, form: { image: File.new(image_path) })
  67. verify_status(response, 200)
  68. body = json_body(response)
  69. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  70. verify_uploaded_image(body, "image", "image/jpeg")
  71. return unless can_run_ractor_tests?
  72. response2 = Ractor.new(meth, uri, image_path) do |meth, uri, image_path|
  73. HTTPX.send(meth, uri, form: { image: File.new(image_path) })
  74. end.value
  75. verify_status(response2, 200)
  76. body2 = json_body(response2)
  77. verify_header(body2["headers"], "Content-Type", "multipart/form-data")
  78. verify_uploaded_image(body2, "image", "image/jpeg")
  79. end
  80. 4 define_method :"test_multipart_file_repeated_#{meth}" do
  81. uri = build_uri("/#{meth}")
  82. response = HTTPX.send(meth, uri, form: [
  83. %w[foo bar1],
  84. ["image1", File.new(fixture_file_path)],
  85. %w[foo bar2],
  86. ["image2", File.new(fixture_file_path)],
  87. ])
  88. verify_status(response, 200)
  89. body = json_body(response)
  90. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  91. verify_uploaded(body, "form", "foo" => %w[bar1 bar2])
  92. verify_uploaded_image(body, "image1", "image/jpeg")
  93. verify_uploaded_image(body, "image2", "image/jpeg")
  94. end
  95. 4 define_method :"test_multipart_nested_file_#{meth}" do
  96. uri = build_uri("/#{meth}")
  97. response = HTTPX.send(meth, uri, form: { q: { image: File.new(fixture_file_path) } })
  98. verify_status(response, 200)
  99. body = json_body(response)
  100. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  101. verify_uploaded_image(body, "q[image]", "image/jpeg")
  102. end
  103. 4 define_method :"test_multipart_nested_ary_file_#{meth}" do
  104. uri = build_uri("/#{meth}")
  105. response = HTTPX.send(meth, uri, form: { images: [File.new(fixture_file_path)] })
  106. verify_status(response, 200)
  107. body = json_body(response)
  108. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  109. verify_uploaded_image(body, "images[]", "image/jpeg")
  110. end
  111. 4 define_method :"test_multipart_filename_#{meth}" do
  112. uri = build_uri("/#{meth}")
  113. response = HTTPX.send(meth, uri, form: { image: { filename: "selfie", body: File.new(fixture_file_path) } })
  114. verify_status(response, 200)
  115. body = json_body(response)
  116. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  117. verify_uploaded_image(body, "image", "image/jpeg")
  118. # TODO: find out how to check the filename given.
  119. end
  120. 4 define_method :"test_multipart_nested_filename_#{meth}" do
  121. uri = build_uri("/#{meth}")
  122. response = HTTPX.send(meth, uri, form: { q: { image: { filename: "selfie", body: File.new(fixture_file_path) } } })
  123. verify_status(response, 200)
  124. body = json_body(response)
  125. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  126. verify_uploaded_image(body, "q[image]", "image/jpeg")
  127. # TODO: find out how to check the filename given.
  128. end
  129. 4 define_method :"test_multipart_subnested_filename_#{meth}" do
  130. uri = build_uri("/#{meth}")
  131. response = HTTPX.send(meth, uri, form: { q: { image: File.new(fixture_file_path) } })
  132. verify_status(response, 200)
  133. body = json_body(response)
  134. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  135. verify_uploaded_image(body, "q[image]", "image/jpeg")
  136. end
  137. 4 define_method :"test_multipart_pathname_#{meth}" do
  138. uri = build_uri("/#{meth}")
  139. image_path = fixture_file_path
  140. response = HTTPX.send(meth, uri, form: { image: Pathname.new(image_path) })
  141. verify_status(response, 200)
  142. body = json_body(response)
  143. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  144. verify_uploaded_image(body, "image", "image/jpeg")
  145. return unless can_run_ractor_tests?
  146. response2 = Ractor.new(meth, uri, image_path) do |meth, uri, image_path|
  147. HTTPX.send(meth, uri, form: { image: Pathname.new(image_path) })
  148. end.value
  149. verify_status(response2, 200)
  150. body2 = json_body(response2)
  151. verify_header(body2["headers"], "Content-Type", "multipart/form-data")
  152. verify_uploaded_image(body2, "image", "image/jpeg")
  153. end
  154. 4 define_method :"test_multipart_nested_pathname_#{meth}" do
  155. uri = build_uri("/#{meth}")
  156. file = Pathname.new(fixture_file_path)
  157. response = HTTPX.send(meth, uri, form: { q: { image: file } })
  158. verify_status(response, 200)
  159. body = json_body(response)
  160. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  161. verify_uploaded_image(body, "q[image]", "image/jpeg")
  162. end
  163. 4 define_method :"test_multipart_http_formdata_#{meth}" do
  164. uri = build_uri("/#{meth}")
  165. file = HTTP::FormData::File.new(fixture_file_path, content_type: "image/jpeg")
  166. response = HTTPX.send(meth, uri, form: { image: file })
  167. verify_status(response, 200)
  168. body = json_body(response)
  169. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  170. verify_uploaded_image(body, "image", "image/jpeg")
  171. end
  172. 4 define_method :"test_multipart_nested_http_formdata_#{meth}" do
  173. uri = build_uri("/#{meth}")
  174. file = HTTP::FormData::File.new(fixture_file_path, content_type: "image/jpeg")
  175. response = HTTPX.send(meth, uri, form: { q: { image: file } })
  176. verify_status(response, 200)
  177. body = json_body(response)
  178. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  179. verify_uploaded_image(body, "q[image]", "image/jpeg")
  180. end
  181. 4 define_method :"test_multipart_spoofed_file_#{meth}" do
  182. uri = build_uri("/#{meth}")
  183. response = HTTPX.send(meth, uri, form: { image: {
  184. content_type: "image/jpeg",
  185. filename: "selfie",
  186. body: "spoofpeg",
  187. } })
  188. verify_status(response, 200)
  189. body = json_body(response)
  190. verify_header(body["headers"], "Content-Type", "multipart/form-data")
  191. # httpbin accepts the spoofed part, but it wipes our the content-type header
  192. verify_uploaded_image(body, "image", "spoofpeg", skip_verify_data: true)
  193. end
  194. end
  195. 1 def test_multipart_response_decoder
  196. form_response = HTTPX::Response.new(
  197. HTTPX::Request.new("GET", "http://example.com", HTTPX::Options.new),
  198. 200,
  199. "2.0",
  200. { "content-type" => "multipart/form-data; boundary=90" }
  201. )
  202. form_response << [
  203. "--90\r\n",
  204. "Content-Disposition: form-data; name=\"text\"\r\n\r\n",
  205. "text default\r\n",
  206. "--90\r\n",
  207. "Content-Disposition: form-data; name=\"file1\"; filename=\"a.txt\"\r\n",
  208. "Content-Type: text/plain\r\n\r\n",
  209. "Content of a.txt.\r\n\r\n",
  210. "--90\r\n",
  211. "Content-Disposition: form-data; name=\"file2\"; filename=\"a.html\"\r\n",
  212. "Content-Type: text/html\r\n\r\n",
  213. "<!DOCTYPE html><title>Content of a.html.</title>\r\n\r\n",
  214. "--90--",
  215. ].join
  216. form = form_response.form
  217. begin
  218. assert form["text"] == "text default"
  219. assert form["file1"].original_filename == "a.txt"
  220. assert form["file1"].content_type == "text/plain"
  221. assert form["file1"].read == "Content of a.txt."
  222. assert form["file2"].original_filename == "a.html"
  223. assert form["file2"].content_type == "text/html"
  224. assert form["file2"].read == "<!DOCTYPE html><title>Content of a.html.</title>"
  225. ensure
  226. form["file1"].close
  227. form["file1"].unlink
  228. form["file2"].close
  229. form["file2"].unlink
  230. end
  231. end
  232. end
  233. end

test/support/requests/plugins/auth.rb

13.36% lines covered

217 relevant lines. 29 lines covered and 188 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module Authentication
  5. 1 def test_plugin_auth
  6. get_uri = build_uri("/get")
  7. session = HTTPX.plugin(:auth)
  8. response = session.authorization("TOKEN").get(get_uri)
  9. verify_status(response, 200)
  10. body = json_body(response)
  11. verify_header(body["headers"], "Authorization", "TOKEN")
  12. end
  13. 1 def test_plugin_auth_with_block
  14. get_uri = build_uri("/get")
  15. session = HTTPX.plugin(:auth)
  16. i = 0
  17. response = session.authorization { "TOKEN#{i += 1}" }.get(get_uri)
  18. verify_status(response, 200)
  19. body = json_body(response)
  20. verify_header(body["headers"], "Authorization", "TOKEN1")
  21. response = session.authorization { "TOKEN#{i += 1}" }.get(get_uri)
  22. verify_status(response, 200)
  23. body = json_body(response)
  24. verify_header(body["headers"], "Authorization", "TOKEN2")
  25. end
  26. 1 def test_plugin_auth_reset_auth_value
  27. get_uri = build_uri("/get")
  28. session = HTTPX.plugin(:auth)
  29. i = 0
  30. authed = session.authorization { "TOKEN#{i += 1}" }
  31. 2.times do
  32. # proves that token is reused
  33. response = authed.get(get_uri)
  34. verify_status(response, 200)
  35. body = json_body(response)
  36. verify_header(body["headers"], "Authorization", "TOKEN1")
  37. end
  38. # proves that token is discarded
  39. authed.reset_auth_header_value!
  40. response = authed.get(get_uri)
  41. verify_status(response, 200)
  42. body = json_body(response)
  43. verify_header(body["headers"], "Authorization", "TOKEN2")
  44. end
  45. 1 def test_plugin_auth_reset_auth_value_expires_at
  46. get_uri = build_uri("/get")
  47. session = HTTPX.plugin(:auth, auth_header_expires_at: ->(_req) { Time.now.to_i + 2 })
  48. i = 0
  49. authed = session.authorization { "TOKEN#{i += 1}" }
  50. 2.times do
  51. # proves that token is reused
  52. response = authed.get(get_uri)
  53. verify_status(response, 200)
  54. body = json_body(response)
  55. verify_header(body["headers"], "Authorization", "TOKEN1")
  56. end
  57. sleep 2
  58. # proves that token is discarded
  59. response = authed.get(get_uri)
  60. verify_status(response, 200)
  61. body = json_body(response)
  62. verify_header(body["headers"], "Authorization", "TOKEN2")
  63. end
  64. 1 def test_plugin_auth_reset_auth_value_expires_in
  65. get_uri = build_uri("/get")
  66. session = HTTPX.plugin(:auth, auth_header_expires_in: 2)
  67. i = 0
  68. authed = session.authorization { "TOKEN#{i += 1}" }
  69. 2.times do
  70. # proves that token is reused
  71. response = authed.get(get_uri)
  72. verify_status(response, 200)
  73. body = json_body(response)
  74. verify_header(body["headers"], "Authorization", "TOKEN1")
  75. end
  76. sleep 2
  77. # proves that token is discarded
  78. response = authed.get(get_uri)
  79. verify_status(response, 200)
  80. body = json_body(response)
  81. verify_header(body["headers"], "Authorization", "TOKEN2")
  82. end
  83. 1 def test_plugin_auth_generate_token_once_for_multi_request
  84. get_uri = build_uri("/get")
  85. authed = HTTPX.plugin(:auth)
  86. i = 0
  87. r1, r2 = authed.authorization { "TOKEN#{i += 1}" }.get(get_uri, get_uri)
  88. verify_status(r1, 200)
  89. body = json_body(r1)
  90. verify_header(body["headers"], "Authorization", "TOKEN1")
  91. verify_status(r2, 200)
  92. body = json_body(r2)
  93. verify_header(body["headers"], "Authorization", "TOKEN1")
  94. end
  95. 1 def test_plugin_auth_regenerate_on_retry
  96. i = 0
  97. session = HTTPX.plugin(RequestInspector)
  98. .plugin(:retries, max_retries: 1, retry_on: ->(res) { res.respond_to?(:status) && res.status == 400 })
  99. .plugin(:auth, generate_auth_value_on_retry: ->(res) { res.respond_to?(:status) && res.status == 400 })
  100. .with(timeout: { request_timeout: 3 })
  101. .authorization { "TOKEN#{i += 1}" }
  102. response = session.get(build_uri("/status/400"))
  103. verify_status(response, 400)
  104. assert session.calls == 1, "expected two errors to have been sent"
  105. req1, req2 = session.total_requests
  106. assert req1.headers["authorization"] == "TOKEN1"
  107. assert req2.headers["authorization"] == "TOKEN2"
  108. session.reset
  109. # 401 errors are always retried with a fresh token, no matter the verb
  110. response = session.get(build_uri("/status/401"))
  111. verify_status(response, 401)
  112. assert session.calls == 1, "expected two errors to have been sent"
  113. req1, req2 = session.total_requests
  114. assert req1.headers["authorization"] == "TOKEN2", "the last successful token should have been reused"
  115. assert req2.headers["authorization"] == "TOKEN3"
  116. session.reset
  117. # on regular errors, it should try to reuse the same token
  118. response = session.get(build_uri("/delay/10"))
  119. verify_error_response(response, HTTPX::RequestTimeoutError)
  120. assert session.calls == 1, "expected two errors to have been sent"
  121. req1, req2 = session.total_requests
  122. assert req1.headers["authorization"] == "TOKEN3", "the last successful token should have been reused"
  123. assert req2.headers["authorization"] == "TOKEN3", "the previous token should have been reused"
  124. end
  125. 1 def test_plugin_auth_multi_request_regenerate_on_retry
  126. i = 0
  127. session = HTTPX.plugin(RequestInspector)
  128. .plugin(:retries, max_retries: 1, retry_on: ->(res) { res.respond_to?(:status) && res.status == 400 })
  129. .plugin(:auth, generate_auth_value_on_retry: ->(res) { res.respond_to?(:status) && res.status == 400 })
  130. .with(timeout: { request_timeout: 3 })
  131. .authorization { "TOKEN#{i += 1}" }
  132. uri = build_uri("/status/401")
  133. responses = session.get(uri, uri, uri)
  134. assert responses.size == 3
  135. assert session.calls == 5, "expected two errors to have been sent per request"
  136. responses.each do |response|
  137. verify_status(response, 401)
  138. request = response.request
  139. assert request.headers["authorization"] == "TOKEN2", "the last successful token should have been reused"
  140. end
  141. end
  142. # Bearer Auth
  143. 1 def test_plugin_bearer_auth
  144. get_uri = build_uri("/get")
  145. session = HTTPX.plugin(:auth)
  146. response = session.bearer_auth("TOKEN").get(get_uri)
  147. verify_status(response, 200)
  148. body = json_body(response)
  149. verify_header(body["headers"], "Authorization", "Bearer TOKEN")
  150. end
  151. # Basic Auth
  152. 1 def test_plugin_basic_auth
  153. no_auth_response = HTTPX.get(basic_auth_uri)
  154. verify_status(no_auth_response, 401)
  155. verify_header(no_auth_response.headers, "www-authenticate", "Basic realm=\"Fake Realm\"")
  156. no_auth_response.close
  157. session = HTTPX.plugin(:basic_auth)
  158. response = session.basic_auth(user, pass).get(basic_auth_uri)
  159. verify_status(response, 200)
  160. body = json_body(response)
  161. verify_header(body, "authenticated", true)
  162. verify_header(body, "user", user)
  163. invalid_response = session.basic_auth(user, "fake").get(basic_auth_uri)
  164. verify_status(invalid_response, 401)
  165. end
  166. # Digest
  167. 1 def test_plugin_digest_auth
  168. session = HTTPX.plugin(:digest_auth).with_headers("cookie" => "fake=fake_value")
  169. response = session.digest_auth(user, pass).get(digest_auth_uri)
  170. verify_status(response, 200)
  171. body = json_body(response)
  172. verify_header(body, "authenticated", true)
  173. verify_header(body, "user", user)
  174. end
  175. 1 %w[SHA1 SHA2 SHA256 SHA384 SHA512 RMD160].each do |alg|
  176. 6 define_method :"test_plugin_digest_auth_#{alg}" do
  177. session = HTTPX.plugin(:digest_auth).with_headers("cookie" => "fake=fake_value")
  178. response = session.digest_auth(user, pass).get("#{digest_auth_uri}/#{alg}")
  179. verify_status(response, 200)
  180. body = json_body(response)
  181. verify_header(body, "authenticated", true)
  182. verify_header(body, "user", user)
  183. end
  184. end
  185. 1 %w[MD5 SHA1].each do |alg|
  186. 2 define_method :"test_plugin_digest_auth_#{alg}_sess" do
  187. start_test_servlet(DigestServer, algorithm: "#{alg}-sess") do |server|
  188. uri = "#{server.origin}/"
  189. session = HTTPX.plugin(:digest_auth).with_headers("cookie" => "fake=fake_value")
  190. response = session.digest_auth(user, server.get_passwd(user), hashed: true).get(uri)
  191. verify_status(response, 200)
  192. assert response.read == "yay"
  193. end
  194. end
  195. end
  196. 1 def test_plugin_digest_auth_bypass
  197. session = HTTPX.plugin(:digest_auth).with_headers("cookie" => "fake=fake_value")
  198. response = session.get(digest_auth_uri)
  199. verify_status(response, 401)
  200. response = session.get(build_uri("/get"))
  201. verify_status(response, 200)
  202. response = session.digest_auth(user, pass).get(build_uri("/get"))
  203. verify_status(response, 200)
  204. end
  205. 1 def test_plugin_digest_auth_invalid_header
  206. start_test_servlet(InvalidDigestServer) do |server|
  207. uri = "#{server.origin}/"
  208. session = HTTPX.plugin(:digest_auth)
  209. response = session.digest_auth("user", "pass").get(uri)
  210. verify_error_response(response, "unsupported digest header format")
  211. end
  212. end
  213. # NTLM
  214. 1 if RUBY_VERSION < "3.1.0"
  215. # TODO: enable again once ruby-openssl 3 supports legacy ciphers
  216. def test_plugin_ntlm_auth
  217. return if origin.start_with?("https")
  218. start_test_servlet(NTLMServer) do |server|
  219. uri = "#{server.origin}/"
  220. HTTPX.plugin(SessionWithPool).plugin(:ntlm_auth).wrap do |http|
  221. # skip unless NTLM
  222. no_auth_response = http.get(uri)
  223. verify_status(no_auth_response, 401)
  224. no_auth_response.close
  225. response = http.ntlm_auth("user", "password").get(uri)
  226. verify_status(response, 200)
  227. # bypass
  228. response = http.get(build_uri("/get"))
  229. verify_status(response, 200)
  230. response = http.ntlm_auth("user", "password").get(build_uri("/get"))
  231. verify_status(response, 200)
  232. # invalid_response = http.ntlm_auth("user", "fake").get(uri)
  233. # verify_status(invalid_response, 401)
  234. end
  235. end
  236. end
  237. end
  238. # NTLMv2
  239. 1 def test_plugin_ntlm_v2_auth
  240. return if origin.start_with?("https")
  241. start_test_servlet(NTLMServer) do |server|
  242. uri = "#{server.origin}/"
  243. HTTPX.plugin(SessionWithPool).plugin(:ntlm_v2_auth).wrap do |http|
  244. # authenticated request should succeed via NTLMv2 handshake
  245. response = http.ntlm_auth("user", "password").get(uri)
  246. verify_status(response, 200)
  247. end
  248. end
  249. end
  250. 1 def test_plugin_ntlm_v2_auth_with_domain
  251. return if origin.start_with?("https")
  252. start_test_servlet(NTLMServer) do |server|
  253. uri = "#{server.origin}/"
  254. HTTPX.plugin(SessionWithPool).plugin(:ntlm_v2_auth).wrap do |http|
  255. response = http.ntlm_auth("user", "password", "DOMAIN").get(uri)
  256. verify_status(response, 200)
  257. end
  258. end
  259. end
  260. 1 def test_plugin_ntlm_v2_auth_option_type_check
  261. session = HTTPX.plugin(:ntlm_v2_auth)
  262. assert_raises(TypeError) do
  263. session.with(ntlm: "not an authenticator")
  264. end
  265. end
  266. 1 private
  267. 1 def basic_auth_uri
  268. build_uri("/basic-auth/#{user}/#{pass}")
  269. end
  270. 1 def digest_auth_uri(qop = "auth")
  271. build_uri("/digest-auth/#{qop}/#{user}/#{pass}")
  272. end
  273. 1 def user
  274. "user"
  275. end
  276. 1 def pass
  277. "pass"
  278. end
  279. end
  280. end
  281. end

test/support/requests/plugins/aws_authentication.rb

45.45% lines covered

22 relevant lines. 10 lines covered and 12 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "aws-sdk-s3"
  3. 1 module Requests
  4. 1 module Plugins
  5. 1 module AWSAuthentication
  6. 1 AWS_URI = ENV.fetch("AMZ_HOST", "aws:9090")
  7. 1 AWSS_URI = ENV.fetch("AMZS_HOST", "aws:9191")
  8. 1 def test_plugin_aws_authentication_put_object
  9. begin
  10. s3_client = Aws::S3::Client.new(
  11. endpoint: amz_uri,
  12. force_path_style: true,
  13. ssl_verify_peer: false,
  14. # http_wire_trace: true,
  15. # logger: Logger.new(STDERR)
  16. )
  17. s3_client.create_bucket(bucket: "test", acl: "private")
  18. rescue Aws::S3::Errors::BucketAlreadyExists,
  19. Aws::S3::Errors::BucketAlreadyOwnedByYou
  20. # because this test will run 2 times (http and https)
  21. end
  22. object = s3_client.put_object(bucket: "test", key: "testimage", body: "bucketz")
  23. # now let's get it
  24. # no_sig_response = HTTPX.get("http://#{AWS_URI}/test/testimage")
  25. # verify_error_response(no_sig_response)
  26. aws_req_headers = object.context.http_request.headers
  27. response = aws_s3_session(unsigned_headers: %w[accept content-type content-length])
  28. .with(ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE },
  29. headers: {
  30. "user-agent" => aws_req_headers["user-agent"],
  31. # gotta fix localstack first
  32. # "expect" => "100-continue",
  33. "x-amz-date" => aws_req_headers["x-amz-date"],
  34. "content-md5" => OpenSSL::Digest.base64digest("MD5", "bucketz"),
  35. })
  36. .put("#{amz_uri}/test/testimage", body: "bucketz")
  37. verify_status(response, 200)
  38. # testing here to make sure the plugin is loaded
  39. config = HTTPX::Plugins::AwsSdkAuthentication::Configuration.new("default")
  40. assert config.respond_to?(:balls)
  41. assert config.balls.nil?
  42. end
  43. 1 private
  44. 1 def aws_s3_session(**options)
  45. HTTPX.plugin(:aws_sdk_authentication, aws_profile: "default").aws_sdk_authentication(service: "s3", **options)
  46. end
  47. 1 def amz_uri
  48. uri = scheme == "https://" ? AWSS_URI : AWS_URI
  49. origin(uri)
  50. end
  51. end
  52. end
  53. end

test/support/requests/plugins/brotli.rb

12.82% lines covered

39 relevant lines. 5 lines covered and 34 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module Brotli
  5. 1 def test_brotli
  6. session = HTTPX.plugin(:brotli)
  7. response = session.get("http://nghttp2.org/httpbin/brotli")
  8. if (response.respond_to?(:status) && response.status.between?(502, 504)) ||
  9. (response.respond_to?(:error) && response.error.is_a?(HTTPX::TimeoutError))
  10. skip "`#{response.uri}` is down again"
  11. end
  12. verify_status(response, 200)
  13. body = json_body(response)
  14. assert body["brotli"], "response should be deflated"
  15. # but gzip still works
  16. uri = build_uri("/gzip")
  17. response = session.get(uri)
  18. verify_status(response, 200)
  19. assert response.headers["content-length"].to_i != response.body.bytesize
  20. body = json_body(response)
  21. assert body["gzipped"]
  22. end
  23. 1 def test_brotli_post
  24. session = HTTPX.plugin(:brotli)
  25. uri = build_uri("/post")
  26. response = session.with_headers("content-encoding" => "br")
  27. .post(uri, body: "a" * 8012)
  28. verify_status(response, 200)
  29. body = json_body(response)
  30. verify_header(body["headers"], "Content-Type", "application/octet-stream")
  31. compressed_data = body["data"]
  32. compressed_data = compressed_data.delete_prefix("data:application/octet-stream;base64,")
  33. compressed_data = Base64.decode64(compressed_data)
  34. assert compressed_data.bytesize < 8012, "body hasn't been compressed"
  35. assert ::Brotli.inflate(compressed_data) == "a" * 8012
  36. # but gzip still works
  37. uri = build_uri("/post")
  38. response = session.with_headers("content-encoding" => "gzip")
  39. .post(uri, body: "a" * 8012)
  40. verify_status(response, 200)
  41. body = json_body(response)
  42. verify_header(body["headers"], "Content-Type", "application/octet-stream")
  43. compressed_data = body["data"]
  44. compressed_data = compressed_data.delete_prefix("data:application/octet-stream;base64,")
  45. compressed_data = Base64.decode64(compressed_data)
  46. assert compressed_data.bytesize < 8012, "body hasn't been compressed"
  47. assert inflate_test_data(compressed_data) == "a" * 8012
  48. end
  49. end
  50. end
  51. end

test/support/requests/plugins/cache.rb

16.22% lines covered

37 relevant lines. 6 lines covered and 31 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "securerandom"
  3. 1 module Requests
  4. 1 module Plugins
  5. 1 module Cache
  6. 1 def test_plugin_cache_options
  7. cache_client = HTTPX.plugin(:cache, response_cache_store: :store)
  8. assert cache_client.class.default_options.response_cache_store.is_a?(HTTPX::Plugins::Cache::Store)
  9. cache_client = HTTPX.plugin(:cache, response_cache_store: :file_store)
  10. assert cache_client.class.default_options.response_cache_store.is_a?(HTTPX::Plugins::Cache::FileStore)
  11. end
  12. 1 def test_plugin_cache_cacheable_request_and_response
  13. cache_client = HTTPX.plugin(
  14. :cache,
  15. cache_key: ->(req) { req.uri.path },
  16. cacheable_request: ->(req) { req.uri.path.end_with?("/200", "/202") },
  17. cacheable_response: ->(_, res) { res.status == 200 },
  18. valid_cached_response: ->(_, _) { true },
  19. )
  20. cacheable_request_uri = build_uri("/status/200")
  21. uncacheable_request_uri = build_uri("/status/201")
  22. uncacheable_response_uri = build_uri("/status/202")
  23. # cacheable request path
  24. original = cache_client.get(cacheable_request_uri)
  25. verify_status(original, 200)
  26. cached = cache_client.get(cacheable_request_uri)
  27. verify_status(cached, 200)
  28. assert original.body == cached.body
  29. cache_client.clear_response_cache
  30. uncached = cache_client.get(cacheable_request_uri)
  31. verify_status(uncached, 200)
  32. assert uncached != original
  33. # uncacheable request
  34. original = cache_client.get(uncacheable_request_uri)
  35. verify_status(original, 201)
  36. uncached = cache_client.get(uncacheable_request_uri)
  37. verify_status(uncached, 201)
  38. assert uncached != original
  39. # uncacheable response
  40. original = cache_client.get(uncacheable_response_uri)
  41. verify_status(original, 202)
  42. uncached = cache_client.get(uncacheable_response_uri)
  43. verify_status(uncached, 202)
  44. assert uncached != original
  45. end
  46. end
  47. end
  48. end

test/support/requests/plugins/circuit_breaker.rb

10.47% lines covered

86 relevant lines. 9 lines covered and 77 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module CircuitBreaker
  5. 1 using HTTPX::URIExtensions
  6. 1 def test_plugin_circuit_breaker_lifecycles
  7. return unless origin.start_with?("http://")
  8. unknown_uri = "http://www.qwwqjqwdjqiwdj.com"
  9. session = HTTPX.plugin(:circuit_breaker,
  10. circuit_breaker_max_attempts: 2,
  11. circuit_breaker_break_in: 2,
  12. circuit_breaker_half_open_drip_rate: 1.0)
  13. # circuit closed
  14. response1 = session.get(unknown_uri)
  15. verify_error_response(response1)
  16. response2 = session.get(unknown_uri)
  17. verify_error_response(response2)
  18. assert response2 != response1
  19. # circuit open
  20. response3 = session.get(unknown_uri)
  21. verify_error_response(response3)
  22. assert response3 == response2
  23. sleep 3
  24. # circuit half-closed
  25. response4 = session.get(unknown_uri)
  26. assert response4 != response3
  27. end
  28. 1 def test_plugin_circuit_breaker_reset_attempts
  29. return unless origin.start_with?("http://")
  30. unknown_uri = URI("http://www.qwwqjqwdjqiwdj.com")
  31. session = HTTPX.plugin(:circuit_breaker,
  32. circuit_breaker_max_attempts: 2,
  33. circuit_breaker_reset_attempts_in: 2)
  34. store = session.instance_variable_get(:@circuit_store)
  35. circuit = store.instance_variable_get(:@circuits)[unknown_uri.origin]
  36. # circuit closed
  37. response1 = session.get(unknown_uri)
  38. verify_error_response(response1)
  39. assert circuit.instance_variable_get(:@attempts) == 1
  40. sleep 2
  41. response1 = session.get(unknown_uri)
  42. verify_error_response(response1)
  43. # because it reset
  44. assert circuit.instance_variable_get(:@attempts) == 1
  45. end
  46. 1 def test_plugin_circuit_breaker_break_on
  47. break_on = ->(response) { response.is_a?(HTTPX::ErrorResponse) || response.status == 404 }
  48. session = HTTPX.plugin(:circuit_breaker, circuit_breaker_max_attempts: 1, circuit_breaker_break_on: break_on)
  49. response1 = session.get(build_uri("/status/404"))
  50. verify_status(response1, 404)
  51. response2 = session.get(build_uri("/status/404"))
  52. verify_status(response2, 404)
  53. assert response1 == response2
  54. end
  55. 1 def test_plugin_circuit_breaker_on_circuit_open
  56. return unless origin.start_with?("http://")
  57. unknown_uri = "http://www.qwwqjqwdjqiwdj.com"
  58. circuit_opened = false
  59. session = HTTPX.plugin(:circuit_breaker,
  60. circuit_breaker_max_attempts: 1,
  61. circuit_breaker_break_in: 2,
  62. circuit_breaker_half_open_drip_rate: 1.0)
  63. .on_circuit_open { circuit_opened = true }
  64. # circuit closed
  65. response1 = session.get(unknown_uri)
  66. verify_error_response(response1)
  67. # circuit open
  68. response2 = session.get(unknown_uri)
  69. verify_error_response(response2)
  70. assert response2 == response1
  71. assert circuit_opened
  72. end
  73. 1 def test_plugin_circuit_breaker_half_open_drip_rate
  74. delay_url = URI(build_uri("/delay/2"))
  75. session = HTTPX.plugin(:circuit_breaker, circuit_breaker_max_attempts: 2, circuit_breaker_half_open_drip_rate: 0.5,
  76. circuit_breaker_break_in: 1)
  77. store = session.instance_variable_get(:@circuit_store)
  78. circuit = store.instance_variable_get(:@circuits)[delay_url.origin]
  79. response1 = session.get(delay_url, timeout: { request_timeout: 0.5 })
  80. response2 = session.get(delay_url, timeout: { request_timeout: 0.5 })
  81. verify_error_response(response1, HTTPX::RequestTimeoutError)
  82. verify_error_response(response2, HTTPX::RequestTimeoutError)
  83. # circuit open
  84. assert circuit.instance_variable_get(:@attempts) == 2
  85. assert circuit.instance_variable_get(:@state) == :open
  86. sleep 1.5
  87. # circuit half-open
  88. response3 = session.get(delay_url)
  89. verify_status(response3, 200)
  90. assert circuit.instance_variable_get(:@attempts) == 1
  91. assert circuit.instance_variable_get(:@state) == :half_open
  92. response4 = session.get(delay_url)
  93. verify_error_response(response4, HTTPX::RequestTimeoutError)
  94. assert circuit.instance_variable_get(:@attempts) == 2
  95. assert circuit.instance_variable_get(:@state) == :half_open
  96. # circuit closed again
  97. response5 = session.get(delay_url)
  98. verify_status(response5, 200)
  99. assert circuit.instance_variable_get(:@state) == :closed
  100. response1 = session.get(delay_url, timeout: { request_timeout: 0.5 })
  101. response2 = session.get(delay_url, timeout: { request_timeout: 0.5 })
  102. verify_error_response(response1, HTTPX::RequestTimeoutError)
  103. verify_error_response(response2, HTTPX::RequestTimeoutError)
  104. # circuit open
  105. assert circuit.instance_variable_get(:@attempts) == 2
  106. assert circuit.instance_variable_get(:@state) == :open
  107. sleep 1.5
  108. # circuit half-open
  109. response3 = session.get(delay_url, timeout: { request_timeout: 0.5 })
  110. verify_error_response(response3, HTTPX::RequestTimeoutError)
  111. # attempts reset, haf-open -> open transition
  112. assert circuit.instance_variable_get(:@attempts) == 1
  113. assert circuit.instance_variable_get(:@state) == :open
  114. end
  115. end
  116. end
  117. end

test/support/requests/plugins/content_digest.rb

18.92% lines covered

74 relevant lines. 14 lines covered and 60 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module ContentDigest
  5. 1 IGNORE_MISSING_HEADER = ->(res) { res.headers.key?("content-digest") }
  6. 1 def test_content_digest_missing_no_validation
  7. start_test_servlet(ContentDigestServer) do |server|
  8. http = HTTPX.plugin(:content_digest, validate_content_digest: false)
  9. %w[/no_content_digest /invalid_content_digest].each do |path|
  10. response = http.get(server.origin + path)
  11. verify_status(response, 200)
  12. assert response.body.content_digest_buffer.nil?
  13. end
  14. end
  15. end
  16. 1 def test_content_digest_missing_validation_if_present
  17. start_test_servlet(ContentDigestServer) do |server|
  18. http = HTTPX.plugin(:content_digest, validate_content_digest: IGNORE_MISSING_HEADER)
  19. response = http.get("#{server.origin}/no_content_digest")
  20. verify_status(response, 200)
  21. assert response.body.content_digest_buffer.nil?
  22. end
  23. end
  24. 1 def test_content_digest_missing_validation_always
  25. start_test_servlet(ContentDigestServer) do |server|
  26. http = HTTPX.plugin(:content_digest, validate_content_digest: true)
  27. response = http.get("#{server.origin}/no_content_digest")
  28. verify_error_response(response, HTTPX::Plugins::ContentDigest::MissingContentDigestError)
  29. end
  30. end
  31. 1 def test_content_digest_present_validation_if_present
  32. start_test_servlet(ContentDigestServer) do |server|
  33. http = HTTPX.plugin(:content_digest, validate_content_digest: IGNORE_MISSING_HEADER)
  34. response = http.get("#{server.origin}/valid_content_digest")
  35. verify_status(response, 200)
  36. assert !response.body.content_digest_buffer.nil?
  37. response.close
  38. assert response.body.content_digest_buffer.nil?
  39. end
  40. end
  41. 1 def test_content_digest_present_validation_always
  42. start_test_servlet(ContentDigestServer) do |server|
  43. http = HTTPX.plugin(:content_digest, validate_content_digest: true)
  44. response = http.get("#{server.origin}/valid_content_digest")
  45. verify_status(response, 200)
  46. assert !response.body.content_digest_buffer.nil?
  47. response.close
  48. assert response.body.content_digest_buffer.nil?
  49. end
  50. end
  51. 1 def test_content_digest_invalid_validation_if_present
  52. start_test_servlet(ContentDigestServer) do |server|
  53. http = HTTPX.plugin(:content_digest, validate_content_digest: IGNORE_MISSING_HEADER)
  54. response = http.get("#{server.origin}/invalid_content_digest")
  55. verify_error_response(response, HTTPX::Plugins::ContentDigest::InvalidContentDigestError)
  56. response.close
  57. end
  58. end
  59. 1 def test_content_digest_invalid_validation_always
  60. start_test_servlet(ContentDigestServer) do |server|
  61. http = HTTPX.plugin(:content_digest, validate_content_digest: true)
  62. response = http.get("#{server.origin}/invalid_content_digest")
  63. verify_error_response(response, HTTPX::Plugins::ContentDigest::InvalidContentDigestError)
  64. response.close
  65. end
  66. end
  67. 1 def test_content_digest_multiple_validation_always
  68. start_test_servlet(ContentDigestServer) do |server|
  69. http = HTTPX.plugin(:content_digest, validate_content_digest: true)
  70. response = http.get("#{server.origin}/multiple_content_digests")
  71. verify_status(response, 200)
  72. assert !response.body.content_digest_buffer.nil?
  73. response.close
  74. assert response.body.content_digest_buffer.nil?
  75. end
  76. end
  77. 1 def test_content_digest_gzip_encoding
  78. start_test_servlet(ContentDigestServer) do |server|
  79. http = HTTPX.plugin(:content_digest, validate_content_digest: true)
  80. response = http.get("#{server.origin}/gzip_content_digest")
  81. verify_status(response, 200)
  82. assert !response.body.content_digest_buffer.nil?
  83. response.close
  84. assert response.body.content_digest_buffer.nil?
  85. end
  86. end
  87. 1 def test_content_digest_large_response_body
  88. start_test_servlet(ContentDigestServer) do |server|
  89. http = HTTPX.plugin(:content_digest, validate_content_digest: true)
  90. response = http.get("#{server.origin}/large_body_content_digest")
  91. verify_status(response, 200)
  92. assert !response.body.content_digest_buffer.nil?
  93. response.close
  94. assert response.body.content_digest_buffer.nil?
  95. end
  96. end
  97. end
  98. end
  99. end

test/support/requests/plugins/cookies.rb

15.0% lines covered

100 relevant lines. 15 lines covered and 85 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module Cookies
  5. 1 using HTTPX::URIExtensions
  6. 1 def test_plugin_cookies_get
  7. session = HTTPX.plugin(:cookies)
  8. response = session.get(cookies_uri)
  9. body = json_body(response)
  10. assert body.key?("cookies")
  11. assert body["cookies"].empty?
  12. session_response = session.with(cookies: [%w[abc def]]).get(cookies_uri)
  13. body = json_body(session_response)
  14. assert body.key?("cookies")
  15. assert body["cookies"]["abc"] == "def", "abc wasn't properly set"
  16. end
  17. 1 def test_plugin_cookies_get_with_hash
  18. session = HTTPX.plugin(:cookies)
  19. session_response = session.with(cookies: [{ "name" => "abc", "value" => "def" }]).get(cookies_uri)
  20. body = json_body(session_response)
  21. assert body.key?("cookies")
  22. assert body["cookies"]["abc"] == "def", "abc wasn't properly set"
  23. end
  24. 1 def test_plugin_cookies_get_with_cookie
  25. session = HTTPX.plugin(:cookies)
  26. session_response = session.with(cookies: [HTTPX::Plugins::Cookies::Cookie.new("abc", "def")]).get(cookies_uri)
  27. body = json_body(session_response)
  28. assert body.key?("cookies")
  29. assert body["cookies"]["abc"] == "def", "abc wasn't properly set"
  30. end
  31. 1 def test_plugin_cookies_set
  32. session = HTTPX.plugin(:cookies)
  33. session_cookies = { "a" => "b", "c" => "d" }
  34. session_uri = cookies_set_uri(session_cookies)
  35. session_response = session.get(session_uri)
  36. verify_status(session_response, 302)
  37. verify_cookies(session.cookies[session_uri], session_cookies)
  38. # first request sets the session
  39. response = session.get(cookies_uri)
  40. body = json_body(response)
  41. assert body.key?("cookies")
  42. verify_cookies(body["cookies"], session_cookies)
  43. # second request reuses the session
  44. extra_cookie_response = session.with(cookies: { "e" => "f" }).get(cookies_uri)
  45. body = json_body(extra_cookie_response)
  46. assert body.key?("cookies")
  47. verify_cookies(body["cookies"], session_cookies.merge("e" => "f"))
  48. # redirect to a different origin only uses the option cookies
  49. other_origin_response = session.with(cookies: { "e" => "f" }).get(redirect_uri(origin("google.com")))
  50. verify_status(other_origin_response, 302)
  51. assert !other_origin_response.headers.key?("set-cookie"), "cookies should not transition to next origin"
  52. end
  53. 1 def test_cookies_wrap
  54. session = HTTPX.plugin(:cookies).with(cookies: { "abc" => "def" })
  55. session.wrap do |_http|
  56. set_cookie_uri = cookies_set_uri("123" => "456")
  57. session_response = session.get(set_cookie_uri)
  58. verify_status(session_response, 302)
  59. session_response = session.get(cookies_uri)
  60. body = json_body(session_response)
  61. assert body.key?("cookies")
  62. assert body["cookies"]["abc"] == "def", "abc wasn't properly set"
  63. assert body["cookies"]["123"] == "456", "123 wasn't properly set"
  64. set_cookie_uri = cookies_set_uri("abc" => "123")
  65. session_response = session.get(set_cookie_uri)
  66. verify_status(session_response, 302)
  67. session_response = session.get(cookies_uri)
  68. body = json_body(session_response)
  69. assert body.key?("cookies")
  70. assert body["cookies"]["abc"] == "123", "abc wasn't properly set"
  71. end
  72. session_response = session.get(cookies_uri)
  73. body = json_body(session_response)
  74. assert body.key?("cookies")
  75. assert body["cookies"]["abc"] == "def", "abc wasn't properly set"
  76. end
  77. 1 def test_plugin_cookies_follow_redirects
  78. session = HTTPX.plugin(:follow_redirects).plugin(:cookies)
  79. session_cookies = { "a" => "b", "c" => "d" }
  80. session_uri = cookies_set_uri(session_cookies)
  81. response = session.get(session_uri)
  82. verify_status(response, 200)
  83. assert response.uri.to_s == cookies_uri
  84. body = json_body(response)
  85. assert body.key?("cookies")
  86. verify_cookies(body["cookies"], session_cookies)
  87. end
  88. 1 def test_plugin_cookies_jar_management
  89. cookie_header = lambda do |response|
  90. JSON.parse(response.to_s)["headers"]
  91. end
  92. uri = build_uri("/headers")
  93. http = HTTPX.plugin(:cookies).with(cookies: { :a => 1, :b => 2 })
  94. verify_header(cookie_header.call(http.get(uri)), "Cookie", "a=1; b=2")
  95. http = http.with(cookies: { :a => 3 })
  96. verify_header(cookie_header.call(http.get(uri)), "Cookie", "a=3; b=2")
  97. verify_header(cookie_header.call(http.get(uri, cookies: { :a => 4 })), "Cookie", "a=4; b=2")
  98. http = http.with(headers: { "Cookie" => "a=1;f=6" })
  99. verify_header(cookie_header.call(http.get(uri)), "Cookie", "a=1; b=2; f=6")
  100. verify_header(cookie_header.call(http.get(uri, cookies: { :a => 4 })), "Cookie", "a=4; b=2; f=6")
  101. end
  102. 1 private
  103. 1 def cookies_uri
  104. build_uri("/cookies")
  105. end
  106. 1 def cookies_set_uri(cookies)
  107. URI(build_uri("/cookies/set?#{URI.encode_www_form(cookies)}"))
  108. end
  109. 1 def verify_cookies(jar, cookies)
  110. assert !jar.nil? && !jar.empty?, "there should be cookies in the response"
  111. assert jar.all? { |cookie|
  112. case cookie
  113. when HTTPX::Plugins::Cookies::Cookie
  114. cookies.one? { |k, v| k == cookie.name && v == cookie.value }
  115. else
  116. cookie_name, cookie_value = cookie
  117. cookies.one? { |k, v| k == cookie_name && v == cookie_value }
  118. end
  119. }, "jar should contain all expected cookies"
  120. end
  121. end
  122. end
  123. end

test/support/requests/plugins/expect.rb

14.89% lines covered

47 relevant lines. 7 lines covered and 40 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module Expect
  5. 1 def test_plugin_expect_100_form_params
  6. uri = build_uri("/post")
  7. response = HTTPX.plugin(:expect).post(uri, form: { "foo" => "bar" })
  8. verify_status(response, 200)
  9. body = json_body(response)
  10. verify_header(body["headers"], "Content-Type", "application/x-www-form-urlencoded")
  11. verify_header(body["headers"], "Expect", "100-continue")
  12. verify_uploaded(body, "form", "foo" => "bar")
  13. end
  14. 1 def test_plugin_expect_100_with_delay_form_params
  15. # run this only for http/1.1 mode, as this is a local test server
  16. return unless origin.start_with?("https://")
  17. start_test_servlet(Expect100Server) do |server|
  18. http = HTTPX.plugin(:expect)
  19. uri = build_uri("/delay?delay=4", server.origin)
  20. response = http.post(uri, body: "helloworld")
  21. # sometimes httpbin delivers back the intermediate response after the body is sent after the delay
  22. skip if response.respond_to?(:error) && response.error.is_a?(EOFError)
  23. verify_status(response, 200)
  24. body = response.body.to_s
  25. assert body == "echo: helloworld"
  26. verify_header(response.instance_variable_get(:@request).headers, "expect", "100-continue")
  27. next_request = http.build_request("POST", build_uri("/", server.origin), body: "helloworld")
  28. verify_header(next_request.headers, "expect", "100-continue")
  29. end
  30. end
  31. 1 def test_plugin_expect_100_form_params_under_threshold
  32. uri = build_uri("/post")
  33. session = HTTPX.plugin(:expect, expect_threshold_size: 4)
  34. response = session.post(uri, body: "a" * 3)
  35. verify_status(response, 200)
  36. body = json_body(response)
  37. verify_no_header(body["headers"], "Expect")
  38. response = session.post(uri, body: "a" * 5)
  39. verify_status(response, 200)
  40. body = json_body(response)
  41. verify_header(body["headers"], "Expect", "100-continue")
  42. end
  43. 1 def test_plugin_expect_100_send_body_after_delay
  44. # run this only for http/1.1 mode, as this is a local test server
  45. return unless origin.start_with?("http://")
  46. start_test_servlet(Expect100Server) do |server|
  47. http = HTTPX.plugin(:expect)
  48. uri = build_uri("/no-expect", server.origin)
  49. response = http.post(uri, body: "helloworld")
  50. verify_status(response, 200)
  51. body = response.body.to_s
  52. assert body == "echo: helloworld"
  53. verify_no_header(response.instance_variable_get(:@request).headers, "expect")
  54. next_request = http.build_request("POST", build_uri("/", server.origin), body: "helloworld")
  55. verify_no_header(next_request.headers, "expect")
  56. end
  57. end
  58. end
  59. end
  60. end

test/support/requests/plugins/follow_redirects.rb

17.6% lines covered

125 relevant lines. 22 lines covered and 103 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module FollowRedirects
  5. 1 def test_plugin_follow_redirects
  6. no_redirect_response = HTTPX.get(redirect_uri)
  7. verify_status(no_redirect_response, 302)
  8. verify_header(no_redirect_response.headers, "location", redirect_location)
  9. session = HTTPX.plugin(:follow_redirects)
  10. redirect_response = session.get(redirect_uri)
  11. verify_status(redirect_response, 200)
  12. body = json_body(redirect_response)
  13. assert body.key?("url"), "url should be set"
  14. assert body["url"] == redirect_location, "url should have been the given redirection url"
  15. end
  16. 1 def test_plugin_follow_redirects_on_post_302
  17. session = HTTPX.plugin(:follow_redirects)
  18. redirect_response = session.post(redirect_uri, body: "bang")
  19. verify_status(redirect_response, 200)
  20. body = json_body(redirect_response)
  21. assert body.key?("url"), "url should be set"
  22. assert body["url"] == redirect_location, "url should have been the given redirection url"
  23. request = redirect_response.instance_variable_get(:@request)
  24. assert request.uri.to_s == redirect_location
  25. assert request.verb == "GET"
  26. verify_no_header(request.headers, "content-type")
  27. verify_no_header(request.headers, "content-length")
  28. root_request = request.root_request
  29. assert root_request.uri.to_s == redirect_uri
  30. assert root_request.verb == "POST"
  31. verify_header(root_request.headers, "content-type", "application/octet-stream")
  32. verify_header(root_request.headers, "content-length", "4")
  33. end
  34. 1 def test_plugin_follow_redirects_on_post_307
  35. return unless origin.start_with?("http://")
  36. start_test_servlet(Redirector307Server) do |server|
  37. uri = "#{server.origin}/307"
  38. session = HTTPX.plugin(:follow_redirects)
  39. redirect_response = session.post(uri, body: "bang")
  40. verify_status(redirect_response, 200)
  41. assert redirect_response.body == "ok"
  42. request = redirect_response.instance_variable_get(:@request)
  43. assert request.uri.to_s == "#{server.origin}/"
  44. assert request.verb == "POST"
  45. verify_header(request.headers, "content-type", "application/octet-stream")
  46. verify_header(request.headers, "content-length", "4")
  47. root_request = request.root_request
  48. assert root_request.uri.to_s == "#{server.origin}/307"
  49. assert root_request.verb == "POST"
  50. verify_header(root_request.headers, "content-type", "application/octet-stream")
  51. verify_header(root_request.headers, "content-length", "4")
  52. end
  53. end
  54. 1 def test_plugin_follow_redirects_no_location_no_follow
  55. session = HTTPX.plugin(:follow_redirects)
  56. response = session.with(headers: { "if-none-match" => "justforcingcachedresponse" }).get(redirect_no_follow_uri)
  57. verify_status(response, 304)
  58. end
  59. 1 def test_plugin_follow_redirects_relative_path
  60. session = HTTPX.plugin(:follow_redirects)
  61. uri = redirect_uri("../get")
  62. redirect_response = session.get(uri)
  63. body = json_body(redirect_response)
  64. assert body.key?("url"), "url should be set"
  65. assert body["url"] == redirect_location, "url should have been the given redirection url"
  66. end
  67. 1 def test_plugin_follow_redirects_default_max_redirects
  68. session = HTTPX.plugin(:follow_redirects)
  69. response = session.get(max_redirect_uri(3))
  70. verify_status(response, 200)
  71. response = session.get(max_redirect_uri(4))
  72. verify_status(response, 302)
  73. end
  74. 1 def test_plugin_follow_redirects_max_redirects
  75. session = HTTPX.plugin(:follow_redirects)
  76. response = session.max_redirects(1).get(max_redirect_uri(1))
  77. verify_status(response, 200)
  78. response = session.max_redirects(1).get(max_redirect_uri(2))
  79. verify_status(response, 302)
  80. end
  81. 1 def test_plugin_follow_redirects_retry_after
  82. session = HTTPX.plugin(SessionWithMockResponse, mock_status: 302, mock_headers: { "retry-after" => "2" }).plugin(:follow_redirects)
  83. before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  84. response = session.get(max_redirect_uri(1))
  85. after_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  86. verify_status(response, 200)
  87. total_time = after_time - before_time
  88. assert total_time >= 2, "request didn't take as expected to redirect (#{total_time} secs)"
  89. end
  90. 1 def test_plugin_follow_redirects_retry_after_with_request_timeout
  91. session = HTTPX.plugin(SessionWithMockResponse, mock_status: 302, mock_headers: { "retry-after" => "2" }).plugin(:follow_redirects)
  92. timeout_response = session.get(max_redirect_uri(2), timeout: { request_timeout: 1 })
  93. verify_error_response(timeout_response, HTTPX::RequestTimeoutError)
  94. end
  95. 1 def test_plugin_follow_redirects_total_request_timeout_across_redirects
  96. uri = max_redirect_uri(20) # high enough that the timeout should apply
  97. session = HTTPX.plugin(RequestInspector)
  98. .plugin(SessionWithMockResponse, mock_tries: 4, mock_status: 302, mock_headers: { "retry-after" => "2" })
  99. .plugin(:follow_redirects)
  100. .max_redirects(200)
  101. .with(timeout: { total_request_timeout: 8 })
  102. response = session.get(uri)
  103. verify_error_response(response, HTTPX::TotalRequestTimeoutError)
  104. assert session.total_responses.size > 2, "not enough redirections happening"
  105. end
  106. 1 def test_plugin_follow_insecure_no_insecure_downgrade
  107. return unless origin.start_with?("https")
  108. session = HTTPX.plugin(:follow_redirects).max_redirects(1)
  109. response = session.get(insecure_redirect_uri)
  110. verify_error_response(response)
  111. insecure_session = HTTPX.plugin(:follow_redirects)
  112. .max_redirects(1)
  113. .with(follow_insecure_redirects: true)
  114. insecure_response = insecure_session.get(insecure_redirect_uri)
  115. verify_status(insecure_response, 200)
  116. assert insecure_response.is_a?(HTTPX::Response),
  117. "request should follow insecure URLs (instead: #{insecure_response.status})"
  118. end
  119. 1 def test_plugin_follow_redirects_removes_authorization_header
  120. return unless origin.start_with?("http://")
  121. session = HTTPX.plugin(:follow_redirects).with(headers: { "authorization" => "Bearer SECRET" })
  122. # response = session.get(max_redirect_uri(1))
  123. # verify_status(response, 200)
  124. # body = json_body(response)
  125. # assert body["headers"].key?("Authorization")
  126. response = session.get(redirect_uri("#{httpbin_no_proxy}/get"))
  127. verify_status(response, 200)
  128. body = json_body(response)
  129. assert !body["headers"].key?("Authorization")
  130. response = session.with(allow_auth_to_other_origins: true).get(redirect_uri("#{httpbin_no_proxy}/get"))
  131. verify_status(response, 200)
  132. body = json_body(response)
  133. assert body["headers"].key?("Authorization")
  134. end
  135. 1 def test_plugin_follow_redirects_redirect_on
  136. session = HTTPX.plugin(:follow_redirects).with(redirect_on: ->(location_uri) { !location_uri.path.end_with?("1") })
  137. redirect_response = session.get(max_redirect_uri(3))
  138. verify_status(redirect_response, 302)
  139. verify_header(redirect_response.headers, "location", "/relative-redirect/1")
  140. end
  141. 1 private
  142. 1 def redirect_uri(redirect_uri = redirect_location)
  143. build_uri("/redirect-to?url=#{redirect_uri}")
  144. end
  145. 1 def redirect_no_follow_uri
  146. build_uri("/cache") # 304
  147. end
  148. 1 def max_redirect_uri(n)
  149. build_uri("/redirect/#{n}")
  150. end
  151. 1 def insecure_redirect_uri
  152. build_uri("/redirect-to?url=http://www.google.com")
  153. end
  154. 1 def redirect_location
  155. build_uri("/get")
  156. end
  157. end
  158. end
  159. end

test/support/requests/plugins/grpc.rb

14.29% lines covered

112 relevant lines. 16 lines covered and 96 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module GRPC
  5. 1 include GRPCHelpers
  6. 1 def test_plugin_grpc_stub_rpc_defines_snake_case_methods
  7. server_port = run_rpc(TestService)
  8. grpc = grpc_plugin
  9. # build service
  10. stub = grpc.build_stub("localhost:#{server_port}")
  11. sv = stub.rpc(:aCamelCaseRpc, EchoMsg, EchoMsg, marshal_method: :marshal, unmarshal_method: :unmarshal)
  12. assert sv.respond_to? :a_camel_case_rpc
  13. assert sv.respond_to? :aCamelCaseRpc
  14. end
  15. 1 def test_plugin_grpc_unary_plain_bytestreams
  16. no_marshal = proc { |x| x }
  17. server_port = run_request_response("a_reply", OK, marshal: no_marshal) do |call|
  18. assert call.remote_read == "a_request"
  19. assert call.metadata["k1"] == "v1"
  20. assert call.metadata["k2"] == "v2"
  21. end
  22. grpc = grpc_plugin
  23. # build service
  24. stub = grpc.build_stub("localhost:#{server_port}")
  25. result = stub.execute("an_rpc_method", "a_request", metadata: { k1: "v1", k2: "v2" })
  26. assert result.to_s == "a_reply"
  27. end
  28. 1 def test_plugin_grpc_call_credentials
  29. return unless origin.start_with?("https")
  30. call_credentials = -> { { "k1" => "updated-k1" } }
  31. no_marshal = proc { |x| x }
  32. server_port = run_request_response("a_reply", OK, marshal: no_marshal) do |call|
  33. assert call.remote_read == "a_request"
  34. assert call.metadata["k1"] == "updated-k1"
  35. assert call.metadata["k2"] == "v2"
  36. end
  37. grpc = grpc_plugin
  38. # build service
  39. stub = grpc.with_call_credentials(call_credentials).build_stub("localhost:#{server_port}")
  40. result = stub.execute("an_rpc_method", "a_request", metadata: { k1: "v1", k2: "v2" })
  41. assert result.to_s == "a_reply"
  42. end
  43. 1 def test_plugin_grpc_compressed_request
  44. no_marshal = proc { |x| x }
  45. server_port = run_request_response("a_reply", OK, marshal: no_marshal) do |call|
  46. # assert call.metadata["grpc-encoding"] == "gzip", "request wasn't compressed"
  47. # TODO: find a way to test if request payload was indeed compressed
  48. assert call.remote_read == "A" * 2000
  49. end
  50. grpc = grpc_plugin
  51. # build service
  52. stub = grpc.build_stub("localhost:#{server_port}", compression: "gzip")
  53. result = stub.execute("an_rpc_method", "A" * 2000)
  54. assert result.to_s == "a_reply"
  55. end
  56. 1 def test_plugin_grpc_compressed_response
  57. no_marshal = proc { |x| x }
  58. server_port = run_request_response("A" * 2000, OK, marshal: no_marshal,
  59. server_initial_md: { "grpc-internal-encoding-request" => "gzip" }) do |call|
  60. assert call.remote_read == "a_request"
  61. end
  62. grpc = grpc_plugin
  63. # build service
  64. stub = grpc.build_stub("localhost:#{server_port}")
  65. result = stub.execute("an_rpc_method", "a_request")
  66. assert result.to_s == "A" * 2000
  67. end
  68. # Cancellation on error
  69. 1 def test_plugin_grpc_deadline_exceeded
  70. no_marshal = proc { |x| x }
  71. server_port = run_request_response("a_reply", OK, marshal: no_marshal) do |call|
  72. sleep(3)
  73. assert call.remote_read == "a_request"
  74. end
  75. grpc = grpc_plugin
  76. # build service
  77. stub = grpc.build_stub("localhost:#{server_port}")
  78. error = assert_raises(HTTPX::GRPCError) { stub.execute("an_rpc_method", "a request", deadline: 2).to_s }
  79. assert error.status == ::GRPC::Core::StatusCodes::DEADLINE_EXCEEDED
  80. end
  81. 1 def test_plugin_grpc_cancellation_on_client_error
  82. no_marshal = proc { |x| x }
  83. input = Enumerator.new do |_y|
  84. # y << "a_request"
  85. raise "oh crap"
  86. end
  87. server_port = run_request_response("a_reply", OK, marshal: no_marshal) do |call|
  88. # not supposed to arrive here
  89. begin
  90. call.remote_read
  91. rescue StandardError
  92. nil
  93. end
  94. end
  95. grpc = grpc_plugin
  96. # build service
  97. stub = grpc.build_stub("localhost:#{server_port}")
  98. error = assert_raises(HTTPX::Error) { stub.execute("an_rpc_method", input).to_s }
  99. assert error.message.include?("oh crap")
  100. end
  101. 1 def test_plugin_grpc_cancellation_on_server_error
  102. server_port = run_rpc(TestService)
  103. grpc = grpc_plugin
  104. # build service
  105. test_service_stub = grpc.build_stub("localhost:#{server_port}", service: TestService)
  106. error = assert_raises(HTTPX::GRPCError) { test_service_stub.a_cancellable_rpc(EchoMsg.new(msg: "ping")).to_s }
  107. assert error.status == 1
  108. assert error.details == "dump"
  109. end
  110. 1 def test_plugin_grpc_unary_protobuf
  111. server_port = run_rpc(TestService)
  112. grpc = grpc_plugin
  113. # build service
  114. test_service_stub = grpc.build_stub("localhost:#{server_port}", service: TestService)
  115. echo_response = test_service_stub.an_rpc(EchoMsg.new(msg: "ping"))
  116. assert echo_response.msg == "ping"
  117. assert echo_response.trailing_metadata["grpc-message"] == "OK"
  118. end
  119. 1 def test_plugin_grpc_client_stream_protobuf
  120. server_port = run_rpc(TestService)
  121. grpc = grpc_plugin
  122. # build service
  123. test_service_stub = grpc.build_stub("localhost:#{server_port}", service: TestService)
  124. input = [EchoMsg.new(msg: "ping"), EchoMsg.new(msg: "ping")]
  125. response = test_service_stub.a_client_streaming_rpc(input)
  126. assert response.msg == "client stream pong"
  127. assert response.trailing_metadata["grpc-message"] == "OK"
  128. end
  129. 1 def test_plugin_grpc_server_stream_protobuf
  130. server_port = run_rpc(TestService)
  131. grpc = grpc_plugin
  132. # build service
  133. test_service_stub = grpc.build_stub("localhost:#{server_port}", service: TestService)
  134. streaming_response = test_service_stub.a_server_streaming_rpc(EchoMsg.new(msg: "ping"))
  135. assert streaming_response.respond_to?(:each)
  136. assert streaming_response.trailing_metadata.nil?
  137. echo_responses = streaming_response.each.to_a
  138. assert echo_responses.map(&:msg) == ["server stream pong", "server stream pong"]
  139. assert streaming_response.trailing_metadata["grpc-message"] == "OK"
  140. end
  141. 1 def test_plugin_grpc_bidi_stream_protobuf
  142. server_port = run_rpc(TestService)
  143. grpc = grpc_plugin
  144. # build service
  145. test_service_stub = grpc.build_stub("localhost:#{server_port}", service: TestService)
  146. input = [EchoMsg.new(msg: "ping"), EchoMsg.new(msg: "ping")]
  147. streaming_response = test_service_stub.a_bidi_rpc(input)
  148. assert streaming_response.respond_to?(:each)
  149. assert streaming_response.trailing_metadata.nil?
  150. echo_responses = streaming_response.each.to_a
  151. assert echo_responses.map(&:msg) == ["bidi pong", "bidi pong"]
  152. assert streaming_response.trailing_metadata["grpc-message"] == "OK"
  153. end
  154. end
  155. end
  156. end

test/support/requests/plugins/h2c.rb

19.23% lines covered

26 relevant lines. 5 lines covered and 21 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module H2C
  5. 1 def test_plugin_h2c
  6. HTTPX.plugin(SessionWithPool).plugin(:h2c).wrap do |session|
  7. uri = build_uri("/get")
  8. request = session.build_request("GET", uri)
  9. request2 = session.build_request("GET", uri)
  10. response = session.request(request)
  11. verify_status(response, 200)
  12. assert response.version == "2.0", "http h2c requests should be in HTTP/2"
  13. response.close
  14. # verifies that first request was used to upgrade the connection
  15. verify_header(request.headers, "connection", "upgrade, http2-settings")
  16. response = session.request(request2)
  17. verify_status(response, 200)
  18. assert response.version == "2.0", "http h2c requests should be in HTTP/2"
  19. response.close
  20. # verifies that first request was used to upgrade the connection
  21. verify_no_header(request2.headers, "connection")
  22. end
  23. end
  24. 1 def test_plugin_h2c_multiple
  25. session = HTTPX.plugin(SessionWithPool).plugin(:h2c)
  26. uri = build_uri("/get")
  27. responses = session.get(uri, uri, uri)
  28. responses.each do |response|
  29. verify_status(response, 200)
  30. assert response.version == "2.0", "http h2c requests should be in HTTP/2"
  31. response.close
  32. end
  33. end
  34. end
  35. end
  36. end

test/support/requests/plugins/oauth.rb

12.17% lines covered

115 relevant lines. 14 lines covered and 101 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module OAuth
  5. 1 def test_plugin_oauth_oauth_session
  6. with_oauth_metadata do |server|
  7. # from options
  8. oauth_session = HTTPX.plugin(:oauth).with_oauth_options(
  9. issuer: server.origin,
  10. client_id: "CLIENT_ID", client_secret: "SECRET",
  11. scope: "all"
  12. ).send(:oauth_session)
  13. assert oauth_session.token_endpoint == "#{server.origin}/token"
  14. assert oauth_session.token_endpoint_auth_method == "client_secret_basic"
  15. assert oauth_session.instance_variable_get(:@grant_type) == "client_credentials"
  16. assert oauth_session.instance_variable_get(:@scope) == %w[all]
  17. assert oauth_session.instance_variable_get(:@audience).nil?
  18. # with audience
  19. oauth_session = HTTPX.plugin(:oauth).with_oauth_options(
  20. issuer: server.origin,
  21. client_id: "CLIENT_ID", client_secret: "SECRET",
  22. scope: "all",
  23. audience: "audience"
  24. ).send(:oauth_session)
  25. assert oauth_session.token_endpoint.to_s == "#{server.origin}/token"
  26. assert oauth_session.token_endpoint_auth_method == "client_secret_basic"
  27. assert oauth_session.instance_variable_get(:@grant_type) == "client_credentials"
  28. assert oauth_session.instance_variable_get(:@scope) == %w[all]
  29. assert oauth_session.instance_variable_get(:@audience) == "audience"
  30. # from options, pointing to refresh
  31. oauth_session = HTTPX.plugin(:oauth).with_oauth_options(
  32. issuer: "https://smthelse",
  33. token_endpoint_auth_method: "client_secret_post",
  34. client_id: "CLIENT_ID", client_secret: "SECRET",
  35. refresh_token: "REFRESH_TOKEN", access_token: "TOKEN",
  36. scope: %w[foo bar]
  37. ).send(:oauth_session)
  38. assert oauth_session.token_endpoint.to_s == "https://smthelse/token"
  39. assert oauth_session.token_endpoint_auth_method == "client_secret_post"
  40. assert oauth_session.instance_variable_get(:@grant_type) == "refresh_token"
  41. assert oauth_session.instance_variable_get(:@scope) == %w[foo bar]
  42. # from oauth server metadata url
  43. session = HTTPX.plugin(:oauth).with_oauth_options(
  44. issuer: server.origin,
  45. client_id: "CLIENT_ID", client_secret: "SECRET",
  46. )
  47. oauth_session = session.send(:oauth_session)
  48. oauth_session.send(:load, session)
  49. assert oauth_session.token_endpoint.to_s == "#{server.origin}/token"
  50. assert oauth_session.token_endpoint_auth_method == "client_secret_basic"
  51. assert oauth_session.instance_variable_get(:@grant_type) == "client_credentials"
  52. assert oauth_session.instance_variable_get(:@scope) == %w[openid profile email address phone offline_access]
  53. # from hash
  54. HTTPX.plugin(:oauth).with_oauth_options(
  55. issuer: server.origin,
  56. client_id: "CLIENT_ID", client_secret: "SECRET",
  57. scope: "all"
  58. ).send(:oauth_session)
  59. assert oauth_session.token_endpoint.to_s == "#{server.origin}/token"
  60. assert oauth_session.token_endpoint_auth_method == "client_secret_basic"
  61. assert oauth_session.instance_variable_get(:@grant_type) == "client_credentials"
  62. assert oauth_session.instance_variable_get(:@scope) == %w[openid profile email address phone offline_access]
  63. assert_raises(HTTPX::Error) do
  64. HTTPX.plugin(:oauth).with_oauth_options(
  65. issuer: server.origin,
  66. client_id: "CLIENT_ID", client_secret: "SECRET",
  67. token_endpoint_auth_method: "unsupported"
  68. )
  69. end
  70. assert_raises(HTTPX::Error) do
  71. HTTPX.plugin(:oauth).with_oauth_options(
  72. issuer: server.origin,
  73. client_id: "CLIENT_ID", client_secret: "SECRET",
  74. grant_type: "implicit_grant" # not supported
  75. )
  76. end
  77. assert_raises(ArgumentError) do
  78. HTTPX.plugin(:oauth).with_oauth_options("wrong")
  79. end
  80. end
  81. end
  82. 1 def test_plugin_oauth_access_token_audience
  83. with_oauth_metadata do |server|
  84. http = HTTPX.plugin(
  85. :oauth,
  86. oauth_options: {
  87. issuer: server.origin,
  88. client_id: "CLIENT_ID", client_secret: "SECRET",
  89. scope: "all"
  90. }
  91. )
  92. http_aud = http.with_oauth_options(
  93. issuer: server.origin,
  94. client_id: "CLIENT_ID", client_secret: "SECRET",
  95. scope: "all", audience: "audience"
  96. )
  97. access_token = http.send(:oauth_session).fetch_access_token(http)
  98. aud_access_token = http_aud.send(:oauth_session).fetch_access_token(http_aud)
  99. assert access_token == "CLIENT-CREDS-AUTH"
  100. assert aud_access_token == "CLIENT-CREDS-AUTH-audience"
  101. end
  102. end
  103. 1 def test_plugin_oauth_client_credentials
  104. with_oauth_metadata do |server|
  105. session = HTTPX.plugin(
  106. :oauth, oauth_options: {
  107. issuer: server.origin,
  108. client_id: "CLIENT_ID", client_secret: "SECRET", scope: "all"
  109. }
  110. )
  111. client_creds_uri = build_uri("/client-credentials-authed", server.origin)
  112. response = HTTPX.get(client_creds_uri)
  113. verify_status(response, 401)
  114. response = session.get(client_creds_uri)
  115. verify_status(response, 200)
  116. end
  117. end
  118. 1 def test_plugin_oauth_refresh_oauth_tokens
  119. with_oauth_metadata do |server|
  120. session = HTTPX.plugin(
  121. :oauth, oauth_options: {
  122. issuer: server.origin,
  123. client_id: "CLIENT_ID", client_secret: "SECRET", scope: "all"
  124. }
  125. )
  126. oauth_session = session.send(:oauth_session)
  127. assert oauth_session.access_token.nil?
  128. session.refresh_oauth_tokens!
  129. assert !oauth_session.access_token.nil?
  130. end
  131. end
  132. 1 def test_plugin_oauth_expires_in
  133. with_oauth_metadata(expires_in: 1) do |server|
  134. session = HTTPX.plugin(
  135. :oauth,
  136. oauth_options: {
  137. issuer: server.origin,
  138. client_id: "CLIENT_ID", client_secret: "SECRET", scope: "all"
  139. }
  140. )
  141. oauth_session = session.send(:oauth_session)
  142. token = oauth_session.fetch_access_token(session)
  143. assert token == oauth_session.access_token
  144. sleep(2)
  145. assert oauth_session.access_token.nil?
  146. end
  147. end
  148. 1 def test_plugin_oauth_retries_refresh_token_on_retry
  149. with_oauth_metadata do |server|
  150. session = HTTPX.plugin(:retries).plugin(
  151. :oauth,
  152. oauth_options: {
  153. issuer: server.origin,
  154. token_endpoint_auth_method: "client_secret_post",
  155. client_id: "CLIENT_ID", client_secret: "SECRET",
  156. refresh_token: "REFRESH_TOKEN", access_token: "TOKEN", scope: %w[foo bar]
  157. }
  158. )
  159. refresh_token_uri = build_uri("/refresh-token-authed", server.origin)
  160. response = HTTPX.get(refresh_token_uri)
  161. verify_status(response, 401)
  162. response = session.get(refresh_token_uri)
  163. verify_status(response, 200)
  164. end
  165. end
  166. 1 def test_plugin_oauth_deprecated_oauth_session_option
  167. with_oauth_metadata do |server|
  168. oauth_session = nil
  169. assert_output(nil, /DEPRECATION WARNING: option `:oauth_session` is deprecated/) do
  170. # from options
  171. oauth_session = HTTPX.plugin(:oauth).with_oauth_session(
  172. issuer: server.origin,
  173. client_id: "CLIENT_ID", client_secret: "SECRET",
  174. scope: "all"
  175. ).send(:oauth_session)
  176. end
  177. assert oauth_session.token_endpoint == "#{server.origin}/token"
  178. assert oauth_session.token_endpoint_auth_method == "client_secret_basic"
  179. assert oauth_session.instance_variable_get(:@grant_type) == "client_credentials"
  180. assert oauth_session.instance_variable_get(:@scope) == %w[all]
  181. assert oauth_session.instance_variable_get(:@audience).nil?
  182. end
  183. end
  184. 1 def test_plugin_oauth_deprecated_oauth_auth
  185. with_oauth_metadata do |server|
  186. oauth_session = nil
  187. assert_output(nil, /DEPRECATION WARNING: `oauth_auth` is deprecated/) do
  188. # from options
  189. oauth_session = HTTPX.plugin(:oauth).oauth_auth(
  190. issuer: server.origin,
  191. client_id: "CLIENT_ID", client_secret: "SECRET",
  192. scope: "all"
  193. ).send(:oauth_session)
  194. end
  195. assert oauth_session.token_endpoint == "#{server.origin}/token"
  196. assert oauth_session.token_endpoint_auth_method == "client_secret_basic"
  197. assert oauth_session.instance_variable_get(:@grant_type) == "client_credentials"
  198. assert oauth_session.instance_variable_get(:@scope) == %w[all]
  199. assert oauth_session.instance_variable_get(:@audience).nil?
  200. end
  201. end
  202. 1 def test_plugin_oauth_deprecated_with_access_token
  203. with_oauth_metadata do |server|
  204. oauth_session = nil
  205. assert_output(nil, /DEPRECATION WARNING: `with_access_token` is deprecated/) do
  206. # from oauth server metadata url
  207. session = HTTPX.plugin(:oauth).with_oauth_options(
  208. issuer: server.origin,
  209. client_id: "CLIENT_ID", client_secret: "SECRET",
  210. )
  211. oauth_session = session.with_access_token.send(:oauth_session)
  212. end
  213. assert oauth_session.token_endpoint == "#{server.origin}/token"
  214. assert oauth_session.token_endpoint_auth_method == "client_secret_basic"
  215. assert oauth_session.instance_variable_get(:@grant_type) == "client_credentials"
  216. assert oauth_session.instance_variable_get(:@scope) == %w[openid profile email address phone offline_access]
  217. assert oauth_session.access_token == "CLIENT-CREDS-AUTH"
  218. end
  219. end
  220. 1 private
  221. 1 def with_oauth_metadata(metadata = {}, **kwargs)
  222. start_test_servlet(OAuthProviderServer, **kwargs) do |server|
  223. server.metadata.merge!(metadata)
  224. yield server
  225. end
  226. end
  227. end
  228. end
  229. end

test/support/requests/plugins/persistent.rb

6.51% lines covered

215 relevant lines. 14 lines covered and 201 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module Persistent
  5. 1 def test_persistent
  6. uri = build_uri("/get")
  7. non_persistent_session = HTTPX.plugin(SessionWithPool)
  8. response = non_persistent_session.get(uri)
  9. verify_status(response, 200)
  10. assert non_persistent_session.connections.size == 1, "should have been just 1"
  11. assert non_persistent_session.connections.one?(&:closed?), "should have been no open connections"
  12. persistent_session = non_persistent_session.plugin(:persistent)
  13. response = persistent_session.get(uri)
  14. verify_status(response, 200)
  15. assert persistent_session.connections.size == 1, "should have been just 1"
  16. assert persistent_session.connections.none?(&:closed?), "should have been open connections"
  17. persistent_session.close
  18. assert persistent_session.connections.one?(&:closed?), "should have been no connections"
  19. end
  20. 1 def test_persistent_options
  21. retry_persistent_session = HTTPX.plugin(:persistent).plugin(:retries, max_retries: 4)
  22. options = retry_persistent_session.send(:default_options)
  23. assert options.max_retries == 4
  24. assert options.persistent
  25. persistent_retry_session = HTTPX.plugin(:retries, max_retries: 4).plugin(:persistent)
  26. options = persistent_retry_session.send(:default_options)
  27. assert options.max_retries == 4
  28. assert options.persistent
  29. end
  30. 1 def test_plugin_persistent_does_not_retry_timeout_requests
  31. before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  32. persistent_session = HTTPX
  33. .plugin(RequestInspector)
  34. .plugin(:persistent)
  35. .with(timeout: { request_timeout: 3 })
  36. retries_response = persistent_session.get(build_uri("/delay/10"))
  37. after_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  38. total_time = after_time - before_time
  39. verify_error_response(retries_response, HTTPX::RequestTimeoutError)
  40. assert persistent_session.calls.zero?, "expect request to not be resent (was #{persistent_session.calls})"
  41. verify_execution_delta(3, total_time, 1)
  42. end
  43. 1 def test_plugin_persistent_does_not_retry_change_requests_on_timeouts
  44. check_error = ->(response) { response.is_a?(HTTPX::ErrorResponse) || response.status == 405 }
  45. persistent_session = HTTPX
  46. .plugin(RequestInspector)
  47. .plugin(:persistent, retry_on: check_error) # because CI
  48. .with(timeout: { request_timeout: 3 })
  49. response = persistent_session.post(build_uri("/delay/10"), body: ["a" * 1024])
  50. assert check_error[response]
  51. assert persistent_session.calls.zero?, "expect request to be built 0 times (was #{persistent_session.calls})"
  52. end
  53. 1 def test_plugin_persistent_does_not_retry_change_requests_on_keep_alive_interval_timeouts
  54. start_test_servlet(KeepAlivePongThenTimeoutSocketServer) do |server|
  55. check_error = ->(response) { response.is_a?(HTTPX::ErrorResponse) || response.status == 405 }
  56. persistent_session = HTTPX
  57. .plugin(RequestInspector)
  58. .plugin(:persistent, retry_on: check_error)
  59. .with(
  60. ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE },
  61. timeout: { keep_alive_timeout: 1, request_timeout: 2 }
  62. )
  63. response = persistent_session.post(server.origin, body: "test")
  64. verify_status(response, 200)
  65. assert persistent_session.calls.zero?, "expect request to be built 0 times (was #{persistent_session.calls})"
  66. sleep(2)
  67. response = persistent_session.post(server.origin, body: "test")
  68. assert check_error[response]
  69. assert persistent_session.calls == 1, "expect request to be built 1 time (was #{persistent_session.calls})"
  70. end
  71. end
  72. def test_plugin_persistent_coalescing
  73. coalesced_origin = "https://#{ENV["HTTPBIN_COALESCING_HOST"]}"
  74. http = HTTPX.plugin(SessionWithPool).plugin(:persistent)
  75. response1 = http.get(origin)
  76. verify_status(response1, 200)
  77. response2 = http.get(coalesced_origin)
  78. verify_status(response2, 200)
  79. # introspection time
  80. connections = http.connections
  81. assert connections.size == 2
  82. origins = connections.map(&:origins)
  83. assert origins.any? { |orgs| orgs.sort == [origin, coalesced_origin].sort },
  84. "connections for #{[origin, coalesced_origin]} didn't coalesce (expected connection with both origins (#{origins}))"
  85. assert http.pool.connections.size == 1, "coalesced connection should have been dropped"
  86. assert http.pool.connections_counter == 1, "coalesced connection should not have been accounted for in the pool"
  87. unsafe_origin = URI(origin)
  88. unsafe_origin.scheme = "http"
  89. response3 = http.get(unsafe_origin)
  90. verify_status(response3, 200)
  91. # introspection time
  92. connections = http.connections
  93. assert connections.size == 3
  94. origins = connections.map(&:origins)
  95. refute origins.any?([origin]),
  96. "connection coalesced inexpectedly (expected connection with both origins (#{origins}))"
  97. http.close
  98. 1 end if ENV.key?("HTTPBIN_COALESCING_HOST")
  99. 1 def test_persistent_with_io
  100. return unless origin.start_with?("https")
  101. io = origin_io
  102. uri = build_uri("/get")
  103. session = HTTPX.plugin(SessionWithPool).plugin(:persistent)
  104. response = session.get(uri)
  105. verify_status(response, 200)
  106. assert !io.closed?, "io should have been left open"
  107. connections = session.pool.connections
  108. assert connections.size == 1
  109. response = session.get(uri)
  110. verify_status(response, 200)
  111. assert !io.closed?, "io should have been left open"
  112. connections = session.pool.connections
  113. assert connections.size == 1
  114. session.close
  115. assert session.connections.one?(&:closed?), "should have been no connections"
  116. end
  117. 1 def test_persistent_retry_http2_ping_timeout
  118. return unless origin.start_with?("https")
  119. start_test_servlet(DelayedPingServer, ping_delay: 2) do |server|
  120. http = HTTPX.plugin(SessionWithPool)
  121. .plugin(RequestInspector)
  122. .plugin(:persistent) # implicit max_retries == 1
  123. .with(timeout: { keep_alive_timeout: 0, ping_timeout: 4 }, ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE })
  124. uri = "#{server.origin}/"
  125. response = http.get(uri)
  126. verify_status(response, 200)
  127. response = http.get(uri)
  128. verify_status(response, 200)
  129. assert http.calls == 1, "expect request to be built 1 time (was #{http.calls})"
  130. http.close
  131. other_http = http.with(timeout: { ping_timeout: 1 })
  132. uri = "#{server.origin}/"
  133. response = other_http.get(uri)
  134. verify_status(response, 200)
  135. before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  136. response = other_http.get(uri)
  137. after_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  138. total_time = after_time - before_time
  139. verify_status(response, 200)
  140. assert other_http.calls == 2, "expect request to be built 2 times (was #{other_http.calls})"
  141. verify_execution_delta(1, total_time, 1)
  142. http.close
  143. end
  144. end
  145. def test_persistent_retry_http2_goaway
  146. return unless origin.start_with?("https")
  147. start_test_servlet(KeepAlivePongThenGoawayServer) do |server|
  148. http = HTTPX.plugin(SessionWithPool)
  149. .plugin(RequestInspector)
  150. .plugin(:persistent) # implicit max_retries == 1
  151. .with(ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE })
  152. uri = "#{server.origin}/"
  153. response = http.get(uri)
  154. verify_status(response, 200)
  155. response = http.get(uri)
  156. verify_status(response, 200)
  157. assert http.calls == 2, "expect request to be built 2 times (was #{http.calls})"
  158. http.close
  159. end
  160. 1 end unless RUBY_ENGINE == "jruby"
  161. def test_persistent_proxy_retry_http2_goaway
  162. return unless origin.start_with?("https")
  163. start_test_servlet(KeepAlivePongThenGoawayServer) do |server|
  164. start_test_servlet(ProxyServer) do |proxy|
  165. http = HTTPX.plugin(SessionWithPool)
  166. .plugin(RequestInspector)
  167. .plugin(:persistent) # implicit max_retries == 1
  168. .plugin(:proxy)
  169. .with(
  170. proxy: { uri: proxy.origin },
  171. ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE }
  172. )
  173. uri = "#{server.origin}/"
  174. response = http.get(uri)
  175. verify_status(response, 200)
  176. response = http.get(uri)
  177. verify_status(response, 200)
  178. assert http.calls == 2, "expect request to be built 2 times (was #{http.calls})"
  179. http.close
  180. end
  181. end
  182. 1 end unless RUBY_ENGINE == "jruby"
  183. 1 unless RUBY_ENGINE != "jruby" || JRUBY_VERSION >= "10.0.0.0"
  184. def test_persistent_on_stream_close_get_retried
  185. return unless origin.start_with?("https")
  186. start_test_servlet(CloseAfterXThenDelaySeconds, seconds_to_close: 1) do |server|
  187. uri = "#{server.origin}/"
  188. http = HTTPX.plugin(SessionWithPool)
  189. .plugin(RequestInspector)
  190. .plugin(:persistent) # implicit max_retries == 1
  191. .with(ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE })
  192. response = http.get(uri)
  193. verify_status(response, 200)
  194. sleep 2
  195. response = http.get(uri)
  196. verify_status(response, 200)
  197. verify_body_length(response)
  198. _, error_response, _ = http.total_responses
  199. verify_error_response(error_response)
  200. assert http.calls == 2, "expect request to be built 2 times (was #{http.calls})"
  201. end
  202. end
  203. def test_persistent_on_stream_close_post_body_string_retried
  204. return unless origin.start_with?("https")
  205. start_test_servlet(CloseAfterXThenDelaySeconds, seconds_to_close: 1) do |server|
  206. uri = "#{server.origin}/"
  207. http = HTTPX.plugin(SessionWithPool)
  208. .plugin(RequestInspector)
  209. .plugin(:persistent) # implicit max_retries == 1
  210. .with(ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE })
  211. response = http.get(uri)
  212. verify_status(response, 200)
  213. sleep 2
  214. response = http.post(uri, body: "BANGARANG")
  215. verify_status(response, 200)
  216. assert response.body.to_s == "BANGARANG"
  217. _, error_response, _ = http.total_responses
  218. verify_error_response(error_response)
  219. assert http.calls == 2, "expect request to be built 2 times (was #{http.calls})"
  220. end
  221. end
  222. def test_persistent_on_stream_close_post_body_stringio_retried
  223. return unless origin.start_with?("https")
  224. start_test_servlet(CloseAfterXThenDelaySeconds, seconds_to_close: 1) do |server|
  225. uri = "#{server.origin}/"
  226. http = HTTPX.plugin(SessionWithPool)
  227. .plugin(RequestInspector)
  228. .plugin(:persistent) # implicit max_retries == 1
  229. .with(ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE })
  230. response = http.get(uri)
  231. verify_status(response, 200)
  232. sleep 2
  233. response = http.post(uri, body: StringIO.new("BANGARANG"))
  234. verify_status(response, 200)
  235. assert response.body.to_s == "BANGARANG"
  236. _, error_response, _ = http.total_responses
  237. verify_error_response(error_response)
  238. assert http.calls == 2, "expect request to be built 2 times (was #{http.calls})"
  239. end
  240. end
  241. def test_persistent_on_stream_close_post_body_file_retried
  242. return unless origin.start_with?("https")
  243. start_test_servlet(CloseAfterXThenDelaySeconds, seconds_to_close: 1) do |server|
  244. uri = "#{server.origin}/"
  245. http = HTTPX.plugin(SessionWithPool)
  246. .plugin(RequestInspector)
  247. .plugin(:persistent) # implicit max_retries == 1
  248. .with(ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE })
  249. rng = Random.new(42)
  250. req_body = Tempfile.new("httpx-body", binmode: true)
  251. begin
  252. response = http.get(uri)
  253. verify_status(response, 200)
  254. sleep 2
  255. req_body.write(rng.bytes(16_385))
  256. req_body.rewind
  257. response = http.post(uri, body: req_body)
  258. verify_status(response, 200)
  259. assert response.body.bytesize == 16_385
  260. _, error_response, _ = http.total_responses
  261. verify_error_response(error_response)
  262. assert http.calls == 2, "expect request to be built 2 times (was #{http.calls})"
  263. ensure
  264. req_body.close
  265. req_body.unlink
  266. end
  267. end
  268. end
  269. def test_persistent_on_stream_close_post_body_multipart_retried
  270. return unless origin.start_with?("https")
  271. start_test_servlet(CloseAfterXThenDelaySeconds, seconds_to_close: 1) do |server|
  272. uri = "#{server.origin}/"
  273. http = HTTPX.plugin(SessionWithPool)
  274. .plugin(RequestInspector)
  275. .plugin(:persistent) # implicit max_retries == 1
  276. .with(ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE })
  277. begin
  278. response = http.get(uri)
  279. verify_status(response, 200)
  280. sleep 2
  281. file = File.new(fixture_file_path)
  282. response = http.post(uri, form: [
  283. ["image1", file],
  284. ])
  285. verify_status(response, 200)
  286. assert response.body.bytesize > File.size(fixture_file_path)
  287. _, error_response, _ = http.total_responses
  288. verify_error_response(error_response)
  289. assert http.calls == 2, "expect request to be built 2 times (was #{http.calls})"
  290. end
  291. end
  292. end
  293. end
  294. end
  295. end
  296. end

test/support/requests/plugins/proxy.rb

14.29% lines covered

210 relevant lines. 30 lines covered and 180 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "resolv"
  3. 1 module Requests
  4. 1 module Plugins
  5. 1 module Proxy
  6. 1 include ProxyHelper
  7. 1 using HTTPX::URIExtensions
  8. 1 RESOLVER = Resolv::DNS.new
  9. 1 def test_plugin_no_proxy_defined
  10. http = HTTPX.plugin(:proxy)
  11. uri = build_uri("/get")
  12. res = http.with_proxy(uri: []).get(uri)
  13. verify_error_response(res, HTTPX::ProxyError)
  14. end
  15. 1 def test_plugin_http_http_proxy
  16. return unless origin.start_with?("http://")
  17. session = HTTPX.plugin(:proxy, fallback_protocol: "http/1.1").plugin(ProxyResponseDetector).with_proxy(uri: http_proxy)
  18. uri = build_uri("/get")
  19. response = session.get(uri)
  20. verify_status(response, 200)
  21. verify_body_length(response)
  22. assert response.proxied?
  23. end
  24. 1 def test_plugin_http_no_proxy
  25. return unless origin.start_with?("http://")
  26. session = HTTPX.plugin(:proxy).plugin(ProxyResponseDetector).with_proxy(uri: http_proxy, no_proxy: [httpbin_no_proxy.host])
  27. # proxy
  28. uri = build_uri("/get")
  29. response = session.get(uri)
  30. verify_status(response, 200)
  31. verify_body_length(response)
  32. assert response.proxied?
  33. # no proxy
  34. no_proxy_uri = build_uri("/get", httpbin_no_proxy)
  35. no_proxy_response = session.get(no_proxy_uri)
  36. verify_status(no_proxy_response, 200)
  37. verify_body_length(no_proxy_response)
  38. assert !no_proxy_response.proxied?
  39. end
  40. 1 def test_plugin_http_h2_proxy
  41. return unless origin.start_with?("http://")
  42. session = HTTPX.plugin(:proxy, fallback_protocol: "h2").plugin(ProxyResponseDetector).with_proxy(uri: http2_proxy)
  43. uri = build_uri("/get")
  44. response = session.get(uri)
  45. verify_status(response, 200)
  46. verify_body_length(response)
  47. assert response.proxied?
  48. end
  49. 1 def test_plugin_https_connect_http1_proxy
  50. # return unless origin.start_with?("https://")
  51. session = HTTPX.plugin(:proxy).plugin(ProxyResponseDetector).with_proxy(uri: http_proxy)
  52. uri = build_uri("/get")
  53. response = session.get(uri)
  54. verify_status(response, 200)
  55. verify_body_length(response)
  56. assert response.proxied?
  57. end
  58. # TODO: uncomment when supporting H2 CONNECT
  59. # def test_plugin_https_connect_h2_proxy
  60. # return unless origin.start_with?("https://")
  61. # session = HTTPX.plugin(:proxy, alpn_protocols: %w[h2]).with_proxy(uri: http2_proxy)
  62. # uri = build_uri("/get")
  63. # response = session.get(uri)
  64. # verify_status(response, 200)
  65. # verify_body_length(response)
  66. # end
  67. 1 def test_plugin_http_next_proxy
  68. session = HTTPX.plugin(SessionWithPool)
  69. .plugin(:proxy)
  70. .plugin(ProxyResponseDetector)
  71. .with_proxy(uri: ["http://unavailable-proxy", *http_proxy])
  72. uri = build_uri("/get")
  73. response = session.get(uri)
  74. verify_status(response, 200)
  75. verify_body_length(response)
  76. assert response.proxied?
  77. end
  78. 1 def test_plugin_http_proxy_auth_options
  79. auth_proxy = URI(http_proxy.first)
  80. return unless auth_proxy.user
  81. user = auth_proxy.user
  82. pass = auth_proxy.password
  83. auth_proxy.user = nil
  84. auth_proxy.password = nil
  85. session = HTTPX.plugin(:proxy).plugin(ProxyResponseDetector).with_proxy(
  86. uri: auth_proxy.to_s,
  87. username: user,
  88. password: pass
  89. )
  90. uri = build_uri("/get")
  91. response = session.get(uri)
  92. verify_status(response, 200)
  93. verify_body_length(response)
  94. assert response.proxied?
  95. end
  96. 1 def test_plugin_http_proxy_auth_error
  97. no_auth_proxy = URI(http_proxy.first)
  98. return unless no_auth_proxy.user
  99. no_auth_proxy.user = nil
  100. no_auth_proxy.password = nil
  101. session = HTTPX.plugin(:proxy).with_proxy(uri: no_auth_proxy.to_s)
  102. uri = build_uri("/get")
  103. response = session.get(uri)
  104. verify_status(response, 407)
  105. end
  106. 1 def test_plugin_http_proxy_basic_auth_wrong_error
  107. wrong_auth_proxy = URI(http_proxy.first)
  108. return unless wrong_auth_proxy.user
  109. wrong_auth_proxy.password = "wrongpass"
  110. session = HTTPX.plugin(:proxy).with_proxy(uri: wrong_auth_proxy.to_s)
  111. uri = build_uri("/get")
  112. response = session.get(uri)
  113. verify_status(response, 407)
  114. end
  115. 1 def test_plugin_http_proxy_digest_auth
  116. auth_proxy = URI(http_proxy.first)
  117. return unless auth_proxy.user
  118. user = auth_proxy.user
  119. pass = auth_proxy.password
  120. auth_proxy.user = nil
  121. auth_proxy.password = nil
  122. session = HTTPX.plugin(:proxy)
  123. .plugin(ProxyResponseDetector)
  124. .with_proxy_digest_auth(
  125. uri: auth_proxy.to_s,
  126. username: user,
  127. password: pass
  128. )
  129. uri = build_uri("/get")
  130. response = session.get(uri)
  131. verify_status(response, 200)
  132. verify_body_length(response)
  133. assert response.proxied?
  134. end
  135. def test_plugin_http_proxy_connection_coalescing
  136. return unless origin.start_with?("https://")
  137. coalesced_origin = "https://#{ENV["HTTPBIN_COALESCING_HOST"]}"
  138. HTTPX.plugin(:proxy).with_proxy(uri: http_proxy).plugin(SessionWithPool).wrap do |http|
  139. response1 = http.get(origin)
  140. verify_status(response1, 200)
  141. response2 = http.get(coalesced_origin)
  142. verify_status(response2, 200)
  143. # introspection time
  144. connections = http.connections
  145. origins = connections.map(&:origins)
  146. assert origins.any? { |orgs| orgs.sort == [origin, coalesced_origin].sort },
  147. "connections for #{[origin, coalesced_origin]} didn't coalesce (expected connection with both origins (#{origins}))"
  148. unsafe_origin = URI(origin)
  149. unsafe_origin.scheme = "http"
  150. response3 = http.get(unsafe_origin)
  151. verify_status(response3, 200)
  152. # introspection time
  153. connections = http.connections
  154. origins = connections.map(&:origins)
  155. refute origins.any?([origin]),
  156. "connection coalesced inexpectedly (expected connection with both origins (#{origins}))"
  157. end
  158. 1 end if ENV.key?("HTTPBIN_COALESCING_HOST")
  159. 1 def test_plugin_http_proxy_redirect_305
  160. return unless origin.start_with?("http://")
  161. start_test_servlet(ProxyServer) do |proxy|
  162. start_test_servlet(ProxyRedirectorServer, proxy.origin) do |server|
  163. session = HTTPX.plugin(:follow_redirects)
  164. .plugin(:proxy)
  165. .plugin(ProxyResponseDetector)
  166. uri = "#{server.origin}/"
  167. response = session.get(uri)
  168. verify_status(response, 200)
  169. verify_body_length(response)
  170. assert response.body.to_s == proxy.origin.to_s
  171. end
  172. end
  173. end
  174. 1 def test_plugin_socks4_proxy
  175. session = HTTPX.plugin(:proxy).plugin(ProxyResponseDetector).with_proxy(uri: socks4_proxy)
  176. uri = build_uri("/get")
  177. response = session.get(uri)
  178. verify_status(response, 200)
  179. verify_body_length(response)
  180. assert response.proxied?
  181. end
  182. 1 def test_plugin_socks4_proxy_ip
  183. proxy = URI(socks4_proxy.first)
  184. # doing this bit of song and dance due to URI's CVE "fix" from 1.0.4
  185. # https://github.com/ruby/uri/issues/184
  186. user, _ = proxy.userinfo
  187. proxy.host = Resolv.getaddress(proxy.host)
  188. proxy.user = user
  189. session = HTTPX.plugin(:proxy).plugin(ProxyResponseDetector).with_proxy(uri: [proxy])
  190. uri = build_uri("/get")
  191. response = session.get(uri)
  192. verify_status(response, 200)
  193. verify_body_length(response)
  194. assert response.proxied?
  195. end
  196. 1 def test_plugin_socks4_proxy_error
  197. proxy = URI(socks4_proxy.first)
  198. proxy.user = nil
  199. session = HTTPX.plugin(:proxy).with_proxy(uri: [proxy])
  200. uri = build_uri("/get")
  201. response = session.get(uri)
  202. verify_error_response(response, HTTPX::Socks4Error)
  203. end
  204. 1 def test_plugin_socks4a_proxy
  205. session = HTTPX.plugin(:proxy).plugin(ProxyResponseDetector).with_proxy(uri: socks4a_proxy)
  206. uri = build_uri("/get")
  207. response = session.get(uri)
  208. verify_status(response, 200)
  209. verify_body_length(response)
  210. assert response.proxied?
  211. end
  212. 1 def test_plugin_socks5_proxy
  213. session = HTTPX.plugin(:proxy).plugin(ProxyResponseDetector).with_proxy(uri: socks5_proxy)
  214. uri = build_uri("/get")
  215. response = session.get(uri)
  216. verify_status(response, 200)
  217. verify_body_length(response)
  218. assert response.proxied?
  219. end
  220. 1 def test_plugin_socks5_ipv4_proxy
  221. session = HTTPX.plugin(:proxy).plugin(ProxyResponseDetector).with_proxy(uri: socks5_proxy)
  222. uri = URI(build_uri("/get"))
  223. hostname = uri.host
  224. ipv4 = RESOLVER.getresource(hostname, Resolv::DNS::Resource::IN::A).address.to_s
  225. uri.hostname = ipv4
  226. response = session.get(uri, headers: { "host" => uri.authority }, ssl: { hostname: hostname })
  227. verify_status(response, 200)
  228. verify_body_length(response)
  229. assert response.proxied?
  230. end
  231. # TODO: enable when docker-compose supports ipv6 out of the box
  232. # def test_plugin_socks5_ipv6_proxy
  233. # session = HTTPX.plugin(:proxy).with_proxy(uri: socks5_proxy)
  234. # uri = URI(build_uri("/get"))
  235. # hostname = uri.host
  236. # ipv6 = RESOLVER.getresource(hostname, Resolv::DNS::Resource::IN::AAAA).address.to_s
  237. # uri.hostname = ipv6
  238. # response = session.get(uri, headers: { "host" => uri.authority }, ssl: { hostname: hostname })
  239. # verify_status(response, 200)
  240. # verify_body_length(response)
  241. # end
  242. 1 def test_plugin_socks5_proxy_negotiation_error
  243. proxy = URI(socks5_proxy.first)
  244. proxy.password = nil
  245. session = HTTPX.plugin(:proxy).with_proxy(uri: [proxy])
  246. uri = build_uri("/get")
  247. response = session.get(uri)
  248. verify_error_response(response, /negotiation error/)
  249. end
  250. 1 def test_plugin_socks5_proxy_authentication_error
  251. proxy = URI(socks5_proxy.first)
  252. proxy.password = "1"
  253. session = HTTPX.plugin(:proxy).with_proxy(uri: [proxy])
  254. uri = build_uri("/get")
  255. response = session.get(uri)
  256. verify_error_response(response, /authentication error:/)
  257. end
  258. 1 def test_plugin_socks5_proxy_none_error
  259. start_test_servlet(Sock5WithNoneServer) do |server|
  260. proxy = server.origin
  261. session = HTTPX.plugin(:proxy).with_proxy(uri: [proxy])
  262. uri = build_uri("/get")
  263. response = session.get(uri)
  264. verify_error_response(response, /no supported authorization methods/)
  265. end
  266. end
  267. def test_plugin_ssh_proxy
  268. session = HTTPX.plugin(:"proxy/ssh")
  269. .with_proxy(uri: ssh_proxy,
  270. username: "root",
  271. auth_methods: %w[publickey],
  272. host_key: "ssh-rsa",
  273. keys: %w[test/support/ssh/ssh_host_ed25519_key])
  274. uri = build_uri("/get")
  275. response = session.get(uri)
  276. verify_status(response, 200)
  277. verify_body_length(response)
  278. 1 end if ENV.key?("HTTPX_SSH_PROXY") && RUBY_ENGINE == "ruby" &&
  279. # TODO: remove after https://bugs.ruby-lang.org/issues/22083 is fixed
  280. RUBY_VERSION < "4.0.0"
  281. 1 def test_plugin_retries_on_proxy_error
  282. start_test_servlet(Sock5WithNoneServer) do |server|
  283. proxy = server.origin
  284. uri = build_uri("/get")
  285. session = HTTPX.plugin(RequestInspector).plugin(:proxy).plugin(:retries).with_proxy(uri: [proxy])
  286. res = session.get(uri)
  287. verify_error_response(res, /no supported authorization methods/)
  288. assert session.calls == 3, "expect request to be built 3 times (was #{session.calls})"
  289. end
  290. end
  291. end
  292. end
  293. end

test/support/requests/plugins/push_promise.rb

28.57% lines covered

35 relevant lines. 10 lines covered and 25 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module PushPromise
  5. 1 def test_plugin_no_push_promise
  6. html, css = HTTPX.get(push_html_uri, push_css_uri, max_concurrent_requests: 1,
  7. http2_settings: { settings_enable_push: 1 })
  8. verify_status(html, 200)
  9. verify_status(css, 200)
  10. verify_no_header(css.headers, "x-http2-push")
  11. html.close
  12. css.close
  13. end
  14. 1 def test_plugin_push_promise_get
  15. session = HTTPX.plugin(:push_promise)
  16. html, css = session.get(push_html_uri, push_css_uri)
  17. verify_status(html, 200)
  18. verify_status(css, 200)
  19. verify_header(css.headers, "x-http2-push", "1")
  20. assert css.pushed?
  21. html.close
  22. css.close
  23. end
  24. 1 def test_plugin_push_promise_concurrent
  25. session = HTTPX.plugin(:push_promise).with(max_concurrent_requests: 100)
  26. html, css = session.get(push_html_uri, push_css_uri)
  27. verify_status(html, 200)
  28. verify_status(css, 200)
  29. verify_no_header(css.headers, "x-http2-push")
  30. assert !css.pushed?
  31. html.close
  32. css.close
  33. end
  34. 1 private
  35. 1 def push_origin
  36. "https://nghttp2.org"
  37. end
  38. 1 def push_html_uri
  39. "#{push_origin}/"
  40. end
  41. 1 def push_css_uri
  42. "#{push_origin}/stylesheets/screen.css"
  43. end
  44. end
  45. end
  46. end

test/support/requests/plugins/query.rb

30.0% lines covered

20 relevant lines. 6 lines covered and 14 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module Query
  5. 1 QUERY_FAILED_STATUS_CODE = ENV.key?("CI") ? 501 : 405
  6. 1 def test_plugin_query
  7. session = HTTPX.plugin(:query)
  8. assert session.respond_to?(:query)
  9. uri = build_uri("/get")
  10. response = session.query(uri)
  11. verify_status(response, QUERY_FAILED_STATUS_CODE) # not implemented yet
  12. request = response.instance_variable_get(:@request)
  13. assert request.verb == "QUERY"
  14. end
  15. 1 def test_plugin_retries_query_can_be_retried
  16. check_error = ->(response) {
  17. response.is_a?(HTTPX::ErrorResponse) || response.status == QUERY_FAILED_STATUS_CODE
  18. }
  19. retries_session = HTTPX.plugin(RequestInspector).plugin(:query).plugin(:retries, retry_on: check_error)
  20. uri = build_uri("/get")
  21. retries_response = retries_session.query(uri)
  22. verify_status(retries_response, QUERY_FAILED_STATUS_CODE) # not implemented yet
  23. assert retries_session.calls == 3, "expect request to be built 3 times (was #{retries_session.calls})"
  24. end
  25. end
  26. end
  27. end

test/support/requests/plugins/rate_limiter.rb

23.68% lines covered

38 relevant lines. 9 lines covered and 29 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module RateLimiter
  5. 1 def test_plugin_rate_limiter_429
  6. rate_limiter_session = HTTPX.plugin(RequestInspector)
  7. .plugin(SessionWithMockResponse, mock_status: 429)
  8. .plugin(:rate_limiter)
  9. uri = build_uri("/get")
  10. rate_limiter_session.get(uri)
  11. verify_rated_responses(rate_limiter_session, 429)
  12. end
  13. 1 def test_plugin_rate_limiter_503
  14. rate_limiter_session = HTTPX.plugin(RequestInspector)
  15. .plugin(SessionWithMockResponse, mock_status: 503)
  16. .plugin(:rate_limiter)
  17. uri = build_uri("/get")
  18. rate_limiter_session.get(uri)
  19. verify_rated_responses(rate_limiter_session, 503)
  20. end
  21. 1 def test_plugin_rate_limiter_retry_after_integer
  22. rate_limiter_session = HTTPX.plugin(RequestInspector)
  23. .plugin(SessionWithMockResponse, mock_status: 429, mock_headers: { "retry-after" => "2" })
  24. .plugin(:rate_limiter)
  25. uri = build_uri("/get")
  26. before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  27. rate_limiter_session.get(uri)
  28. after_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  29. verify_rated_responses(rate_limiter_session, 429)
  30. total_time = after_time - before_time
  31. verify_execution_delta(2, total_time, 1)
  32. end
  33. 1 def test_plugin_rate_limiter_retry_after_date
  34. retry_after = (Time.now + 3).httpdate
  35. rate_limiter_session = HTTPX.plugin(RequestInspector)
  36. .plugin(SessionWithMockResponse, mock_status: 429, mock_headers: { "retry-after" => retry_after })
  37. .plugin(:rate_limiter)
  38. uri = build_uri("/get")
  39. before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  40. rate_limiter_session.get(uri)
  41. after_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  42. verify_rated_responses(rate_limiter_session, 429)
  43. total_time = after_time - before_time
  44. verify_execution_delta(2, total_time, 1)
  45. end
  46. 1 private
  47. 1 def verify_rated_responses(session, rated_status)
  48. assert session.total_responses.size == 2, "expected 2 responses(was #{session.total_responses.size})"
  49. rated_response, response = session.total_responses
  50. verify_status(rated_response, rated_status)
  51. verify_status(response, 200)
  52. end
  53. end
  54. end
  55. end

test/support/requests/plugins/response_cache.rb

14.29% lines covered

70 relevant lines. 10 lines covered and 60 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "securerandom"
  3. 1 module Requests
  4. 1 module Plugins
  5. 1 module ResponseCache
  6. 1 def test_plugin_response_cache_options
  7. cache_client = HTTPX.plugin(:response_cache, response_cache_store: :store)
  8. assert cache_client.class.default_options.response_cache_store.is_a?(HTTPX::Plugins::Cache::Store)
  9. cache_client = HTTPX.plugin(:response_cache, response_cache_store: :file_store)
  10. assert cache_client.class.default_options.response_cache_store.is_a?(HTTPX::Plugins::Cache::FileStore)
  11. end
  12. 1 def test_plugin_response_cache_etag
  13. cache_client = HTTPX.plugin(:response_cache)
  14. etag_uri = build_uri("/cache")
  15. original = cache_client.get(etag_uri)
  16. verify_status(original, 200)
  17. assert original.instance_variable_get(:@revalidated_at).nil?
  18. cached = cache_client.get(etag_uri)
  19. verify_status(cached, 304)
  20. assert original.body == cached.body
  21. refute original.instance_variable_get(:@revalidated_at).nil?
  22. cache_client.clear_response_cache
  23. uncached = cache_client.get(etag_uri)
  24. verify_status(uncached, 200)
  25. assert uncached != original
  26. end
  27. 1 def test_plugin_response_cache_cache_control
  28. cache_client = HTTPX.plugin(:response_cache)
  29. cache_control_uri = build_uri("/cache")
  30. original = cache_client.get(cache_control_uri)
  31. verify_status(original, 200)
  32. assert original.instance_variable_get(:@revalidated_at).nil?
  33. cached = cache_client.get(cache_control_uri)
  34. verify_status(cached, 304)
  35. refute original.instance_variable_get(:@revalidated_at).nil?
  36. assert original.body == cached.body
  37. end
  38. 1 def test_plugin_response_cache_do_not_cache_on_error_status
  39. cache_client = HTTPX.plugin(SessionWithPool).plugin(:response_cache)
  40. store = cache_client.instance_variable_get(:@options).response_cache_store.instance_variable_get(:@store)
  41. response_404 = cache_client.get(build_uri("/status/404"))
  42. verify_status(response_404, 404)
  43. assert !store.value?(response_404)
  44. response_410 = cache_client.get(build_uri("/status/410"))
  45. verify_status(response_410, 410)
  46. assert store.value?(response_410)
  47. end
  48. 1 def test_plugin_response_cache_do_not_store_on_no_store_header
  49. return if origin.start_with?("https")
  50. start_test_servlet(ResponseCacheServer) do |server|
  51. cache_client = HTTPX.plugin(:response_cache)
  52. store = cache_client.instance_variable_get(:@options).response_cache_store.instance_variable_get(:@store)
  53. response = cache_client.get("#{server.origin}/no-store")
  54. verify_status(response, 200)
  55. assert store.empty?, "request should not have been cached with no-store header"
  56. end
  57. end
  58. 1 def test_plugin_response_cache_return_cached_while_fresh
  59. cache_client = HTTPX.plugin(SessionWithPool).plugin(:response_cache)
  60. cache_control_uri = build_uri("/cache/2")
  61. store = cache_client.instance_variable_get(:@options).response_cache_store.instance_variable_get(:@store)
  62. original = cache_client.get(cache_control_uri)
  63. verify_status(original, 200)
  64. assert cache_client.connection_count == 1, "a request should have been made"
  65. assert store.value?(original)
  66. cached = cache_client.get(cache_control_uri)
  67. verify_status(cached, 200)
  68. assert cache_client.connection_count == 1, "no request should have been performed"
  69. assert original.body == cached.body, "bodies should have the same value"
  70. assert !original.body.eql?(cached.body), "bodies should have different references"
  71. assert store.value?(original)
  72. sleep(2)
  73. after_expired = cache_client.get(cache_control_uri)
  74. verify_status(after_expired, 200)
  75. assert cache_client.connection_count == 2, "a conditional request should have been made"
  76. assert !store.value?(original)
  77. assert store.value?(after_expired)
  78. end
  79. end
  80. end
  81. end

test/support/requests/plugins/retries.rb

14.69% lines covered

143 relevant lines. 21 lines covered and 122 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module Retries
  5. 1 def test_plugin_retries
  6. no_retries_session = HTTPX.plugin(RequestInspector).with(timeout: { request_timeout: 3 })
  7. no_retries_response = no_retries_session.get(build_uri("/delay/10"))
  8. verify_error_response(no_retries_response)
  9. assert no_retries_session.calls.zero?, "expected request to be retried 1 time (was #{no_retries_session.calls})"
  10. retries_session = HTTPX
  11. .plugin(RequestInspector)
  12. .plugin(:retries)
  13. .with(timeout: { request_timeout: 3 })
  14. retries_response = retries_session.get(build_uri("/delay/10"))
  15. verify_error_response(retries_response)
  16. assert retries_session.calls == 3, "expected request to be retried 3 times (was #{retries_session.calls})"
  17. end
  18. 1 def test_plugin_retries_total_request_timeout_across_attempts
  19. uri = build_uri("/delay/10")
  20. session = HTTPX.plugin(RequestInspector)
  21. .plugin(:retries, max_retries: 3)
  22. .with(timeout: { total_request_timeout: 8, read_timeout: 5 })
  23. response = session.get(uri)
  24. verify_error_response(response, HTTPX::TotalRequestTimeoutError)
  25. assert session.total_responses.size == 2
  26. end
  27. 1 def test_plugin_retries_change_requests
  28. check_error = ->(response) { response.is_a?(HTTPX::ErrorResponse) || response.status == 405 }
  29. retries_session = HTTPX
  30. .plugin(RequestInspector)
  31. .plugin(:retries, retry_on: check_error) # because CI
  32. .with(timeout: { request_timeout: 3 })
  33. retries_response = retries_session.post(build_uri("/delay/10"), body: ["a" * 1024])
  34. assert check_error[retries_response]
  35. assert retries_session.calls.zero?, "expected request to be built 0 times (was #{retries_session.calls})"
  36. retries_session.reset
  37. retries_response = retries_session.post(build_uri("/delay/10"), body: ["a" * 1024], retry_change_requests: true)
  38. assert check_error[retries_response]
  39. assert retries_session.calls == 3, "expected request to be retried 3 times (was #{retries_session.calls})"
  40. end
  41. 1 def test_plugin_retries_multi_request
  42. retries_session = HTTPX
  43. .plugin(RequestInspector)
  44. .plugin(:retries)
  45. .with(timeout: { request_timeout: 3 })
  46. .max_retries(1)
  47. uri = build_uri("/delay/10")
  48. expected = 6 # each request should retry once and fail after 3 seconds
  49. started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  50. responses = retries_session.get(uri, uri, uri)
  51. actual = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
  52. assert responses.size == 3
  53. responses.each do |response|
  54. verify_error_response(response)
  55. # we're comparing against max-retries + 1, because the calls increment will happen
  56. # also in the last call, where the request is not going to be retried.
  57. end
  58. assert retries_session.calls == 5, "expected each request to be retried 2 times (was #{retries_session.calls})"
  59. assert_in_delta expected, actual, 2, "expected to have executed in #{expected} secs (actual: #{actual} secs)"
  60. end
  61. 1 def test_plugin_retries_max_retries
  62. retries_session = HTTPX
  63. .plugin(RequestInspector)
  64. .plugin(:retries)
  65. .with(timeout: { request_timeout: 3 })
  66. .max_retries(2)
  67. retries_response = retries_session.get(build_uri("/delay/10"))
  68. verify_error_response(retries_response)
  69. # we're comparing against max-retries + 1, because the calls increment will happen
  70. # also in the last call, where the request is not going to be retried.
  71. assert retries_session.calls == 2, "expected request to be retried 2 times (was #{retries_session.calls})"
  72. end
  73. 1 def test_plugin_retries_retry_on
  74. retry_callback = lambda do |response|
  75. response.is_a?(HTTPX::Response) && response.status == 400
  76. end
  77. retries_session = HTTPX
  78. .plugin(RequestInspector)
  79. .plugin(:retries, retry_on: retry_callback)
  80. .with(timeout: { request_timeout: 3 })
  81. .max_retries(2)
  82. retries_response = retries_session.get(build_uri("/status/400"))
  83. verify_status(retries_response, 400)
  84. assert retries_session.calls == 2, "expected request to be retried for 400 status code (it was, #{retries_session.calls} times)"
  85. retries_session.reset
  86. retries_response = retries_session.get(build_uri("/status/401"))
  87. verify_status(retries_response, 401)
  88. assert retries_session.calls.zero?,
  89. "expected request not to be retried for 401 status code (it was, #{retries_session.calls} times)"
  90. retries_session.reset
  91. retries_response = retries_session.get(build_uri("/delay/10"))
  92. verify_error_response(retries_response)
  93. assert retries_session.calls == 2,
  94. "expected request to still be retried for regular socket errors (it was, #{retries_session.calls} times)"
  95. end
  96. 1 def test_plugin_retries_retry_after
  97. before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  98. retries_session = HTTPX
  99. .plugin(RequestInspector)
  100. .plugin(:retries, retry_after: 2)
  101. .with(timeout: { request_timeout: 3 })
  102. .max_retries(1)
  103. retries_response = retries_session.get(build_uri("/delay/10"))
  104. after_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  105. total_time = after_time - before_time
  106. verify_error_response(retries_response, HTTPX::RequestTimeoutError)
  107. verify_execution_delta(3 + 2 + 3, total_time, 1)
  108. end
  109. 1 def test_plugin_retries_retry_after_with_jitter
  110. before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  111. retries_session = HTTPX
  112. .plugin(RequestInspector)
  113. .plugin(:retries, retry_after: 2, retry_jitter: ->(_) { 1 })
  114. .with(timeout: { request_timeout: 3 })
  115. .max_retries(1)
  116. retries_response = retries_session.get(build_uri("/delay/10"))
  117. after_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  118. total_time = after_time - before_time
  119. verify_error_response(retries_response, HTTPX::RequestTimeoutError)
  120. verify_execution_delta(3 + 2 + 1 + 3, total_time, 1)
  121. end
  122. 1 def test_plugin_retries_retry_after_exponential
  123. before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  124. retries_session = HTTPX
  125. .plugin(RequestInspector)
  126. .plugin(:retries, retry_after: :exponential_backoff)
  127. .with(timeout: { request_timeout: 3 })
  128. .max_retries(2)
  129. retries_response = retries_session.get(build_uri("/delay/10"))
  130. after_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  131. total_time = after_time - before_time
  132. verify_error_response(retries_response, HTTPX::RequestTimeoutError)
  133. verify_execution_delta(3 + 3 + 2 + 3 + 4, total_time, 1)
  134. end
  135. 1 def test_plugin_retries_retry_after_polynomial
  136. before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  137. retries_session = HTTPX
  138. .plugin(RequestInspector)
  139. .plugin(:retries, retry_after: :polynomial_backoff)
  140. .with(timeout: { request_timeout: 3 })
  141. .max_retries(2)
  142. retries_response = retries_session.get(build_uri("/delay/10"))
  143. after_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  144. total_time = after_time - before_time
  145. verify_error_response(retries_response, HTTPX::RequestTimeoutError)
  146. verify_execution_delta(3 + 3 + 1 + 3 + 1, total_time, 1)
  147. end
  148. 1 def test_plugin_retries_retry_after_callable
  149. retries = 0
  150. exponential = ->(*) { (retries += 1) * 2 }
  151. before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  152. retries_session = HTTPX
  153. .plugin(RequestInspector)
  154. .plugin(:retries, retry_after: exponential)
  155. .with(timeout: { request_timeout: 3 })
  156. .max_retries(2)
  157. retries_response = retries_session.get(build_uri("/delay/10"))
  158. after_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  159. total_time = after_time - before_time
  160. verify_error_response(retries_response, HTTPX::RequestTimeoutError)
  161. verify_execution_delta(3 + 2 + 3 + 4 + 3, total_time, 1)
  162. end
  163. 1 def test_plugin_retries_resumable
  164. resumable_uri = build_uri("/range/200?chunk_size=100")
  165. full_payload = HTTPX.get(resumable_uri).raise_for_status.to_s
  166. retries_session = HTTPX
  167. .plugin(RequestInspector)
  168. .plugin(RequestFailAfter100Bytes)
  169. .plugin(:retries)
  170. .max_retries(2)
  171. .with(retry_on: ->(res) {
  172. res.error && res.error.message == "over 100 bytes"
  173. }, window_size: 50, buffer_size: 50, http2_settings: { settings_initial_window_size: 100 })
  174. retries_response = retries_session.get(resumable_uri)
  175. verify_status(retries_response, 200)
  176. assert retries_response.to_s == full_payload
  177. total_responses = retries_session.total_responses
  178. assert total_responses.size == 2
  179. total_requests = total_responses.map { |res| res.instance_variable_get(:@request) }
  180. assert total_requests.uniq.size == 1
  181. request = total_requests.first
  182. assert request.headers.key?("range")
  183. assert request.headers["range"].match(/bytes=\d+-/)
  184. end
  185. # safety-check test only check if request is successfully rewinded
  186. 1 def test_plugin_retries_multipart_file_post
  187. check_error = lambda { |response|
  188. (response.is_a?(HTTPX::ErrorResponse) && response.error.is_a?(HTTPX::TimeoutError)) || response.status == 405
  189. }
  190. uri = build_uri("/delay/4")
  191. retries_session = HTTPX.plugin(RequestInspector)
  192. .plugin(:retries, max_retries: 1, retry_on: check_error) # because CI...
  193. .with_timeout(request_timeout: 2)
  194. retries_response = retries_session.post(uri, retry_change_requests: true, form: { image: File.new(fixture_file_path) })
  195. assert check_error[retries_response], "expected #{retries_response} to be an error response"
  196. assert retries_session.calls == 1, "expected request to be retried 1 time (was #{retries_session.calls})"
  197. end
  198. 1 def test_plugin_retries_multipart_tempfile_post
  199. check_error = lambda { |response|
  200. (response.is_a?(HTTPX::ErrorResponse) && response.error.is_a?(HTTPX::TimeoutError)) || response.status == 405
  201. }
  202. uri = build_uri("/delay/4")
  203. retries_session = HTTPX.plugin(RequestInspector)
  204. .plugin(:retries, max_retries: 1, retry_on: check_error) # because CI...
  205. .with_timeout(request_timeout: 2)
  206. retries_response = nil
  207. Tempfile.open do |file|
  208. retries_response = retries_session.post(uri, retry_change_requests: true, form: { image: file })
  209. end
  210. assert check_error[retries_response], "expected #{retries_response} to be an error response"
  211. assert retries_session.calls == 1, "expected request to be retried 1 time (was #{retries_session.calls})"
  212. end
  213. 1 module RequestFailAfter100Bytes
  214. 1 class BiggerThan100Bytes < HTTPX::Error; end
  215. 1 module ResponseBodyMethods
  216. 1 def write(chunk)
  217. val = super
  218. raise(BiggerThan100Bytes, "over 100 bytes") if (100..199).cover?(@length)
  219. val
  220. end
  221. end
  222. end
  223. end
  224. end
  225. end

test/support/requests/plugins/server_sent_events.rb

9.3% lines covered

86 relevant lines. 8 lines covered and 78 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module ServerSentEvents
  5. 1 def test_plugin_server_sent_events
  6. session = HTTPX.plugin(:server_sent_events).with(ssl: { verify_hostname: false })
  7. start_test_servlet(SSE, tls: tls?, messages: [{ data: "test" }]) do |server|
  8. uri = "#{server.origin}/"
  9. no_sse_response = session.get(uri)
  10. sse_response = session.get(uri, event_stream: true)
  11. assert sse_response.respond_to?(:headers) # test respond_to_missing?
  12. no_sse_headers = no_sse_response.headers
  13. no_sse_headers.delete("date")
  14. sse_headers = sse_response.headers
  15. sse_headers.delete("date")
  16. verify_header(no_sse_headers, "content-type", "text/plain")
  17. verify_header(sse_headers, "content-type", "text/event-stream")
  18. verify_header(sse_headers, "cache-control", "no-cache")
  19. verify_no_header(sse_headers, "content-length")
  20. end
  21. end
  22. 1 def test_plugin_server_sent_events_each_message
  23. messages = [
  24. { event: "test", data: "test1", id: 1 },
  25. { event: "test", data: "test2", id: 2 },
  26. { comment: "this is a comment" },
  27. { event: "test", data: "test3" },
  28. ]
  29. session = HTTPX.plugin(:server_sent_events).with(ssl: { verify_hostname: false })
  30. start_test_servlet(SSE, tls: tls?, messages: messages) do |server|
  31. uri = "#{server.origin}/"
  32. response = session.get(uri, event_stream: true)
  33. messages = response.each_message.to_a
  34. assert messages.size == 3, "all the messages should have been yielded"
  35. assert(messages.all? { |m| m.event == "test" })
  36. assert messages[0].id == "1"
  37. assert messages[0].data == "test1"
  38. assert messages[1].id == "2"
  39. assert messages[1].data == "test2"
  40. assert messages[2].id.nil?
  41. assert messages[2].data == "test3"
  42. end
  43. end
  44. 1 def test_plugin_server_sent_events_multiple_datas_concat_single_message
  45. messages = [
  46. { data: "test1" },
  47. { data: %w[test2 test3] },
  48. ]
  49. session = HTTPX.plugin(:server_sent_events).with(ssl: { verify_hostname: false })
  50. start_test_servlet(SSE, tls: tls?, messages: messages) do |server|
  51. uri = "#{server.origin}/"
  52. response = session.get(uri, event_stream: true)
  53. messages = response.each_message.to_a
  54. assert messages.size == 2, "all the messages should have been yielded"
  55. assert messages[0].data == "test1"
  56. assert messages[1].data == "test2\ntest3"
  57. end
  58. end
  59. 1 def test_plugin_server_sent_events_retries_last_used_id
  60. return unless tls?
  61. messages = [
  62. { data: "test1", id: 1 },
  63. { data: "test2", id: 2 },
  64. { data: "test3", id: 3 },
  65. ]
  66. session = HTTPX.plugin(RequestInspector)
  67. .plugin(:retries)
  68. .plugin(:server_sent_events)
  69. .with(ssl: { verify_hostname: false })
  70. start_test_servlet(SSE, tls: tls?, messages: messages, close_after: 2) do |server|
  71. uri = "#{server.origin}/"
  72. response = session.get(uri, event_stream: true)
  73. messages = response.each_message.to_a
  74. total_requests = session.total_requests
  75. # assert that there was an actual retry
  76. assert total_requests.size == 2
  77. first_request, retry_request = total_requests
  78. verify_no_header(first_request.headers, "last-event-id")
  79. verify_header(retry_request.headers, "last-event-id", "2")
  80. # assert that the messages before the retry aren't repeated
  81. assert messages.size == 3, "all the messages should have been yielded"
  82. assert messages[0].id == "1"
  83. assert messages[0].data == "test1"
  84. assert messages[1].id == "2"
  85. assert messages[1].data == "test2"
  86. assert messages[2].id == "3"
  87. assert messages[2].data == "test3"
  88. end
  89. end
  90. 1 def test_plugin_server_sent_events_retries_last_used_id_retry_after
  91. return unless tls?
  92. messages = [
  93. { data: "test1", id: 1, retry: 2000 },
  94. { data: "test2", id: 2, retry: 2000 },
  95. { data: "test3", id: 3, retry: 2000 },
  96. ]
  97. session = HTTPX.plugin(RequestInspector).plugin(:retries).plugin(:server_sent_events).with(ssl: { verify_hostname: false })
  98. start_test_servlet(SSE, tls: tls?, messages: messages, close_after: 2) do |server|
  99. uri = "#{server.origin}/"
  100. before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  101. response = session.get(uri, event_stream: true)
  102. messages = response.each_message.to_a
  103. after_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  104. total_time = after_time - before_time
  105. total_requests = session.total_requests
  106. # assert that there was an actual retry
  107. assert total_requests.size == 2
  108. first_request, retry_request = total_requests
  109. verify_no_header(first_request.headers, "last-event-id")
  110. verify_header(retry_request.headers, "last-event-id", "2")
  111. verify_execution_delta(2, total_time, 1)
  112. # assert that the messages before the retry aren't repeated
  113. assert messages.size == 3, "all the messages should have been yielded"
  114. assert messages[0].id == "1"
  115. assert messages[0].data == "test1"
  116. assert messages[1].id == "2"
  117. assert messages[1].data == "test2"
  118. assert messages[2].data == "test3"
  119. end
  120. end
  121. end
  122. end
  123. end

test/support/requests/plugins/ssrf_filter.rb

25.64% lines covered

39 relevant lines. 10 lines covered and 29 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module SsrfFilter
  5. 1 def test_plugin_ssrf_filter_allows
  6. uri = "#{scheme}nghttp2.org"
  7. session = HTTPX.plugin(:ssrf_filter)
  8. response = session.get(uri)
  9. verify_status(response, 200)
  10. end
  11. 1 def test_plugin_ssrf_filter_not_allowed_scheme
  12. return unless origin.start_with?("http://")
  13. session = HTTPX.plugin(:ssrf_filter, allowed_schemes: %w[https])
  14. response = session.get("#{scheme}localhost/get")
  15. verify_error_response(response, HTTPX::ServerSideRequestForgeryError)
  16. end
  17. 1 def test_plugin_ssrf_filter_localhost
  18. session = HTTPX.plugin(:ssrf_filter)
  19. response = session.get("#{scheme}localhost/get")
  20. verify_error_response(response, HTTPX::ServerSideRequestForgeryError)
  21. response = session.get("#{scheme}google.com", addresses: %w[127.0.0.1])
  22. verify_error_response(response, HTTPX::ServerSideRequestForgeryError)
  23. end
  24. 1 def test_plugin_ssrf_filter_extra_unsafe_ranges
  25. # Intentionally blocking IP addresses resolved from nghttp2.org, which normally works
  26. session = HTTPX.plugin(:ssrf_filter, extra_unsafe_ranges: Resolv.getaddresses("nghttp2.org"))
  27. response = session.get("#{scheme}nghttp2.org")
  28. verify_error_response(response, HTTPX::ServerSideRequestForgeryError)
  29. end
  30. 1 def test_plugin_ssrf_filter_safe_private_ranges
  31. session = HTTPX.plugin(:ssrf_filter, safe_private_ranges: ["127.0.0.1", "::1"])
  32. response = session.get("#{scheme}localhost/get")
  33. # connection error means that a connection was attempted, not blocked by SSRF filtering
  34. verify_error_response(response, HTTPX::ConnectionError)
  35. end
  36. 1 def test_plugin_ssrf_filter_aws_metadata_endpoint
  37. session = HTTPX.plugin(:ssrf_filter)
  38. response = session.get("#{scheme}169.254.169.254/latest/meta-data")
  39. verify_error_response(response, HTTPX::ServerSideRequestForgeryError)
  40. end
  41. def test_plugin_ssrf_filter_dns_answer_spoof
  42. dns_spoof_resolver = Class.new(TestDNSResolver) do
  43. def resolve(_, family)
  44. family == 1 ? ["255.255.255.255"] : []
  45. end
  46. end
  47. start_test_servlet(dns_spoof_resolver) do |spoof_dns|
  48. HTTPX.plugin(SessionWithPool).plugin(:ssrf_filter).wrap do |session|
  49. response = session.get("https://wqwereasdsada.xyz", resolver_options: { nameserver: [spoof_dns.nameserver], cache: false })
  50. verify_error_response(response, "wqwereasdsada.xyz has no allowed IP addresses")
  51. end
  52. end
  53. 1 end unless RUBY_ENGINE == "jruby"
  54. end
  55. end
  56. end

test/support/requests/plugins/stream.rb

15.56% lines covered

90 relevant lines. 14 lines covered and 76 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module Stream
  5. 1 include FiberSchedulerTestHelpers
  6. 1 def test_plugin_stream
  7. session = HTTPX.plugin(:stream)
  8. uri = build_uri("/get")
  9. no_stream_response = session.get(uri)
  10. stream_response = session.get(uri, stream: true)
  11. assert no_stream_response.to_s != stream_response.to_s, "stream response should only eager load the first chunk"
  12. assert stream_response.respond_to?(:headers) # test respond_to_missing?
  13. no_stream_headers = no_stream_response.headers.to_h
  14. no_stream_headers.delete("date")
  15. stream_headers = no_stream_response.headers.to_h
  16. stream_headers.delete("date")
  17. assert no_stream_headers == stream_headers, "headers should be the same " \
  18. "(h1: #{no_stream_response.headers}, " \
  19. "(h2: #{stream_response.headers}) "
  20. end
  21. 1 def test_plugin_stream_each
  22. session = HTTPX.plugin(:stream)
  23. response = session.get(build_uri("/stream/3"), stream: true)
  24. body = response.each
  25. payload = body.to_a.join
  26. assert payload.lines.size == 3, "all the lines should have been yielded"
  27. end
  28. 1 def test_plugin_stream_each_after_buffering_some_content
  29. session = HTTPX.plugin(:stream)
  30. response = session.get(build_uri("/stream/3"), stream: true)
  31. verify_status(response, 200) # forces buffering
  32. payload = response.each.to_a.join
  33. assert payload.lines.size == 3, "all the lines should have been yielded"
  34. end
  35. 1 def test_plugin_stream_each_line
  36. session = HTTPX.plugin(:stream)
  37. response = session.get(build_uri("/stream/3"), stream: true)
  38. lines = response.each_line.with_index.map do |line, idx|
  39. assert !line.end_with?("\n")
  40. data = JSON.parse(line)
  41. assert data["id"] == idx
  42. end
  43. assert lines.size == 3, "all the lines should have been yielded"
  44. end
  45. 1 def test_plugin_stream_compressed
  46. session = HTTPX.plugin(:stream)
  47. response = session.get(build_uri("/gzip"), stream: true)
  48. payload = response.each.to_a.join
  49. assert response.headers["content-length"].to_i != payload.lines.sum(&:bytesize), "all the lines should have been yielded"
  50. end
  51. 1 def test_plugin_stream_multiple_responses_error
  52. session = HTTPX.plugin(:stream)
  53. assert_raises(HTTPX::Error, "support only 1 response at a time") do
  54. response = session.get(build_uri("/stream/2"), build_uri("/stream/3"), stream: true)
  55. # force request
  56. response.each_line.to_a
  57. end
  58. end
  59. 1 def test_plugin_stream_response_error
  60. session = HTTPX.plugin(:stream)
  61. assert_raises(HTTPX::HTTPError) do
  62. response = session.get(build_uri("/status/404"), stream: true)
  63. # force request
  64. response.each_line.to_a
  65. end
  66. end
  67. 1 def test_plugin_stream_connection_error
  68. session = HTTPX.with_timeout(request_timeout: 1).plugin(:stream)
  69. assert_raises(HTTPX::TimeoutError) do
  70. response = session.get(build_uri("/delay/10"), stream: true)
  71. # force request
  72. response.each_line.to_a
  73. end
  74. end
  75. 1 def test_plugin_stream_follow_redirects
  76. session = HTTPX.plugin(:follow_redirects).plugin(:stream)
  77. stream_uri = build_uri("/stream/3")
  78. redirect_to_stream_uri = redirect_uri(stream_uri)
  79. response = session.get(redirect_to_stream_uri, stream: true)
  80. payload = response.each.to_a.join
  81. assert payload.lines.size == 3, "all the lines should have been yielded"
  82. end
  83. def test_plugin_stream_fiber_concurrency_close_stream_before_request_starts
  84. skip unless scheme == "https://"
  85. start_test_servlet(SettingsTimeoutServer) do |server|
  86. delay_uri = "#{server.origin}/"
  87. session = HTTPX.plugin(:fiber_concurrency)
  88. .plugin(:stream)
  89. .with(ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE })
  90. err = Class.new(StandardError)
  91. with_test_fiber_scheduler do
  92. err = Class.new(StandardError)
  93. stream_response = error = nil
  94. req_fiber = Fiber.schedule do
  95. begin
  96. stream_response = session.get(delay_uri, timeout: { settings_timeout: 20 }, stream: true)
  97. stream_response.raise_for_status
  98. rescue HTTPX::Error => e
  99. error = e
  100. stream_response.close
  101. end
  102. end
  103. Fiber.schedule do
  104. sleep 1
  105. # sometimes, in CI, it takes quite long for this thread to be prioritized
  106. # back, to the point where settings timeout expires before we have a chance
  107. # to inspect
  108. unless error.is_a?(HTTPX::SettingsTimeoutError)
  109. assert stream_response
  110. request = stream_response.request
  111. assert request.state == :idle
  112. assert request.response.nil?
  113. stream_response.close
  114. assert request.state == :idle
  115. assert request.response.nil?
  116. begin
  117. req_fiber.raise(err) unless req_fiber.alive?
  118. rescue FiberError
  119. # this may happen if this thread takes too long being picked up by the scheduler, and the request timeout
  120. # triggers and reactivates the request fiber, at which point we won't be able to raise an error on it, as
  121. # one can't wait an error on a resuming fiber.
  122. nil
  123. end
  124. end
  125. end
  126. end
  127. assert true
  128. end
  129. 1 end if Fiber.respond_to?(:set_scheduler) && RUBY_VERSION >= "3.1.0"
  130. end
  131. end
  132. end

test/support/requests/plugins/stream_bidi.rb

8.76% lines covered

137 relevant lines. 12 lines covered and 125 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module StreamBidi
  5. 1 def test_plugin_stream_bidi
  6. uri = build_uri("/get")
  7. session = HTTPX.plugin(:stream_bidi)
  8. response = session.get(uri)
  9. verify_status(response, 200)
  10. end
  11. 1 def test_plugin_stream_bidi_does_not_support_non_body_request
  12. uri = build_uri("/post")
  13. session = HTTPX.plugin(:stream_bidi)
  14. assert_raises(HTTPX::Error) do
  15. session.build_request("POST", uri, json: { foo: "bar" }, stream: true)
  16. end
  17. end
  18. 1 def test_plugin_stream_bidi_persistent_session_close
  19. session = HTTPX.with(persistent: true).plugin(:stream_bidi)
  20. session.close # should not raise NoMethodError: undefined method `inflight?' for Signal
  21. end
  22. 1 def test_plugin_stream_bidi_each
  23. start_test_servlet(Bidi, tls: false) do |server|
  24. uri = "#{server.origin}/"
  25. start_msg = "{\"message\":\"started\"}\n"
  26. pong_msg = "{\"message\":\"pong\"}\n"
  27. session = HTTPX.plugin(:stream_bidi)
  28. request = session.build_request(
  29. "POST",
  30. uri,
  31. headers: { "content-type" => "application/x-ndjson" },
  32. body: [start_msg],
  33. stream: true
  34. )
  35. response = session.request(request)
  36. chunks = []
  37. response.each.each_with_index do |chunk, idx| # rubocop:disable Style/RedundantEach
  38. if idx < 4
  39. request << pong_msg
  40. else
  41. request.close
  42. end
  43. chunks << chunk
  44. end
  45. assert chunks.size == 5, "all the lines should have been yielded"
  46. end
  47. end
  48. 1 def test_plugin_stream_bidi_buffer_data_from_separate_thread
  49. start_test_servlet(Bidi, tls: false) do |server|
  50. uri = "#{server.origin}/"
  51. q = Queue.new
  52. start_msg = "{\"message\":\"started\"}\n"
  53. pong_msg = "{\"message\":\"pong\"}\n"
  54. session = HTTPX.plugin(:stream_bidi)
  55. request = session.build_request(
  56. "POST",
  57. uri,
  58. headers: { "content-type" => "application/x-ndjson" },
  59. body: [start_msg],
  60. stream: true
  61. )
  62. response = session.request(request)
  63. th = Thread.start do
  64. 4.times do
  65. msg = q.pop
  66. request << msg
  67. end
  68. request.close
  69. end
  70. chunks = []
  71. response.each.each_with_index do |chunk, _idx| # rubocop:disable Style/RedundantEach
  72. chunks << chunk
  73. q << pong_msg
  74. end
  75. th.join
  76. assert chunks.size == 5, "all the lines should have been yielded"
  77. end
  78. end
  79. 1 def test_plugin_stream_bidi_reuse_persistent_connection_across_threads
  80. start_test_servlet(Bidi, tls: false) do |server|
  81. uri = "#{server.origin}/"
  82. start_msg = "{\"message\":\"started\"}\n"
  83. pong_msg = "{\"message\":\"pong\"}\n"
  84. # Create persistent session (connection will be reused across threads)
  85. session = HTTPX.plugin(:stream_bidi)
  86. begin
  87. # Thread A: First request (creates the connection)
  88. thread_a_chunks = []
  89. thread_a = Thread.start do
  90. request = session.build_request(
  91. "POST",
  92. uri,
  93. headers: { "content-type" => "application/x-ndjson" },
  94. body: [start_msg],
  95. stream: true
  96. )
  97. response = session.request(request)
  98. response.each.each_with_index do |chunk, idx| # rubocop:disable Style/RedundantEach
  99. if idx < 4
  100. request << pong_msg
  101. else
  102. request.close
  103. end
  104. thread_a_chunks << chunk
  105. end
  106. end
  107. thread_a.join
  108. thread_b_chunks = []
  109. thread_b = Thread.start do
  110. request = session.build_request(
  111. "POST",
  112. uri,
  113. headers: { "content-type" => "application/x-ndjson" },
  114. body: [start_msg],
  115. stream: true
  116. )
  117. response = session.request(request)
  118. response.each.each_with_index do |chunk, idx| # rubocop:disable Style/RedundantEach
  119. if idx < 4
  120. request << pong_msg
  121. else
  122. request.close
  123. end
  124. thread_b_chunks << chunk
  125. end
  126. end
  127. thread_b.join
  128. # Both requests should succeed
  129. assert thread_a_chunks.size == 5, "thread A should receive all chunks"
  130. assert thread_b_chunks.size == 5, "thread B should receive all chunks"
  131. ensure
  132. session.close
  133. end
  134. end
  135. end
  136. 1 def test_plugin_stream_bidi_retry_after_headers_sent
  137. start_test_servlet(BidiFailOnce, tls: false) do |server|
  138. uri = "#{server.origin}/"
  139. start_msg = "{\"message\":\"started\"}\n"
  140. # Use both stream_bidi and retries plugins
  141. # retry_change_requests: true because POST is not idempotent
  142. session = HTTPX.plugin(:stream_bidi)
  143. .plugin(:retries, retry_change_requests: true, max_retries: 2)
  144. request = session.build_request(
  145. "POST",
  146. uri,
  147. headers: { "content-type" => "application/x-ndjson" },
  148. body: [start_msg],
  149. stream: true
  150. )
  151. # Close the request immediately - we just want to test that
  152. # the retry doesn't crash due to @headers_sent not being reset
  153. request.close
  154. response = session.request(request)
  155. # If the bug exists (headers_sent not reset), this will raise
  156. # HTTP2::Error::InternalError or cause a deadlock
  157. # With the fix, the response should be a valid StreamResponse
  158. refute response.is_a?(HTTPX::ErrorResponse),
  159. "expected successful response after retry, got #{response.class}: #{response.error if response.respond_to?(:error)}"
  160. verify_status(response, 200)
  161. end
  162. end
  163. # Tests that stream_bidi + retries works correctly when user continues
  164. # to write data after a retry is triggered. This specifically tests
  165. # the callback leak fix where stale :body callbacks from previous
  166. # connection attempts could cause protocol errors.
  167. 1 def test_plugin_stream_bidi_retry_with_ongoing_writes
  168. start_test_servlet(BidiFailAfterData, tls: false) do |server|
  169. uri = "#{server.origin}/"
  170. start_msg = "{\"message\":\"started\"}\n"
  171. session = HTTPX.plugin(:stream_bidi)
  172. .plugin(:retries, retry_change_requests: true, max_retries: 2)
  173. request = session.build_request(
  174. "POST",
  175. uri,
  176. headers: { "content-type" => "application/x-ndjson" },
  177. body: [start_msg],
  178. stream: true
  179. )
  180. response = session.request(request)
  181. # Read response in a separate thread while we continue writing
  182. chunks = []
  183. error = nil
  184. reader = Thread.start do
  185. Thread.abort_on_exception = true
  186. response.each do |chunk|
  187. chunks << chunk
  188. end
  189. end
  190. # Continue writing data - this is where the callback leak bug manifests
  191. # Without the fix, stale callbacks fire and cause protocol_error
  192. 3.times do |i|
  193. request << "{\"message\":\"update_#{i}\"}\n"
  194. sleep 0.05
  195. end
  196. request.close
  197. reader.join
  198. refute error, "expected no error during response reading, got: #{error}"
  199. refute response.is_a?(HTTPX::ErrorResponse), "expected successful response after retry"
  200. end
  201. end
  202. # Tests that stream_bidi + retries correctly handles the case where
  203. # user writes data from a separate thread while a retry happens.
  204. 1 def test_plugin_stream_bidi_retry_with_concurrent_writes
  205. start_test_servlet(BidiFailOnce, tls: false) do |server|
  206. uri = "#{server.origin}/"
  207. q = Queue.new
  208. start_msg = "{\"message\":\"started\"}\n"
  209. pong_msg = "{\"message\":\"pong\"}\n"
  210. session = HTTPX.plugin(:stream_bidi)
  211. .plugin(:retries, retry_change_requests: true, max_retries: 2)
  212. request = session.build_request(
  213. "POST",
  214. uri,
  215. headers: { "content-type" => "application/x-ndjson" },
  216. body: [start_msg],
  217. stream: true
  218. )
  219. response = session.request(request)
  220. # Writer thread - continues writing data regardless of retry
  221. writer_error = nil
  222. writer = Thread.start do
  223. 4.times do
  224. msg = q.pop
  225. request << msg
  226. end
  227. request.close
  228. rescue StandardError => e
  229. writer_error = e
  230. end
  231. # Read responses and signal writer to send more
  232. chunks = []
  233. reader_error = nil
  234. begin
  235. response.each do |chunk|
  236. chunks << chunk
  237. q << pong_msg
  238. end
  239. rescue StandardError => e
  240. reader_error = e
  241. end
  242. writer.join(10)
  243. refute writer_error, "writer thread should not error: #{writer_error&.class}: #{writer_error&.message}"
  244. refute reader_error, "reader should not error: #{reader_error&.class}: #{reader_error&.message}"
  245. refute response.is_a?(HTTPX::ErrorResponse), "expected successful response"
  246. assert chunks.size >= 1, "expected to receive response chunks"
  247. end
  248. end
  249. end
  250. end
  251. end

test/support/requests/plugins/tracing.rb

18.09% lines covered

94 relevant lines. 17 lines covered and 77 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module Tracing
  5. 1 def test_plugin_tracing_request_callbacks
  6. http = HTTPX.plugin(:tracing, tracer: test_tracer)
  7. uri = build_uri("/get")
  8. request = http.build_request("GET", uri)
  9. response = http.request(request)
  10. verify_status(response, 200)
  11. assert test_tracer.started[request] == 1
  12. assert test_tracer.finished[request] == 1
  13. end
  14. 1 def test_plugin_tracing_multiple_tracers_propagates
  15. tracer1 = TestTracer.new
  16. tracer2 = TestTracer.new
  17. http = HTTPX.plugin(:tracing, tracer: tracer1).with(tracer: tracer2)
  18. uri = build_uri("/get")
  19. request = http.build_request("GET", uri)
  20. response = http.request(request)
  21. verify_status(response, 200)
  22. assert tracer1.started[request] == 1
  23. assert tracer1.finished[request] == 1
  24. assert tracer2.started[request] == 1
  25. assert tracer2.finished[request] == 1
  26. end
  27. 1 def test_plugin_tracing_retries_one_for_each
  28. http = HTTPX.plugin(RequestInspector)
  29. .plugin(:retries)
  30. .plugin(:tracing, tracer: test_tracer)
  31. .with(timeout: { request_timeout: 3 })
  32. request = http.build_request("GET", build_uri("/delay/10"))
  33. retries_response = http.request(request)
  34. verify_error_response(retries_response)
  35. assert http.calls == 3, "expect request to be retried 3 times (was #{http.calls})"
  36. assert test_tracer.started[request] == 4
  37. assert test_tracer.finished[request] == 4
  38. assert test_tracer.reset_times[request].size == 3
  39. test_tracer.reset_times[request].each do |time|
  40. assert_in_delta(3, time, 3, "expected all requests to have taken 3 seconds")
  41. end
  42. end
  43. 1 def test_plugin_tracing_retries_with_delayed_ping
  44. start_test_servlet(DelayedPingServer, ping_delay: 2) do |server|
  45. uri = "#{server.origin}/"
  46. HTTPX.plugin(RequestInspector)
  47. .plugin(:retries)
  48. .plugin(:tracing, tracer: test_tracer)
  49. .with(timeout: { keep_alive_timeout: 1 }, ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE,
  50. verify_hostname: false }).wrap do |http|
  51. response1 = http.get(uri)
  52. sleep 2
  53. response2 = http.get(uri)
  54. sleep 2
  55. response3 = http.get(uri)
  56. verify_status(response1, 200)
  57. verify_status(response2, 200)
  58. verify_status(response3, 200)
  59. assert test_tracer.total_times.size == 3
  60. test_tracer.total_times.each_value.with_index do |times, idx|
  61. next unless idx.positive?
  62. assert_in_delta(2, times.first, 2, "expected all requests to have taken 2 seconds to ping")
  63. end
  64. end
  65. end
  66. end
  67. 1 def test_plugin_tracing_merge_tracers
  68. tracer1 = TestTracer.new
  69. tracer2 = TestTracer.new
  70. tracer3 = TestTracer.new
  71. http1 = HTTPX.plugin(:tracing, tracer: tracer1)
  72. def http1.options
  73. @options
  74. end
  75. assert http1.options.tracer.is_a?(TestTracer)
  76. assert http1.options.tracer == tracer1
  77. http2 = http1.with(tracer: tracer2)
  78. def http2.options
  79. @options
  80. end
  81. assert !http2.options.tracer.is_a?(TestTracer)
  82. assert http2.options.tracer.send(:tracers) == [tracer1, tracer2]
  83. http3 = http2.with(tracer: tracer3)
  84. def http3.options
  85. @options
  86. end
  87. assert !http3.options.tracer.is_a?(TestTracer)
  88. assert http3.options.tracer.send(:tracers) == [tracer1, tracer2, tracer3]
  89. end
  90. 1 private
  91. 1 def test_tracer
  92. @test_tracer ||= TestTracer.new
  93. end
  94. 1 class TestTracer
  95. 1 attr_reader :requests, :started, :finished, :errored, :reset_times, :total_times
  96. 1 def initialize(enabled = true)
  97. @enabled = enabled
  98. @requests = []
  99. @started = Hash.new(0)
  100. @finished = Hash.new(0)
  101. @errored = Hash.new(0)
  102. @reset_times = Hash.new { |hs, k| hs[k] = [] }
  103. @started_at = {}
  104. @total_times = Hash.new { |hs, k| hs[k] = [] }
  105. end
  106. 1 def enabled?(_)
  107. @enabled
  108. end
  109. 1 def start(request)
  110. @requests << request
  111. @started[request] += 1
  112. @started_at[request] = Time.now
  113. end
  114. 1 def reset(request)
  115. @reset_times[request] << (Time.now - request.init_time)
  116. end
  117. 1 def finish(request, _response)
  118. @finished[request] += 1
  119. @total_times[request] << (Time.now - @started_at[request])
  120. end
  121. end
  122. end
  123. end
  124. end

test/support/requests/plugins/upgrade.rb

12.5% lines covered

40 relevant lines. 5 lines covered and 35 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module Upgrade
  5. def test_plugin_upgrade_h2
  6. return unless origin.start_with?("https://")
  7. start_test_servlet(H2Upgrade, alpn_protocols: %w[http/1.1 h2]) do |server|
  8. http = HTTPX.plugin(SessionWithPool)
  9. http = http.with(ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE, alpn_protocols: %w[http/1.1] }) # disable alpn negotiation
  10. http.plugin(:upgrade).wrap do |session|
  11. uri = "#{server.origin}/"
  12. request = session.build_request("GET", uri)
  13. request2 = session.build_request("GET", uri)
  14. response = session.request(request)
  15. verify_status(response, 200)
  16. assert response.version == "1.1", "first request should be in HTTP/1.1"
  17. response.close
  18. # verifies that first request was used to upgrade the connection
  19. verify_header(response.headers, "upgrade", "h2")
  20. response2 = session.request(request2)
  21. verify_status(response2, 200)
  22. assert response2.version == "2.0", "second request should already be in HTTP/2"
  23. response2.close
  24. end
  25. end
  26. 1 end unless RUBY_ENGINE == "jruby"
  27. 1 def test_plugin_upgrade_websockets
  28. return unless origin.start_with?("http://")
  29. http = HTTPX.plugin(SessionWithPool).plugin(:upgrade)
  30. response = http.get("http://ws-echo-server")
  31. verify_status(response, 200)
  32. http = http.plugin(WSTestPlugin)
  33. response = http.get("http://ws-echo-server")
  34. verify_status(response, 101)
  35. websocket = response.websocket
  36. assert !websocket.nil?, "websocket wasn't created"
  37. websocket.send("ping")
  38. websocket.send("pong")
  39. sleep 2
  40. echo_messages = websocket.messages
  41. assert echo_messages.size >= 3
  42. assert echo_messages.include?("handshake")
  43. assert echo_messages.include?("ping")
  44. assert echo_messages.include?("pong")
  45. websocket.close
  46. end
  47. end
  48. end
  49. end

test/support/requests/plugins/webdav.rb

15.58% lines covered

77 relevant lines. 12 lines covered and 65 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Plugins
  4. 1 module WebDav
  5. 1 def test_plugin_webdav_mkcol
  6. # put file
  7. webdav_client.delete("/mkcol_dir_test/")
  8. response = webdav_client.mkcol("/mkcol_dir_test/")
  9. verify_status(response, 201)
  10. end
  11. 1 def test_plugin_webdav_copy
  12. # put file
  13. webdav_client.delete("/copied_copy.html")
  14. webdav_client.put("/copy.html", body: "<html></html>")
  15. response = webdav_client.get("/copied_copy.html")
  16. verify_status(response, 404)
  17. response = webdav_client.copy("/copy.html", "/copied_copy.html")
  18. verify_status(response, 201)
  19. response = webdav_client.get("/copied_copy.html")
  20. verify_status(response, 200)
  21. response = webdav_client.get("/copy.html")
  22. verify_status(response, 200)
  23. end
  24. 1 def test_plugin_webdav_move
  25. # put file
  26. webdav_client.delete("/moved_move.html")
  27. webdav_client.put("/move.html", body: "<html></html>")
  28. response = webdav_client.get("/moved_move.html")
  29. verify_status(response, 404)
  30. response = webdav_client.move("/move.html", "/moved_move.html")
  31. verify_status(response, 201)
  32. response = webdav_client.get("/move.html")
  33. verify_status(response, 404)
  34. response = webdav_client.get("/moved_move.html")
  35. verify_status(response, 200)
  36. end
  37. 1 def test_plugin_webdav_lock
  38. # put file
  39. webdav_client.put("/lockfile.html", body: "bang")
  40. response = webdav_client.lock("/lockfile.html")
  41. verify_status(response, 200)
  42. lock_token = response.headers["lock-token"]
  43. response = webdav_client.delete("/lockfile.html")
  44. verify_status(response, 423)
  45. response = webdav_client.unlock("/lockfile.html", lock_token)
  46. verify_status(response, 204)
  47. response = webdav_client.delete("/lockfile.html")
  48. verify_status(response, 204)
  49. webdav_client.put("/lockfile.html", body: "bang")
  50. response = webdav_client.lock("/lockfile.html", timeout: 2)
  51. verify_status(response, 200)
  52. response = webdav_client.delete("/lockfile.html")
  53. verify_status(response, 423)
  54. sleep 3
  55. response = webdav_client.delete("/lockfile.html")
  56. verify_status(response, 204)
  57. end
  58. 1 def test_plugin_webdav_lock_blk
  59. # put file
  60. webdav_client.put("/lockfileblk.html", body: "bang")
  61. webdav_client.lock("/lockfileblk.html") do |response|
  62. verify_status(response, 200)
  63. response = webdav_client.delete("/lockfileblk.html")
  64. verify_status(response, 423)
  65. end
  66. response = webdav_client.delete("/lockfileblk.html")
  67. verify_status(response, 204)
  68. end
  69. 1 def test_plugin_webdav_propfind_proppatch
  70. # put file
  71. webdav_client.put("/propfind.html", body: "bang")
  72. response = webdav_client.propfind("/propfind.html")
  73. verify_status(response, 207)
  74. xml = "<D:set>" \
  75. "<D:prop>" \
  76. "<Z:Authors>" \
  77. "<Z:Author>Jim Bean</Z:Author>" \
  78. "</Z:Authors>" \
  79. "</D:prop>" \
  80. "</D:set>"
  81. response = webdav_client.proppatch("/propfind.html", xml)
  82. verify_status(response, 207)
  83. response = webdav_client.propfind("/propfind.html")
  84. verify_status(response, 207)
  85. assert response.to_s.include?("Jim Bean")
  86. response = webdav_client.propfind("/propfind.html", :acl)
  87. verify_status(response, 207)
  88. response = webdav_client.propfind(
  89. "/propfind.html",
  90. '<?xml version="1.0" encoding="utf-8"?><DAV:propfind xmlns:DAV="DAV:"><DAV:allprop/></DAV:propfind>'
  91. )
  92. verify_status(response, 207)
  93. assert response.to_s.include?("Jim Bean")
  94. end
  95. 1 private
  96. 1 def webdav_client
  97. @webdav_client ||= HTTPX.plugin(:basic_auth).plugin(:webdav, origin: start_webdav_server).basic_auth("user", "pass")
  98. end
  99. 1 def start_webdav_server
  100. origin = ENV.fetch("WEBDAV_HOST")
  101. "http://#{origin}"
  102. end
  103. end
  104. end
  105. end

test/support/requests/plugins/xml.rb

28.0% lines covered

25 relevant lines. 7 lines covered and 18 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "nokogiri"
  3. 1 module Requests
  4. 1 module Plugins
  5. 1 module XML
  6. 1 def test_plugin_xml_request_body_document
  7. uri = build_uri("/post")
  8. response = HTTPX.plugin(:xml).post(uri, xml: Nokogiri::XML("<xml></xml>"))
  9. verify_status(response, 200)
  10. body = json_body(response)
  11. verify_header(body["headers"], "Content-Type", "application/xml; charset=utf-8")
  12. # nokogiri in cruby adds \n trailer, jruby doesn't
  13. assert body["data"].start_with?("<?xml version=\"1.0\"?>\n<xml/>")
  14. end
  15. 1 def test_plugin_xml_request_body_string
  16. uri = build_uri("/post")
  17. response = HTTPX.plugin(:xml).post(uri, xml: "<xml></xml>")
  18. verify_status(response, 200)
  19. body = json_body(response)
  20. verify_header(body["headers"], "Content-Type", "application/xml; charset=utf-8")
  21. assert body["data"] == "<xml></xml>"
  22. end
  23. 1 def test_plugin_xml_response
  24. uri = build_uri("/xml")
  25. response = HTTPX.plugin(:xml).get(uri)
  26. verify_status(response, 200)
  27. verify_body_length(response)
  28. xml = response.xml
  29. assert xml.is_a?(Nokogiri::XML::Node)
  30. end
  31. end
  32. end
  33. end

test/support/requests/resolvers.rb

12.17% lines covered

304 relevant lines. 37 lines covered and 267 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module Resolvers
  4. 1 using HTTPX::URIExtensions
  5. 1 ResolverTimeoutPlugin = Module.new do
  6. 1 self::ResolverNativeMethods = Module.new do
  7. 1 def dread
  8. @io.read(16_384, "".b)
  9. super
  10. end
  11. end
  12. 1 self::ResolverHTTPSMethods = Module.new do
  13. # this forces the resolver connection to timeout by setting it to read
  14. # when it'll be ready to write requests.
  15. 1 def resolver_connection
  16. super.tap do |conn|
  17. def conn.interests
  18. return super unless @state == :open
  19. :r
  20. end
  21. end
  22. end
  23. end
  24. 1 self::ResolverSystemMethods = Module.new do
  25. # this forces the system resolver to timeout by cleaning the pipe signal
  26. # telling the main thread that there's a response.
  27. 1 def consume
  28. sleep(0.5)
  29. @pipe_read.read_nonblock(1, exception: false) # drain
  30. super
  31. end
  32. end
  33. end
  34. {
  35. 1 native: { cache: false },
  36. system: { cache: false },
  37. https: { uri: ENV["HTTPX_RESOLVER_URI"], cache: false },
  38. }.each do |resolver_type, options|
  39. 3 define_method :"test_resolver_#{resolver_type}_multiple_errors" do
  40. 2.times do |i|
  41. session = HTTPX.plugin(SessionWithPool)
  42. unknown_uri = "http://www.sfjewjfwigiewpgwwg-native-#{i}.com"
  43. response = session.get(unknown_uri, resolver_class: resolver_type, resolver_options: options)
  44. verify_error_response(response, HTTPX::ResolveError)
  45. end
  46. end
  47. 3 define_method :"test_resolver_#{resolver_type}_request" do
  48. session = HTTPX.plugin(SessionWithPool)
  49. uri = build_uri("/get")
  50. response = session.head(uri, resolver_class: resolver_type, resolver_options: options)
  51. verify_status(response, 200)
  52. response.close
  53. end
  54. 3 define_method :"test_resolver_#{resolver_type}_alias_request" do
  55. session = HTTPX.plugin(SessionWithPool)
  56. uri = URI(build_uri("/get"))
  57. # this google host will resolve to a CNAME
  58. uri.host = "lh3.googleusercontent.com"
  59. response = session.head(uri, resolver_class: resolver_type, resolver_options: options)
  60. assert !response.is_a?(HTTPX::ErrorResponse), "response was an error (#{response})"
  61. assert response.status < 500, "unexpected HTTP error (#{response})"
  62. response.close
  63. end
  64. 3 define_method :"test_resolver_#{resolver_type}_timeout" do
  65. resolver_opts = options.merge(timeouts: [1, 2])
  66. HTTPX.plugin(ResolverTimeoutPlugin).plugin(SessionWithPool).wrap do |session|
  67. uri = build_uri("/get")
  68. # before_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  69. response = session.get(uri, resolver_class: resolver_type, resolver_options: options.merge(resolver_opts))
  70. # after_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
  71. # total_time = after_time - before_time
  72. verify_error_response(response, HTTPX::ResolveTimeoutError)
  73. # assert_in_delta 2 + 1, total_time, 12, "request didn't take as expected to retry dns queries (#{total_time} secs)"
  74. end
  75. end
  76. 3 define_method :"test_resolver_#{resolver_type}_happy_eyeballs" do
  77. skip if resolver_type == :system # still no way to pass the nameserver to getaddrinfo via ruby
  78. uri = URI(build_uri("/get"))
  79. start_test_servlet(TestDNSResolver) do |dns_server|
  80. resolver_opts = options.merge(
  81. nameserver: [dns_server.nameserver],
  82. )
  83. HTTPX
  84. .plugin(SessionWithPool)
  85. .with(ip_families: [Socket::AF_INET6, Socket::AF_INET]) do |session|
  86. response = session.get(uri, resolver_class: resolver_type, resolver_options: options.merge(resolver_opts))
  87. verify_status(response, 200)
  88. conns = session.connections
  89. if resolver_type == :https
  90. assert conns.size == 3
  91. resolver_uri = URI(resolver_opts[:uri])
  92. conns.reject! { |c| c.origin.to_s == resolver_uri.origin }
  93. else
  94. assert conns.size == 2
  95. end
  96. assert(conns.all? { |c| c.origin.to_s == uri.origin })
  97. assert(conns.one? { |c| c.family == Socket::AF_INET6 })
  98. assert(conns.one? { |c| c.family == Socket::AF_INET })
  99. assert(conns.one?(&:main_sibling))
  100. end
  101. end
  102. end
  103. 3 case resolver_type
  104. when :https
  105. 1 define_method :"test_resolver_#{resolver_type}_get_request" do
  106. HTTPX.plugin(SessionWithPool).wrap do |http|
  107. uri = build_uri("/get")
  108. response = http.head(uri, resolver_class: resolver_type, resolver_options: options.merge(use_get: true))
  109. verify_status(response, 200)
  110. response.close
  111. resolver_uri = URI(options[:uri])
  112. resolver_conn = http.pool.connections.find { |c| c.origin.to_s == resolver_uri.origin }
  113. assert resolver_conn, "https resolver connection should still be there"
  114. assert resolver_conn.open?, "resolver connection should be kept around open"
  115. end
  116. end
  117. 1 define_method :"test_resolver_#{resolver_type}_unresolvable_servername" do
  118. session = HTTPX.plugin(SessionWithPool)
  119. uri = build_uri("/get")
  120. response = session.head(uri, resolver_class: resolver_type, resolver_options: options.merge(uri: "https://unexisting-doh/dns-query"))
  121. verify_error_response(response, HTTPX::ResolveError)
  122. end
  123. 1 define_method :"test_resolver_#{resolver_type}_server_error" do
  124. session = HTTPX.plugin(SessionWithPool)
  125. uri = URI(build_uri("/get"))
  126. resolver_class = Class.new(HTTPX::Resolver::HTTPS) do
  127. def build_request(_hostname)
  128. @options.request_class.new("POST", @uri, @options)
  129. end
  130. end
  131. response = session.head(uri, resolver_class: resolver_class, resolver_options: options)
  132. verify_error_response(response, HTTPX::ResolveError)
  133. end
  134. 1 define_method :"test_resolver_#{resolver_type}_decoding_error" do
  135. session = HTTPX.plugin(SessionWithPool)
  136. uri = URI(build_uri("/get"))
  137. resolver_class = Class.new(HTTPX::Resolver::HTTPS) do
  138. def decode_response_body(_response)
  139. [:decode_error, Resolv::DNS::DecodeError.new("smth")]
  140. end
  141. end
  142. response = session.head(uri, resolver_class: resolver_class, resolver_options: options.merge(record_types: %w[]))
  143. verify_error_response(response, HTTPX::ResolveError)
  144. end
  145. 1 define_method :"test_resolver_#{resolver_type}_encoding_error" do
  146. session = HTTPX.plugin(SessionWithPool)
  147. uri = URI(build_uri("/get"))
  148. resolver_class = Class.new(HTTPX::Resolver::HTTPS) do
  149. def build_request(*)
  150. raise Resolv::DNS::EncodeError, "ups"
  151. end
  152. end
  153. assert_raises(Resolv::DNS::EncodeError) do
  154. session.head(uri, resolver_class: resolver_class, resolver_options: options.merge(record_types: %w[]))
  155. end
  156. end
  157. 1 define_method :"test_resolver_#{resolver_type}_dns_error" do
  158. session = HTTPX.plugin(SessionWithPool)
  159. uri = URI(build_uri("/get"))
  160. resolver_class = Class.new(HTTPX::Resolver::HTTPS) do
  161. def decode_response_body(*)
  162. [:dns_error, nil]
  163. end
  164. end
  165. response = session.head(uri, resolver_class: resolver_class, resolver_options: options.merge(record_types: %w[]))
  166. verify_error_response(response, HTTPX::ResolveError)
  167. assert session.pool.connections.empty?
  168. end
  169. 1 define_method :"test_resolver_#{resolver_type}_no_answers" do
  170. HTTPX.plugin(SessionWithPool).wrap do |http|
  171. uri = URI(build_uri("/get"))
  172. resolver_class = Class.new(HTTPX::Resolver::HTTPS) do
  173. def parse_addresses(_, request)
  174. super([], request)
  175. end
  176. end
  177. response = http.head(uri, resolver_class: resolver_class, resolver_options: options.merge(record_types: %w[]))
  178. verify_error_response(response, HTTPX::ResolveError)
  179. resolver_uri = URI(options[:uri])
  180. resolver_conn = http.pool.connections.find { |c| c.origin.to_s == resolver_uri.origin }
  181. assert resolver_conn, "https resolver connection should still be there"
  182. assert resolver_conn.state == :closed, "resolver connection should have closed after error"
  183. end
  184. end
  185. when :native
  186. 1 define_method :"test_resolver_#{resolver_type}_tcp_request" do
  187. tcp_socket = nil
  188. resolver_class = Class.new(HTTPX::Resolver::Native) do
  189. define_method :build_socket do
  190. tcp_socket = super()
  191. end
  192. end
  193. session = HTTPX.plugin(SessionWithPool)
  194. uri = build_uri("/get")
  195. response = session.head(uri, resolver_class: resolver_class, resolver_options: options.merge(socket_type: :tcp))
  196. verify_status(response, 200)
  197. response.close
  198. assert !tcp_socket.nil?
  199. assert tcp_socket.is_a?(HTTPX::TCP)
  200. end
  201. 1 define_method :"test_resolver_#{resolver_type}_same_relative_name" do
  202. addresses = nil
  203. resolver_class = Class.new(HTTPX::Resolver::Native) do
  204. define_method :parse_addresses do |addrs|
  205. addresses = addrs
  206. super(addrs)
  207. end
  208. end
  209. start_test_servlet(DNSSameRelativeName) do |slow_dns_server|
  210. start_test_servlet(DNSSameRelativeName) do |not_so_slow_dns_server|
  211. nameservers = [slow_dns_server.nameserver, not_so_slow_dns_server.nameserver]
  212. resolver_opts = options.merge(nameserver: nameservers)
  213. session = HTTPX.plugin(SessionWithPool)
  214. uri = URI(build_uri("/get"))
  215. response = session.head(uri, resolver_class: resolver_class, resolver_options: resolver_opts)
  216. verify_status(response, 200)
  217. response.close
  218. assert !addresses.nil?
  219. addr = addresses.first
  220. assert addr["name"] != uri.host
  221. end
  222. end
  223. end
  224. # this test mocks the case where there's no nameserver set to send the DNS queries to.
  225. 1 define_method :"test_resolver_#{resolver_type}_no_nameserver" do
  226. session = HTTPX.plugin(SessionWithPool)
  227. uri = build_uri("/get")
  228. response = session.head(uri, resolver_class: resolver_type, resolver_options: options.merge(nameserver: nil))
  229. verify_error_response(response, HTTPX::ResolveError)
  230. end
  231. 1 define_method :"test_resolver_#{resolver_type}_slow_nameserver" do
  232. start_test_servlet(SlowDNSServer, 6) do |slow_dns_server|
  233. start_test_servlet(SlowDNSServer, 1) do |not_so_slow_dns_server|
  234. nameservers = [slow_dns_server.nameserver, not_so_slow_dns_server.nameserver]
  235. resolver_opts = options.merge(nameserver: nameservers, timeouts: [3])
  236. HTTPX.plugin(SessionWithPool).wrap do |session|
  237. uri = build_uri("/get")
  238. response = session.get(uri, resolver_class: resolver_type, resolver_options: options.merge(resolver_opts))
  239. verify_status(response, 200)
  240. resolver = session.resolver
  241. assert resolver.instance_variable_get(:@ns_index) == 1
  242. end
  243. end
  244. end
  245. end
  246. 1 define_method :"test_resolver_#{resolver_type}_dns_error" do
  247. start_test_servlet(DNSErrorServer) do |slow_dns_server|
  248. start_test_servlet(DNSErrorServer) do |not_so_slow_dns_server|
  249. nameservers = [slow_dns_server.nameserver, not_so_slow_dns_server.nameserver]
  250. resolver_opts = options.merge(nameserver: nameservers)
  251. HTTPX.plugin(SessionWithPool).wrap do |session|
  252. uri = build_uri("/get")
  253. response = session.get(uri, resolver_class: resolver_type, resolver_options: options.merge(resolver_opts))
  254. verify_error_response(response, /unknown DNS error/)
  255. end
  256. end
  257. end
  258. end
  259. # this test mocks a DNS server invalid messages back
  260. 1 define_method :"test_resolver_#{resolver_type}_decoding_error" do
  261. HTTPX.plugin(SessionWithPool).wrap do |session|
  262. uri = URI(build_uri("/get"))
  263. before_connections = nil
  264. resolver_class = Class.new(HTTPX::Resolver::Native) do
  265. attr_reader :connections
  266. define_method :parse do |buffer|
  267. before_connections = @connections.size
  268. super(buffer[0..-2])
  269. end
  270. end
  271. response = session.head(uri, resolver_class: resolver_class, resolver_options: options.merge(record_types: %w[]))
  272. verify_error_response(response, HTTPX::NativeResolveError)
  273. assert session.resolvers.size == 1
  274. resolver = session.resolvers.first
  275. resolver = resolver.resolvers.first # because it's a multi
  276. assert resolver.state == :closed
  277. assert before_connections == 1, "resolver should have been resolving one connection"
  278. assert resolver.connections.empty?, "resolver should not hold connections at this point anymore"
  279. end
  280. end
  281. # this test mocks a DNS server breaking the socket with Errno::EHOSTUNREACH
  282. 1 define_method :"test_resolver_#{resolver_type}_unreachable" do
  283. session = HTTPX.plugin(SessionWithPool)
  284. uri = URI(build_uri("/get"))
  285. resolver_class = Class.new(HTTPX::Resolver::Native) do
  286. class << self
  287. attr_accessor :attempts
  288. end
  289. self.attempts = 0
  290. def dwrite
  291. self.class.attempts += 1
  292. raise Errno::EHOSTUNREACH, "host unreachable"
  293. end
  294. end
  295. response = session.head(uri, resolver_class: resolver_class, resolver_options: options.merge(nameserver: %w[127.0.0.1] * 3))
  296. verify_error_response(response, HTTPX::ResolveError)
  297. assert resolver_class.attempts == 3, "should have attempted to use all 3 nameservers"
  298. end
  299. 1 define_method :"test_resolver_#{resolver_type}_max_udp_size_exceeded" do
  300. uri = origin("1024.size.dns.netmeister.org")
  301. session = HTTPX.plugin(SessionWithPool)
  302. resolver_class = Class.new(HTTPX::Resolver::Native) do
  303. @ios = []
  304. class << self
  305. attr_reader :ios
  306. end
  307. private
  308. def build_socket
  309. io = super
  310. self.class.ios << io
  311. io
  312. end
  313. end
  314. response = session.head(uri, timeout: { connect_timeout: 2 }, resolver_class: resolver_class,
  315. resolver_options: options.merge(nameserver: %w[166.84.7.99]))
  316. verify_error_response(response, HTTPX::Error)
  317. assert resolver_class.ios.any?(HTTPX::TCP), "resolver did not upgrade to tcp"
  318. end
  319. 1 define_method :"test_resolver_#{resolver_type}_max_udp_size_exceeded_with_cname" do
  320. uri = origin("1024.size.dns.netmeister.org")
  321. session = HTTPX.plugin(SessionWithPool)
  322. resolver_class = Class.new(HTTPX::Resolver::Native) do
  323. @ios = []
  324. class << self
  325. attr_reader :ios
  326. end
  327. def build_socket
  328. io = super
  329. self.class.ios << io
  330. io
  331. end
  332. def parse_addresses(addresses)
  333. addr = addresses.first
  334. return super unless addr["name"] == "1024.size.dns.netmeister.org"
  335. # insert bogus CNAME
  336. addresses.unshift(
  337. {
  338. "name" => "1024.size.dns.netmeister.org",
  339. "TTL" => 10,
  340. "alias" => ENV.fetch("HTTPBIN_HOST", "nghttp2.org/httpbin"),
  341. }
  342. )
  343. super
  344. end
  345. end
  346. response = session.head(uri, timeout: { connect_timeout: 2 }, resolver_class: resolver_class,
  347. resolver_options: options.merge(nameserver: %w[166.84.7.99]))
  348. verify_error_response(response, HTTPX::Error)
  349. assert resolver_class.ios.any?(HTTPX::TCP), "resolver did not upgrade to tcp"
  350. end
  351. 1 define_method :"test_resolver_#{resolver_type}_no_addresses" do
  352. start_test_servlet(DNSNoAddress) do |slow_dns_server|
  353. start_test_servlet(DNSNoAddress) do |not_so_slow_dns_server|
  354. nameservers = [slow_dns_server.nameserver, not_so_slow_dns_server.nameserver]
  355. resolver_opts = options.merge(nameserver: nameservers)
  356. HTTPX.plugin(SessionWithPool).wrap do |session|
  357. uri = build_uri("/get")
  358. response = session.get(uri, resolver_class: resolver_type, resolver_options: resolver_opts)
  359. verify_error_response(response, /Can't resolve/)
  360. end
  361. end
  362. end
  363. end
  364. 1 define_method :"test_resolver_#{resolver_type}_ttl_expired" do
  365. start_test_servlet(TestDNSResolver, ttl: 4) do |short_ttl_dns_server|
  366. nameservers = [short_ttl_dns_server.nameserver]
  367. resolver_opts = options.merge(nameserver: nameservers)
  368. session = HTTPX.plugin(SessionWithPool)
  369. 2.times do
  370. uri = URI(build_uri("/get"))
  371. response = session.head(uri, resolver_class: resolver_type, resolver_options: resolver_opts)
  372. verify_status(response, 200)
  373. response.close
  374. end
  375. # expire ttl
  376. sleep 4
  377. uri = URI(build_uri("/get"))
  378. response = session.head(uri, resolver_class: resolver_type, resolver_options: resolver_opts)
  379. verify_status(response, 200)
  380. response.close
  381. num_answers = short_ttl_dns_server.answers
  382. assert num_answers == 2, "should have only answered 2 times for DNS queries, instead is #{num_answers}"
  383. end
  384. end
  385. 1 define_method :"test_resolver_#{resolver_type}_candidate" do
  386. uri = URI(build_uri("/get"))
  387. only_to_candidate = Class.new(TestDNSResolver) do
  388. define_method :dns_response do |query|
  389. domain = extract_domain(query)
  390. return unless domain == "#{uri.hostname}.local." # last condidate
  391. super(query)
  392. end
  393. def resolve(domain, typevalue)
  394. super(domain.delete_suffix(".local."), typevalue)
  395. end
  396. end
  397. start_test_servlet(only_to_candidate) do |slow_dns_server|
  398. dns_config = {
  399. nameserver: [slow_dns_server.nameserver],
  400. timeouts: [1, 2],
  401. dots: 1,
  402. search: "local",
  403. }
  404. resolver_opts = options.merge(dns_config)
  405. HTTPX.plugin(SessionWithPool).wrap do |session|
  406. response = session.get(uri, resolver_class: resolver_type, resolver_options: options.merge(resolver_opts))
  407. verify_status(response, 200)
  408. assert session.resolvers.size == 1
  409. resolver = session.resolvers.first
  410. resolver = resolver.resolvers.first # because it's a multi
  411. assert resolver.state == :closed
  412. tries = resolver.tries
  413. assert tries.keys.size == 2
  414. assert tries.key?(uri.hostname)
  415. assert tries[uri.hostname] == 2, "should have tried canonical 2 times"
  416. assert tries.key?("#{uri.hostname}.local")
  417. assert resolver.timeouts.empty?, "should have cleaned up all candidate timeouts"
  418. end
  419. end
  420. end
  421. 1 define_method :"test_resolver_#{resolver_type}_servfail_should_retry" do
  422. uri = URI(build_uri("/get"))
  423. start_test_servlet(DNSServFailOnce) do |dns_server|
  424. resolver_opts = options.merge(
  425. nameserver: [dns_server.nameserver],
  426. timeouts: [1, 1, 2] # more than 2 in case one of the writes fail
  427. )
  428. session = HTTPX.plugin(SessionWithPool).with(ip_families: [Socket::AF_INET])
  429. response = session.get(uri, resolver_class: resolver_type, resolver_options: options.merge(resolver_opts))
  430. verify_status(response, 200)
  431. assert dns_server.failed
  432. assert dns_server.queries == 2
  433. end
  434. end
  435. end
  436. end
  437. end
  438. end

test/support/requests/response_body.rb

10.87% lines covered

46 relevant lines. 5 lines covered and 41 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module ResponseBody
  4. 1 def test_http_response_copy_to_file
  5. file = Tempfile.new(%w[cat .jpeg])
  6. uri = build_uri("/image")
  7. response = HTTPX.get(uri, headers: { "accept" => "image/jpeg" })
  8. verify_status(response, 200)
  9. verify_header(response.headers, "content-type", "image/jpeg")
  10. verify_body_length(response)
  11. response.copy_to(file)
  12. content_length = response.headers["content-length"].to_i
  13. assert file.size == content_length, "file should contain the content of response"
  14. ensure
  15. if file
  16. file.close
  17. file.unlink
  18. end
  19. end
  20. 1 def test_http_response_copy_to_io
  21. io = StringIO.new
  22. uri = build_uri("/image")
  23. response = HTTPX.get(uri, headers: { "accept" => "image/jpeg" })
  24. verify_status(response, 200)
  25. verify_header(response.headers, "content-type", "image/jpeg")
  26. response.copy_to(io)
  27. content_length = response.headers["content-length"].to_i
  28. assert io.size == content_length, "file should contain the content of response"
  29. ensure
  30. io.close if io
  31. end
  32. 1 def test_http_response_buffer_to_custom
  33. uri = build_uri("/")
  34. custom_body = Class.new(HTTPX::Response::Body) do
  35. attr_reader :file
  36. def initialize(_response, _opts)
  37. super
  38. @file = Tempfile.new("httpx-test")
  39. end
  40. def write(data)
  41. @file << data
  42. end
  43. def close
  44. return unless @file
  45. @file.close
  46. @file.unlink
  47. end
  48. end
  49. response = HTTPX.with(response_body_class: custom_body).get(uri)
  50. verify_status(response, 200)
  51. assert response.body.is_a?(custom_body), "body should be from custom type"
  52. file = response.body.file
  53. file.rewind
  54. content_length = response.headers["content-length"].to_i
  55. assert file.size == content_length, "didn't buffer the whole body"
  56. ensure
  57. response.close if response
  58. end
  59. end
  60. end

test/support/requests/with_body.rb

10.92% lines covered

174 relevant lines. 19 lines covered and 155 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module Requests
  3. 1 module WithBody
  4. 1 %w[post put patch delete].each do |meth|
  5. 4 define_method :"test_#{meth}_query_params" do
  6. uri = build_uri("/#{meth}")
  7. response = HTTPX.send(meth, uri, params: { "q" => "this is a test" })
  8. verify_status(response, 200)
  9. body = json_body(response)
  10. verify_uploaded(body, "args", "q" => "this is a test")
  11. verify_uploaded(body, "url", build_uri("/#{meth}?q=this+is+a+test"))
  12. return unless can_run_ractor_tests?
  13. response2 = Ractor.new(meth, uri) do |meth, uri|
  14. HTTPX.send(meth, uri, params: { "q" => "this is a test" })
  15. end.value
  16. verify_status(response2, 200)
  17. body2 = json_body(response2)
  18. verify_uploaded(body2, "args", "q" => "this is a test")
  19. verify_uploaded(body2, "url", build_uri("/#{meth}?q=this+is+a+test"))
  20. end
  21. 4 define_method :"test_#{meth}_query_params_empty" do
  22. uri = build_uri("/#{meth}")
  23. response = HTTPX.send(meth, "#{uri}?foo=bar", params: {})
  24. verify_status(response, 200)
  25. body = json_body(response)
  26. verify_uploaded(body, "args", "foo" => "bar")
  27. verify_uploaded(body, "url", build_uri("/#{meth}?foo=bar"))
  28. end
  29. 4 define_method :"test_#{meth}_query_nested_params" do
  30. uri = build_uri("/#{meth}")
  31. response = HTTPX.send(meth, uri, params: { "q" => { "a" => "z" }, "a" => %w[1 2], "b" => [] })
  32. verify_status(response, 200)
  33. body = json_body(response)
  34. verify_uploaded(body, "args", "q[a]" => "z", "a[]" => %w[1 2], "b[]" => "")
  35. verify_uploaded(body, "url", build_uri("/#{meth}?q[a]=z&a[]=1&a[]=2&b[]"))
  36. end
  37. 4 define_method :"test_#{meth}_form_params" do
  38. uri = build_uri("/#{meth}")
  39. response = HTTPX.send(meth, uri, form: { "foo" => "bar" })
  40. verify_status(response, 200)
  41. body = json_body(response)
  42. verify_header(body["headers"], "Content-Type", "application/x-www-form-urlencoded")
  43. verify_uploaded(body, "form", "foo" => "bar")
  44. return unless can_run_ractor_tests?
  45. response2 = Ractor.new(meth, uri) do |meth, uri|
  46. HTTPX.send(meth, uri, form: { "foo" => "bar" })
  47. end.value
  48. verify_status(response2, 200)
  49. body2 = json_body(response2)
  50. verify_header(body2["headers"], "Content-Type", "application/x-www-form-urlencoded")
  51. verify_uploaded(body2, "form", "foo" => "bar")
  52. end
  53. 4 define_method :"test_#{meth}_form_nested_params" do
  54. uri = build_uri("/#{meth}")
  55. response = HTTPX.send(meth, uri, form: { "q" => { "a" => "z" }, "a" => %w[1 2], "b" => [] })
  56. verify_status(response, 200)
  57. body = json_body(response)
  58. verify_header(body["headers"], "Content-Type", "application/x-www-form-urlencoded")
  59. verify_uploaded(body, "form", "q[a]" => "z", "a[]" => %w[1 2], "b[]" => "")
  60. end
  61. 4 define_method :"test_#{meth}_expect_100_form_params" do
  62. uri = build_uri("/#{meth}")
  63. response = HTTPX.with_headers("expect" => "100-continue")
  64. .send(meth, uri, form: { "foo" => "bar" })
  65. verify_status(response, 200)
  66. body = json_body(response)
  67. verify_header(body["headers"], "Content-Type", "application/x-www-form-urlencoded")
  68. verify_header(body["headers"], "Expect", "100-continue")
  69. verify_uploaded(body, "form", "foo" => "bar")
  70. end
  71. 4 define_method :"test_#{meth}_json_params" do
  72. uri = build_uri("/#{meth}")
  73. response = HTTPX.send(meth, uri, json: { "foo" => "bar" })
  74. verify_status(response, 200)
  75. body = json_body(response)
  76. verify_header(body["headers"], "Content-Type", "application/json")
  77. verify_uploaded(body, "json", "foo" => "bar")
  78. return unless can_run_ractor_tests?
  79. response2 = Ractor.new(meth, uri) do |meth, uri|
  80. HTTPX.send(meth, uri, json: { "foo" => "bar" })
  81. end.value
  82. verify_status(response2, 200)
  83. body2 = json_body(response2)
  84. verify_header(body2["headers"], "Content-Type", "application/json")
  85. verify_uploaded(body2, "json", "foo" => "bar")
  86. end
  87. 4 define_method :"test_#{meth}_body_params" do
  88. uri = build_uri("/#{meth}")
  89. response = HTTPX.send(meth, uri, body: "data")