From 5488d0219a2280595fe0f7adc1259d3befee9f77 Mon Sep 17 00:00:00 2001 From: gngpp Date: Fri, 17 Jul 2026 02:24:54 +0800 Subject: [PATCH 01/11] feat(client): accept sub-second timeouts --- lib/wreq.rb | 72 ++++++++++---- lib/wreq_ruby/client.rb | 126 +++++++++++++++-------- src/client.rs | 86 ++++++++-------- src/client/req.rs | 24 +++-- src/lib.rs | 1 + src/time.rs | 56 +++++++++++ test/timeout_test.rb | 214 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 468 insertions(+), 111 deletions(-) create mode 100644 src/time.rs create mode 100644 test/timeout_test.rb diff --git a/lib/wreq.rb b/lib/wreq.rb index 41cd88f..9d15dfe 100644 --- a/lib/wreq.rb +++ b/lib/wreq.rb @@ -50,8 +50,12 @@ module Wreq # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -83,8 +87,12 @@ def self.request(method, url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -116,8 +124,12 @@ def self.get(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -149,8 +161,12 @@ def self.head(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -182,8 +198,12 @@ def self.post(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -215,8 +235,12 @@ def self.put(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -248,8 +272,12 @@ def self.delete(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -281,8 +309,12 @@ def self.options(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -314,8 +346,12 @@ def self.trace(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. diff --git a/lib/wreq_ruby/client.rb b/lib/wreq_ruby/client.rb index d9a4cfb..90f8e0a 100644 --- a/lib/wreq_ruby/client.rb +++ b/lib/wreq_ruby/client.rb @@ -70,31 +70,40 @@ class Client # used to store and retrieve cookies for all requests made by this # client. Typically used together with `cookie_store: true`. # - # @param timeout [Integer, nil] Overall timeout for the entire request + # @param timeout [Numeric, nil] Overall timeout for the entire request # in seconds, including connection establishment, request transmission, - # and response reading. If not set, requests may wait indefinitely. - # - # @param connect_timeout [Integer, nil] Maximum time in seconds to wait - # when establishing a connection to the remote server. This is separate - # from the overall timeout. - # - # @param read_timeout [Integer, nil] Maximum time in seconds to wait - # between reading chunks of data from the server. Applies to each - # read operation, not the entire response. - # - # @param tcp_keepalive [Integer, nil] Time in seconds that a connection - # must be idle before TCP keepalive probes are sent. Helps detect - # broken connections. - # - # @param tcp_keepalive_interval [Integer, nil] Time in seconds between - # individual TCP keepalive probes. Only relevant if tcp_keepalive is set. + # and response reading. Fractional seconds are accepted. The value must + # be finite and non-negative; 0 expires immediately. Nil or omission + # leaves the timeout unset. + # + # @param connect_timeout [Numeric, nil] Maximum time in seconds to wait + # when establishing a connection to the remote server. Fractional seconds + # are accepted. The value must be finite and non-negative; 0 expires + # immediately. Nil or omission leaves the timeout unset. + # + # @param read_timeout [Numeric, nil] Maximum time in seconds to wait + # between reading chunks of data from the server. Fractional seconds are + # accepted. The value must be finite and non-negative; 0 expires + # immediately. Nil or omission leaves the timeout unset. + # + # @param tcp_keepalive [Numeric, nil] Time in seconds that a connection + # must be idle before TCP keepalive probes are sent. Fractional seconds + # are accepted. The value must be finite and non-negative; 0 is passed + # through as a zero duration. Nil or omission leaves the option unset. + # + # @param tcp_keepalive_interval [Numeric, nil] Time in seconds between + # individual TCP keepalive probes. Fractional seconds are accepted. The + # value must be finite and non-negative; 0 is passed through as a zero + # duration. Nil or omission leaves the option unset. # # @param tcp_keepalive_retries [Integer, nil] Number of failed keepalive # probes before the connection is considered dead and closed. # - # @param tcp_user_timeout [Integer, nil] Maximum time in seconds that + # @param tcp_user_timeout [Numeric, nil] Maximum time in seconds that # transmitted data may remain unacknowledged before the connection is - # forcibly closed. Available on Android, Fuchsia, and Linux only. + # forcibly closed. Fractional seconds are accepted. The value must be + # finite and non-negative; 0 is passed through as a zero duration. Nil or + # omission leaves the option unset. Available on Android, Fuchsia, and Linux only. # # @param tcp_nodelay [Boolean, nil] Enable TCP_NODELAY socket option, # which disables Nagle's algorithm. When true, small packets are sent @@ -105,9 +114,10 @@ class Client # allowing the reuse of local addresses in TIME_WAIT state. Useful for # reducing port exhaustion in high-throughput scenarios. # - # @param pool_idle_timeout [Integer, nil] Time in seconds before idle - # connections in the pool are evicted and closed. Helps free up - # resources for long-running applications. + # @param pool_idle_timeout [Numeric, nil] Time in seconds before idle + # connections in the pool are evicted and closed. Fractional seconds are + # accepted. The value must be finite and non-negative; 0 expires idle + # entries immediately. Nil or omission leaves the timeout unset. # # @param pool_max_idle_per_host [Integer, nil] Maximum number of idle # connections to maintain per host in the connection pool. Connections @@ -270,8 +280,12 @@ def self.new(**options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -305,8 +319,12 @@ def request(method, url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -338,8 +356,12 @@ def get(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -371,8 +393,12 @@ def head(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -404,8 +430,12 @@ def post(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -437,8 +467,12 @@ def put(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -470,8 +504,12 @@ def delete(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -503,8 +541,12 @@ def options(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -536,8 +578,12 @@ def trace(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. diff --git a/src/client.rs b/src/client.rs index fc4dd6e..f759584 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4,7 +4,7 @@ mod query; mod req; pub mod resp; -use std::{net::IpAddr, time::Duration}; +use std::net::IpAddr; use ::serde::Deserialize; use magnus::{Module, Object, RModule, Ruby, TryConvert, Value, function, method, typed_data::Obj}; @@ -22,6 +22,7 @@ use crate::{ http::Method, options::{NativeOption, Options}, rt, + time::Duration, }; /// A builder for `Client`. @@ -54,31 +55,38 @@ struct Builder { cookie_provider: NativeOption, // ========= Timeout options ========= - /// The timeout to use for the client. (in seconds) - timeout: Option, - /// The connect timeout to use for the client. (in seconds) - connect_timeout: Option, - /// The read timeout to use for the client. (in seconds) - read_timeout: Option, + /// The timeout to use for the client, in seconds. + #[serde(default)] + timeout: NativeOption, + /// The connect timeout to use for the client, in seconds. + #[serde(default)] + connect_timeout: NativeOption, + /// The read timeout to use for the client, in seconds. + #[serde(default)] + read_timeout: NativeOption, // ========= TCP options ========= - /// Set that all sockets have `SO_KEEPALIVE` set with the supplied duration. (in seconds) - tcp_keepalive: Option, - /// Set the interval between TCP keepalive probes. (in seconds) - tcp_keepalive_interval: Option, + /// Set `SO_KEEPALIVE` with the supplied duration, in seconds. + #[serde(default)] + tcp_keepalive: NativeOption, + /// Set the interval between TCP keepalive probes, in seconds. + #[serde(default)] + tcp_keepalive_interval: NativeOption, /// Set the number of retries for TCP keepalive. tcp_keepalive_retries: Option, - /// Set an optional user timeout for TCP sockets. (in seconds) + /// Set an optional user timeout for TCP sockets, in seconds. + #[serde(default)] #[allow(dead_code)] - tcp_user_timeout: Option, + tcp_user_timeout: NativeOption, /// Set that all sockets have `NO_DELAY` set. tcp_nodelay: Option, /// Set that all sockets have `SO_REUSEADDR` set. tcp_reuse_address: Option, // ========= Connection pool options ========= - /// Set an optional timeout for idle sockets being kept-alive. (in seconds) - pool_idle_timeout: Option, + /// Set an optional timeout for idle pooled sockets, in seconds. + #[serde(default)] + pool_idle_timeout: NativeOption, /// Sets the maximum idle connection per host allowed in the pool. pool_max_idle_per_host: Option, /// Sets the maximum number of connections in the pool. @@ -175,6 +183,16 @@ impl Builder { cookie_provider, Obj => |value| (*value).clone() ); + + // Duration options. + extract_native_option!(options, builder, timeout); + extract_native_option!(options, builder, connect_timeout); + extract_native_option!(options, builder, read_timeout); + extract_native_option!(options, builder, tcp_keepalive); + extract_native_option!(options, builder, tcp_keepalive_interval); + extract_native_option!(options, builder, tcp_user_timeout); + extract_native_option!(options, builder, pool_idle_timeout); + builder .proxy .set(Extractor::::try_convert(options.as_value())?.into_inner()); @@ -273,18 +291,16 @@ impl Client { // TCP options. apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.tcp_keepalive, - tcp_keepalive, - Duration::from_secs + tcp_keepalive ); apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.tcp_keepalive_interval, - tcp_keepalive_interval, - Duration::from_secs + tcp_keepalive_interval ); apply_option!( set_if_some, @@ -294,11 +310,10 @@ impl Client { ); #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.tcp_user_timeout, - tcp_user_timeout, - Duration::from_secs + tcp_user_timeout ); apply_option!(set_if_some, builder, params.tcp_nodelay, tcp_nodelay); apply_option!( @@ -309,35 +324,26 @@ impl Client { ); // Timeout options. + apply_option!(set_if_some_inner, builder, params.timeout, timeout); apply_option!( - set_if_some_map, - builder, - params.timeout, - timeout, - Duration::from_secs - ); - apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.connect_timeout, - connect_timeout, - Duration::from_secs + connect_timeout ); apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.read_timeout, - read_timeout, - Duration::from_secs + read_timeout ); // Pool options. apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.pool_idle_timeout, - pool_idle_timeout, - Duration::from_secs + pool_idle_timeout ); apply_option!( set_if_some, diff --git a/src/client/req.rs b/src/client/req.rs index 0603d9e..cb0045d 100644 --- a/src/client/req.rs +++ b/src/client/req.rs @@ -1,4 +1,4 @@ -use std::{net::IpAddr, time::Duration}; +use std::net::IpAddr; use ::serde::Deserialize; use http::header; @@ -17,6 +17,7 @@ use crate::{ http::{Method, Version}, options::{NativeOption, Options}, rt, + time::Duration, }; /// The parameters for a request. @@ -39,10 +40,12 @@ pub struct Request { interface: Option, /// The timeout to use for the request. - timeout: Option, + #[serde(default)] + timeout: NativeOption, /// The read timeout to use for the request. - read_timeout: Option, + #[serde(default)] + read_timeout: NativeOption, /// The HTTP version to use for the request. #[serde(default)] @@ -133,6 +136,8 @@ impl Request { Obj => |value| (*value).clone() ); extract_native_option!(options, builder, version); + extract_native_option!(options, builder, timeout); + extract_native_option!(options, builder, read_timeout); extract_native_option!(options, builder, headers); extract_native_option!(options, builder, orig_headers); extract_native_option!(options, builder, cookies); @@ -202,19 +207,12 @@ pub fn execute_request>( ); // Timeout options. + apply_option!(set_if_some_inner, builder, request.timeout, timeout); apply_option!( - set_if_some_map, - builder, - request.timeout, - timeout, - Duration::from_secs - ); - apply_option!( - set_if_some_map, + set_if_some_inner, builder, request.read_timeout, - read_timeout, - Duration::from_secs + read_timeout ); // Network options. diff --git a/src/lib.rs b/src/lib.rs index 82d852d..ffe35ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ mod http; mod options; mod rt; mod serde; +mod time; use magnus::{Error, Module, Ruby, Value}; diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..a2ae8c6 --- /dev/null +++ b/src/time.rs @@ -0,0 +1,56 @@ +//! Ruby time-value conversions shared by native binding modules. + +use std::time::Duration as StdDuration; + +use magnus::numeric::NumericValue; +use magnus::{Error, Integer, Ruby, TryConvert, Value, value::ReprValue}; + +use crate::error::argument_error; + +/// A crate-wide duration converted from non-negative Ruby `Numeric` seconds. +/// +/// Ruby integers are converted directly so the full `u64` seconds range is +/// retained. Other numeric values are converted through `f64` and then rounded +/// to the nanosecond precision of [`StdDuration`]. +pub(crate) struct Duration(pub(crate) StdDuration); + +impl TryConvert for Duration { + /// Convert integer or fractional Ruby seconds without string coercion. + /// + /// # Errors + /// + /// Returns `TypeError` for non-numeric values and `ArgumentError` for + /// negative, non-finite, or out-of-range durations. + fn try_convert(value: Value) -> Result { + let ruby = Ruby::get_with(value); + let numeric = NumericValue::try_convert(value)?; + + // `u64::try_convert` starts with `Integer::try_convert`. Probe with + // `from_value` so a fractional Numeric takes the fallback without + // using `TypeError` as control flow or repeating the Integer check. + if let Some(integer) = Integer::from_value(numeric.as_value()) { + return integer + .to_u64() + .map(StdDuration::from_secs) + .map(Self) + .map_err(|_| invalid_duration(&ruby)); + } + + // `Float::try_convert(...).to_f64()` takes a Ruby Float detour. The + // `rb_num2dbl`-backed conversion yields the primitive required by + // `StdDuration` directly from the already-checked Numeric. + f64::try_convert(numeric.as_value()) + .and_then(|seconds| { + StdDuration::try_from_secs_f64(seconds).map_err(|_| invalid_duration(&ruby)) + }) + .map(Self) + } +} + +/// Build the shared Ruby error for a numeric value outside `Duration`'s domain. +fn invalid_duration(ruby: &Ruby) -> Error { + argument_error( + ruby, + "duration must be finite, non-negative, and within the supported range", + ) +} diff --git a/test/timeout_test.rb b/test/timeout_test.rb new file mode 100644 index 0000000..eb734bc --- /dev/null +++ b/test/timeout_test.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require "test_helper" +require "socket" + +class TimeoutTest < Minitest::Test + SUBSECOND_TIMEOUT = 0.25 + SERVER_DELAY = 1.2 + + def test_client_duration_options_accept_numeric_seconds + [1, 0.125, Rational(1, 8), 0, nil].each do |value| + options = client_duration_options.to_h { |name| [name, value] } + + assert_instance_of Wreq::Client, Wreq::Client.new(**options) + end + end + + def test_each_client_duration_option_rejects_negative_seconds + client_duration_options.each do |name| + error = assert_raises(ArgumentError) do + Wreq::Client.new(**{name => -0.25}) + end + + assert_includes error.message, ":#{name}" + end + end + + def test_duration_rejects_invalid_numeric_seconds + invalid_values = [ + -1, + Float::NAN, + Float::INFINITY, + -Float::INFINITY, + Float::MAX, + 2**256 + ] + + invalid_values.each do |value| + error = assert_raises(ArgumentError) do + Wreq::Client.new(timeout: value) + end + + assert_includes error.message, ":timeout" + end + end + + def test_duration_rejects_non_numeric_values + error = assert_raises(TypeError) do + Wreq::Client.new(timeout: "0.25") + end + + assert_includes error.message, ":timeout" + end + + def test_request_duration_options_reject_invalid_values_before_network_io + invalid_values = [ + -1, + -0.25, + Float::NAN, + Float::INFINITY, + -Float::INFINITY, + Float::MAX, + 2**256, + "0.25" + ] + + %i[timeout read_timeout].each do |name| + invalid_values.each do |value| + error_class = value.is_a?(String) ? TypeError : ArgumentError + error = assert_raises(error_class) do + Wreq.get("not a url", **{name => value}) + end + + assert_includes error.message, ":#{name}" + end + end + end + + def test_request_timeouts_accept_integer_and_nil_values + with_http_server do |url| + response = Wreq.get(url, timeout: 1, read_timeout: 1) + + assert_equal 200, response.code + assert_equal "ok", response.text + end + + with_http_server do |url| + response = Wreq.get(url, timeout: nil, read_timeout: nil) + + assert_equal 200, response.code + assert_equal "ok", response.text + end + end + + def test_client_fractional_timeout_preserves_subsecond_value + client = Wreq::Client.new(timeout: SUBSECOND_TIMEOUT) + + with_http_server(response_delay: SERVER_DELAY) do |url| + assert_fractional_timeout { client.get(url) } + end + end + + def test_request_timeout_override_preserves_subsecond_value + client = Wreq::Client.new(timeout: 2) + + with_http_server(response_delay: SERVER_DELAY) do |url| + assert_fractional_timeout do + client.get(url, timeout: SUBSECOND_TIMEOUT) + end + end + end + + def test_request_read_timeout_override_preserves_subsecond_value + client = Wreq::Client.new(read_timeout: 2) + + with_http_server(body_delay: SERVER_DELAY) do |url| + assert_fractional_timeout do + client.get(url, read_timeout: SUBSECOND_TIMEOUT).text + end + end + end + + def test_zero_request_timeouts_expire_immediately + client = Wreq::Client.new + + with_http_server(response_delay: SERVER_DELAY) do |url| + assert_immediate_timeout { client.get(url, timeout: 0) } + end + + with_http_server(body_delay: SERVER_DELAY) do |url| + assert_immediate_timeout do + client.get(url, read_timeout: 0).text + end + end + end + + private + + def client_duration_options + options = %i[ + timeout + connect_timeout + read_timeout + tcp_keepalive + tcp_keepalive_interval + pool_idle_timeout + ] + if RUBY_PLATFORM.match?(/linux|android|fuchsia/) + options << :tcp_user_timeout + end + options + end + + def assert_fractional_timeout + elapsed = measure_elapsed do + assert_raises(Wreq::TimeoutError) { yield } + end + + assert_operator elapsed, :>=, 0.1, + "Fractional timeout fired too early after #{elapsed.round(3)} seconds" + assert_operator elapsed, :<, 0.8, + "Fractional timeout fired too late after #{elapsed.round(3)} seconds" + end + + def assert_immediate_timeout + elapsed = measure_elapsed do + assert_raises(Wreq::TimeoutError) { yield } + end + + assert_operator elapsed, :<, 0.5, + "Zero timeout did not expire immediately (#{elapsed.round(3)} seconds)" + end + + def measure_elapsed + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + yield + Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + end + + def with_http_server(response_delay: 0, body_delay: 0) + server = TCPServer.new("127.0.0.1", 0) + thread = Thread.new do + socket = server.accept + read_request(socket) + sleep response_delay if response_delay.positive? + + socket.write( + "HTTP/1.1 200 OK\r\n" \ + "Content-Length: 2\r\n" \ + "Connection: close\r\n\r\n" + ) + sleep body_delay if body_delay.positive? + socket.write("ok") + rescue IOError, SystemCallError + nil + ensure + socket&.close + server.close unless server.closed? + end + thread.report_on_exception = false + + yield "http://127.0.0.1:#{server.addr[1]}/" + ensure + server&.close unless server&.closed? + thread&.kill + thread&.join(1) + end + + def read_request(socket) + while (line = socket.gets) + break if line == "\r\n" + end + end +end From fca217fabe0211ce080708f3fd7ed338726b6898 Mon Sep 17 00:00:00 2001 From: gngpp Date: Fri, 17 Jul 2026 02:37:47 +0800 Subject: [PATCH 02/11] Clarify timeout and TCP option documentation --- src/client.rs | 14 +++++++------- src/client/req.rs | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/client.rs b/src/client.rs index f759584..5b5f848 100644 --- a/src/client.rs +++ b/src/client.rs @@ -55,26 +55,26 @@ struct Builder { cookie_provider: NativeOption, // ========= Timeout options ========= - /// The timeout to use for the client, in seconds. + /// Overall timeout for a request, including connection and response body. #[serde(default)] timeout: NativeOption, - /// The connect timeout to use for the client, in seconds. + /// Maximum duration allowed to establish a connection. #[serde(default)] connect_timeout: NativeOption, - /// The read timeout to use for the client, in seconds. + /// Maximum idle duration between response body reads. #[serde(default)] read_timeout: NativeOption, // ========= TCP options ========= - /// Set `SO_KEEPALIVE` with the supplied duration, in seconds. + /// Idle duration before TCP keepalive probes begin. #[serde(default)] tcp_keepalive: NativeOption, - /// Set the interval between TCP keepalive probes, in seconds. + /// Interval between TCP keepalive probes. #[serde(default)] tcp_keepalive_interval: NativeOption, /// Set the number of retries for TCP keepalive. tcp_keepalive_retries: Option, - /// Set an optional user timeout for TCP sockets, in seconds. + /// Maximum duration for which transmitted data may remain unacknowledged. #[serde(default)] #[allow(dead_code)] tcp_user_timeout: NativeOption, @@ -84,7 +84,7 @@ struct Builder { tcp_reuse_address: Option, // ========= Connection pool options ========= - /// Set an optional timeout for idle pooled sockets, in seconds. + /// Maximum idle duration before a pooled connection is evicted. #[serde(default)] pool_idle_timeout: NativeOption, /// Sets the maximum idle connection per host allowed in the pool. diff --git a/src/client/req.rs b/src/client/req.rs index cb0045d..353bb63 100644 --- a/src/client/req.rs +++ b/src/client/req.rs @@ -39,11 +39,11 @@ pub struct Request { #[allow(dead_code)] interface: Option, - /// The timeout to use for the request. + /// Overall timeout for this request, overriding the client default. #[serde(default)] timeout: NativeOption, - /// The read timeout to use for the request. + /// Maximum idle duration between body reads, overriding the client default. #[serde(default)] read_timeout: NativeOption, From f8c103351ee211ec91640839e42260958e99d65a Mon Sep 17 00:00:00 2001 From: 0x676e67 Date: Tue, 28 Jul 2026 12:21:32 +0800 Subject: [PATCH 03/11] ci: latest rust toolchain (#150) --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5feb172..adcd6a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,12 +43,14 @@ jobs: steps: - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: ruby/setup-ruby@v1 with: ruby-version: "3.4" bundler-cache: true - - run: bundle exec rb-sys-dock --ruby-versions 4.0,3.4,3.3 --platform ${{ matrix.platform }} --build + - run: bundle exec rb-sys-dock --ruby-versions 4.0,3.4,3.3 --platform ${{ matrix.platform }} --mount-toolchains --build - uses: actions/upload-artifact@v7 with: From 2e4ab8c31537d55ddaeab97fdf65860e9a554c7e Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Thu, 23 Jul 2026 05:29:17 +0000 Subject: [PATCH 04/11] expose tls info on responses optionally --- lib/wreq_ruby/client.rb | 5 ++ lib/wreq_ruby/response.rb | 72 +++++++++++++++++++ src/client.rs | 3 + src/client/resp.rs | 68 +++++++++++++++++- test/tls_info_test.rb | 144 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 test/tls_info_test.rb diff --git a/lib/wreq_ruby/client.rb b/lib/wreq_ruby/client.rb index e2ff2a3..3bd95c9 100644 --- a/lib/wreq_ruby/client.rb +++ b/lib/wreq_ruby/client.rb @@ -133,6 +133,11 @@ class Client # verification. When false, the client will accept any certificate, # including self-signed or expired ones. Should only be disabled # for testing purposes. + # + # @param tls_info [Boolean, nil] Enable collection of TLS certificate + # information on responses. When true, {Response#tls_info} will return + # a {Wreq::TlsInfo} object for HTTPS responses. Collection is opt-in + # because retaining certificate-chain bytes has a cost. Defaults to false. # # @param no_proxy [Boolean, nil] Disable use of any configured proxy # for this client, even if proxy settings are detected from the diff --git a/lib/wreq_ruby/response.rb b/lib/wreq_ruby/response.rb index 8a497db..d2e717b 100644 --- a/lib/wreq_ruby/response.rb +++ b/lib/wreq_ruby/response.rb @@ -177,6 +177,78 @@ def chunks # response.close def close end + + # Get TLS certificate information from the response. + # + # Returns a {Wreq::TlsInfo} object when TLS information collection was + # enabled on the client via +tls_info: true+ and the response was received + # over HTTPS. Returns +nil+ when collection was not enabled, the response + # did not use TLS, or the native transport has no TLS information. + # + # @return [Wreq::TlsInfo, nil] TLS certificate information, or nil + # @example + # client = Wreq::Client.new(tls_info: true) + # response = client.get("https://example.com") + # tls = response.tls_info + # tls.peer_certificate # => DER-encoded binary String + # tls.peer_certificate_chain # => frozen Array of DER binary Strings + def tls_info + end + end + + # TLS certificate information extracted from a response. + # + # This is an immutable value object returned by {Response#tls_info} when + # TLS information collection is enabled on the client. Certificate data + # is DER-encoded and independent of response-body consumption and + # connection-pool reuse. + # + # Callers can pass DER bytes to +OpenSSL::X509::Certificate.new+ for + # parsing, subject/issuer inspection, or fingerprint formatting. + # + # @example Inspect TLS info + # tls = response.tls_info + # tls.peer_certificate # => "\x30\x82..." (DER binary String) + # tls.peer_certificate_chain # => ["\x30\x82...", ...] (frozen Array) + # + # @example Parse with OpenSSL + # cert = OpenSSL::X509::Certificate.new(tls.peer_certificate) + # puts cert.subject + class TlsInfo + # Get the DER-encoded leaf certificate of the peer. + # + # @return [String, nil] DER-encoded certificate as a binary String + # (+Encoding::BINARY+), or +nil+ if unavailable + def peer_certificate + end + + # Get the full peer certificate chain. + # + # The returned array is frozen and contains DER-encoded binary Strings. + # It includes the leaf certificate when the native transport supplies it. + # + # @return [Array, nil] frozen Array of DER-encoded binary Strings, + # or +nil+ if unavailable + def peer_certificate_chain + end + + # Returns a compact string representation for debugging. + # + # Only shows byte counts and certificate counts; no raw certificate + # bytes are included. + # + # @return [String] human-readable representation + # @example + # tls.inspect + # # => "#" + def inspect + end + + # Returns the same representation as {#inspect}. + # + # @return [String] + def to_s + end end end end diff --git a/src/client.rs b/src/client.rs index 6f8bf85..956239a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -95,6 +95,8 @@ struct Builder { // ========= TLS options ========= /// Whether to verify TLS certificates. verify: Option, + /// Whether to collect TLS information on responses. + tls_info: Option, // ========= Network options ========= /// Whether to disable the proxy for the client. @@ -358,6 +360,7 @@ impl Client { // TLS options. apply_option!(set_if_some, builder, params.verify, tls_cert_verification); + apply_option!(set_if_some, builder, params.tls_info, tls_info); // Network options. apply_option!(set_if_some, builder, params.proxy, proxy); diff --git a/src/client/resp.rs b/src/client/resp.rs index 9cc2f98..bf31bf9 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -5,8 +5,10 @@ use bytes::Bytes; use futures_util::TryFutureExt; use http::{Extensions, HeaderMap, response::Response as HttpResponse}; use http_body_util::BodyExt; -use magnus::{Error, Module, RArray, RModule, Ruby, Value, scan_args::scan_args}; +use magnus::{Error, Module, RArray, RModule, RString, Ruby, Value, scan_args::scan_args}; use wreq::Uri; +use wreq::tls::TlsInfo as WreqTlsInfo; +use magnus::value::ReprValue; use crate::{ arch::ProcessLocal, @@ -46,6 +48,45 @@ struct NativeResponseState { extensions: Extensions, } +/// TLS certificate information extracted from a response. +#[magnus::wrap(class = "Wreq::TlsInfo", free_immediately, size)] +struct TlsInfo { + peer_certificate: Option>, + peer_certificate_chain: Option>>, +} + +impl TlsInfo { + /// Get the DER-encoded leaf certificate of the peer as a binary Ruby String. + fn peer_certificate(ruby: &Ruby, rb_self: &Self) -> Option { + rb_self.peer_certificate.as_ref().map(|der| { + ruby.str_from_slice(der) + }) + } + /// Get the full certificate chain as a frozen Array of binary Ruby Strings. + fn peer_certificate_chain(ruby: &Ruby, rb_self: &Self) -> Option { + rb_self.peer_certificate_chain.as_ref().map(|chain| { + let ary = ruby.ary_new_capa(chain.len()); + for cert in chain { + let _ = ary.push(ruby.str_from_slice(cert)); + } + let _: Result = ary.funcall("freeze", ()); + ary + }) + } + + fn inspect(&self) -> String { + let cert_info = match &self.peer_certificate { + Some(der) => format!("peer_certificate=({} bytes)", der.len()), + None => "peer_certificate=nil".to_owned(), + }; + let chain_info = match &self.peer_certificate_chain { + Some(chain) => format!("peer_certificate_chain=({} certs)", chain.len()), + None => "peer_certificate_chain=nil".to_owned(), + }; + format!("#") + } +} + impl Response { /// Create a new [`Response`] instance. pub fn new(response: wreq::Response) -> Self { @@ -180,6 +221,18 @@ impl Response { self.remote_addr.map(|addr| addr.to_string()) } + /// Get TLS certificate information, if available. + fn tls_info(&self) -> Option { + self.extensions.get::().map(|info| { + TlsInfo { + peer_certificate: info.peer_certificate().map(|der| der.to_vec()), + peer_certificate_chain: info + .peer_certificate_chain() + .map(|chain| chain.map(|cert| cert.to_vec()).collect()), + } + }) + } + /// Get the response body as bytes. pub fn bytes(ruby: &Ruby, rb_self: &Self) -> Result { let response = rb_self.response(ruby, false)?; @@ -258,5 +311,18 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { response.define_method("json", magnus::method!(Response::json, 0))?; response.define_method("chunks", magnus::method!(Response::chunks, 0))?; response.define_method("close", magnus::method!(Response::close, 0))?; + response.define_method("tls_info", magnus::method!(Response::tls_info, 0))?; + + let tls_info_class = gem_module.define_class("TlsInfo", ruby.class_object())?; + tls_info_class.define_method( + "peer_certificate", + magnus::method!(TlsInfo::peer_certificate, 0), + )?; + tls_info_class.define_method( + "peer_certificate_chain", + magnus::method!(TlsInfo::peer_certificate_chain, 0), + )?; + tls_info_class.define_method("inspect", magnus::method!(TlsInfo::inspect, 0))?; + tls_info_class.define_method("to_s", magnus::method!(TlsInfo::inspect, 0))?; Ok(()) } diff --git a/test/tls_info_test.rb b/test/tls_info_test.rb new file mode 100644 index 0000000..fc25e00 --- /dev/null +++ b/test/tls_info_test.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require "test_helper" + +class TlsInfoTest < Minitest::Test + # ---- Opt-in behavior ---- + + def test_tls_info_nil_when_not_enabled + response = Wreq.get("#{HTTPBIN_URL}/get") + assert_nil response.tls_info + end + + def test_tls_info_nil_on_default_client + client = Wreq::Client.new + response = client.get("#{HTTPBIN_URL}/get") + assert_nil response.tls_info + end + + def test_tls_info_present_when_enabled + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + refute_nil response.tls_info + assert_instance_of Wreq::TlsInfo, response.tls_info + end + + # ---- Plain HTTP returns nil ---- + + def test_tls_info_nil_for_plain_http + client = Wreq::Client.new(tls_info: true) + response = client.get("http://httpbin.io/get") + assert_nil response.tls_info + end + + # ---- Peer certificate ---- + + def test_peer_certificate_is_binary_string + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + tls = response.tls_info + + cert = tls.peer_certificate + refute_nil cert + assert_instance_of String, cert + assert_equal Encoding::BINARY, cert.encoding + assert cert.bytesize > 0 + end + + # ---- Peer certificate chain ---- + + def test_peer_certificate_chain_is_frozen_array + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + tls = response.tls_info + + chain = tls.peer_certificate_chain + refute_nil chain + assert_instance_of Array, chain + assert chain.frozen?, "certificate chain array must be frozen" + assert chain.length > 0 + end + + def test_peer_certificate_chain_contains_binary_strings + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + chain = response.tls_info.peer_certificate_chain + + chain.each do |cert| + assert_instance_of String, cert + assert_equal Encoding::BINARY, cert.encoding + assert cert.bytesize > 0 + end + end + + def test_peer_certificate_chain_immutable + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + chain = response.tls_info.peer_certificate_chain + + assert_raises(FrozenError) { chain.push("test") } + end + + # ---- Data survives body consumption ---- + + def test_tls_info_available_after_body_read + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + + _body = response.text + tls = response.tls_info + + refute_nil tls + refute_nil tls.peer_certificate + assert tls.peer_certificate.bytesize > 0 + end + + def test_tls_info_available_after_close + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + + response.close + tls = response.tls_info + + refute_nil tls + refute_nil tls.peer_certificate + end + + # ---- Inspect does not leak certificate bytes ---- + + def test_inspect_shows_byte_counts_only + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + tls = response.tls_info + + inspection = tls.inspect + assert_match(/peer_certificate=\(\d+ bytes\)/, inspection) + assert_match(/peer_certificate_chain=\(\d+ certs\)/, inspection) + assert_match(/\A# 0 + assert tls2.peer_certificate.bytesize > 0 + end +end \ No newline at end of file From 62aff70aa7cbc7e5d4732078252dc5f47898769f Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Thu, 23 Jul 2026 05:51:21 +0000 Subject: [PATCH 05/11] fix rust formatting --- src/client/resp.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/client/resp.rs b/src/client/resp.rs index bf31bf9..2c9e9c9 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -5,10 +5,10 @@ use bytes::Bytes; use futures_util::TryFutureExt; use http::{Extensions, HeaderMap, response::Response as HttpResponse}; use http_body_util::BodyExt; +use magnus::value::ReprValue; use magnus::{Error, Module, RArray, RModule, RString, Ruby, Value, scan_args::scan_args}; use wreq::Uri; use wreq::tls::TlsInfo as WreqTlsInfo; -use magnus::value::ReprValue; use crate::{ arch::ProcessLocal, @@ -58,9 +58,10 @@ struct TlsInfo { impl TlsInfo { /// Get the DER-encoded leaf certificate of the peer as a binary Ruby String. fn peer_certificate(ruby: &Ruby, rb_self: &Self) -> Option { - rb_self.peer_certificate.as_ref().map(|der| { - ruby.str_from_slice(der) - }) + rb_self + .peer_certificate + .as_ref() + .map(|der| ruby.str_from_slice(der)) } /// Get the full certificate chain as a frozen Array of binary Ruby Strings. fn peer_certificate_chain(ruby: &Ruby, rb_self: &Self) -> Option { @@ -223,13 +224,11 @@ impl Response { /// Get TLS certificate information, if available. fn tls_info(&self) -> Option { - self.extensions.get::().map(|info| { - TlsInfo { - peer_certificate: info.peer_certificate().map(|der| der.to_vec()), - peer_certificate_chain: info - .peer_certificate_chain() - .map(|chain| chain.map(|cert| cert.to_vec()).collect()), - } + self.extensions.get::().map(|info| TlsInfo { + peer_certificate: info.peer_certificate().map(|der| der.to_vec()), + peer_certificate_chain: info + .peer_certificate_chain() + .map(|chain| chain.map(|cert| cert.to_vec()).collect()), }) } From 4063f04d9435091304d39de4750cb1e5c7659c9d Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Tue, 28 Jul 2026 18:19:40 +0000 Subject: [PATCH 06/11] changes after rebase --- src/client/resp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/resp.rs b/src/client/resp.rs index 2c9e9c9..ea24ea8 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -224,7 +224,7 @@ impl Response { /// Get TLS certificate information, if available. fn tls_info(&self) -> Option { - self.extensions.get::().map(|info| TlsInfo { + self.state.as_ref().extensions.get::().map(|info| TlsInfo { peer_certificate: info.peer_certificate().map(|der| der.to_vec()), peer_certificate_chain: info .peer_certificate_chain() From f20573f408faf0171b5221eb4f5eec77fc4ca845 Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Tue, 28 Jul 2026 18:22:30 +0000 Subject: [PATCH 07/11] fix rust formatting --- src/client/resp.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/client/resp.rs b/src/client/resp.rs index ea24ea8..8a4035b 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -224,12 +224,16 @@ impl Response { /// Get TLS certificate information, if available. fn tls_info(&self) -> Option { - self.state.as_ref().extensions.get::().map(|info| TlsInfo { - peer_certificate: info.peer_certificate().map(|der| der.to_vec()), - peer_certificate_chain: info - .peer_certificate_chain() - .map(|chain| chain.map(|cert| cert.to_vec()).collect()), - }) + self.state + .as_ref() + .extensions + .get::() + .map(|info| TlsInfo { + peer_certificate: info.peer_certificate().map(|der| der.to_vec()), + peer_certificate_chain: info + .peer_certificate_chain() + .map(|chain| chain.map(|cert| cert.to_vec()).collect()), + }) } /// Get the response body as bytes. From f42e534ecf0eb1bb4eaf957b0ad3cf8b43cad95a Mon Sep 17 00:00:00 2001 From: 0x676e67 Date: Wed, 29 Jul 2026 09:43:00 +0800 Subject: [PATCH 08/11] docs: document interrupt handling policy (#151) --- docs/interrupt-handling.md | 133 +++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/interrupt-handling.md diff --git a/docs/interrupt-handling.md b/docs/interrupt-handling.md new file mode 100644 index 0000000..660be00 --- /dev/null +++ b/docs/interrupt-handling.md @@ -0,0 +1,133 @@ +# Interrupt handling policy + +wreq-ruby must not construct or raise Ruby's built-in `Interrupt` to report a +request cancellation. This rule applies to the Rust extension and to Ruby +wrappers in this repository. A pull request that turns a wreq-owned +cancellation into the built-in class must not be merged. + +Represent native cancellation as a Rust value until the Ruby-owned calling +thread has reacquired the GVL. Then map a wreq-owned request cancellation to +`Wreq::InterruptError`: + +```ruby +Wreq::InterruptError < Interrupt +``` + +Keep this class outside `StandardError`. A broad transport rescue such as +`rescue StandardError` must not swallow an interruption. + +## Why `Interrupt` is reserved + +Ruby documents `Interrupt` as the exception raised for an interrupt signal, +usually when the user presses Control-C. Its hierarchy is: + +```text +Exception +└── SignalException + └── Interrupt +``` + +`Interrupt` is not a `StandardError`. Ruby's default `rescue` catches +`StandardError`, so it does not catch `Interrupt` or `Wreq::InterruptError`. +Code that explicitly uses `rescue Interrupt` catches both because +`Wreq::InterruptError` is a subclass. + +The exact built-in class therefore carries Ruby-level control-flow meaning. If +wreq creates that class for its own cancellation, callers cannot tell whether +Ruby delivered an interrupt or the HTTP library cancelled a request. A +library-specific subclass preserves that distinction while keeping the +interruption outside ordinary transport errors. + +## Required behavior + +| Event | wreq-ruby behavior | +| --- | --- | +| Ruby raises its built-in `Interrupt`, including an exception supplied through `Thread#raise` | Propagate the original exception. Do not replace or wrap it. | +| `Thread#kill`, `Thread#terminate`, or `Thread#exit` stops a thread | Let Ruby perform the fatal thread termination. The native unblock callback may request cancellation, but wreq must not translate the event into `Interrupt`. | +| wreq's native cancellation path finishes without a pending Ruby exception | Raise `Wreq::InterruptError`. | +| A connection, timeout, protocol, or other transport operation fails | Raise the matching wreq transport error under `StandardError`. | + +Ruby's implementation also makes an important distinction here. +`Thread#raise` queues the exception chosen by the caller. `Thread#kill` queues +Ruby's internal fatal thread-kill event instead of an `Interrupt` object, and +its termination is asynchronous. Once a no-GVL callback returns, Ruby handles +that fatal event after reacquiring the GVL and before the native call can return +normally to wreq's error mapper. + +## Native no-GVL boundary + +There are two separate rules at this boundary: + +1. A Tokio worker, other Rust background thread, no-GVL callback, or UBF must + not construct or raise any Ruby exception. +2. Rust code running on the Ruby-owned calling thread with the GVL may construct + Ruby exceptions, but it must not turn a wreq-owned cancellation into Ruby's + built-in `Interrupt`. + +Requests run through `rb_thread_call_without_gvl`. Ruby's C API documents this +sequence: + +1. Handle pending interrupts. +2. Release the GVL. +3. Run the native callback. +4. Reacquire the GVL. +5. Handle interrupts received while the callback was running. + +Ruby may call the unblock function, or UBF, when another thread interacts with +the blocked thread. The UBF is a request to stop the native operation. It does +not identify which Ruby exception, if any, is pending. + +The UBF in [`src/gvl.rs`](../src/gvl.rs) must only signal cancellation. It must +not call Ruby APIs or raise an exception while the GVL is released. The request +future returns its result as a Rust value. Only after the no-GVL call returns +to the Ruby-owned thread with the GVL may [`src/rt.rs`](../src/rt.rs) map a +wreq-owned cancellation to the `Wreq::InterruptError` defined in +[`src/error.rs`](../src/error.rs). + +Keep this conversion centralized in `rt::try_block_on`. Request, response, and +body operations may call `try_block_on`, but they must not construct their own +Ruby cancellation exception. + +These forms are forbidden for wreq-owned cancellation: + +```rust +MagnusError::new(ruby.exception_interrupt(), "request interrupted") +``` + +```ruby +raise Interrupt, "request interrupted" +``` + +Using `exception_interrupt` as the parent when defining +`Wreq::InterruptError` is still required. Using it as the class passed to +`MagnusError::new` is not. + +## Review checklist + +- Reject direct construction or raising of Ruby's built-in `Interrupt` for a + wreq-owned cancellation. +- Keep `Wreq::InterruptError` as a direct subclass of `Interrupt`. +- Keep Ruby API calls and exception construction out of the no-GVL callback + and UBF. +- Preserve an exception supplied by Ruby through `Thread#raise`. +- Do not turn `Thread#kill`, `Thread#terminate`, or `Thread#exit` into a new + exception. +- Test the real cancellation path, the exception hierarchy, and the + `StandardError` boundary when changing this code. + +## Ruby references + +- [Ruby `Interrupt`](https://docs.ruby-lang.org/en/3.4/Interrupt.html) explains + that the class represents an interrupt signal, usually Control-C, and + inherits from `SignalException`. +- [Ruby's built-in exception hierarchy](https://docs.ruby-lang.org/en/4.0/Exception.html#class-Exception-label-Built-In+Exception+Class+Hierarchy) + shows that `SignalException` and `StandardError` are separate branches. +- [`Thread#raise`](https://docs.ruby-lang.org/en/4.0/Thread.html#method-i-raise) + raises the caller-supplied exception in another thread. +- [`Thread#kill`](https://docs.ruby-lang.org/en/4.0/Thread.html#method-i-kill) + documents asynchronous termination and its `terminate` and `exit` aliases. +- [`rb_thread_call_without_gvl`](https://docs.ruby-lang.org/capi/en/master/d6/dfb/include_2ruby_2thread_8h.html) + documents interrupt checks, GVL reacquisition, UBF cancellation, and the + restriction on Ruby API calls from no-GVL callbacks. +- [Issue #111](https://github.com/SearchApi/wreq-ruby/issues/111) contains the + original error-hierarchy discussion. From 0b8c1cbf19f6e77a11d4f8d0902866cc6566d927 Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Sun, 2 Aug 2026 08:06:58 +0000 Subject: [PATCH 09/11] refactor to reduce fragmentation --- src/client/resp.rs | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/src/client/resp.rs b/src/client/resp.rs index 8a4035b..aaf3802 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -50,23 +50,21 @@ struct NativeResponseState { /// TLS certificate information extracted from a response. #[magnus::wrap(class = "Wreq::TlsInfo", free_immediately, size)] -struct TlsInfo { - peer_certificate: Option>, - peer_certificate_chain: Option>>, -} +struct TlsInfo(WreqTlsInfo); impl TlsInfo { /// Get the DER-encoded leaf certificate of the peer as a binary Ruby String. fn peer_certificate(ruby: &Ruby, rb_self: &Self) -> Option { rb_self - .peer_certificate - .as_ref() + .0 + .peer_certificate() .map(|der| ruby.str_from_slice(der)) } + /// Get the full certificate chain as a frozen Array of binary Ruby Strings. fn peer_certificate_chain(ruby: &Ruby, rb_self: &Self) -> Option { - rb_self.peer_certificate_chain.as_ref().map(|chain| { - let ary = ruby.ary_new_capa(chain.len()); + rb_self.0.peer_certificate_chain().map(|chain| { + let ary = ruby.ary_new(); for cert in chain { let _ = ary.push(ruby.str_from_slice(cert)); } @@ -76,14 +74,14 @@ impl TlsInfo { } fn inspect(&self) -> String { - let cert_info = match &self.peer_certificate { + let cert_info = match self.0.peer_certificate() { Some(der) => format!("peer_certificate=({} bytes)", der.len()), None => "peer_certificate=nil".to_owned(), }; - let chain_info = match &self.peer_certificate_chain { - Some(chain) => format!("peer_certificate_chain=({} certs)", chain.len()), - None => "peer_certificate_chain=nil".to_owned(), - }; + let chain_info = self.0.peer_certificate_chain().map_or_else( + || "peer_certificate_chain=nil".to_owned(), + |chain| format!("peer_certificate_chain=({} certs)", chain.count()), + ); format!("#") } } @@ -228,12 +226,8 @@ impl Response { .as_ref() .extensions .get::() - .map(|info| TlsInfo { - peer_certificate: info.peer_certificate().map(|der| der.to_vec()), - peer_certificate_chain: info - .peer_certificate_chain() - .map(|chain| chain.map(|cert| cert.to_vec()).collect()), - }) + .cloned() + .map(TlsInfo) } /// Get the response body as bytes. From e1978e837e1552cd29f005514acb2fb399301754 Mon Sep 17 00:00:00 2001 From: gngpp Date: Mon, 3 Aug 2026 09:46:13 +0800 Subject: [PATCH 10/11] refactor(tls): align response metadata API --- docs/fork-safety.md | 14 +-- lib/wreq.rb | 1 + lib/wreq_ruby/client.rb | 10 +- lib/wreq_ruby/response.rb | 74 +++------------ lib/wreq_ruby/tls.rb | 49 ++++++++++ src/arch.rs | 2 +- src/client.rs | 2 +- src/client/resp.rs | 59 +----------- src/lib.rs | 2 + src/tls.rs | 51 +++++++++++ test/support/tls_server.rb | 95 +++++++++++++++++++ test/tls_info_test.rb | 183 ++++++++++--------------------------- 12 files changed, 275 insertions(+), 267 deletions(-) create mode 100644 lib/wreq_ruby/tls.rb create mode 100644 src/tls.rs create mode 100644 test/support/tls_server.rb diff --git a/docs/fork-safety.md b/docs/fork-safety.md index 2824be3..0dfce5c 100644 --- a/docs/fork-safety.md +++ b/docs/fork-safety.md @@ -9,18 +9,20 @@ connections are not safe to reuse. If the parent has already loaded wreq-ruby, native HTTP operations in the child raise `Wreq::ForkError`. This applies to new and existing clients, module -request methods, streaming request bodies, and response body methods. Retrying -the operation in the same child raises the same error. +request methods, streaming request bodies, and response methods backed by native +state. Retrying the operation in the same child raises the same error. Read-only +response metadata such as status, headers, and captured TLS information remains +available. The parent can continue using its clients. When inherited Ruby objects are collected in the child, their native runtime state is left for the operating system to reclaim when the process exits. -## Child processes are unsupported +## HTTP work in forked children is unsupported -A process created with `fork` must not use wreq-ruby, even when it first loads -the extension after the fork. If the parent loaded wreq-ruby, native operations -in the child raise `Wreq::ForkError`. +A process created with `fork` must not start or continue HTTP work through +wreq-ruby, even when it first loads the extension after the fork. If the parent +loaded wreq-ruby, native HTTP operations in the child raise `Wreq::ForkError`. When the extension was not present in the parent, no wreq-ruby state or fork marker reaches the child. The extension cannot reliably distinguish that child diff --git a/lib/wreq.rb b/lib/wreq.rb index 18ccb36..8fca103 100644 --- a/lib/wreq.rb +++ b/lib/wreq.rb @@ -12,6 +12,7 @@ require_relative "wreq_ruby/emulate" require_relative "wreq_ruby/client" require_relative "wreq_ruby/response" +require_relative "wreq_ruby/tls" require_relative "wreq_ruby/body" require_relative "wreq_ruby/header" require_relative "wreq_ruby/error" diff --git a/lib/wreq_ruby/client.rb b/lib/wreq_ruby/client.rb index 3bd95c9..31cbeb1 100644 --- a/lib/wreq_ruby/client.rb +++ b/lib/wreq_ruby/client.rb @@ -133,11 +133,11 @@ class Client # verification. When false, the client will accept any certificate, # including self-signed or expired ones. Should only be disabled # for testing purposes. - # - # @param tls_info [Boolean, nil] Enable collection of TLS certificate - # information on responses. When true, {Response#tls_info} will return - # a {Wreq::TlsInfo} object for HTTPS responses. Collection is opt-in - # because retaining certificate-chain bytes has a cost. Defaults to false. + # + # @param tls_info [Boolean, nil] Retain peer certificate data for HTTPS + # responses. When true, {Wreq::Response#tls_info} may return a + # {Wreq::TlsInfo} object. Disabled by default because retaining + # certificate data uses additional memory. # # @param no_proxy [Boolean, nil] Disable use of any configured proxy # for this client, even if proxy settings are detected from the diff --git a/lib/wreq_ruby/response.rb b/lib/wreq_ruby/response.rb index d2e717b..4985495 100644 --- a/lib/wreq_ruby/response.rb +++ b/lib/wreq_ruby/response.rb @@ -178,76 +178,24 @@ def chunks def close end - # Get TLS certificate information from the response. + # Return TLS information captured for this response. # - # Returns a {Wreq::TlsInfo} object when TLS information collection was - # enabled on the client via +tls_info: true+ and the response was received - # over HTTPS. Returns +nil+ when collection was not enabled, the response - # did not use TLS, or the native transport has no TLS information. + # Returns +nil+ when +tls_info: true+ was not enabled, the response used + # plain HTTP, or the transport supplied no TLS information. Reading or + # closing the response body does not discard captured TLS data. # - # @return [Wreq::TlsInfo, nil] TLS certificate information, or nil + # @return [Wreq::TlsInfo, nil] TLS information for this response, or +nil+ + # when unavailable # @example # client = Wreq::Client.new(tls_info: true) # response = client.get("https://example.com") # tls = response.tls_info - # tls.peer_certificate # => DER-encoded binary String - # tls.peer_certificate_chain # => frozen Array of DER binary Strings - def tls_info - end - end - - # TLS certificate information extracted from a response. - # - # This is an immutable value object returned by {Response#tls_info} when - # TLS information collection is enabled on the client. Certificate data - # is DER-encoded and independent of response-body consumption and - # connection-pool reuse. - # - # Callers can pass DER bytes to +OpenSSL::X509::Certificate.new+ for - # parsing, subject/issuer inspection, or fingerprint formatting. - # - # @example Inspect TLS info - # tls = response.tls_info - # tls.peer_certificate # => "\x30\x82..." (DER binary String) - # tls.peer_certificate_chain # => ["\x30\x82...", ...] (frozen Array) - # - # @example Parse with OpenSSL - # cert = OpenSSL::X509::Certificate.new(tls.peer_certificate) - # puts cert.subject - class TlsInfo - # Get the DER-encoded leaf certificate of the peer. - # - # @return [String, nil] DER-encoded certificate as a binary String - # (+Encoding::BINARY+), or +nil+ if unavailable - def peer_certificate - end - - # Get the full peer certificate chain. # - # The returned array is frozen and contains DER-encoded binary Strings. - # It includes the leaf certificate when the native transport supplies it. - # - # @return [Array, nil] frozen Array of DER-encoded binary Strings, - # or +nil+ if unavailable - def peer_certificate_chain - end - - # Returns a compact string representation for debugging. - # - # Only shows byte counts and certificate counts; no raw certificate - # bytes are included. - # - # @return [String] human-readable representation - # @example - # tls.inspect - # # => "#" - def inspect - end - - # Returns the same representation as {#inspect}. - # - # @return [String] - def to_s + # if tls + # tls.peer_certificate # => DER-encoded binary String + # tls.peer_certificate_chain # => frozen Array of DER binary Strings + # end + def tls_info end end end diff --git a/lib/wreq_ruby/tls.rb b/lib/wreq_ruby/tls.rb new file mode 100644 index 0000000..bed4b6d --- /dev/null +++ b/lib/wreq_ruby/tls.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +unless defined?(Wreq) + module Wreq + # Peer certificate data captured for one HTTPS response. + # + # Instances are returned by {Wreq::Response#tls_info}. Certificate bytes + # remain available after the response body is read or closed, even if the + # connection is later reused. + # + # The returned certificate Strings are Ruby-owned copies. Changing one does + # not alter the stored TLS data or values returned by later calls. The chain + # Array is frozen, but its String elements remain mutable. + # + # Certificates use the DER encoding described by the X.509 profile in + # RFC 5280. + # + # @example Parse the peer certificate with OpenSSL + # require "openssl" + # + # client = Wreq::Client.new(tls_info: true) + # response = client.get("https://example.com") + # der = response.tls_info&.peer_certificate + # + # if der + # certificate = OpenSSL::X509::Certificate.new(der) + # puts certificate.subject + # end + # @see https://www.rfc-editor.org/rfc/rfc5280#section-4.1 X.509 certificate format + class TlsInfo + # Return the peer's leaf certificate. + # + # @return [String, nil] a new DER-encoded String with + # +Encoding::BINARY+, or +nil+ when the transport did not provide one + def peer_certificate + end + + # Return the peer certificate chain. + # + # The Array is frozen. Each element is a new DER-encoded binary String. + # The chain includes the leaf certificate when the transport supplies it. + # + # @return [Array, nil] a frozen Array of certificate copies, or + # +nil+ when the transport did not provide a chain + def peer_certificate_chain + end + end + end +end diff --git a/src/arch.rs b/src/arch.rs index 91d542b..afbbc81 100644 --- a/src/arch.rs +++ b/src/arch.rs @@ -16,7 +16,7 @@ use std::mem::ManuallyDrop; /// system reclaim it when the process exits. /// /// This wrapper only controls destruction. Call [`crate::rt::ensure_current`] -/// before accessing the inner value. +/// before using process-bound state stored inside it. #[derive(Clone)] pub(crate) struct ProcessLocal(ManuallyDrop); diff --git a/src/client.rs b/src/client.rs index 956239a..9a2b5c4 100644 --- a/src/client.rs +++ b/src/client.rs @@ -95,7 +95,7 @@ struct Builder { // ========= TLS options ========= /// Whether to verify TLS certificates. verify: Option, - /// Whether to collect TLS information on responses. + /// Whether to retain peer certificate data on responses. tls_info: Option, // ========= Network options ========= diff --git a/src/client/resp.rs b/src/client/resp.rs index aaf3802..a2f25d4 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -5,10 +5,8 @@ use bytes::Bytes; use futures_util::TryFutureExt; use http::{Extensions, HeaderMap, response::Response as HttpResponse}; use http_body_util::BodyExt; -use magnus::value::ReprValue; -use magnus::{Error, Module, RArray, RModule, RString, Ruby, Value, scan_args::scan_args}; +use magnus::{Error, Module, RArray, RModule, Ruby, Value, scan_args::scan_args}; use wreq::Uri; -use wreq::tls::TlsInfo as WreqTlsInfo; use crate::{ arch::ProcessLocal, @@ -19,6 +17,7 @@ use crate::{ header::Headers, http::{StatusCode, Version}, rt, + tls::TlsInfo, }; /// A response from a request. @@ -48,44 +47,6 @@ struct NativeResponseState { extensions: Extensions, } -/// TLS certificate information extracted from a response. -#[magnus::wrap(class = "Wreq::TlsInfo", free_immediately, size)] -struct TlsInfo(WreqTlsInfo); - -impl TlsInfo { - /// Get the DER-encoded leaf certificate of the peer as a binary Ruby String. - fn peer_certificate(ruby: &Ruby, rb_self: &Self) -> Option { - rb_self - .0 - .peer_certificate() - .map(|der| ruby.str_from_slice(der)) - } - - /// Get the full certificate chain as a frozen Array of binary Ruby Strings. - fn peer_certificate_chain(ruby: &Ruby, rb_self: &Self) -> Option { - rb_self.0.peer_certificate_chain().map(|chain| { - let ary = ruby.ary_new(); - for cert in chain { - let _ = ary.push(ruby.str_from_slice(cert)); - } - let _: Result = ary.funcall("freeze", ()); - ary - }) - } - - fn inspect(&self) -> String { - let cert_info = match self.0.peer_certificate() { - Some(der) => format!("peer_certificate=({} bytes)", der.len()), - None => "peer_certificate=nil".to_owned(), - }; - let chain_info = self.0.peer_certificate_chain().map_or_else( - || "peer_certificate_chain=nil".to_owned(), - |chain| format!("peer_certificate_chain=({} certs)", chain.count()), - ); - format!("#") - } -} - impl Response { /// Create a new [`Response`] instance. pub fn new(response: wreq::Response) -> Self { @@ -220,12 +181,12 @@ impl Response { self.remote_addr.map(|addr| addr.to_string()) } - /// Get TLS certificate information, if available. + /// Return peer certificate data retained for this response. fn tls_info(&self) -> Option { self.state .as_ref() .extensions - .get::() + .get::() .cloned() .map(TlsInfo) } @@ -309,17 +270,5 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { response.define_method("chunks", magnus::method!(Response::chunks, 0))?; response.define_method("close", magnus::method!(Response::close, 0))?; response.define_method("tls_info", magnus::method!(Response::tls_info, 0))?; - - let tls_info_class = gem_module.define_class("TlsInfo", ruby.class_object())?; - tls_info_class.define_method( - "peer_certificate", - magnus::method!(TlsInfo::peer_certificate, 0), - )?; - tls_info_class.define_method( - "peer_certificate_chain", - magnus::method!(TlsInfo::peer_certificate_chain, 0), - )?; - tls_info_class.define_method("inspect", magnus::method!(TlsInfo::inspect, 0))?; - tls_info_class.define_method("to_s", magnus::method!(TlsInfo::inspect, 0))?; Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 82d852d..549a1f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ mod http; mod options; mod rt; mod serde; +mod tls; use magnus::{Error, Module, Ruby, Value}; @@ -98,6 +99,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> { http::include(ruby, &gem_module)?; header::include(ruby, &gem_module)?; cookie::include(ruby, &gem_module)?; + tls::include(ruby, &gem_module)?; client::include(ruby, &gem_module)?; emulate::include(ruby, &gem_module)?; #[cfg(unix)] diff --git a/src/tls.rs b/src/tls.rs new file mode 100644 index 0000000..98f7e22 --- /dev/null +++ b/src/tls.rs @@ -0,0 +1,51 @@ +//! Ruby wrappers for TLS metadata attached to a response. +//! +//! Certificates use the DER encoding described by the X.509 profile in +//! [RFC 5280 section 4.1](https://www.rfc-editor.org/rfc/rfc5280#section-4.1). + +use magnus::{Error, Module, RArray, RModule, RString, Ruby, value::ReprValue}; + +/// Read-only Ruby wrapper around [`wreq::tls::TlsInfo`]. +/// +/// The native value keeps certificate bytes alive independently of the response +/// body. Its `Bytes` buffers are cheap to clone, while accessors copy the data +/// into Ruby-owned Strings so callers cannot mutate the stored metadata. +#[derive(Clone)] +#[magnus::wrap(class = "Wreq::TlsInfo", free_immediately, size)] +pub(crate) struct TlsInfo(pub(crate) wreq::tls::TlsInfo); + +impl TlsInfo { + /// Copy the DER-encoded leaf certificate into a binary Ruby String. + fn peer_certificate(ruby: &Ruby, rb_self: &Self) -> Option { + rb_self + .0 + .peer_certificate() + .map(|der| ruby.str_from_slice(der)) + } + + /// Copy the certificate chain into a frozen Array of binary Ruby Strings. + /// + /// Only the Array is frozen. Its Strings are independent copies and remain + /// mutable in Ruby. + fn peer_certificate_chain(ruby: &Ruby, rb_self: &Self) -> Option { + rb_self.0.peer_certificate_chain().map(|chain| { + let certificates = ruby.ary_from_iter(chain.map(|cert| ruby.str_from_slice(cert))); + certificates.freeze(); + certificates + }) + } +} + +/// Define the `Wreq::TlsInfo` Ruby class and its readers. +pub(crate) fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { + let tls_info_class = gem_module.define_class("TlsInfo", ruby.class_object())?; + tls_info_class.define_method( + "peer_certificate", + magnus::method!(TlsInfo::peer_certificate, 0), + )?; + tls_info_class.define_method( + "peer_certificate_chain", + magnus::method!(TlsInfo::peer_certificate_chain, 0), + )?; + Ok(()) +} diff --git a/test/support/tls_server.rb b/test/support/tls_server.rb new file mode 100644 index 0000000..a8ce995 --- /dev/null +++ b/test/support/tls_server.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "openssl" +require "socket" +require "timeout" + +# A small HTTPS server that serves every expected request on one TLS connection. +module TlsTestServer + RESPONSE_BODY = "ok" + + module_function + + def with_connection(request_count:) + tcp_server = TCPServer.new("127.0.0.1", 0) + context, certificate_der = server_context + ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, context) + outcome = Queue.new + server_thread = Thread.new do + socket = ssl_server.accept + request_lines = [] + + request_count.times do |index| + request_lines << read_request(socket) + connection = (index == request_count - 1) ? "close" : "keep-alive" + socket.write(response(connection)) + socket.flush + end + + outcome << {connections: 1, requests: request_lines} + rescue => error + outcome << error + ensure + socket&.close + end + server_thread.report_on_exception = false + + yield "https://127.0.0.1:#{tcp_server.addr[1]}/", certificate_der + + result = Timeout.timeout(5) { outcome.pop } + raise result if result.is_a?(StandardError) + + result + ensure + tcp_server&.close + server_thread&.join(5) + if server_thread&.alive? + server_thread.kill + server_thread.join + end + end + + def read_request(socket) + request_line = socket.gets + raise EOFError, "client closed before sending a request" unless request_line + + loop do + line = socket.gets + raise EOFError, "client closed while sending headers" unless line + break if line == "\r\n" + end + + request_line + end + private_class_method :read_request + + def response(connection) + [ + "HTTP/1.1 200 OK", + "Content-Length: #{RESPONSE_BODY.bytesize}", + "Connection: #{connection}", + "", + RESPONSE_BODY + ].join("\r\n") + end + private_class_method :response + + def server_context + key = OpenSSL::PKey::RSA.new(2048) + certificate = OpenSSL::X509::Certificate.new + certificate.version = 2 + certificate.serial = 1 + certificate.subject = certificate.issuer = OpenSSL::X509::Name.parse("/CN=127.0.0.1") + certificate.public_key = key.public_key + certificate.not_before = Time.now - 60 + certificate.not_after = Time.now + 3600 + certificate.sign(key, OpenSSL::Digest.new("SHA256")) + + context = OpenSSL::SSL::SSLContext.new.tap do |ssl_context| + ssl_context.cert = certificate + ssl_context.key = key + end + [context, certificate.to_der] + end + private_class_method :server_context +end diff --git a/test/tls_info_test.rb b/test/tls_info_test.rb index fc25e00..60f1d43 100644 --- a/test/tls_info_test.rb +++ b/test/tls_info_test.rb @@ -1,144 +1,55 @@ # frozen_string_literal: true require "test_helper" +require_relative "support/tls_server" class TlsInfoTest < Minitest::Test - # ---- Opt-in behavior ---- - - def test_tls_info_nil_when_not_enabled - response = Wreq.get("#{HTTPBIN_URL}/get") - assert_nil response.tls_info - end - - def test_tls_info_nil_on_default_client - client = Wreq::Client.new - response = client.get("#{HTTPBIN_URL}/get") - assert_nil response.tls_info - end - - def test_tls_info_present_when_enabled - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - refute_nil response.tls_info - assert_instance_of Wreq::TlsInfo, response.tls_info - end - - # ---- Plain HTTP returns nil ---- - - def test_tls_info_nil_for_plain_http - client = Wreq::Client.new(tls_info: true) - response = client.get("http://httpbin.io/get") - assert_nil response.tls_info - end - - # ---- Peer certificate ---- - - def test_peer_certificate_is_binary_string - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - tls = response.tls_info - - cert = tls.peer_certificate - refute_nil cert - assert_instance_of String, cert - assert_equal Encoding::BINARY, cert.encoding - assert cert.bytesize > 0 - end - - # ---- Peer certificate chain ---- - - def test_peer_certificate_chain_is_frozen_array - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - tls = response.tls_info - - chain = tls.peer_certificate_chain - refute_nil chain - assert_instance_of Array, chain - assert chain.frozen?, "certificate chain array must be frozen" - assert chain.length > 0 - end - - def test_peer_certificate_chain_contains_binary_strings - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - chain = response.tls_info.peer_certificate_chain - - chain.each do |cert| - assert_instance_of String, cert - assert_equal Encoding::BINARY, cert.encoding - assert cert.bytesize > 0 + HTTPBIN_HTTP_URL = ENV.fetch("HTTPBIN_HTTP_URL", HTTPBIN_URL.sub(/\Ahttps:/, "http:")) + + def test_tls_info_is_nil_when_disabled_or_request_is_plain_http + default_response = Wreq::Client.new.get("#{HTTPBIN_URL}/get") + plain_response = Wreq::Client.new(tls_info: true).get("#{HTTPBIN_HTTP_URL}/get") + + assert_nil default_response.tls_info + assert_nil plain_response.tls_info + end + + def test_certificate_data_survives_body_lifecycle_on_a_reused_connection + fixture = TlsTestServer.with_connection(request_count: 2) do |base_url, certificate_der| + client = Wreq::Client.new( + tls_info: true, + verify: false, + http1_only: true, + no_proxy: true, + timeout: 5 + ) + + read_response = client.get("#{base_url}read") + assert_equal "ok", read_response.text + read_tls = read_response.tls_info + + closed_response = client.get("#{base_url}close") + closed_response.close + closed_tls = closed_response.tls_info + + assert_instance_of Wreq::TlsInfo, read_tls + certificate = read_tls.peer_certificate + chain = read_tls.peer_certificate_chain + assert_equal certificate_der, certificate + assert_equal Encoding::BINARY, certificate.encoding + assert_equal [certificate_der], chain + assert_equal Encoding::BINARY, chain.first.encoding + assert_predicate chain, :frozen? + assert_empty Wreq::TlsInfo.instance_methods(false) & %i[inspect to_h to_s] + + certificate.clear + assert_equal certificate_der, read_tls.peer_certificate + assert_equal certificate_der, closed_tls.peer_certificate end - end - - def test_peer_certificate_chain_immutable - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - chain = response.tls_info.peer_certificate_chain - - assert_raises(FrozenError) { chain.push("test") } - end - - # ---- Data survives body consumption ---- - - def test_tls_info_available_after_body_read - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - - _body = response.text - tls = response.tls_info - - refute_nil tls - refute_nil tls.peer_certificate - assert tls.peer_certificate.bytesize > 0 - end - - def test_tls_info_available_after_close - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - - response.close - tls = response.tls_info - - refute_nil tls - refute_nil tls.peer_certificate - end - - # ---- Inspect does not leak certificate bytes ---- - - def test_inspect_shows_byte_counts_only - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - tls = response.tls_info - - inspection = tls.inspect - assert_match(/peer_certificate=\(\d+ bytes\)/, inspection) - assert_match(/peer_certificate_chain=\(\d+ certs\)/, inspection) - assert_match(/\A# 0 - assert tls2.peer_certificate.bytesize > 0 + assert_equal( + {connections: 1, requests: ["GET /read HTTP/1.1\r\n", "GET /close HTTP/1.1\r\n"]}, + fixture + ) end -end \ No newline at end of file +end From ee3b1ecc47d9352784f1e0a8d90b64f559f2cebf Mon Sep 17 00:00:00 2001 From: gngpp Date: Wed, 5 Aug 2026 11:35:20 +0800 Subject: [PATCH 11/11] feat(tls): add compact inspection --- examples/tls_info.rb | 27 +++++++++++++++++++++++++++ lib/wreq_ruby/tls.rb | 24 ++++++++++++++++++++++++ test/tls_info_test.rb | 6 +++++- 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 examples/tls_info.rb diff --git a/examples/tls_info.rb b/examples/tls_info.rb new file mode 100644 index 0000000..38b25ff --- /dev/null +++ b/examples/tls_info.rb @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "openssl" +require_relative "../lib/wreq" + +url = ARGV.fetch(0, "https://example.com") +client = Wreq::Client.new(tls_info: true) +response = client.get(url) +tls_info = response.tls_info +response.close + +abort "TLS information is unavailable for #{url}" unless tls_info + +p tls_info + +if (der = tls_info.peer_certificate) + certificate = OpenSSL::X509::Certificate.new(der) + puts "Subject: #{certificate.subject}" + puts "Issuer: #{certificate.issuer}" + puts "Valid from: #{certificate.not_before}" + puts "Valid until: #{certificate.not_after}" +end + +chain = tls_info.peer_certificate_chain +chain_size = chain ? chain.length : "unavailable" +puts "Certificate chain: #{chain_size}" diff --git a/lib/wreq_ruby/tls.rb b/lib/wreq_ruby/tls.rb index bed4b6d..65b4682 100644 --- a/lib/wreq_ruby/tls.rb +++ b/lib/wreq_ruby/tls.rb @@ -47,3 +47,27 @@ def peer_certificate_chain end end end + +# ======================== Ruby API Extensions ======================== + +module Wreq + class TlsInfo + # Return a compact summary for debugging. + # + # The summary reports the leaf certificate size and the number of + # certificates in the chain without printing the DER data. + # + # @return [String] TLS certificate metadata + # @example + # tls_info.inspect + # # => "#" + def inspect + certificate = peer_certificate + chain = peer_certificate_chain + certificate_size = certificate ? "#{certificate.bytesize}B" : "nil" + chain_size = chain ? chain.length : "nil" + + "#<#{self.class} peer_certificate=#{certificate_size} peer_certificate_chain=#{chain_size}>" + end + end +end diff --git a/test/tls_info_test.rb b/test/tls_info_test.rb index 60f1d43..a92b8ff 100644 --- a/test/tls_info_test.rb +++ b/test/tls_info_test.rb @@ -40,7 +40,11 @@ def test_certificate_data_survives_body_lifecycle_on_a_reused_connection assert_equal [certificate_der], chain assert_equal Encoding::BINARY, chain.first.encoding assert_predicate chain, :frozen? - assert_empty Wreq::TlsInfo.instance_methods(false) & %i[inspect to_h to_s] + assert_equal( + "#", + read_tls.inspect + ) + assert_empty Wreq::TlsInfo.instance_methods(false) & %i[to_h to_s] certificate.clear assert_equal certificate_der, read_tls.peer_certificate