diff --git a/examples/error.rb b/examples/error.rb new file mode 100644 index 0000000..8d53189 --- /dev/null +++ b/examples/error.rb @@ -0,0 +1,12 @@ +#!/usr/bin/env ruby + +require_relative "../lib/wreq" + +begin + Wreq.get("not-a-valid-url") +rescue Wreq::Error => error + puts "#{error.class}: #{error.message}" + puts "builder: #{error.builder?}" + puts "uri: #{error.uri.inspect}" + puts "status: #{error.status.inspect}" +end diff --git a/lib/wreq_ruby/error.rb b/lib/wreq_ruby/error.rb index f1e42f1..973fafd 100644 --- a/lib/wreq_ruby/error.rb +++ b/lib/wreq_ruby/error.rb @@ -2,171 +2,299 @@ unless defined?(Wreq) module Wreq + # Base class for wreq-ruby runtime errors. + # + # Error remains a RuntimeError so existing rescue handlers keep working. + # Its subclass records one primary category. Predicate methods retain every + # classification reported by the native `wreq::Error`, so more than one can + # be true. For example, a request timeout raises TimeoutError while both + # `timeout?` and `request?` return true. + # + # wreq-ruby records the native checks as facts, then chooses the exception + # class using its own rules. Native body, TLS, and status kinds take + # precedence over details found in their cause chains. The remaining errors + # are classified as connection reset, timeout, proxy connect failure, + # destination connect failure, or RequestError, in that order. These + # transport details do not depend on `request?` also being true. + # + # Use the predicates when code needs every native fact. Errors created by + # the binding return false for all of them. New facts may be exposed as + # predicates without changing the exception class for existing failures. + # + # @example Rescue any wreq-ruby runtime error + # begin + # Wreq.get("not-a-valid-url") + # rescue Wreq::Error => error + # warn "#{error.class}: #{error.message}" + # warn "invalid request" if error.builder? + # end + class Error < RuntimeError + # Get the URI recorded by the native error. + # + # This value may contain credentials, query parameters, or fragments. + # Error messages and `inspect` omit the URI. Redact it before logging it + # explicitly. + # + # @return [String, nil] Frozen URI string, if one was recorded + attr_reader :uri + + # Get the HTTP status recorded by the native error. + # + # @return [Integer, nil] HTTP status code, if one was recorded + attr_reader :status + + # @return [Boolean] Whether the native error came from a builder + def builder? + end + + # @return [Boolean] Whether the native error came from redirect handling + def redirect? + end + + # @return [Boolean] Whether the native error represents an HTTP status + def status? + end + + # A request timeout uses TimeoutError even when this is also a connection + # or proxy connection error. + # + # @return [Boolean] Whether the native error is related to a timeout + def timeout? + end + + # This may be true on connection and timeout subclasses because those + # errors occur while sending a request. + # + # @return [Boolean] Whether the native error is related to a request + def request? + end + + # @return [Boolean] Whether the native error occurred while acquiring a + # connection to the destination + def connect? + end + + # @return [Boolean] Whether the native error occurred while connecting + # through a proxy + def proxy_connect? + end + + # @return [Boolean] Whether the native error is a connection reset + def connection_reset? + end + + # @return [Boolean] Whether the native error is related to a body + def body? + end + + # @return [Boolean] Whether the native error is related to TLS + def tls? + end + + # @return [Boolean] Whether the native error is related to decoding + def decoding? + end + + # @return [Boolean] Whether the native error is related to an upgrade + def upgrade? + end + end + + # Raised when Ruby interrupts a native request wait. + # + # This inherits from Interrupt instead of Error, so `rescue StandardError` + # does not swallow the interrupt. + # + # @example Handle an interrupted request separately + # begin + # Wreq.get("https://example.com", timeout: 30) + # rescue Wreq::InterruptError + # warn "request interrupted" + # rescue Wreq::Error => error + # warn error.message + # end # Keep interruption outside StandardError so a broad transport rescue # never swallows a Ruby interrupt. class InterruptError < Interrupt; end - # System-level and runtime errors - - # Memory allocation failed. - class MemoryError < StandardError; end + # Raised when single-use native state was already consumed or is borrowed. + # + # @example A closed response no longer has a readable body + # response = Wreq.get("https://example.com") + # response.close + # response.bytes # Raises Wreq::MemoryError + class MemoryError < Error; end - # The child process inherited wreq-ruby from its parent. + # Raised when a forked child tries to use inherited native state. # - # Tokio worker threads do not survive fork, and inherited pooled - # connections are not safe to reuse. This error is raised before a child - # can access that state. + # Tokio worker threads and pooled connections cannot be reused after fork. # - # @example - # Process.fork do - # Wreq::Client.new # Raises if the parent loaded wreq-ruby. + # @example Native operations are rejected in an inherited child + # pid = Process.fork do + # begin + # Wreq::Client.new + # rescue Wreq::ForkError => error + # warn error.message + # end # end + # Process.wait(pid) + # # @see https://github.com/SearchApi/wreq-ruby/blob/main/docs/fork-safety.md - class ForkError < RuntimeError; end + class ForkError < Error; end - # Network connection errors - - # Connection to the server failed. + # Raised when the client cannot acquire a usable connection to the + # destination server. # - # Raised when the client cannot establish a connection to the server. + # If the native error reports both a destination connection failure and a + # timeout, Wreq::TimeoutError is raised and `connect?` remains true. A + # system proxy or VPN that accepts the connection but never responds may + # instead appear as a general request timeout. # - # @example + # @example Handle a destination connection failure + # client = Wreq::Client.new(no_proxy: true) # begin - # client.get("http://localhost:9999") - # rescue Wreq::ConnectionError => e - # puts "Connection failed: #{e.message}" - # retry_with_backoff + # client.get("http://127.0.0.1:1") + # rescue Wreq::ConnectError => error + # warn "connection failed: #{error.message}" # end - class ConnectionError < StandardError; end + class ConnectError < Error; end - # Proxy Connection to the server failed. + # Raised when the client cannot establish a connection through the + # configured proxy. This includes failures while connecting to the proxy or + # negotiating a proxy tunnel. # - # Raised when the client cannot establish a connection to the proxy server. - # @example + # If the native error reports both a proxy connection failure and a timeout, + # Wreq::TimeoutError is raised and `proxy_connect?` remains true. + # + # @example Handle a proxy connection failure # begin - # client.get("http://example.com", proxy: "http://invalid-proxy:8080") - # rescue Wreq::ProxyConnectionError => e - # puts "Proxy connection failed: #{e.message}" - # retry_with_different_proxy + # Wreq.get( + # "https://example.com", + # proxy: "http://127.0.0.1:1" + # ) + # rescue Wreq::ProxyConnectError => error + # warn "proxy connection failed: #{error.message}" # end - class ProxyConnectionError < StandardError; end + class ProxyConnectError < Error; end - # Connection was reset by the server. - # - # Raised when the server closes the connection unexpectedly. + # Raised when a peer resets the connection. # - # @example - # rescue Wreq::ConnectionResetError => e - # puts "Connection reset: #{e.message}" + # @example Handle a reset while streaming a response + # response = Wreq.get("https://example.com") + # begin + # File.open("response.bin", "wb") do |file| + # response.chunks { |chunk| file.write(chunk) } + # end + # rescue Wreq::ConnectionResetError => error + # warn "connection reset: #{error.message}" # end - class ConnectionResetError < StandardError; end + class ConnectionResetError < Error; end - # TLS/SSL error occurred. + # Raised when native TLS setup fails while constructing a client. # - # Raised when there's an error with TLS/SSL, such as certificate - # verification failure or protocol mismatch. + # The current Ruby API does not expose certificate or identity inputs that + # can deliberately trigger this error. TLS handshake and certificate + # verification failures happen while connecting and normally raise + # Wreq::ConnectError instead. # - # @example + # @example Distinguish TLS setup errors from connection errors # begin - # client.get("https://self-signed.badssl.com") - # rescue Wreq::TlsError => e - # puts "TLS error: #{e.message}" + # Wreq::Client.new(verify: true).get("https://example.com") + # rescue Wreq::TlsError => error + # warn "TLS setup failed: #{error.message}" + # rescue Wreq::ConnectError => error + # warn "TLS connection failed: #{error.message}" # end - class TlsError < StandardError; end + class TlsError < Error; end - # HTTP protocol and request/response errors - - # Request failed. + # Raised for a request failure without a more specific error subclass. # - # Generic error for request failures that don't fit other categories. + # Connection reset and timeout causes use their corresponding subclasses + # first. A reset takes precedence if both predicates are present. Other + # proxy and destination connection failures use their connection subclasses + # before this fallback. # - # @example - # rescue Wreq::RequestError => e - # puts "Request failed: #{e.message}" + # @example Rescue the native fallback request category + # client = Wreq::Client.new + # begin + # client.get("https://example.com") + # rescue Wreq::RequestError => error + # warn "request failed: #{error.message}" # end - class RequestError < StandardError; end + class RequestError < Error; end - # HTTP status code indicates an error. + # Raised when Response#raise_for_status! sees a 4xx or 5xx response. # - # Raised when the server returns an error status code (4xx or 5xx). + # Requests return error responses normally until this opt-in check is made. + # The inherited `status` reader returns the integer HTTP status. # # @example + # client = Wreq::Client.new # begin - # response = client.get("https://httpbin.io/status/404") - # rescue Wreq::StatusError => e - # puts "HTTP error: #{e.message}" - # # e.response contains the full response + # client.get("https://httpbin.io/status/404").raise_for_status! + # rescue Wreq::StatusError => error + # warn "HTTP #{error.status}: #{error.message}" # end - class StatusError < StandardError; end + class StatusError < Error; end - # Redirect handling failed. + # Raised when redirect handling fails, such as after too many redirects. # - # Raised when too many redirects occur or redirect logic fails. - # - # @example + # @example Limit the number of redirects + # client = Wreq::Client.new(allow_redirects: true, max_redirects: 3) # begin - # client = Wreq::Client.new(allow_redirects: true, max_redirects: 3) # client.get("https://httpbin.io/redirect/10") - # rescue Wreq::RedirectError => e - # puts "Too many redirects: #{e.message}" + # rescue Wreq::RedirectError => error + # warn "redirect failed: #{error.message}" # end - class RedirectError < StandardError; end + class RedirectError < Error; end - # Request timed out. + # Raised when a request operation exceeds its timeout. # - # Raised when the request exceeds the configured timeout. + # This includes timeouts while connecting to the destination or proxy. Check + # `connect?` or `proxy_connect?` to see whether the native error also + # identifies the connect phase. `request?` can be true on the same error. # - # @example + # @example Handle a request timeout + # client = Wreq::Client.new(timeout: 1) # begin - # client = Wreq::Client.new(timeout: 5) # client.get("https://httpbin.io/delay/10") - # rescue Wreq::TimeoutError => e - # puts "Request timed out: #{e.message}" - # retry_with_longer_timeout + # rescue Wreq::TimeoutError => error + # warn "request timed out: #{error.message}" # end - class TimeoutError < StandardError; end + class TimeoutError < Error; end - # Data processing and encoding errors - - # Response body processing failed. - # - # Raised when there's an error reading or processing the response body. + # Raised while sending, reading, or streaming an HTTP body. # - # @example - # rescue Wreq::BodyError => e - # puts "Body error: #{e.message}" + # @example Handle a body error while streaming + # response = Wreq.get("https://example.com") + # begin + # File.open("response.bin", "wb") do |file| + # response.chunks { |chunk| file.write(chunk) } + # end + # rescue Wreq::BodyError => error + # warn "body failed: #{error.message}" # end - class BodyError < StandardError; end + class BodyError < Error; end - # Decoding response failed. - # - # Raised when response content cannot be decoded (e.g., invalid UTF-8, - # malformed JSON, corrupted compression). + # Raised when a response body cannot be decoded or parsed. # - # @example + # @example Fall back to bytes when a response is not valid JSON + # response = Wreq.get("https://example.com") # begin - # response = client.get("https://example.com/invalid-utf8") - # response.text # May raise DecodingError - # rescue Wreq::DecodingError => e - # puts "Decoding error: #{e.message}" - # # Fall back to binary data - # data = response.body + # data = response.json + # rescue Wreq::DecodingError + # data = response.bytes # end - class DecodingError < StandardError; end + class DecodingError < Error; end - # Configuration and builder errors - - # A native client or request configuration could not be built. - # - # Raised when validated Ruby options cannot be represented by the native - # builder or request body. + # Raised when client, request, header, or body configuration is invalid. # - # @example + # @example Handle an invalid request URL # begin - # client = Wreq::Client.new(proxy: "invalid://") - # rescue Wreq::BuilderError => e - # puts "Invalid configuration: #{e.message}" + # Wreq.get("not-a-valid-url") + # rescue Wreq::BuilderError => error + # warn "invalid request: #{error.message}" # end - class BuilderError < StandardError; end + class BuilderError < Error; end end end diff --git a/lib/wreq_ruby/response.rb b/lib/wreq_ruby/response.rb index 4985495..c107359 100644 --- a/lib/wreq_ruby/response.rb +++ b/lib/wreq_ruby/response.rb @@ -2,14 +2,12 @@ unless defined?(Wreq) module Wreq - # HTTP response object containing status, headers, and body. + # An HTTP response returned by wreq-ruby. # - # This class wraps a native Rust implementation providing efficient - # access to HTTP response data including status codes, headers, body - # content, and streaming capabilities. - # - # Body methods raise Wreq::ForkError if the child inherited wreq-ruby from - # its parent. + # Response metadata can be read repeatedly. Body helpers either buffer the + # body for reuse or stream it once. Body methods and {#raise_for_status!} + # raise Wreq::ForkError in a child that inherited the extension from its + # parent. # # @example Basic response handling # response = client.get("https://api.example.com") @@ -22,8 +20,8 @@ module Wreq # # @example Streaming response # response = client.get("https://example.com/large-file") - # response.stream.each do |chunk| - # # Process chunk + # File.open("download.bin", "wb") do |file| + # response.chunks { |chunk| file.write(chunk) } # end class Response # Get the HTTP status code as an integer. @@ -43,6 +41,20 @@ def code def status end + # Return this response or raise for a 4xx or 5xx status. + # + # Requests do not raise for HTTP status codes by default. This opt-in + # check leaves the response body available. + # + # @return [Wreq::Response] The same response for a non-error status + # @raise [Wreq::StatusError] If the status is in the 4xx or 5xx range + # @raise [Wreq::ForkError] If the child inherited wreq-ruby from its parent + # @example + # response = client.get("https://example.com/missing") + # response.raise_for_status! + def raise_for_status! + end + # Get the HTTP protocol version used. # # @return [Wreq::Version] HTTP version (HTTP/1.1, HTTP/2, etc.) diff --git a/src/client/resp.rs b/src/client/resp.rs index a2f25d4..db2223a 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -5,7 +5,7 @@ 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, Ruby, Value, scan_args::scan_args, typed_data::Obj}; use wreq::Uri; use crate::{ @@ -72,6 +72,14 @@ impl Response { } } + /// Build a body-free native response for its status classification logic. + fn response_for_status(&self) -> wreq::Response { + let mut response = HttpResponse::new(Bytes::new()); + *response.status_mut() = self.status.0; + *response.extensions_mut() = self.state.as_ref().extensions.clone(); + wreq::Response::from(response) + } + /// Internal method to get the wreq::Response, optionally streaming the body. fn response(&self, ruby: &Ruby, stream: bool) -> Result { rt::ensure_current(ruby)?; @@ -135,6 +143,19 @@ impl Response { self.status } + /// Return this response unless its status is a client or server error. + /// + /// Delegates classification to [`wreq::Response::error_for_status_ref`] + /// without consuming the response body. + pub fn raise_for_status(ruby: &Ruby, rb_self: Obj) -> Result, Error> { + rt::ensure_current(ruby)?; + rb_self + .response_for_status() + .error_for_status_ref() + .map_err(|err| wreq_error(ruby, err))?; + Ok(rb_self) + } + /// Get the response HTTP version. #[inline] pub fn version(&self) -> Version { @@ -254,6 +275,10 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { let response = gem_module.define_class("Response", ruby.class_object())?; response.define_method("code", magnus::method!(Response::code, 0))?; response.define_method("status", magnus::method!(Response::status, 0))?; + response.define_method( + "raise_for_status!", + magnus::method!(Response::raise_for_status, 0), + )?; response.define_method("version", magnus::method!(Response::version, 0))?; response.define_method("url", magnus::method!(Response::url, 0))?; response.define_method( diff --git a/src/error.rs b/src/error.rs index 1620a40..00553b6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,14 +1,18 @@ use std::{ + borrow::Cow, cell::{BorrowError, BorrowMutError}, fmt, }; use magnus::{ - Error as MagnusError, RModule, Ruby, error::ErrorType, exception::ExceptionClass, prelude::*, - value::Lazy, + Attr, Class, Error as MagnusError, Exception, RModule, RObject, Ruby, TryConvert, + error::ErrorType, exception::ExceptionClass, prelude::*, value::Lazy, }; use tokio::sync::mpsc::error::SendError; +const ERROR_PREDICATES_IVAR: &str = "wreq_error_predicates"; +type ErrorPredicateBits = u64; + const RACE_CONDITION_ERROR_MSG: &str = r#"Due to Rust's memory management with borrowing, you cannot use certain instances multiple times as they may be consumed. @@ -35,60 +39,260 @@ macro_rules! define_exception { } macro_rules! initialize_exception { - ($ruby:expr, $module:expr, $name:ident, $ruby_name:literal, $parent_method:ident) => {{ - $module.define_error($ruby_name, $ruby.$parent_method())?; + ($ruby:expr, $module:expr, $name:ident, $ruby_name:literal, $parent:expr) => {{ + $module.define_error($ruby_name, $parent)?; Lazy::force(&$name, $ruby); }}; } -macro_rules! map_wreq_error { - ($ruby:expr, $err:expr, $msg:expr, $($check_method:ident => $exception:ident),* $(,)?) => { - { +macro_rules! define_native_error_predicates { + ( + $( + $predicate:ident [$role:ident]: + $native_method:ident as $ruby_method:ident + ),+ $(,)? + ) => { + /// How a native predicate participates in the Ruby error contract. + #[cfg(test)] + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum ErrorPredicateRole { + NativeKind, + TransportDetail, + Diagnostic, + } + + /// Predicates captured from a native `wreq::Error`. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + #[repr(u8)] + enum ErrorPredicate { + $($predicate),+ + } + + impl ErrorPredicate { + /// Every predicate retained in Ruby exception metadata. + const ALL: &'static [Self] = &[$(Self::$predicate),+]; + + /// Return this predicate's position in the compact Ruby metadata. + const fn mask(self) -> ErrorPredicateBits { + 1 << (self as u8) + } + + /// Return how this native fact participates in the Ruby contract. + #[cfg(test)] + const fn role(self) -> ErrorPredicateRole { + match self { + $(Self::$predicate => ErrorPredicateRole::$role,)+ + } + } + + /// Evaluate this predicate before the native error is consumed. + fn matches_wreq(self, error: &wreq::Error) -> bool { + match self { + $(Self::$predicate => error.$native_method(),)+ + } + } + } + + const _: () = + assert!(ErrorPredicate::ALL.len() <= ErrorPredicateBits::BITS as usize); + + $( + fn $native_method(rb_self: RObject) -> Result { + error_has_predicate(rb_self, ErrorPredicate::$predicate) + } + )+ + + /// Define idiomatic Ruby predicate methods on `Wreq::Error`. + fn include_error_predicates(class: ExceptionClass) -> Result<(), MagnusError> { $( - if $err.$check_method() { - return MagnusError::new($ruby.get_inner(&$exception), $msg); + class.define_method( + concat!(stringify!($ruby_method), "?"), + magnus::method!($native_method, 0), + )?; + )+ + Ok(()) + } + }; +} + +macro_rules! define_ruby_error_categories { + ( + $( + $category:ident => $class:ident $(($ruby_name:literal))? + ),+ $(,)? + ) => { + /// Stable exception categories owned by the Ruby API. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum RubyErrorCategory { + $($category),+ + } + + impl RubyErrorCategory { + /// Return the Ruby exception class for this stable category. + fn error_class(self) -> &'static Lazy { + match self { + $(Self::$category => &$class,)+ } - )* - MagnusError::new($ruby.exception_runtime_error(), $msg) + } + } + + $( + $(define_exception!($class, $ruby_name, exception_runtime_error);)? + )+ + + /// Define and retain every mapped Ruby exception class. + fn initialize_mapped_errors( + ruby: &Ruby, + gem_module: &RModule, + parent: ExceptionClass, + ) -> Result<(), MagnusError> { + $( + $( + initialize_exception!(ruby, gem_module, $class, $ruby_name, parent); + )? + )+ + Ok(()) } }; } -// System-level and runtime errors -define_exception!(MEMORY, "MemoryError", exception_runtime_error); -define_exception!(FORK_ERROR, "ForkError", exception_runtime_error); +// wreq keeps its error kind private. Keep its mutually exclusive kind predicates +// separate from transport details, which inspect the source chain and may overlap. +// Each entry maps the native method before `as` to the Ruby predicate after it. +// Classification order is declared separately as part of the Ruby contract. +define_native_error_predicates! { + Builder [NativeKind]: is_builder as builder, + Body [NativeKind]: is_body as body, + Tls [NativeKind]: is_tls as tls, + Decode [NativeKind]: is_decode as decoding, + Redirect [NativeKind]: is_redirect as redirect, + Status [NativeKind]: is_status as status, + Upgrade [NativeKind]: is_upgrade as upgrade, + Request [NativeKind]: is_request as request, + ConnectionReset [TransportDetail]: + is_connection_reset as connection_reset, + Timeout [TransportDetail]: is_timeout as timeout, + ProxyConnect [TransportDetail]: + is_proxy_connect as proxy_connect, + Connect [TransportDetail]: is_connect as connect, +} -// Network connection errors -define_exception!(CONNECTION_ERROR, "ConnectionError", exception_runtime_error); -define_exception!( - PROXY_CONNECTION_ERROR, - "ProxyConnectionError", - exception_runtime_error -); -define_exception!( - CONNECTION_RESET_ERROR, - "ConnectionResetError", - exception_runtime_error -); -define_exception!(TLS_ERROR, "TlsError", exception_runtime_error); - -// HTTP protocol and request/response errors -define_exception!(REQUEST_ERROR, "RequestError", exception_runtime_error); -define_exception!(STATUS_ERROR, "StatusError", exception_runtime_error); -define_exception!(REDIRECT_ERROR, "RedirectError", exception_runtime_error); -define_exception!(TIMEOUT_ERROR, "TimeoutError", exception_runtime_error); - -// Data processing and encoding errors -define_exception!(BODY_ERROR, "BodyError", exception_runtime_error); -define_exception!(DECODING_ERROR, "DecodingError", exception_runtime_error); - -// Configuration and builder errors -define_exception!(BUILDER_ERROR, "BuilderError", exception_runtime_error); +define_ruby_error_categories! { + Base => WREQ_ERROR, + Builder => BUILDER_ERROR("BuilderError"), + Body => BODY_ERROR("BodyError"), + Tls => TLS_ERROR("TlsError"), + Decoding => DECODING_ERROR("DecodingError"), + Redirect => REDIRECT_ERROR("RedirectError"), + Status => STATUS_ERROR("StatusError"), + Request => REQUEST_ERROR("RequestError"), + ConnectionReset => CONNECTION_RESET_ERROR("ConnectionResetError"), + Timeout => TIMEOUT_ERROR("TimeoutError"), + ProxyConnect => PROXY_CONNECT_ERROR("ProxyConnectError"), + Connect => CONNECT_ERROR("ConnectError"), +} + +/// Stable mapping from native facts to Ruby exception categories. +/// +/// Non-request kinds keep their existing precedence. Source-chain details are +/// then classified independently of `is_request()`, with the generic request +/// category retained only as a fallback. This order belongs to the Ruby API. +const RUBY_ERROR_CLASSIFICATION: &[(ErrorPredicate, RubyErrorCategory)] = &[ + (ErrorPredicate::Builder, RubyErrorCategory::Builder), + (ErrorPredicate::Body, RubyErrorCategory::Body), + (ErrorPredicate::Tls, RubyErrorCategory::Tls), + (ErrorPredicate::Decode, RubyErrorCategory::Decoding), + (ErrorPredicate::Redirect, RubyErrorCategory::Redirect), + (ErrorPredicate::Status, RubyErrorCategory::Status), + (ErrorPredicate::Upgrade, RubyErrorCategory::Base), + ( + ErrorPredicate::ConnectionReset, + RubyErrorCategory::ConnectionReset, + ), + (ErrorPredicate::Timeout, RubyErrorCategory::Timeout), + ( + ErrorPredicate::ProxyConnect, + RubyErrorCategory::ProxyConnect, + ), + (ErrorPredicate::Connect, RubyErrorCategory::Connect), + (ErrorPredicate::Request, RubyErrorCategory::Request), +]; + +/// Native facts exposed to Ruby without changing the exception class. +/// +/// Add new overlapping predicates here unless a major release intentionally +/// changes which exception existing rescue clauses receive. +#[cfg(test)] +const RUBY_DIAGNOSTIC_PREDICATES: &[ErrorPredicate] = &[]; + +/// Native error facts retained after consuming a wreq error. +#[derive(Clone, Copy, Default)] +struct NativeErrorFacts(ErrorPredicateBits); + +impl NativeErrorFacts { + /// Restore predicates from compact Ruby metadata. + const fn from_bits(bits: ErrorPredicateBits) -> Self { + Self(bits) + } + + /// Return the compact representation stored on the Ruby exception. + const fn bits(self) -> ErrorPredicateBits { + self.0 + } + + /// Return whether the set contains a native predicate. + const fn contains(self, predicate: ErrorPredicate) -> bool { + self.0 & predicate.mask() != 0 + } + + /// Include a predicate when its native check succeeds. + #[must_use] + const fn include_if(mut self, predicate: ErrorPredicate, include: bool) -> Self { + if include { + self.0 |= predicate.mask(); + } + self + } + + /// Classify captured facts using the binding-owned Ruby contract. + fn ruby_category(self) -> RubyErrorCategory { + RUBY_ERROR_CLASSIFICATION + .iter() + .find_map(|&(predicate, category)| self.contains(predicate).then_some(category)) + .unwrap_or(RubyErrorCategory::Base) + } +} + +impl From<&wreq::Error> for NativeErrorFacts { + /// Snapshot every native predicate before consuming the wreq error. + fn from(error: &wreq::Error) -> Self { + ErrorPredicate::ALL + .iter() + .copied() + .fold(Self::default(), |predicates, predicate| { + predicates.include_if(predicate, predicate.matches_wreq(error)) + }) + } +} + +/// Native error details retained after converting a wreq error to Ruby. +struct ErrorMetadata<'a> { + uri: Option<&'a str>, + status: Option, + facts: NativeErrorFacts, +} + +// Stable roots for native errors. +define_exception!(WREQ_ERROR, "Error", exception_runtime_error); // Keep interruption outside StandardError so a broad transport rescue // never swallows a Ruby interrupt. define_exception!(INTERRUPT_ERROR, "InterruptError", exception_interrupt); +// System-level and runtime errors +define_exception!(MEMORY, "MemoryError", exception_runtime_error); +define_exception!(FORK_ERROR, "ForkError", exception_runtime_error); + /// Memory error constant pub fn memory_error(ruby: &Ruby) -> MagnusError { MagnusError::new(ruby.get_inner(&MEMORY), RACE_CONDITION_ERROR_MSG) @@ -190,19 +394,17 @@ pub fn json_serialization_error(ruby: &Ruby, err: MagnusError) -> MagnusError { ) } -/// Prefix a Magnus error while preserving its original Ruby exception class. -pub(crate) fn contextualize_magnus_error( - err: MagnusError, - context: fmt::Arguments<'_>, -) -> MagnusError { +/// Prefix a Magnus error while preserving its Ruby exception class and cause. +pub fn contextualize_magnus_error(err: MagnusError, context: fmt::Arguments<'_>) -> MagnusError { match err.error_type() { ErrorType::Error(class, message) => { MagnusError::new(*class, format!("{context}: {message}")) } - ErrorType::Exception(exception) => MagnusError::new( - exception.exception_class(), - format!("{context}: {exception}"), - ), + ErrorType::Exception(exception) => { + let contextualized: Result = + exception.funcall("exception", (format!("{context}: {exception}"),)); + contextualized.map_or_else(|error| error, MagnusError::from) + } ErrorType::Jump(_) => err, } } @@ -213,37 +415,77 @@ pub fn option_value_error(option: &str, err: MagnusError) -> MagnusError { } /// Build an `ArgumentError` from a validation message. -pub fn argument_error(ruby: &Ruby, message: impl Into) -> MagnusError { - MagnusError::new(ruby.exception_arg_error(), message.into()) +pub fn argument_error(ruby: &Ruby, message: impl Into>) -> MagnusError { + MagnusError::new(ruby.exception_arg_error(), message) } /// Builds a Ruby `RangeError` from a validation message. -pub fn range_error(ruby: &Ruby, message: impl Into) -> MagnusError { - MagnusError::new(ruby.exception_range_error(), message.into()) +pub fn range_error(ruby: &Ruby, message: impl Into>) -> MagnusError { + MagnusError::new(ruby.exception_range_error(), message) } /// Build a `TypeError` from a conversion message. -pub fn type_error(ruby: &Ruby, message: impl Into) -> MagnusError { - MagnusError::new(ruby.exception_type_error(), message.into()) +pub fn type_error(ruby: &Ruby, message: impl Into>) -> MagnusError { + MagnusError::new(ruby.exception_type_error(), message) } -/// Map [`wreq::Error`] to corresponding [`magnus::Error`] + +/// Select the Ruby exception class from the binding-owned category. +fn wreq_error_class(ruby: &Ruby, facts: NativeErrorFacts) -> ExceptionClass { + ruby.get_inner(facts.ruby_category().error_class()) +} + +/// Read one native predicate from a Ruby error, defaulting to false. +fn error_has_predicate(rb_self: RObject, predicate: ErrorPredicate) -> Result { + rb_self + .ivar_get::<_, Option>(ERROR_PREDICATES_IVAR) + .map(|bits| bits.is_some_and(|bits| NativeErrorFacts::from_bits(bits).contains(predicate))) +} + +/// Construct a Ruby exception and attach captured native error metadata. +fn error_with_metadata( + ruby: &Ruby, + class: ExceptionClass, + message: String, + metadata: ErrorMetadata<'_>, +) -> MagnusError { + match class.new_instance((message,)).and_then(|exception| { + let object = RObject::try_convert(exception.as_value())?; + object.ivar_set(ERROR_PREDICATES_IVAR, metadata.facts.bits())?; + + if let Some(uri) = metadata.uri { + let uri = ruby.str_new(uri); + uri.freeze(); + object.ivar_set("@uri", uri)?; + } + + if let Some(status) = metadata.status { + object.ivar_set("@status", status.as_u16())?; + } + + Ok(exception) + }) { + Ok(exception) => exception.into(), + Err(error) => error, + } +} + +/// Map [`wreq::Error`] to corresponding [`magnus::Error`]. pub fn wreq_error(ruby: &Ruby, err: wreq::Error) -> MagnusError { - let error_msg = err.to_string(); - map_wreq_error!( + let facts = NativeErrorFacts::from(&err); + let class = wreq_error_class(ruby, facts); + let uri = err.uri().map(ToString::to_string); + let status = err.status(); + let message = err.without_uri().to_string(); + + error_with_metadata( ruby, - err, - error_msg, - is_builder => BUILDER_ERROR, - is_body => BODY_ERROR, - is_tls => TLS_ERROR, - is_connection_reset => CONNECTION_RESET_ERROR, - is_connect => CONNECTION_ERROR, - is_proxy_connect => PROXY_CONNECTION_ERROR, - is_decode => DECODING_ERROR, - is_redirect => REDIRECT_ERROR, - is_timeout => TIMEOUT_ERROR, - is_status => STATUS_ERROR, - is_request => REQUEST_ERROR, + class, + message, + ErrorMetadata { + uri: uri.as_deref(), + status, + facts, + }, ) } @@ -253,103 +495,131 @@ pub fn wreq_error(ruby: &Ruby, err: wreq::Error) -> MagnusError { /// /// Returns the Ruby exception raised while defining an error class. pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), MagnusError> { - initialize_exception!( - ruby, - gem_module, - MEMORY, - "MemoryError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - FORK_ERROR, - "ForkError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - CONNECTION_ERROR, - "ConnectionError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - PROXY_CONNECTION_ERROR, - "ProxyConnectionError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - CONNECTION_RESET_ERROR, - "ConnectionResetError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - TLS_ERROR, - "TlsError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - REQUEST_ERROR, - "RequestError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - STATUS_ERROR, - "StatusError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - REDIRECT_ERROR, - "RedirectError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - TIMEOUT_ERROR, - "TimeoutError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - BODY_ERROR, - "BodyError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - DECODING_ERROR, - "DecodingError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - BUILDER_ERROR, - "BuilderError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - INTERRUPT_ERROR, - "InterruptError", - exception_interrupt - ); + let error_class = gem_module.define_error("Error", ruby.exception_runtime_error())?; + error_class.define_attr("uri", Attr::Read)?; + error_class.define_attr("status", Attr::Read)?; + include_error_predicates(error_class)?; + Lazy::force(&WREQ_ERROR, ruby); + + gem_module.define_error("InterruptError", ruby.exception_interrupt())?; + Lazy::force(&INTERRUPT_ERROR, ruby); + + initialize_exception!(ruby, gem_module, MEMORY, "MemoryError", error_class); + initialize_exception!(ruby, gem_module, FORK_ERROR, "ForkError", error_class); + initialize_mapped_errors(ruby, gem_module, error_class)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::{ + ErrorPredicate, ErrorPredicateRole, NativeErrorFacts, RUBY_DIAGNOSTIC_PREDICATES, + RUBY_ERROR_CLASSIFICATION, RubyErrorCategory, + }; + + fn facts(entries: &[ErrorPredicate]) -> NativeErrorFacts { + entries + .iter() + .copied() + .fold(NativeErrorFacts::default(), |facts, predicate| { + facts.include_if(predicate, true) + }) + } + + #[test] + fn error_predicate_bits_are_unique_and_round_trip() { + let facts = ErrorPredicate::ALL.iter().copied().fold( + NativeErrorFacts::default(), + |facts, predicate| { + assert!(!facts.contains(predicate)); + facts.include_if(predicate, true) + }, + ); + + assert_eq!( + ErrorPredicate::ALL.len(), + facts.bits().count_ones() as usize + ); + + let restored = NativeErrorFacts::from_bits(facts.bits()); + for &predicate in ErrorPredicate::ALL { + assert!(restored.contains(predicate)); + } + } + + #[test] + fn ruby_error_contract_covers_every_predicate_once() { + let mut seen = NativeErrorFacts::default(); + + for &(predicate, _) in RUBY_ERROR_CLASSIFICATION { + assert_ne!(ErrorPredicateRole::Diagnostic, predicate.role()); + assert!( + !seen.contains(predicate), + "duplicate predicate: {predicate:?}" + ); + seen = seen.include_if(predicate, true); + } + + for &predicate in RUBY_DIAGNOSTIC_PREDICATES { + assert_eq!(ErrorPredicateRole::Diagnostic, predicate.role()); + assert!( + !seen.contains(predicate), + "duplicate predicate: {predicate:?}" + ); + seen = seen.include_if(predicate, true); + } + + assert_eq!(ErrorPredicate::ALL.len(), seen.bits().count_ones() as usize); + + for &predicate in RUBY_DIAGNOSTIC_PREDICATES { + assert_eq!(RubyErrorCategory::Base, facts(&[predicate]).ruby_category()); + } + } + + #[test] + fn ruby_error_classification_is_owned_by_the_binding() { + for &(predicate, category) in RUBY_ERROR_CLASSIFICATION { + assert_eq!(category, facts(&[predicate]).ruby_category()); + } + + assert_eq!(RubyErrorCategory::Base, facts(&[]).ruby_category()); + + for &(kind, kind_category) in RUBY_ERROR_CLASSIFICATION { + if kind.role() != ErrorPredicateRole::NativeKind || kind == ErrorPredicate::Request { + continue; + } + + for &(detail, _) in RUBY_ERROR_CLASSIFICATION { + if detail.role() == ErrorPredicateRole::TransportDetail { + assert_eq!( + kind_category, + facts(&[kind, detail]).ruby_category(), + "native kind {kind:?} must take precedence over {detail:?}" + ); + } + } + } + + for (index, &(detail, detail_category)) in RUBY_ERROR_CLASSIFICATION.iter().enumerate() { + if detail.role() != ErrorPredicateRole::TransportDetail { + continue; + } + + assert_eq!( + detail_category, + facts(&[ErrorPredicate::Request, detail]).ruby_category(), + "transport detail {detail:?} must not depend on the request kind" + ); + + for &(lower_priority, _) in &RUBY_ERROR_CLASSIFICATION[index + 1..] { + if lower_priority.role() == ErrorPredicateRole::TransportDetail { + assert_eq!( + detail_category, + facts(&[detail, lower_priority]).ruby_category(), + "transport detail {detail:?} must take precedence over {lower_priority:?}" + ); + } + } + } + } +} diff --git a/test/error_handling_test.rb b/test/error_handling_test.rb index e229dba..2d37a2f 100644 --- a/test/error_handling_test.rb +++ b/test/error_handling_test.rb @@ -32,13 +32,12 @@ def test_request_interruption_raises_interrupt_error request_thread&.join(1) end - def test_network_error_handling - # Try to connect to a non-existent domain + def test_connect_error_handling + # wreq classifies DNS failures as connect errors. response = Wreq.get("https://definitely-not-a-real-domain-12345.com") flunk "Expected network error but got response: #{response.code}" rescue => e - assert_instance_of Wreq::ConnectionError, e - # Network errors should be caught and wrapped appropriately + assert_instance_of Wreq::ConnectError, e end def test_invalid_url_handling @@ -97,7 +96,7 @@ def test_empty_response_json end end - def test_proxy_error_handling + def test_proxy_connect_error_handling invalid_proxies = [ "http://invalid.proxy:8080", "https://invalid.proxy:8080", @@ -111,11 +110,11 @@ def test_proxy_error_handling invalid_proxies.each do |proxy| target_urls.each do |url| Wreq.get(url, proxy: proxy, timeout: 5) - flunk "Expected proxy connection error but got response" + flunk "Expected proxy connect error but got response" rescue => e assert( - e.is_a?(Wreq::ProxyConnectionError) || e.is_a?(Wreq::RequestError), - "Expected ProxyConnectionError or RequestError, got #{e.class}: #{e.message}" + e.is_a?(Wreq::ProxyConnectError) || e.is_a?(Wreq::RequestError), + "Expected ProxyConnectError or RequestError, got #{e.class}: #{e.message}" ) end end diff --git a/test/error_hierarchy_test.rb b/test/error_hierarchy_test.rb new file mode 100644 index 0000000..d90ec02 --- /dev/null +++ b/test/error_hierarchy_test.rb @@ -0,0 +1,327 @@ +require "test_helper" +require "socket" +require "timeout" + +class ErrorHierarchyTest < Minitest::Test + REGULAR_ERROR_NAMES = %i[ + MemoryError + ForkError + ConnectError + ProxyConnectError + ConnectionResetError + TlsError + RequestError + StatusError + RedirectError + TimeoutError + BodyError + DecodingError + BuilderError + ].freeze + NATIVE_ERROR_PREDICATES = %i[ + builder? + redirect? + status? + timeout? + request? + connect? + proxy_connect? + connection_reset? + body? + tls? + decoding? + upgrade? + ].freeze + + def test_regular_errors_share_stable_root + assert_equal RuntimeError, Wreq::Error.superclass + assert_operator Wreq::Error, :<, StandardError + + REGULAR_ERROR_NAMES.each do |name| + assert_equal Wreq::Error, Wreq.const_get(name).superclass + end + refute Wreq.const_defined?(:ConnectionError, false) + refute Wreq.const_defined?(:ProxyConnectionError, false) + + error = Wreq::MemoryError.new + assert_nil error.uri + assert_nil error.status + refute_respond_to error, :connection? + refute_respond_to error, :proxy_connection? + NATIVE_ERROR_PREDICATES.each do |predicate| + assert_equal false, error.public_send(predicate) + end + end + + def test_root_and_specific_errors_can_be_rescued + root_error = begin + Wreq.get("not-a-valid-url") + rescue Wreq::Error => error + error + end + + assert_instance_of Wreq::BuilderError, root_error + assert_predicate root_error, :builder? + assert_nil root_error.status + assert_equal [:builder?], active_native_predicates(root_error) + assert_raises(Wreq::BuilderError) { Wreq.get("not-a-valid-url") } + end + + def test_binding_generated_errors_have_no_native_predicates + error = assert_raises(Wreq::BuilderError) { Wreq::Headers.new(Object.new) } + + assert_empty active_native_predicates(error) + end + + def test_upstream_request_error_contract + client = Wreq::Client.new(no_proxy: true) + + with_invalid_tls_server do |url| + error = assert_raises(Wreq::ConnectError) do + client.get(url, timeout: 1) + end + + assert_equal %i[request? connect?], active_native_predicates(error) + end + + with_status_server(502) do |proxy| + error = assert_raises(Wreq::ProxyConnectError) do + Wreq.get( + "https://contract.invalid/", + proxy:, + timeout: 1 + ) + end + + assert_equal %i[request? proxy_connect?], active_native_predicates(error) + end + + with_hanging_server do |url, _accepted| + error = assert_raises(Wreq::TimeoutError) { client.get(url, timeout: 1) } + + assert_equal %i[timeout? request?], active_native_predicates(error) + end + end + + def test_interrupt_error_stays_outside_standard_error + assert_equal Interrupt, Wreq::InterruptError.superclass + refute_operator Wreq::InterruptError, :<, StandardError + + error = assert_raises(Interrupt) do + raise Wreq::InterruptError, "request interrupted" + end + assert_instance_of Wreq::InterruptError, error + end + + def test_request_interruption_raises_interrupt_error + request_thread = nil + with_hanging_server do |url, accepted| + request_thread = Thread.new do + Wreq.get(url, timeout: 60) + rescue Interrupt, StandardError => error + error + end + request_thread.report_on_exception = false + + Timeout.timeout(5) { accepted.pop } + request_thread.wakeup + + assert request_thread.join(5), "Interrupted request thread should stop" + + error = request_thread.value + assert_instance_of Wreq::InterruptError, error + refute_kind_of StandardError, error + end + ensure + request_thread&.kill + request_thread&.join(1) + end + + def test_raise_for_status_returns_same_non_error_response + {200 => "ok", 302 => "redirect"}.each do |status, body| + with_status_server(status, body:) do |url| + response = Wreq.get(url) + + assert_same response, response.raise_for_status! + assert_equal body, response.text + end + end + end + + def test_raise_for_status_exposes_status_without_consuming_body + {404 => "missing", 503 => "unavailable"}.each do |status, body| + with_status_server(status, body:) do |url| + response = Wreq.get("#{url}?token=response-secret#fragment-secret") + error = assert_raises(Wreq::StatusError) { response.raise_for_status! } + + assert_kind_of Wreq::Error, error + assert_instance_of Integer, error.status + assert_equal status, error.status + assert_equal response.url, error.uri + assert_predicate error.uri, :frozen? + assert_predicate error, :status? + assert_equal [:status?], active_native_predicates(error) + refute_respond_to error, :kind + refute_respond_to error, :retryable? + refute_includes error.message, "response-secret" + refute_includes error.inspect, "response-secret" + assert_equal body, response.text + end + end + end + + def test_native_error_messages_hide_sensitive_request_data + port = closed_local_port + error = assert_raises(Wreq::Error) do + Wreq.get( + "http://uri-user:uri-password@127.0.0.1:#{port}/private?token=query-secret#fragment-secret", + proxy: "http://proxy-user:proxy-secret@127.0.0.1:#{port}", + headers: {"Authorization" => "Bearer authorization-secret"}, + cookies: {"session" => "cookie-secret"}, + timeout: 1 + ) + end + + assert_instance_of String, error.uri + assert_predicate error.uri, :frozen? + assert_includes error.uri, "query-secret" + assert NATIVE_ERROR_PREDICATES.any? { |predicate| error.public_send(predicate) } + assert_nil error.status + NATIVE_ERROR_PREDICATES.each do |predicate| + assert_includes [true, false], error.public_send(predicate) + end + + [ + "uri-user", + "uri-password", + "query-secret", + "fragment-secret", + "proxy-user", + "proxy-secret", + "authorization-secret", + "cookie-secret" + ].each do |secret| + refute_includes error.message, secret + refute_includes error.inspect, secret + end + end + + def test_option_conversion_preserves_exception_cause + source = Object.new + source.define_singleton_method(:to_a) do + raise ArgumentError, "original conversion failure" + rescue ArgumentError => cause + raise IOError, "header conversion failed", cause: cause + end + + error = assert_raises(IOError) { Wreq::Client.new(headers: source) } + + assert_includes error.message, ":headers" + assert_instance_of ArgumentError, error.cause + assert_equal "original conversion failure", error.cause.message + end + + def test_option_context_preserves_native_error_metadata + error = assert_raises(Wreq::BuilderError) { Wreq::Client.new(proxy: "://") } + + assert_includes error.message, ":proxy" + assert_equal [:builder?], active_native_predicates(error) + end + + private + + def active_native_predicates(error) + NATIVE_ERROR_PREDICATES.select { |predicate| error.public_send(predicate) } + end + + def closed_local_port + server = TCPServer.new("127.0.0.1", 0) + server.addr[1] + ensure + server&.close + end + + def with_invalid_tls_server + server = TCPServer.new("127.0.0.1", 0) + thread = Thread.new do + socket = server.accept + header = socket.read(5) + payload_size = header.byteslice(3, 2).unpack1("n") + socket.read(payload_size) + + # Reply to the ClientHello with a fatal handshake_failure alert. + # https://www.rfc-editor.org/rfc/rfc8446#section-6 + socket.write [0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x28].pack("C*") + socket.close_write + rescue IOError, SystemCallError + nil + ensure + socket&.close unless socket&.closed? + end + thread.report_on_exception = false + + yield "https://127.0.0.1:#{server.addr[1]}/" + ensure + server&.close unless server&.closed? + thread&.join(1) + end + + def with_hanging_server + server = TCPServer.new("127.0.0.1", 0) + accepted = Queue.new + thread = Thread.new do + socket = server.accept + accepted << true + sleep + rescue IOError, SystemCallError + nil + ensure + socket&.close unless socket&.closed? + end + thread.report_on_exception = false + + yield "http://127.0.0.1:#{server.addr[1]}/", accepted + ensure + server&.close unless server&.closed? + thread&.kill + thread&.join(1) + end + + def with_status_server(status, body: "") + reason = { + 200 => "OK", + 204 => "No Content", + 302 => "Found", + 404 => "Not Found", + 502 => "Bad Gateway", + 503 => "Service Unavailable" + }.fetch(status) + server = TCPServer.new("127.0.0.1", 0) + port = server.addr[1] + thread = Thread.new do + socket = server.accept + begin + while (line = socket.gets) + break if line == "\r\n" + end + socket.write "HTTP/1.1 #{status} #{reason}\r\n" + socket.write "Content-Type: text/plain\r\n" + socket.write "Content-Length: #{body.bytesize}\r\n" + socket.write "Connection: close\r\n\r\n" + socket.write body + ensure + socket.close unless socket.closed? + end + rescue IOError, SystemCallError + nil + ensure + server.close unless server.closed? + end + thread.report_on_exception = false + + yield "http://127.0.0.1:#{port}/" + ensure + server&.close unless server&.closed? + thread&.join(5) + end +end diff --git a/test/fork_test.rb b/test/fork_test.rb index 7fc7b1d..45853dd 100644 --- a/test/fork_test.rb +++ b/test/fork_test.rb @@ -17,6 +17,7 @@ class ForkTest < Minitest::Test fresh_client inherited_client inherited_response + inherited_response_status inherited_response_text inherited_response_chunks inherited_response_close diff --git a/test/scripts/fork_safety.rb b/test/scripts/fork_safety.rb index 7593b40..8cc4819 100644 --- a/test/scripts/fork_safety.rb +++ b/test/scripts/fork_safety.rb @@ -81,6 +81,7 @@ def build_inherited_objects(client, url) expect_fork_error("fresh_client") { Wreq::Client.new } expect_fork_error("inherited_client") { client.get(url) } expect_fork_error("inherited_response") { inherited_objects[2].bytes } +expect_fork_error("inherited_response_status") { inherited_objects[2].raise_for_status! } expect_fork_error("inherited_response_text") { inherited_objects[2].text(1) } expect_fork_error("inherited_response_chunks") { inherited_objects[2].chunks } expect_fork_error("inherited_response_close") { inherited_objects[2].close }