From a731349db42c293c470f27abb7c2af4cef2e6d8e Mon Sep 17 00:00:00 2001 From: gngpp Date: Mon, 10 Aug 2026 12:39:23 +0800 Subject: [PATCH 1/2] feat(error): include native error cause chain --- README.md | 5 + docs/errors.md | 248 +++++++++++++++++++++++++++++++++++ examples/error.rb | 157 +++++++++++++++++++++- lib/wreq_ruby/error.rb | 205 +++++++++++++++++++---------- src/error.rs | 25 +++- test/error_hierarchy_test.rb | 54 ++++++++ 6 files changed, 622 insertions(+), 72 deletions(-) create mode 100644 docs/errors.md diff --git a/README.md b/README.md index 422d8fb..c2d4ad4 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,11 @@ Most browser device models share identical TLS and HTTP/2 configurations, differ +## Error Handling + +See [Error handling](docs/errors.md) for the exception hierarchy, native +predicates, timeout behavior, proxy failures, and safe diagnostic logging. + ## Building Install the BoringSSL build environment by referring to [boring](https://github.com/cloudflare/boring/blob/master/.github/workflows/ci.yml) and [boringssl](https://github.com/google/boringssl/blob/master/BUILDING.md#build-prerequisites). diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..a2d1ecc --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,248 @@ +# Error handling + +wreq-ruby raises native HTTP failures as subclasses of `Wreq::Error`. The +exception class is the primary category selected by the binding. Predicate +methods preserve the facts reported by wreq before its native error is +consumed. + +```ruby +begin + Wreq.get("https://example.com", timeout: 5) +rescue Wreq::Error => error + warn error + warn "connection timed out" if error.timeout? && error.connect? +end +``` + +The standard exception message includes the native error chain. `warn error`, +`error.message`, `error.to_s`, and uncaught exception output therefore show the +underlying network failure without a wreq-ruby-specific logging method. + +Request URIs are not included in diagnostic messages because they may contain +credentials or private query parameters. The original value remains available +through `error.uri`; redact it before logging it. + +## How wreq builds an error + +A native `wreq::Error` contains one top-level kind and an optional chain of +lower-level causes. The two parts answer different questions: + +| Part | Ruby predicates | What it describes | +| --- | --- | --- | +| Top-level kind | `builder?`, `body?`, `tls?`, `decoding?`, `redirect?`, `status?`, `upgrade?`, `request?` | The wreq operation that created the error. These predicates are mutually exclusive. | +| Cause chain | `connection_reset?`, `timeout?`, `proxy_connect?`, `connect?` | Transport facts found while walking Rust's `std::error::Error::source()` chain. Several can be true. | + +For example, a refused destination connection has a message shaped like this: + +```text +error sending request: client error (Connect): tcp connect error: Connection refused +``` + +Each part comes from a different layer: + +| Message layer | Meaning | +| --- | --- | +| `error sending request` | The top-level kind is Request, so `request?` is true. | +| `client error (Connect)` | The cause chain contains wreq's destination connection stage, so `connect?` is true. | +| `tcp connect error` | The connector identifies the operation that failed. | +| `Connection refused` | The operating system reports the root cause. | + +The native cause chain is included as text in the standard Ruby exception +message. It is not converted into a chain of Ruby exception objects, so +`error.cause` continues to mean a Ruby exception cause. + +wreq-ruby snapshots wreq's public predicate methods before consuming the native +error. Application code can rely on the Ruby exception classes and predicates, +but it should not match the exact message text. Connector names and root error +wording can change with wreq, its protocol libraries, or the operating system. + +## Hierarchy + +All regular wreq-ruby errors inherit from `Wreq::Error`, which inherits from +`RuntimeError`: + +```text +RuntimeError +`-- Wreq::Error + +-- Wreq::BuilderError + +-- Wreq::BodyError + +-- Wreq::TlsError + +-- Wreq::DecodingError + +-- Wreq::RedirectError + +-- Wreq::StatusError + +-- Wreq::RequestError + +-- Wreq::ConnectionResetError + +-- Wreq::TimeoutError + +-- Wreq::ProxyConnectError + +-- Wreq::ConnectError + +-- Wreq::MemoryError + `-- Wreq::ForkError +``` + +`Wreq::InterruptError` inherits from `Interrupt`, not `Wreq::Error`. A broad +`rescue StandardError` must not swallow a Ruby interrupt. + +## Error classes + +| Error | Meaning | +| --- | --- | +| `Wreq::Error` | Base class and fallback when no public Ruby subclass matches. Native connection upgrade errors currently use this class with `upgrade? == true`. | +| `Wreq::BuilderError` | wreq could not build a URL, request, client, header, or body, or the binding rejected input while building one. Binding-generated instances have no native predicates. | +| `Wreq::ConnectError` | The cause chain contains wreq's destination Connect stage. The root cause may be DNS, TCP, pool acquisition, TLS handshake, or certificate verification. Reset and timeout categories take precedence. | +| `Wreq::ProxyConnectError` | The cause chain identifies a proxy TCP connection, HTTP CONNECT tunnel, or SOCKS negotiation failure. Errors after the tunnel is established can use `Wreq::ConnectError`. | +| `Wreq::ConnectionResetError` | The cause chain retains `io::ErrorKind::ConnectionReset`. An EOF, graceful close, or incomplete HTTP message alone does not satisfy this condition. | +| `Wreq::TimeoutError` | The cause chain contains wreq's timeout marker, a protocol timeout, or `io::ErrorKind::TimedOut`, and no higher-priority category applies. | +| `Wreq::TlsError` | The top-level kind is TLS. This covers TLS connector, trust store, identity, or option setup, not a remote handshake nested under Connect. | +| `Wreq::RequestError` | The top-level kind is Request, but the cause chain has no reset, timeout, proxy connection, or destination connection category. Send failures and incomplete HTTP responses commonly land here. | +| `Wreq::BodyError` | The top-level kind is Body, or the binding could not access request-body sender state. With the current wreq dependency, total response-body timeout is the main native Body case. | +| `Wreq::DecodingError` | The top-level kind is Decode. It covers parsing values, decoding response data, and response-body transport or protocol failures that wreq wraps as Decode. | +| `Wreq::RedirectError` | The top-level kind is Redirect, usually because redirect policy rejected the next hop or the limit was exceeded. | +| `Wreq::StatusError` | `Response#raise_for_status!` created a Status error for a 4xx or 5xx response. It has a status and no lower-level native cause. | +| `Wreq::MemoryError` | Single-use native state was consumed already or is currently borrowed. | +| `Wreq::ForkError` | A forked child attempted to use native state inherited from its parent. See [Fork safety](fork-safety.md). | +| `Wreq::InterruptError` | Ruby interrupted a native request wait. This inherits from `Interrupt`, outside the hierarchy above. | + +Errors created by the binding rather than by wreq have no active native +predicates. The exception class still identifies the binding operation that +failed. + +## Predicates + +The top-level kind predicates are mutually exclusive. Cause-chain predicates +are independent and can overlap with that kind and with each other: + +```ruby +error.timeout? # the cause chain contains a timeout +error.connect? # failure while acquiring a destination connection +error.proxy_connect? # failure while connecting through a proxy +error.request? # the top-level native kind is Request +``` + +`timeout?` recursively checks for wreq's timeout marker, a timeout reported by +the HTTP protocol layer, or `io::ErrorKind::TimedOut`. `connection_reset?` +requires an `io::ErrorKind::ConnectionReset` in the chain. `connect?` and +`proxy_connect?` look for wreq's internal client-stage errors. None of these +predicates relies on matching message text. + +wreq-ruby records every predicate before consuming the native error, then +selects one Ruby exception class. Native builder, body, TLS, decoding, +redirect, status, and upgrade kinds are considered first. Transport details +then use this order: + +1. Connection reset +2. Timeout +3. Proxy connection +4. Destination connection +5. General request failure + +This order is part of the Ruby API. A future wreq release can add another +native predicate without silently changing which existing exception class an +application rescues. + +## Timeout behavior + +`Wreq::TimeoutError` can represent request and connection timeouts. Predicates +show which stage wreq retained: + +The `timeout?` predicate comes from `wreq::Error::is_timeout()`. It checks the +native cause chain for wreq's timeout marker, a protocol timeout, or +`std::io::ErrorKind::TimedOut`. wreq-ruby does not classify timeouts by matching +message text. + +| Situation | Primary error | Common predicates | +| --- | --- | --- | +| Overall request timeout | `Wreq::TimeoutError` | `timeout?`, `request?` | +| Destination connection timeout | `Wreq::TimeoutError` | `timeout?`, `connect?`, `request?` | +| Proxy connection timeout | `Wreq::TimeoutError` | `timeout?`, `proxy_connect?`, `request?` when wreq preserves the proxy phase | +| Per-read timeout while consuming a response body | `Wreq::TimeoutError` | `timeout?`, `request?` | +| Total timeout while consuming a response body | `Wreq::BodyError` | `body?`, `timeout?` | + +The total body timeout remains `Wreq::BodyError` because wreq marks that error +as a body failure, and the native body kind has higher priority. Code that +retries all timeout-related failures should check `timeout?` instead of +rescuing only `Wreq::TimeoutError`. + +## Proxy failures + +For an HTTPS request through an HTTP proxy, wreq first connects to the proxy, +sends `CONNECT`, and waits for a successful response. Failures in that part of +the exchange use `Wreq::ProxyConnectError`. + +After the proxy accepts the tunnel, wreq performs the destination TLS handshake +through it. A proxy that accepts `CONNECT` and then closes the tunnel can +therefore produce `Wreq::ConnectError`, even though a proxy was configured. +Connector-wide timeouts can also lose the narrower proxy phase. The native +cause chain in the standard error message is the best way to distinguish a TCP +timeout, tunnel rejection, DNS failure, TLS alert, or unexpected EOF. + +## TLS setup and handshakes + +`tls?` checks only wreq's top-level TLS kind. wreq creates that kind while +parsing certificate or identity material, building a trust store, creating the +TLS connector, or applying TLS options. + +A remote handshake happens later, inside the destination connection stage. An +expired certificate or TLS alert therefore normally raises `Wreq::ConnectError` +with `connect?` and `request?` set. The root of the message still reports the +certificate verification failure or alert. `tls?` remains false because the +top-level kind is Request, not TLS. + +## Closed and reset connections + +`connection_reset?` is deliberately narrow. It returns true only when wreq can +still find an operating-system ConnectionReset error in the source chain. A +server can close a socket in ways that the HTTP layer reports as an EOF or an +incomplete message instead. In that case the result is commonly RequestError or +DecodingError, even if a packet capture shows a TCP reset. + +This distinction explains why a raw local TCP RST can produce +`Wreq::ConnectionResetError`, while a public reset endpoint can produce +`Wreq::RequestError` with a message such as `connection closed before message +completed`. + +## Logging + +Use Ruby's standard exception methods: + +```ruby +rescue Wreq::Error => error + warn error + warn error.full_message(highlight: false) +end +``` + +`warn error` prints the native cause chain. `full_message` also includes the +exception class, active native predicates, Ruby causes, and backtrace. Neither +output includes `error.uri`. + +## Reproducing failures + +[`examples/error.rb`](../examples/error.rb) contains runnable cases for each +common transport failure. It uses [badssl.com](https://badssl.com/) for a +rejected certificate and [testserver.host](https://testserver.host/) for HTTP +status, delay, and remote reset responses. The connect, proxy, and raw TCP reset +cases use local sockets, so they do not depend on an external failure staying +available. + +Run every case, or choose individual cases: + +```console +bundle exec ruby examples/error.rb +bundle exec ruby examples/error.rb tls timeout connection-reset +``` + +The `tls` case demonstrates that a rejected remote certificate belongs to the +Connect stage. The local `connection-reset` case sends a TCP RST directly so +the operating-system reset remains in the native source chain. + +These public endpoints are useful for manual checks, but they should not be CI +fixtures. They may be unavailable, and a system TLS proxy can change the +certificate result. + +## Standard Ruby exceptions + +Not every call-site error is a network failure. Invalid or unknown options can +raise `ArgumentError`, values of the wrong Ruby type can raise `TypeError`, and +out-of-range values can raise `RangeError`. A closed `Wreq::BodySender` raises +`IOError`, while calling `Response#chunks` without a block raises +`LocalJumpError`. These exceptions do not inherit from `Wreq::Error`. diff --git a/examples/error.rb b/examples/error.rb index 743b3bc..9e397b8 100644 --- a/examples/error.rb +++ b/examples/error.rb @@ -1,9 +1,160 @@ #!/usr/bin/env ruby +# frozen_string_literal: true +require "socket" require_relative "../lib/wreq" -begin - Wreq.get("not-a-valid-url") +# Run every scenario, or select a few by name: +# bundle exec ruby examples/error.rb tls timeout connection-reset + +$stdout.sync = true +$stderr.sync = true + +ERROR_PREDICATES = %i[ + builder? + body? + tls? + decoding? + redirect? + status? + upgrade? + connection_reset? + timeout? + proxy_connect? + connect? + request? +].freeze + +TEST_SERVER = "https://example.testserver.host" + +def report_error(error, expected:) + # The standard message contains the complete native error chain. + warn "#{error.class}: #{error}" + + facts = ERROR_PREDICATES.select { |predicate| error.public_send(predicate) } + warn "native facts: #{facts.join(", ")}" unless facts.empty? + warn "HTTP status: #{error.status}" if error.status + + # error.uri can contain credentials or private query parameters. Redact it + # before logging it. Use error.full_message(highlight: false) when a + # backtrace and Ruby exception causes are also useful. + + expected = Array(expected) + return if expected.any? { |error_class| error.is_a?(error_class) } + + warn "expected: #{expected.map(&:name).join(" or ")}" +end + +def run_example(name, expected:, note: nil) + puts "\n--- #{name} ---" + puts note if note + + response = yield + expected_names = Array(expected).map(&:name).join(" or ") + puts "no error was raised: HTTP #{response.code} (expected #{expected_names})" rescue Wreq::Error => error - warn error.full_message(highlight: false) + report_error(error, expected: expected) +ensure + response&.close end + +def unused_local_port + server = TCPServer.new("127.0.0.1", 0) + server.addr[1] +ensure + server&.close +end + +def with_reset_server + server = TCPServer.new("127.0.0.1", 0) + port = server.addr[1] + thread = Thread.new do + connection = server.accept + connection.setsockopt(Socket::SOL_SOCKET, Socket::SO_LINGER, [1, 0].pack("ii")) + ensure + connection&.close + end + thread.report_on_exception = false + + yield "http://127.0.0.1:#{port}/" +ensure + server&.close + thread&.join(1) + if thread&.alive? + thread.kill + thread.join + end +end + +def run_scenario(name, client) + case name + when "builder" + run_example("Invalid URL", expected: Wreq::BuilderError) do + client.get("not-a-valid-url") + end + when "status" + run_example("HTTP 404", expected: Wreq::StatusError) do + client.get("#{TEST_SERVER}/status/404").raise_for_status! + end + when "tls" + run_example( + "Rejected TLS certificate", + expected: Wreq::ConnectError, + note: "Certificate verification happens while connecting, so this is a ConnectError." + ) do + client.get("https://expired.badssl.com/", timeout: 10) + end + when "connect" + port = unused_local_port + run_example("Refused destination connection", expected: Wreq::ConnectError) do + client.get("http://127.0.0.1:#{port}/", timeout: 5) + end + when "proxy" + port = unused_local_port + run_example("Refused proxy connection", expected: Wreq::ProxyConnectError) do + client.get( + "https://example.com/", + proxy: "http://127.0.0.1:#{port}", + timeout: 5 + ) + end + when "timeout" + run_example("Slow response", expected: Wreq::TimeoutError) do + client.get("#{TEST_SERVER}/delay/5", timeout: 1) + end + when "remote-reset" + run_example( + "Remote reset after sending a request", + expected: [Wreq::RequestError, Wreq::ConnectionResetError], + note: "The HTTP layer may report an incomplete response before the raw reset is exposed." + ) do + client.get("#{TEST_SERVER}/error/reset", timeout: 5) + end + when "connection-reset" + run_example("Raw TCP reset", expected: Wreq::ConnectionResetError) do + with_reset_server { |url| client.get(url, timeout: 5) } + end + end +end + +scenarios = %w[ + builder + status + tls + connect + proxy + timeout + remote-reset + connection-reset +].freeze +selected = ARGV.empty? ? scenarios : ARGV +unknown = selected - scenarios + +unless unknown.empty? + warn "unknown scenario: #{unknown.join(", ")}" + warn "available scenarios: #{scenarios.join(", ")}" + exit 1 +end + +client = Wreq::Client.new(no_proxy: true) +selected.each { |name| run_scenario(name, client) } diff --git a/lib/wreq_ruby/error.rb b/lib/wreq_ruby/error.rb index 80f3384..be90077 100644 --- a/lib/wreq_ruby/error.rb +++ b/lib/wreq_ruby/error.rb @@ -5,30 +5,35 @@ 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. + # A native `wreq::Error` has one top-level kind and may contain a chain of + # lower-level causes. The builder, body, TLS, decoding, redirect, status, + # upgrade, and request predicates report that top-level kind. Timeout, + # connection reset, proxy connection, and destination connection predicates + # inspect the cause chain and may overlap with the request kind. + # + # The subclass records one primary category. For example, a destination + # connection timeout normally raises TimeoutError while `timeout?`, + # `connect?`, and `request?` can all return true. Top-level native kinds take + # precedence over cause-chain details. Other failures use connection reset, + # timeout, proxy connection, destination connection, then RequestError. # # 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. - # `detailed_message` includes the active facts, and `full_message` adds the - # backtrace and exception causes. Neither method includes `uri`. + # The standard error message includes the native cause chain, so `warn`, + # `message`, `to_s`, and uncaught exception output show the underlying + # network error. `detailed_message` also includes the active facts, and + # `full_message` adds the backtrace and Ruby exception causes. None of these + # outputs includes `uri`. # # @example Rescue any wreq-ruby runtime error # begin # Wreq.get("not-a-valid-url") # rescue Wreq::Error => error - # warn error.full_message(highlight: false) + # warn error # end + # + # @see https://github.com/SearchApi/wreq-ruby/blob/main/docs/errors.md class Error < RuntimeError # Get the URI recorded by the native error. # @@ -44,59 +49,85 @@ class Error < RuntimeError # @return [Integer, nil] HTTP status code, if one was recorded attr_reader :status - # @return [Boolean] Whether the native error came from a builder + # This checks the top-level native error kind. Binding-side validation can + # also raise BuilderError without setting this predicate. + # + # @return [Boolean] Whether the top-level native kind is Builder def builder? end - # @return [Boolean] Whether the native error came from redirect handling + # @return [Boolean] Whether the top-level native kind is Redirect def redirect? end - # @return [Boolean] Whether the native error represents an HTTP status + # @return [Boolean] Whether the top-level native kind is Status def status? end - # A request timeout uses TimeoutError even when this is also a connection - # or proxy connection error. + # This scans the native cause chain for wreq's timeout marker, a protocol + # timeout, or an operating-system timed-out error. A timeout can therefore + # also be a connection, proxy connection, request, or body failure. # - # @return [Boolean] Whether the native error is related to a timeout + # @return [Boolean] Whether the native cause chain contains a timeout def timeout? end - # This may be true on connection and timeout subclasses because those - # errors occur while sending a request. + # This checks the top-level native kind, not every operation performed for + # a request. It is commonly true on connection and timeout subclasses + # because wreq wraps client-layer failures in a Request error. # - # @return [Boolean] Whether the native error is related to a request + # @return [Boolean] Whether the top-level native kind is Request def request? end - # @return [Boolean] Whether the native error occurred while acquiring a - # connection to the destination + # This scans the cause chain for wreq's destination Connect stage. The + # root cause can be DNS, TCP, TLS handshake, or connection-pool failure. + # + # @return [Boolean] Whether the native chain contains a destination + # connection failure def connect? end - # @return [Boolean] Whether the native error occurred while connecting - # through a proxy + # This scans the cause chain for proxy TCP, HTTP CONNECT tunnel, or SOCKS + # negotiation failures. Errors after a tunnel is established may instead + # belong to the destination Connect stage. + # + # @return [Boolean] Whether the native chain contains a proxy connection + # failure def proxy_connect? end - # @return [Boolean] Whether the native error is a connection reset + # A clean EOF or an HTTP "connection closed before message completed" + # error is not sufficient. The chain must retain an operating-system + # ConnectionReset error. + # + # @return [Boolean] Whether the native chain contains a connection reset def connection_reset? end - # @return [Boolean] Whether the native error is related to a body + # This is narrower than any failure encountered while reading or writing a + # body. It checks wreq's top-level Body kind. + # + # @return [Boolean] Whether the top-level native kind is Body def body? end - # @return [Boolean] Whether the native error is related to TLS + # This reports TLS client setup, such as connector, trust store, identity, + # or TLS option configuration. Certificate verification and TLS alerts + # during a remote handshake normally appear under the Connect stage. + # + # @return [Boolean] Whether the top-level native kind is TLS def tls? end - # @return [Boolean] Whether the native error is related to decoding + # The native Decode kind covers value parsing and response-body errors + # that wreq maps through its decoder. It does not mean JSON parsing only. + # + # @return [Boolean] Whether the top-level native kind is Decode def decoding? end - # @return [Boolean] Whether the native error is related to an upgrade + # @return [Boolean] Whether the top-level native kind is Upgrade def upgrade? end end @@ -143,13 +174,14 @@ class MemoryError < Error; end # @see https://github.com/SearchApi/wreq-ruby/blob/main/docs/fork-safety.md class ForkError < Error; end - # Raised when the client cannot acquire a usable connection to the - # destination server. + # Raised when the native cause chain contains a destination Connect stage + # and no higher-priority category applies. # - # 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. + # The root cause can be DNS resolution, TCP connection, connection-pool + # acquisition, TLS negotiation, or certificate verification. Inspect the + # complete error message for that root cause. A timeout raises TimeoutError + # and keeps `connect?` true; a retained operating-system reset raises + # ConnectionResetError instead. # # @example Handle a destination connection failure # client = Wreq::Client.new(no_proxy: true) @@ -160,12 +192,14 @@ class ForkError < Error; end # end class ConnectError < Error; end - # 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 cause chain identifies a proxy connection stage and no + # higher-priority category applies. # - # If the native error reports both a proxy connection failure and a timeout, - # Wreq::TimeoutError is raised and `proxy_connect?` remains true. + # This includes connecting to the proxy, negotiating an HTTP CONNECT tunnel, + # and SOCKS negotiation. Once a tunnel is established, destination TLS or + # connection failures can raise ConnectError instead. A timeout raises + # TimeoutError while `proxy_connect?` remains true when wreq preserves the + # proxy stage in its cause chain. # # @example Handle a proxy connection failure # begin @@ -178,7 +212,12 @@ class ConnectError < Error; end # end class ProxyConnectError < Error; end - # Raised when a peer resets the connection. + # Raised when the native cause chain retains an operating-system connection + # reset and no top-level native kind takes precedence. + # + # An EOF, graceful close, or incomplete HTTP message is not necessarily a + # connection reset. Those failures can raise RequestError or DecodingError + # when no `io::ErrorKind::ConnectionReset` remains in the chain. # # @example Handle a reset while streaming a response # response = Wreq.get("https://example.com") @@ -191,16 +230,19 @@ class ProxyConnectError < Error; end # end class ConnectionResetError < Error; end - # Raised when native TLS setup fails while constructing a client. + # Raised when wreq records a top-level TLS setup error. # - # 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. + # This covers creating the TLS connector and configuring trust stores, + # identities, certificate compression, key logging, or TLS options. The + # current Ruby API does not expose certificate or identity inputs that can + # deliberately trigger every setup path. Remote handshake alerts and + # certificate verification failures normally raise ConnectError because + # they occur after setup while acquiring a connection. # # @example Distinguish TLS setup errors from connection errors + # client = Wreq::Client.new(no_proxy: true, verify: true) # begin - # Wreq::Client.new(verify: true).get("https://example.com") + # client.get("https://expired.badssl.com/") # rescue Wreq::TlsError => error # warn "TLS setup failed: #{error.message}" # rescue Wreq::ConnectError => error @@ -208,12 +250,14 @@ class ConnectionResetError < Error; end # end class TlsError < Error; end - # Raised for a request failure without a more specific error subclass. + # Raised for a top-level native Request error when its cause chain has no + # more specific transport category. # - # 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. + # Typical causes include a request rejected by the HTTP client, a send + # failure, an incomplete response, or a closed connection represented as a + # protocol error rather than an operating-system reset. Connection reset, + # timeout, proxy connection, and destination connection causes use their + # corresponding subclasses before this fallback. # # @example Rescue the native fallback request category # client = Wreq::Client.new @@ -227,18 +271,21 @@ class RequestError < Error; end # Raised when Response#raise_for_status! sees a 4xx or 5xx response. # # Requests return error responses normally until this opt-in check is made. - # The inherited `status` reader returns the integer HTTP status. + # The native Status kind has no lower-level source. The inherited `status` + # reader returns the integer HTTP status. # # @example - # client = Wreq::Client.new + # client = Wreq::Client.new(no_proxy: true) # begin - # client.get("https://httpbin.io/status/404").raise_for_status! + # client.get("https://example.testserver.host/status/404").raise_for_status! # rescue Wreq::StatusError => error # warn "HTTP #{error.status}: #{error.message}" # end class StatusError < Error; end - # Raised when redirect handling fails, such as after too many redirects. + # Raised when wreq records a top-level redirect policy error, such as after + # too many redirects. `uri` contains the last redirect target when wreq + # recorded one. # # @example Limit the number of redirects # client = Wreq::Client.new(allow_redirects: true, max_redirects: 3) @@ -249,22 +296,32 @@ class StatusError < Error; end # end class RedirectError < Error; end - # Raised when a request operation exceeds its timeout. + # Raised when the cause chain contains a timeout and no top-level native kind + # or connection reset takes precedence. # - # 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. + # wreq recognizes its own timeout marker, protocol timeouts, and + # `io::ErrorKind::TimedOut`. This includes overall request, response read, + # destination connection, and proxy connection timeouts. Check `connect?`, + # `proxy_connect?`, `request?`, and `body?` for the retained stage. A native + # Body timeout raises BodyError because the top-level Body kind wins. # # @example Handle a request timeout - # client = Wreq::Client.new(timeout: 1) + # client = Wreq::Client.new(no_proxy: true, timeout: 1) # begin - # client.get("https://httpbin.io/delay/10") + # client.get("https://example.testserver.host/delay/5") # rescue Wreq::TimeoutError => error # warn "request timed out: #{error.message}" # end class TimeoutError < Error; end - # Raised while sending, reading, or streaming an HTTP body. + # Raised for wreq's top-level Body kind or a binding-side body sender state + # error. + # + # This is not the class for every failure encountered while transferring a + # body. In the current wreq version, a total timeout while consuming a + # response body uses the Body kind, while read timeouts and protocol errors + # can use TimeoutError, RequestError, or DecodingError. Binding-side errors + # do not set `body?` because they have no native wreq error. # # @example Handle a body error while streaming # response = Wreq.get("https://example.com") @@ -277,7 +334,13 @@ class TimeoutError < Error; end # end class BodyError < Error; end - # Raised when a response body cannot be decoded or parsed. + # Raised when wreq cannot decode or parse a value, or when it wraps a + # response-body transport or protocol failure in its Decode kind. + # + # The complete message identifies whether the root cause is JSON, character + # or cookie parsing, decompression, an HTTP body failure, or another decoder. + # A timeout or connection reset can remain visible through its predicate + # even when the primary class is DecodingError. # # @example Fall back to bytes when a response is not valid JSON # response = Wreq.get("https://example.com") @@ -288,7 +351,13 @@ class BodyError < Error; end # end class DecodingError < Error; end - # Raised when client, request, header, or body configuration is invalid. + # Raised for wreq's top-level Builder kind or binding-side validation that + # cannot initialize the native runtime or construct a client, request, URL, + # header, or JSON body. + # + # Binding-generated BuilderError instances have no native source chain, so + # `builder?` returns false for them. The exception message still describes + # the rejected value. # # @example Handle an invalid request URL # begin diff --git a/src/error.rs b/src/error.rs index 00553b6..00a0c01 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,7 @@ use std::{ borrow::Cow, cell::{BorrowError, BorrowMutError}, + error::Error as StdError, fmt, }; @@ -282,6 +283,27 @@ struct ErrorMetadata<'a> { facts: NativeErrorFacts, } +/// Format the native error and every cause omitted from its standard message. +/// +/// `wreq::Error` already displays its immediate source. Start with that +/// source's cause so the Ruby message reaches the root error without repeating +/// the first native layer. +struct ErrorMessage<'a>(&'a wreq::Error); + +impl fmt::Display for ErrorMessage<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self.0, formatter)?; + + let mut source = self.0.source().and_then(StdError::source); + while let Some(error) = source { + write!(formatter, ": {error}")?; + source = error.source(); + } + + Ok(()) + } +} + // Stable roots for native errors. define_exception!(WREQ_ERROR, "Error", exception_runtime_error); @@ -475,7 +497,8 @@ pub fn wreq_error(ruby: &Ruby, err: wreq::Error) -> MagnusError { 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(); + let err = err.without_uri(); + let message = ErrorMessage(&err).to_string(); error_with_metadata( ruby, diff --git a/test/error_hierarchy_test.rb b/test/error_hierarchy_test.rb index 257845d..4e27651 100644 --- a/test/error_hierarchy_test.rb +++ b/test/error_hierarchy_test.rb @@ -87,6 +87,13 @@ def test_upstream_request_error_contract assert_equal %i[request? connect?], active_native_predicates(error) assert_detailed_facts error, %i[connect request] + assert_match(/client error \(Connect\): .+/m, error.message) + assert_equal error.message, error.to_s + assert_includes error.inspect, "client error (Connect)" + assert_includes error.full_message(highlight: false), error.message + + _stdout, stderr = capture_io { warn error } + assert_equal "#{error.message}\n", stderr end with_status_server(502) do |proxy| @@ -100,6 +107,8 @@ def test_upstream_request_error_contract assert_equal %i[request? proxy_connect?], active_native_predicates(error) assert_detailed_facts error, %i[proxy_connect request] + assert_includes error.message, + "client error (ProxyConnect): tunnel error: unsuccessful" end with_hanging_server do |url, _accepted| @@ -107,6 +116,26 @@ def test_upstream_request_error_contract assert_equal %i[timeout? request?], active_native_predicates(error) assert_detailed_facts error, %i[timeout request] + assert_includes error.message, "operation timed out" + end + end + + def test_response_body_timeout_contract + client = Wreq::Client.new(no_proxy: true) + cases = [ + [{timeout: 1}, Wreq::BodyError, %i[timeout? body?], %i[body timeout]], + [{read_timeout: 1}, Wreq::TimeoutError, %i[timeout? request?], %i[timeout request]] + ] + + cases.each do |options, error_class, predicates, facts| + with_stalled_body_server do |url| + response = client.get(url, **options) + error = assert_raises(error_class) { response.bytes } + + assert_equal predicates, active_native_predicates(error) + assert_detailed_facts error, facts + assert_includes error.message, "operation timed out" + end end end @@ -308,6 +337,31 @@ def with_hanging_server thread&.join(1) end + def with_stalled_body_server + server = TCPServer.new("127.0.0.1", 0) + thread = Thread.new do + socket = server.accept + while (line = socket.gets) + break if line == "\r\n" + end + socket.write "HTTP/1.1 200 OK\r\n" + socket.write "Content-Length: 1\r\n" + socket.write "Connection: close\r\n\r\n" + 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]}/" + ensure + server&.close unless server&.closed? + thread&.kill + thread&.join(1) + end + def with_status_server(status, body: "") reason = { 200 => "OK", From c0be36c5080c59c73315029d18d41a5797365d53 Mon Sep 17 00:00:00 2001 From: gngpp Date: Mon, 10 Aug 2026 13:02:45 +0800 Subject: [PATCH 2/2] docs(error): simplify diagnostic examples --- docs/errors.md | 13 ++++++----- examples/error.rb | 56 ++++++++++------------------------------------- 2 files changed, 18 insertions(+), 51 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index a2d1ecc..6956421 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -220,20 +220,21 @@ output includes `error.uri`. [`examples/error.rb`](../examples/error.rb) contains runnable cases for each common transport failure. It uses [badssl.com](https://badssl.com/) for a rejected certificate and [testserver.host](https://testserver.host/) for HTTP -status, delay, and remote reset responses. The connect, proxy, and raw TCP reset -cases use local sockets, so they do not depend on an external failure staying -available. +status, delay, and reset responses. The connect and proxy cases use +`127.0.0.1:1`, which normally has no listening service. Run every case, or choose individual cases: ```console bundle exec ruby examples/error.rb -bundle exec ruby examples/error.rb tls timeout connection-reset +bundle exec ruby examples/error.rb tls timeout reset ``` The `tls` case demonstrates that a rejected remote certificate belongs to the -Connect stage. The local `connection-reset` case sends a TCP RST directly so -the operating-system reset remains in the native source chain. +Connect stage. The public `reset` endpoint sends a TCP RST after receiving the +request. The HTTP layer may turn that into an incomplete-response error before +wreq sees the operating-system reset. In that case, the example raises +`RequestError` and `connection_reset?` returns false. These public endpoints are useful for manual checks, but they should not be CI fixtures. They may be unavailable, and a system TLS proxy can change the diff --git a/examples/error.rb b/examples/error.rb index 9e397b8..1fe3d2b 100644 --- a/examples/error.rb +++ b/examples/error.rb @@ -1,11 +1,10 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require "socket" require_relative "../lib/wreq" # Run every scenario, or select a few by name: -# bundle exec ruby examples/error.rb tls timeout connection-reset +# bundle exec ruby examples/error.rb tls timeout reset $stdout.sync = true $stderr.sync = true @@ -31,8 +30,10 @@ def report_error(error, expected:) # The standard message contains the complete native error chain. warn "#{error.class}: #{error}" - facts = ERROR_PREDICATES.select { |predicate| error.public_send(predicate) } - warn "native facts: #{facts.join(", ")}" unless facts.empty? + warn "native facts:" + ERROR_PREDICATES.each do |predicate| + warn " #{predicate}: #{error.public_send(predicate)}" + end warn "HTTP status: #{error.status}" if error.status # error.uri can contain credentials or private query parameters. Redact it @@ -58,34 +59,6 @@ def run_example(name, expected:, note: nil) response&.close end -def unused_local_port - server = TCPServer.new("127.0.0.1", 0) - server.addr[1] -ensure - server&.close -end - -def with_reset_server - server = TCPServer.new("127.0.0.1", 0) - port = server.addr[1] - thread = Thread.new do - connection = server.accept - connection.setsockopt(Socket::SOL_SOCKET, Socket::SO_LINGER, [1, 0].pack("ii")) - ensure - connection&.close - end - thread.report_on_exception = false - - yield "http://127.0.0.1:#{port}/" -ensure - server&.close - thread&.join(1) - if thread&.alive? - thread.kill - thread.join - end -end - def run_scenario(name, client) case name when "builder" @@ -105,16 +78,14 @@ def run_scenario(name, client) client.get("https://expired.badssl.com/", timeout: 10) end when "connect" - port = unused_local_port run_example("Refused destination connection", expected: Wreq::ConnectError) do - client.get("http://127.0.0.1:#{port}/", timeout: 5) + client.get("http://127.0.0.1:1/", timeout: 5) end when "proxy" - port = unused_local_port run_example("Refused proxy connection", expected: Wreq::ProxyConnectError) do client.get( "https://example.com/", - proxy: "http://127.0.0.1:#{port}", + proxy: "http://127.0.0.1:1", timeout: 5 ) end @@ -122,18 +93,14 @@ def run_scenario(name, client) run_example("Slow response", expected: Wreq::TimeoutError) do client.get("#{TEST_SERVER}/delay/5", timeout: 1) end - when "remote-reset" + when "reset" run_example( - "Remote reset after sending a request", + "Server resets the connection", expected: [Wreq::RequestError, Wreq::ConnectionResetError], - note: "The HTTP layer may report an incomplete response before the raw reset is exposed." + note: "The HTTP layer may report an incomplete response before exposing the TCP reset." ) do client.get("#{TEST_SERVER}/error/reset", timeout: 5) end - when "connection-reset" - run_example("Raw TCP reset", expected: Wreq::ConnectionResetError) do - with_reset_server { |url| client.get(url, timeout: 5) } - end end end @@ -144,8 +111,7 @@ def run_scenario(name, client) connect proxy timeout - remote-reset - connection-reset + reset ].freeze selected = ARGV.empty? ? scenarios : ARGV unknown = selected - scenarios