From 5488d0219a2280595fe0f7adc1259d3befee9f77 Mon Sep 17 00:00:00 2001 From: gngpp Date: Fri, 17 Jul 2026 02:24:54 +0800 Subject: [PATCH 1/2] 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 2/2] 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,