diff --git a/examples/error.rb b/examples/error.rb new file mode 100644 index 0000000..8a39b69 --- /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 "URI: #{error.uri.inspect}" + puts "Status: #{error.status.inspect}" + puts "#{error.class}: #{error.message}" + puts "Native builder error: #{error.is_builder}" +end diff --git a/lib/wreq_ruby/error.rb b/lib/wreq_ruby/error.rb index 745433c..dd2c86e 100644 --- a/lib/wreq_ruby/error.rb +++ b/lib/wreq_ruby/error.rb @@ -2,10 +2,86 @@ unless defined?(Wreq) module Wreq + # Base class for regular errors raised by wreq-ruby. + # + # This remains a RuntimeError so existing broad rescue handlers continue + # to work. Predicate methods describe a captured native wreq::Error rather + # than the Ruby exception class, and more than one predicate may be true. + # Errors created entirely by the binding return false for every predicate. + class Error < RuntimeError + # The URI associated with the native error. + # + # This explicit accessor may contain credentials or sensitive query + # parameters. Error messages and inspection output omit it, so avoid + # logging this value without redacting it first. + # + # @return [String, nil] frozen URI string, if one was recorded + attr_reader :uri + + # The response status associated with the 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 is_builder + end + + # @return [Boolean] whether the native error came from redirect handling + def is_redirect + end + + # @return [Boolean] whether the native error represents an HTTP status + def is_status + end + + # @return [Boolean] whether the native error is related to a timeout + def is_timeout + end + + # @return [Boolean] whether the native error is related to a request + def is_request + end + + # @return [Boolean] whether the native error is related to connecting + def is_connect + end + + # @return [Boolean] whether the native error is related to proxy connection + def is_proxy_connect + end + + # @return [Boolean] whether the native error is a connection reset + def is_connection_reset + end + + # @return [Boolean] whether the native error is related to a body + def is_body + end + + # @return [Boolean] whether the native error is related to TLS + def is_tls + end + + # @return [Boolean] whether the native error is related to decoding + def is_decode + end + + # @return [Boolean] whether the native error is related to an upgrade + def is_upgrade + end + end + + # Raised when a native request wait is interrupted. + # + # InterruptError stays outside StandardError so broad application rescues + # do not swallow Ruby interrupts. + class InterruptError < Interrupt; end + # System-level and runtime errors # Memory allocation failed. - class MemoryError < StandardError; end + class MemoryError < Error; end # The native extension was inherited from a parent process. # @@ -17,7 +93,7 @@ class MemoryError < StandardError; end # Process.fork do # Wreq::Client.new # Raises when the parent loaded wreq-ruby. # end - class ForkError < RuntimeError; end + class ForkError < Error; end # Network connection errors @@ -32,7 +108,7 @@ class ForkError < RuntimeError; end # puts "Connection failed: #{e.message}" # retry_with_backoff # end - class ConnectionError < StandardError; end + class ConnectionError < Error; end # Proxy Connection to the server failed. # @@ -44,7 +120,7 @@ class ConnectionError < StandardError; end # puts "Proxy connection failed: #{e.message}" # retry_with_different_proxy # end - class ProxyConnectionError < StandardError; end + class ProxyConnectionError < Error; end # Connection was reset by the server. # @@ -54,7 +130,7 @@ class ProxyConnectionError < StandardError; end # rescue Wreq::ConnectionResetError => e # puts "Connection reset: #{e.message}" # end - class ConnectionResetError < StandardError; end + class ConnectionResetError < Error; end # TLS/SSL error occurred. # @@ -67,7 +143,7 @@ class ConnectionResetError < StandardError; end # rescue Wreq::TlsError => e # puts "TLS error: #{e.message}" # end - class TlsError < StandardError; end + class TlsError < Error; end # HTTP protocol and request/response errors @@ -79,20 +155,21 @@ class TlsError < StandardError; end # rescue Wreq::RequestError => e # puts "Request failed: #{e.message}" # end - class RequestError < StandardError; end + class RequestError < Error; end # HTTP status code indicates an error. # - # Raised when the server returns an error status code (4xx or 5xx). + # Raised by Response#raise_for_status! for a 4xx or 5xx response. Requests + # continue to return these responses normally until that method is called. # # @example # begin # response = client.get("https://httpbin.io/status/404") + # response.raise_for_status! # rescue Wreq::StatusError => e - # puts "HTTP error: #{e.message}" - # # e.response contains the full response + # puts "HTTP #{e.status}: #{e.message}" # end - class StatusError < StandardError; end + class StatusError < Error; end # Redirect handling failed. # @@ -105,7 +182,7 @@ class StatusError < StandardError; end # rescue Wreq::RedirectError => e # puts "Too many redirects: #{e.message}" # end - class RedirectError < StandardError; end + class RedirectError < Error; end # Request timed out. # @@ -119,7 +196,7 @@ class RedirectError < StandardError; end # puts "Request timed out: #{e.message}" # retry_with_longer_timeout # end - class TimeoutError < StandardError; end + class TimeoutError < Error; end # Data processing and encoding errors @@ -131,7 +208,7 @@ class TimeoutError < StandardError; end # rescue Wreq::BodyError => e # puts "Body error: #{e.message}" # end - class BodyError < StandardError; end + class BodyError < Error; end # Decoding response failed. # @@ -147,7 +224,7 @@ class BodyError < StandardError; end # # Fall back to binary data # data = response.body # end - class DecodingError < StandardError; end + class DecodingError < Error; end # Configuration and builder errors @@ -162,6 +239,6 @@ class DecodingError < StandardError; end # rescue Wreq::BuilderError => e # puts "Invalid configuration: #{e.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 fe816fa..d0875ba 100644 --- a/lib/wreq_ruby/response.rb +++ b/lib/wreq_ruby/response.rb @@ -43,6 +43,18 @@ def code def status end + # Raise for a client or server error status. + # + # This check is opt-in and does not consume the response body. + # + # @return [Wreq::Response] This response for non-error statuses + # @raise [Wreq::StatusError] for a 4xx or 5xx status + # @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 10b3811..c814770 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::{ @@ -63,6 +63,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.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)?; @@ -123,6 +131,18 @@ 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> { + 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 { @@ -230,6 +250,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 81a8ed7..d5da40f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,14 +1,17 @@ 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"; + 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,64 +38,162 @@ 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),* $(,)?) => { - { - $( - if $err.$check_method() { - return MagnusError::new($ruby.get_inner(&$exception), $msg); +macro_rules! define_error_mapping { + ($($predicate:ident: $method:ident => $class:ident $(($ruby_name:literal))?),+ $(,)?) => { + /// Native predicates ordered by Ruby exception class priority. + #[derive(Clone, Copy)] + #[repr(u8)] + enum ErrorPredicate { + $($predicate),+ + } + + impl ErrorPredicate { + const CLASSIFICATION_ORDER: &'static [Self] = &[$(Self::$predicate),+]; + + /// Return this predicate's position in the compact Ruby metadata. + const fn mask(self) -> u16 { + 1_u16 << (self as u8) + } + + /// Evaluate this predicate before the native error is consumed. + fn matches_wreq(self, error: &wreq::Error) -> bool { + match self { + $(Self::$predicate => error.$method(),)+ + } + } + + /// Return the Ruby class selected when this predicate has priority. + fn error_class(self) -> &'static Lazy { + match self { + $(Self::$predicate => &$class,)+ } - )* - MagnusError::new($ruby.exception_runtime_error(), $msg) + } + } + + const _: () = + assert!(ErrorPredicate::CLASSIFICATION_ORDER.len() <= u16::BITS as usize); + + $( + $(define_exception!($class, $ruby_name, exception_runtime_error);)? + )+ + + $( + fn $method(rb_self: RObject) -> Result { + error_has_predicate(rb_self, ErrorPredicate::$predicate) + } + )+ + + /// Define the native wreq predicate methods on Wreq::Error. + fn include_error_predicates(class: ExceptionClass) -> Result<(), MagnusError> { + $( + class.define_method(stringify!($method), magnus::method!($method, 0))?; + )+ + Ok(()) + } + + /// 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(()) } }; } +// The first matching entry determines the Ruby exception class. +define_error_mapping! { + Builder: is_builder => BUILDER_ERROR("BuilderError"), + Body: is_body => BODY_ERROR("BodyError"), + Tls: is_tls => TLS_ERROR("TlsError"), + ConnectionReset: is_connection_reset => CONNECTION_RESET_ERROR("ConnectionResetError"), + Connect: is_connect => CONNECTION_ERROR("ConnectionError"), + ProxyConnect: is_proxy_connect => PROXY_CONNECTION_ERROR("ProxyConnectionError"), + Decode: is_decode => DECODING_ERROR("DecodingError"), + Redirect: is_redirect => REDIRECT_ERROR("RedirectError"), + Timeout: is_timeout => TIMEOUT_ERROR("TimeoutError"), + Status: is_status => STATUS_ERROR("StatusError"), + Request: is_request => REQUEST_ERROR("RequestError"), + Upgrade: is_upgrade => WREQ_ERROR, +} + +/// Native predicates retained after consuming a wreq error. +#[derive(Clone, Copy, Default)] +struct ErrorPredicates(u16); + +impl ErrorPredicates { + /// Restore predicates from compact Ruby metadata. + const fn from_bits(bits: u16) -> Self { + Self(bits) + } + + /// Return the compact representation stored on the Ruby exception. + const fn bits(self) -> u16 { + 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 + } +} + +impl From<&wreq::Error> for ErrorPredicates { + /// Snapshot every native predicate before consuming the wreq error. + fn from(error: &wreq::Error) -> Self { + ErrorPredicate::CLASSIFICATION_ORDER + .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, + predicates: ErrorPredicates, +} + +// Stable roots for native errors. +define_exception!(WREQ_ERROR, "Error", exception_runtime_error); +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); -// 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); - /// Memory error constant pub fn memory_error(ruby: &Ruby) -> MagnusError { MagnusError::new(ruby.get_inner(&MEMORY), RACE_CONDITION_ERROR_MSG) } -/// Create Ruby's standard thread interruption error. +/// Create a `Wreq::InterruptError` outside the `StandardError` hierarchy. pub fn interrupt_error(ruby: &Ruby) -> MagnusError { - MagnusError::new(ruby.exception_interrupt(), "request interrupted") + MagnusError::new(ruby.get_inner(&INTERRUPT_ERROR), "request interrupted") } /// Build `Wreq::ForkError` without touching inherited native state. @@ -186,19 +287,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, } } @@ -209,37 +308,83 @@ 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) } /// Build a `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) +} + +/// Select the most specific Ruby exception class for native predicates. +fn wreq_error_class(ruby: &Ruby, predicates: ErrorPredicates) -> ExceptionClass { + for &predicate in ErrorPredicate::CLASSIFICATION_ORDER { + if predicates.contains(predicate) { + return ruby.get_inner(predicate.error_class()); + } + } + + ruby.get_inner(&WREQ_ERROR) +} + +/// 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| ErrorPredicates::from_bits(bits).contains(predicate))) } -/// Map [`wreq::Error`] to corresponding [`magnus::Error`] + +/// Construct a Ruby exception and attach immutable 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.predicates.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 predicates = ErrorPredicates::from(&err); + let class = wreq_error_class(ruby, predicates); + 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, + predicates, + }, ) } @@ -249,96 +394,43 @@ 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 - ); + 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, ErrorPredicates}; + + #[test] + fn error_predicate_bits_are_unique_and_round_trip() { + let predicates = ErrorPredicate::CLASSIFICATION_ORDER.iter().copied().fold( + ErrorPredicates::default(), + |predicates, predicate| { + assert!(!predicates.contains(predicate)); + predicates.include_if(predicate, true) + }, + ); + + assert_eq!( + ErrorPredicate::CLASSIFICATION_ORDER.len(), + predicates.bits().count_ones() as usize + ); + + let restored = ErrorPredicates::from_bits(predicates.bits()); + for &predicate in ErrorPredicate::CLASSIFICATION_ORDER { + assert!(restored.contains(predicate)); + } + } +} diff --git a/src/rt.rs b/src/rt.rs index c037401..a385f37 100644 --- a/src/rt.rs +++ b/src/rt.rs @@ -60,8 +60,8 @@ pub fn ensure_current(ruby: &Ruby) -> Result<(), magnus::Error> { /// # Errors /// /// Returns `Wreq::ForkError` if the extension belongs to a parent process, -/// `Wreq::BuilderError` if the Tokio runtime cannot be initialized, Ruby's -/// standard `Interrupt` if Ruby interrupts the request, or the error produced +/// `Wreq::BuilderError` if the Tokio runtime cannot be initialized, +/// `Wreq::InterruptError` if Ruby interrupts the request, or the error produced /// by `map_err` if the future fails. pub fn try_block_on(ruby: &Ruby, future: F, map_err: M) -> Result where diff --git a/test/error_hierarchy_test.rb b/test/error_hierarchy_test.rb new file mode 100644 index 0000000..b7c1fe9 --- /dev/null +++ b/test/error_hierarchy_test.rb @@ -0,0 +1,271 @@ +require "test_helper" +require "socket" +require "timeout" + +class ErrorHierarchyTest < Minitest::Test + REGULAR_ERROR_NAMES = %i[ + MemoryError + ForkError + ConnectionError + ProxyConnectionError + ConnectionResetError + TlsError + RequestError + StatusError + RedirectError + TimeoutError + BodyError + DecodingError + BuilderError + ].freeze + NATIVE_ERROR_PREDICATES = %i[ + is_builder + is_redirect + is_status + is_timeout + is_request + is_connect + is_proxy_connect + is_connection_reset + is_body + is_tls + is_decode + is_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 + + error = Wreq::MemoryError.new + assert_nil error.uri + assert_nil error.status + 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 root_error.is_builder + assert_nil root_error.status + assert_equal [:is_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_native_error_predicates_are_not_mutually_exclusive + with_hanging_server do |url, _accepted| + error = assert_raises(Wreq::TimeoutError) { Wreq.get(url, timeout: 1) } + + assert error.is_timeout + assert error.is_request + 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_successful_response + with_status_server(204) do |url| + response = Wreq.get(url) + + assert_same response, response.raise_for_status! + 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 error.is_status + assert_equal [:is_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 [:is_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_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 = { + 204 => "No Content", + 404 => "Not Found", + 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