Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/wreq_ruby/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

unless defined?(Wreq)
module Wreq
# 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.
Expand Down
2 changes: 1 addition & 1 deletion src/client/body/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ impl BodySender {
/// # Errors
///
/// Returns `IOError` after either channel side has closed. An interrupted
/// wait raises Ruby's standard `Interrupt` exception.
/// wait raises `Wreq::InterruptError`.
pub fn push(ruby: &Ruby, rb_self: &Self, data: RString) -> Result<(), Error> {
// Clone during the shared borrow, then release it before waiting
// for capacity. Request attachment needs a mutable borrow.
Expand Down
15 changes: 13 additions & 2 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,18 @@ define_exception!(DECODING_ERROR, "DecodingError", exception_runtime_error);
// Configuration and builder errors
define_exception!(BUILDER_ERROR, "BuilderError", exception_runtime_error);

// Keep interruption outside StandardError so a broad transport rescue
// never swallows a Ruby interrupt.
define_exception!(INTERRUPT_ERROR, "InterruptError", exception_interrupt);

/// 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` when Ruby interrupts a request.
pub fn interrupt_error(ruby: &Ruby) -> MagnusError {
MagnusError::new(ruby.exception_interrupt(), "request interrupted")
MagnusError::new(ruby.get_inner(&INTERRUPT_ERROR), "request interrupted")
}

/// Map a Tokio runtime initialization failure to `Wreq::BuilderError`.
Expand Down Expand Up @@ -312,5 +316,12 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), MagnusError> {
"BuilderError",
exception_runtime_error
);
initialize_exception!(
ruby,
gem_module,
INTERRUPT_ERROR,
"InterruptError",
exception_interrupt
);
Ok(())
}
2 changes: 1 addition & 1 deletion src/rt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ enum BlockOnError<E> {
/// # Errors
///
/// Returns `Wreq::BuilderError` if the Tokio runtime cannot be initialized,
/// Ruby's standard `Interrupt` if Ruby interrupts the request, or the error produced
/// `Wreq::InterruptError` if Ruby interrupts the request, or the error produced
/// by `map_err` if the future fails.
pub fn try_block_on<F, T, E, M>(ruby: &Ruby, future: F, map_err: M) -> Result<T, magnus::Error>
where
Expand Down
54 changes: 54 additions & 0 deletions test/error_handling_test.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,37 @@
require "test_helper"
require "socket"
require "timeout"

class ErrorHandlingTest < Minitest::Test
def test_interrupt_error_stays_outside_standard_error
assert_equal Interrupt, Wreq::InterruptError.superclass
refute_operator Wreq::InterruptError, :<, StandardError
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_network_error_handling
# Try to connect to a non-existent domain
response = Wreq.get("https://definitely-not-a-real-domain-12345.com")
Expand Down Expand Up @@ -89,4 +120,27 @@ def test_proxy_error_handling
end
end
end

private

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
end