diff --git a/Cargo.lock b/Cargo.lock index 23ff2ad8..6259e55d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2431,7 +2431,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wreq" version = "0.16.1" -source = "git+https://github.com/0x676e67/wreq?rev=54fdc7c9e0c1290e2f70f38d271ba954e3d86cc2#54fdc7c9e0c1290e2f70f38d271ba954e3d86cc2" +source = "git+https://github.com/0x676e67/wreq?rev=4b481baa15facf369bda2ae5f31a271e2b853802#4b481baa15facf369bda2ae5f31a271e2b853802" dependencies = [ "btls", "btls-sys", @@ -2499,7 +2499,7 @@ dependencies = [ [[package]] name = "wreq-rt" version = "0.2.2-rc.4" -source = "git+https://github.com/0x676e67/wreq?rev=54fdc7c9e0c1290e2f70f38d271ba954e3d86cc2#54fdc7c9e0c1290e2f70f38d271ba954e3d86cc2" +source = "git+https://github.com/0x676e67/wreq?rev=4b481baa15facf369bda2ae5f31a271e2b853802#4b481baa15facf369bda2ae5f31a271e2b853802" dependencies = [ "netty", "pin-project-lite", diff --git a/Cargo.toml b/Cargo.toml index 420648e3..ea151efb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ tikv-jemallocator = { version = "0.7.0", default-features = false, features = [ # Keep the latest emulation profiles and their required TLS support in sync. [patch.crates-io] wreq-util = { git = "https://github.com/0x676e67/wreq-util", rev = "5715529002738f46eea86a7271f918f9374d3582" } -wreq = { git = "https://github.com/0x676e67/wreq", rev = "54fdc7c9e0c1290e2f70f38d271ba954e3d86cc2" } +wreq = { git = "https://github.com/0x676e67/wreq", rev = "4b481baa15facf369bda2ae5f31a271e2b853802" } btls = { git = "https://github.com/0x676e67/btls", branch = "main" } btls-sys = { git = "https://github.com/0x676e67/btls", branch = "main" } tokio-btls = { git = "https://github.com/0x676e67/btls", branch = "main" } diff --git a/docs/source/guide/basic.md b/docs/source/guide/basic.md index 82caf9f8..277764cf 100644 --- a/docs/source/guide/basic.md +++ b/docs/source/guide/basic.md @@ -176,8 +176,8 @@ except (ConnectionError, TimeoutError) as exc: ``` Other failures, including TLS and decoding errors, have their own -[exception classes](../api/exceptions.md). They do not all inherit from -`RequestError`, so catch the types relevant to your application. +[exception classes](../api/exceptions.md). All of them derive from +`wreq.exceptions.Error`, so catch it to handle any other failure. ## Read response metadata and bodies diff --git a/docs/source/guide/redirect-errors.md b/docs/source/guide/redirect-errors.md index 1ae03843..cbe29184 100644 --- a/docs/source/guide/redirect-errors.md +++ b/docs/source/guide/redirect-errors.md @@ -92,7 +92,7 @@ async def main(): async with client.get("https://httpbin.org/status/404") as response: print(await response.text()) except exceptions.StatusError as error: - print("HTTP error:", error) + print("HTTP error:", error.status, error.url) asyncio.run(main()) @@ -127,18 +127,39 @@ async def main(): print(await response.text()) except exceptions.TimeoutError as error: print("Request timed out:", error) - except (exceptions.ConnectionError, exceptions.TlsError) as error: + except exceptions.ConnectionError as error: print("Connection failed:", error) + except exceptions.Error as error: + print("Request failed:", error) asyncio.run(main()) ``` -Catch the failures your application can handle. Other exception types include -`ProxyConnectionError`, `ConnectionResetError`, `BodyError`, `DecodingError`, -`BuilderError`, and `WebSocketError`; see the -[exception reference](../api/exceptions.md). `RequestError` is not a common -base class for all wreq errors. +Every wreq exception derives from `exceptions.Error`, so catch the failures +your application can handle and let `Error` cover the rest. `RequestError` +groups transport failures: `ConnectionError` (including a failed TLS handshake, +`ProxyConnectionError` and `ConnectionResetError`) and `TimeoutError`. +`ConnectionError`, `ConnectionResetError` and `TimeoutError` also derive from +the builtins of the same name. See the +[exception reference](../api/exceptions.md) for the full hierarchy. + +The class names the main cause; `is_*` methods on `Error` report every detail +wreq found. A connect timeout raises `TimeoutError` with both `is_timeout()` and +`is_connect()`, and a DNS failure raises `ConnectionError` with `is_dns()`. +The message leaves out the request URL because it may hold credentials; read it +from `error.url` instead. + +```python +except exceptions.TimeoutError as error: + stage = "connecting" if error.is_connect() else "waiting for a response" + print(f"Timed out while {stage}:", error.url) +``` + +Predicates reflect what wreq, its protocol libraries and the operating system +report, so the ones a failure matches may change between releases, and message +text is unspecified. Choose handlers by exception class, and use predicates to +refine them or for diagnostics. Asynchronous cancellation uses `asyncio.CancelledError`. Allow it to propagate when a caller cancels the task. The same request options and wreq exception diff --git a/examples/exceptions.py b/examples/exceptions.py index e50a5cae..17c44f14 100644 --- a/examples/exceptions.py +++ b/examples/exceptions.py @@ -1,58 +1,25 @@ +import asyncio import datetime import wreq -import asyncio -import wreq.exceptions as exceptions - -wreq_errors = ( - exceptions.BodyError, - exceptions.BuilderError, - exceptions.ConnectionError, - exceptions.ConnectionResetError, - exceptions.DecodingError, - exceptions.RedirectError, - exceptions.TimeoutError, - exceptions.StatusError, - exceptions.RequestError, - exceptions.UpgradeError, -) - - -async def test_bad_builder(): - print("\n--- BuilderError (bad builder) ---") - try: - await wreq.get("htt://httpbin.org/status/404") - except wreq_errors as e: - print(f"Caught: {type(e).__name__}: {e}") - except Exception as e: - print(f"Other error: {type(e).__name__}: {e}") - - -async def test_timeout_error(): - print("\n--- TimeoutError (timeout) ---") - try: - await wreq.get( - "https://httpbin.io/delay/10", timeout=datetime.timedelta(seconds=1) - ) - except wreq_errors as e: - print(f"Caught: {type(e).__name__}: {e}") - except Exception as e: - print(f"Other error: {type(e).__name__}: {e}") -async def test_connection_error(): - print("\n--- ConnectionError (refused) ---") +async def fetch(label, url, **kwargs): + print(f"\n--- {label} ---") try: - await wreq.get("http://127.0.0.1:9999") - except wreq_errors as e: + await wreq.get(url, **kwargs) + except wreq.Error as e: + # Every wreq error derives from `wreq.Error`. print(f"Caught: {type(e).__name__}: {e}") - except Exception as e: - print(f"Other error: {type(e).__name__}: {e}") async def main(): - await test_bad_builder() - await test_timeout_error() - await test_connection_error() + await fetch("BuilderError (bad URL)", "htt://httpbin.org/status/404") + await fetch( + "TimeoutError (timeout)", + "https://httpbin.io/delay/10", + timeout=datetime.timedelta(seconds=1), + ) + await fetch("ConnectionError (refused)", "http://127.0.0.1:9999") if __name__ == "__main__": diff --git a/python/README.md b/python/README.md index 3c6dd5ed..25356020 100644 --- a/python/README.md +++ b/python/README.md @@ -10,7 +10,7 @@ This directory contains Python type hints (Type Stubs) files for the `wreq` pack - `cookie.py` - Cookie management - `dns.py` - DNS resolver options - `emulation.py` - Browser emulation - - `exceptions.py` - Exception types + - `exceptions.py` - Exception hierarchy; unlike the stubs, the runtime classes live here - `header.py` - HTTP header handling - `http1.py` - HTTP/1.x options - `http2.py` - HTTP/2 options diff --git a/python/wreq/exceptions.py b/python/wreq/exceptions.py index b50190c0..962b956f 100644 --- a/python/wreq/exceptions.py +++ b/python/wreq/exceptions.py @@ -1,206 +1,293 @@ """ HTTP Client Exceptions -This module defines all exceptions that can be raised by the wreq HTTP client. -The exceptions are organized into logical categories based on their cause and -severity, making it easier to handle specific types of errors appropriately. +Every exception wreq raises for a failed request, response, or WebSocket derives +from `Error`, so `except wreq.Error` catches them all. Misuse, such as reading a +consumed body or awaiting a coroutine twice, raises Python's builtin exceptions. + + Error + ├── BuilderError + ├── TlsError + ├── RequestError + │ ├── ConnectionError + │ │ ├── ProxyConnectionError + │ │ └── ConnectionResetError + │ └── TimeoutError + ├── BodyError + ├── DecodingError + ├── RedirectError + ├── StatusError + └── WebSocketError + └── UpgradeError + +`ConnectionError`, `ConnectionResetError` and `TimeoutError` also derive from the +builtins of the same name, so generic network handlers catch them too. + +The class names the main cause of a failure; the `is_*` methods of `Error` report +every detail wreq found, and `url` holds the request URL, which the message omits. """ +import builtins +from typing import TYPE_CHECKING, Iterable + +if TYPE_CHECKING: + from .wreq import StatusCode + __all__ = [ + "Error", + "BuilderError", "TlsError", + "RequestError", "ConnectionError", "ProxyConnectionError", "ConnectionResetError", + "TimeoutError", "BodyError", - "BuilderError", "DecodingError", - "StatusError", - "RequestError", "RedirectError", - "UpgradeError", + "StatusError", "WebSocketError", - "TimeoutError", + "UpgradeError", ] -# ======================================== -# Network and System-Level Errors -# ======================================== - -class RustPanic(Exception): +class Error(Exception): r""" - Compatibility exception; Rust panics are not translated to this type. - """ + Base class for all wreq errors. + The `is_*` methods mirror the predicates of the Rust `wreq::Error`. Several + can match one failure: a connect timeout raises `TimeoutError` and matches + both `is_timeout()` and `is_connect()`. + + Predicates depend on what wreq, its protocol libraries and the operating + system report, so the ones a given failure matches may change between + releases; the message text is unspecified as well. Choose handlers by + exception class, and use predicates to refine them or for diagnostics. + """ -class TlsError(Exception): + url: "str | None" r""" - An error occurred in the TLS security layer. + The request URL, if known. The message leaves it out because it may hold + credentials. + """ - This exception covers TLS/SSL related issues such as: - - Certificate verification failures - - TLS handshake failures - - Protocol version mismatches - - Cipher suite negotiations + status: "StatusCode | None" + r""" + The response status of a `StatusError`, otherwise None. """ + def __init__( + self, + message: str = "", + predicates: Iterable[str] = (), + url: "str | None" = None, + status: "StatusCode | None" = None, + ) -> None: + # Only the message goes to the base, so the `OSError` subclasses keep + # `args == (message,)` instead of reading the rest as an errno. + super().__init__(message) + self._predicates = frozenset(predicates) + self.url = url + self.status = status + + def is_builder(self) -> bool: + r""" + Whether building the client, the request, or one of its options failed. + """ + return "builder" in self._predicates + + def is_request(self) -> bool: + r""" + Whether sending the request or receiving its response failed. + """ + return "request" in self._predicates + + def is_connect(self) -> bool: + r""" + Whether connecting to the destination failed, including the TLS handshake. + """ + return "connect" in self._predicates + + def is_proxy_connect(self) -> bool: + r""" + Whether connecting through the proxy failed. + """ + return "proxy_connect" in self._predicates + + def is_connection_reset(self) -> bool: + r""" + Whether the peer reset the connection. + """ + return "connection_reset" in self._predicates + + def is_dns(self) -> bool: + r""" + Whether resolving the host name failed. + """ + return "dns" in self._predicates + + def is_timeout(self) -> bool: + r""" + Whether a timeout elapsed. + """ + return "timeout" in self._predicates + + def is_body(self) -> bool: + r""" + Whether streaming a request or response body failed. + """ + return "body" in self._predicates + + def is_tls(self) -> bool: + r""" + Whether TLS settings or material are invalid; a failed handshake is + `is_connect()`. + """ + return "tls" in self._predicates + + def is_decode(self) -> bool: + r""" + Whether reading or decoding the response failed. + """ + return "decode" in self._predicates + + def is_redirect(self) -> bool: + r""" + Whether the redirect policy stopped the request. + """ + return "redirect" in self._predicates + + def is_status(self) -> bool: + r""" + Whether the response has an error status. + """ + return "status" in self._predicates + + def is_upgrade(self) -> bool: + r""" + Whether upgrading the connection failed. + """ + return "upgrade" in self._predicates + + def is_websocket(self) -> bool: + r""" + Whether a WebSocket operation failed. + """ + return "websocket" in self._predicates -class ConnectionError(Exception): - r""" - An error occurred while establishing a connection. - This exception is raised when the client cannot establish a - TCP connection to the remote server. Common causes include: - - Server is unreachable - - Port is closed or blocked - - Network connectivity issues - - Firewall blocking the connection - """ +# ======================================== +# Configuration Errors +# ======================================== -class ProxyConnectionError(Exception): +class BuilderError(Error): r""" - An error occurred while connecting through a proxy server. + A client, request, or one of their options is invalid. - This exception is raised when the client cannot establish a - connection to the target server via the specified proxy. Common - causes include: - - Invalid proxy address or port - - Proxy server is unreachable - - Authentication failures with the proxy - - Network connectivity issues between client and proxy + Raised for malformed URLs, header names or values, form or JSON bodies, + proxies, and DNS resolver settings. """ -class ConnectionResetError(Exception): +class TlsError(Error): r""" - The connection was reset by the remote peer. + TLS settings or material are invalid, such as an unparsable certificate, + identity, or certificate store. - This exception occurs when an established connection is - unexpectedly closed by the remote server. This can happen - due to server overload, network issues, or server-side - connection limits. + A failed TLS handshake raises `ConnectionError`. """ # ======================================== -# Request/Response Processing Errors +# Transport Errors # ======================================== -class BodyError(Exception): +class RequestError(Error): r""" - An error occurred while processing the body of a request or response. + The request failed in transit: while connecting, sending it, or waiting + on the peer. - This exception covers issues with reading, writing, or processing - HTTP message bodies, including: - - Invalid content encoding - - Incomplete body data - - Body size limit exceeded + Raised directly for transport failures without a more specific subclass, + including errors raised by an upload stream. """ -class BuilderError(Exception): +class ConnectionError(RequestError, builtins.ConnectionError): r""" - An error occurred while building a request or response. - - This exception is raised when there are issues constructing - HTTP requests or responses, such as: - - Invalid header combinations - - Malformed request parameters - - Configuration conflicts + The connection could not be established, for example on a DNS failure, a + refused connection, or a failed TLS handshake. """ -class DecodingError(Exception): +class ProxyConnectionError(ConnectionError): r""" - An error occurred while decoding a response. - - This exception covers failures in decoding response content, - including: - - Character encoding issues (UTF-8, Latin-1, etc.) - - Compression decompression failures (gzip, deflate, etc.) - - Content format parsing errors + The connection through the configured proxy could not be established. """ -class StatusError(Exception): +class ConnectionResetError(ConnectionError, builtins.ConnectionResetError): r""" - An error occurred while processing the status code of a response. - - This exception is typically raised for HTTP error status codes - (4xx, 5xx) when automatic error handling is enabled, or when - there are issues interpreting the status line. + The peer reset the connection. """ -class RequestError(Exception): +class TimeoutError(RequestError, builtins.TimeoutError): r""" - An error occurred while making a request. - - This is a general exception for request-related issues that - don't fit into more specific categories. It covers various - problems during the request lifecycle. + A configured timeout elapsed while connecting, reading the response or its + body, or receiving a WebSocket message. """ # ======================================== -# HTTP Protocol and Navigation Errors +# Response Errors # ======================================== -class RedirectError(Exception): +class BodyError(Error): r""" - An error occurred while following a redirect. - - This exception is raised when there are issues with HTTP - redirects, such as: - - Too many redirects (redirect loop) - - Invalid redirect location - - Cross-protocol redirects when not allowed - - Redirect limit exceeded + Streaming a request or response body failed. """ -class UpgradeError(Exception): +class DecodingError(Error): r""" - An error occurred while upgrading a connection. - - This exception covers failures when upgrading HTTP connections - to other protocols, such as: - - WebSocket upgrade failures - - HTTP/2 upgrade issues - - Protocol negotiation errors + The response body could not be read in full or decoded as requested, + such as an unknown charset, corrupt compression, or invalid JSON. """ -class WebSocketError(Exception): +class RedirectError(Error): r""" - An error occurred while handling a WebSocket connection. + The redirect policy stopped the request, for example after too many + redirects. + """ + - This exception covers WebSocket-specific issues including: - - WebSocket handshake failures - - Frame parsing errors - - Connection state violations - - Message sending/receiving errors +class StatusError(Error): + r""" + The response has an error status (4xx or 5xx) while status checking is + enabled. """ # ======================================== -# Timeout Errors +# WebSocket Errors # ======================================== -class TimeoutError(Exception): +class WebSocketError(Error): r""" - A timeout occurred while waiting for a response. + A WebSocket operation failed, or the connection is already closed. + + A connection reset raises `ConnectionResetError`, and a receive timeout + raises `TimeoutError`; catch `Error` to handle every failure. + """ - This exception is raised when operations exceed their configured - time limits, including: - - Connection timeout (time to establish connection) - - Read timeout (time to receive response) - - Total request timeout (entire request lifecycle) - Timeouts can often be resolved by increasing timeout values - or retrying the request. +class UpgradeError(WebSocketError): + r""" + The WebSocket handshake failed, for example on an unexpected status or a + missing upgrade header. """ diff --git a/python/wreq/wreq.py b/python/wreq/wreq.py index 2466c5f7..4345fecc 100644 --- a/python/wreq/wreq.py +++ b/python/wreq/wreq.py @@ -67,6 +67,12 @@ class StatusCode: HTTP status code. """ + def __init__(self, code: int) -> None: + r""" + Create a status code from an integer in 100-999. + """ + ... + def as_int(self) -> int: r""" Return the status code as an integer. diff --git a/src/error.rs b/src/error.rs index 9420d35b..45899674 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,13 +1,15 @@ -use std::io; +use std::{error::Error as StdError, fmt, io}; use hickory_resolver::net::NetError; use pyo3::{ - PyErr, Python, create_exception, - exceptions::{PyException, PyRuntimeError, PyStopAsyncIteration, PyStopIteration}, + PyErr, PyTypeInfo, Python, + exceptions::{PyRuntimeError, PyStopAsyncIteration, PyStopIteration}, }; use tokio::time::error::Elapsed; use wreq::header; +use crate::http::StatusCode; + 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. @@ -22,49 +24,138 @@ Potential solutions: 3) Change the order of operations to reference the instance before borrowing it. "#; -// System-level and runtime errors -create_exception!(exceptions, RustPanic, PyException); +/// Exception types defined in `wreq/exceptions.py`, which also holds their hierarchy. +mod exceptions { + use pyo3::import_exception; -// Network connection errors -create_exception!(exceptions, ConnectionError, PyException); -create_exception!(exceptions, ProxyConnectionError, PyException); -create_exception!(exceptions, ConnectionResetError, PyException); -create_exception!(exceptions, TlsError, PyException); + import_exception!(wreq.exceptions, Error); + import_exception!(wreq.exceptions, BuilderError); + import_exception!(wreq.exceptions, TlsError); + import_exception!(wreq.exceptions, RequestError); + import_exception!(wreq.exceptions, ConnectionError); + import_exception!(wreq.exceptions, ProxyConnectionError); + import_exception!(wreq.exceptions, ConnectionResetError); + import_exception!(wreq.exceptions, TimeoutError); + import_exception!(wreq.exceptions, BodyError); + import_exception!(wreq.exceptions, DecodingError); + import_exception!(wreq.exceptions, RedirectError); + import_exception!(wreq.exceptions, StatusError); + import_exception!(wreq.exceptions, WebSocketError); + import_exception!(wreq.exceptions, UpgradeError); +} -// HTTP protocol and request/response errors -create_exception!(exceptions, RequestError, PyException); -create_exception!(exceptions, StatusError, PyException); -create_exception!(exceptions, RedirectError, PyException); -create_exception!(exceptions, TimeoutError, PyException); +/// Arguments of `wreq.exceptions.Error`: the message, the names of the matching +/// predicates, the request URL and the response status. +type ErrorArgs = ( + String, + Vec<&'static str>, + Option, + Option, +); -// Data processing and encoding errors -create_exception!(exceptions, BodyError, PyException); -create_exception!(exceptions, DecodingError, PyException); +/// A [`wreq::Error`] predicate and the name its `is_*` method in +/// `wreq.exceptions.Error` looks up. +type Predicate = (&'static str, fn(&wreq::Error) -> bool); -// Configuration and builder errors -create_exception!(exceptions, BuilderError, PyException); +const PREDICATES: [Predicate; 14] = [ + ("builder", wreq::Error::is_builder), + ("request", wreq::Error::is_request), + ("connect", wreq::Error::is_connect), + ("proxy_connect", wreq::Error::is_proxy_connect), + ("connection_reset", wreq::Error::is_connection_reset), + ("dns", wreq::Error::is_dns), + ("timeout", wreq::Error::is_timeout), + ("body", wreq::Error::is_body), + ("tls", wreq::Error::is_tls), + ("decode", wreq::Error::is_decode), + ("redirect", wreq::Error::is_redirect), + ("status", wreq::Error::is_status), + ("upgrade", wreq::Error::is_upgrade), + ("websocket", wreq::Error::is_websocket), +]; -// Protocol upgrade and WebSocket errors -create_exception!(exceptions, UpgradeError, PyException); -create_exception!(exceptions, WebSocketError, PyException); +/// Map a library error to its exception, keeping every predicate it matches. Causes +/// found in the source chain come before error kinds, so a body read that timed out +/// raises `TimeoutError`, not `BodyError`. +fn library_error(error: wreq::Error) -> PyErr { + let predicates = PREDICATES + .iter() + .filter(|(_, matches)| matches(&error)) + .map(|&(name, _)| name) + .collect(); + // The URL may hold credentials, so it stays out of the message. + let url = error.uri().map(ToString::to_string); + let status = error.status().map(StatusCode); + let error = error.without_uri(); + let args: ErrorArgs = (format_library_error(&error), predicates, url, status); -macro_rules! wrap_error { - ($error:expr, $($variant:ident => $exception:ident),*) => { - { + macro_rules! classify { + ($($variant:ident => $exception:ident),*) => { $( - if $error.$variant() { - return $exception::new_err(format_library_error(&$error, concat!(stringify!($variant), " error"))); + if error.$variant() { + return exceptions::$exception::new_err(args); } )* - UpgradeError::new_err(format_library_error(&$error, "error")) - } - }; + }; + } + + classify!( + is_timeout => TimeoutError, + is_proxy_connect => ProxyConnectionError, + is_connection_reset => ConnectionResetError, + is_connect => ConnectionError, + is_tls => TlsError, + is_body => BodyError, + is_decode => DecodingError, + is_redirect => RedirectError, + is_status => StatusError, + is_upgrade => UpgradeError, + is_websocket => WebSocketError, + is_builder => BuilderError, + is_request => RequestError + ); + exceptions::Error::new_err(args) +} + +/// Raise `T` for a failure detected here rather than by wreq, matching the predicate +/// its class implies. +fn binding_error(predicate: &'static str, message: String) -> PyErr { + PyErr::new::((message, vec![predicate], None, None)) } /// Error sources can include PyErr, whose formatting attaches to Python. -fn format_library_error(error: &wreq::Error, label: &str) -> String { - Python::try_attach(|_| format!("{label}: {error:?}")) - .unwrap_or_else(|| format!("{label}: The Python interpreter is not available")) +fn format_library_error(error: &wreq::Error) -> String { + Python::try_attach(|_| SourceChain(error).to_string()) + .unwrap_or_else(|| "The Python interpreter is not available".to_owned()) +} + +/// Displays an error followed by every cause in its source chain. Many errors repeat +/// their cause in their own message, so a cause the previous message already ends with +/// is skipped, and one that starts with it adds only the rest. +struct SourceChain<'a>(&'a wreq::Error); + +impl fmt::Display for SourceChain<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // wreq's own message already ends with its direct source. + fmt::Display::fmt(self.0, f)?; + let mut previous = self.0.source().map(ToString::to_string).unwrap_or_default(); + let mut source = self.0.source().and_then(StdError::source); + while let Some(err) = source { + let message = err.to_string(); + if !previous.ends_with(&message) { + match message + .strip_prefix(&previous) + .filter(|_| !previous.is_empty()) + { + Some(rest) => f.write_str(rest)?, + None => write!(f, ": {message}")?, + } + } + previous = message; + source = err.source(); + } + Ok(()) + } } /// Unified error enum @@ -94,36 +185,43 @@ impl From for PyErr { Error::StopAsyncIteration => { PyStopAsyncIteration::new_err("The async iterator is exhausted") } - Error::WebSocketDisconnected => { - PyRuntimeError::new_err("The WebSocket has been disconnected") - } - Error::InvalidHeaderName(err) => { - PyRuntimeError::new_err(format!("Invalid header name: {err:?}")) + Error::WebSocketDisconnected => binding_error::( + "websocket", + "The WebSocket has been disconnected".to_owned(), + ), + Error::InvalidHeaderName(err) => binding_error::( + "builder", + format!("Invalid header name: {err:?}"), + ), + Error::InvalidHeaderValue(err) => binding_error::( + "builder", + format!("Invalid header value: {err:?}"), + ), + Error::Timeout(err) => binding_error::( + "timeout", + format!("Timeout error: {err:?}"), + ), + // PyO3 raises the matching `OSError` subclass, such as `FileNotFoundError`. + Error::IO(err) => err.into(), + Error::Decode(err) => binding_error::( + "decode", + format!("Decode error: {err:?}"), + ), + Error::Builder(err) => binding_error::( + "builder", + format!("Builder error: {err:?}"), + ), + Error::Dns(err) => binding_error::( + "builder", + format!("DNS resolver error: {err:?}"), + ), + Error::Json(err) => { + binding_error::("builder", format!("JSON error: {err:?}")) } - Error::InvalidHeaderValue(err) => { - PyRuntimeError::new_err(format!("Invalid header value: {err:?}")) + Error::Form(err) => { + binding_error::("builder", format!("Form error: {err:?}")) } - Error::Timeout(err) => TimeoutError::new_err(format!("Timeout error: {err:?}")), - Error::IO(err) => PyRuntimeError::new_err(format!("IO error: {err:?}")), - Error::Decode(err) => DecodingError::new_err(format!("Decode error: {err:?}")), - Error::Builder(err) => BuilderError::new_err(format!("Builder error: {err:?}")), - Error::Dns(err) => BuilderError::new_err(format!("DNS resolver error: {err:?}")), - Error::Json(err) => PyRuntimeError::new_err(format!("JSON error: {err:?}")), - Error::Form(err) => PyRuntimeError::new_err(format!("Form error: {err:?}")), - Error::Library(err) => wrap_error!(err, - is_body => BodyError, - is_tls => TlsError, - is_websocket => WebSocketError, - is_connect => ConnectionError, - is_proxy_connect => ProxyConnectionError, - is_connection_reset => ConnectionResetError, - is_decode => DecodingError, - is_redirect => RedirectError, - is_timeout => TimeoutError, - is_status => StatusError, - is_request => RequestError, - is_builder => BuilderError - ), + Error::Library(err) => library_error(err), } } } @@ -214,8 +312,8 @@ mod tests { let error = body.frame().now_or_never().unwrap().unwrap().unwrap_err(); assert!(error.is_request()); assert_eq!( - format_library_error(&error, "is_request error"), - "is_request error: The Python interpreter is not available" + format_library_error(&error), + "The Python interpreter is not available" ); // Constructing and dropping the public exception must also avoid its source. drop(PyErr::from(Error::Library(error))); diff --git a/src/http.rs b/src/http.rs index af1d09db..0201f277 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,4 +1,7 @@ -use pyo3::{class::basic::CompareOp, prelude::*, pyclass::PyClassGuardError}; +use pyo3::{ + class::basic::CompareOp, exceptions::PyValueError, prelude::*, pyclass::PyClassGuardError, + types::PyType, +}; define_enum!( /// An HTTP version. @@ -28,7 +31,7 @@ define_enum!( /// HTTP status code. #[derive(Clone, Copy)] -#[pyclass(subclass, frozen, str, skip_from_py_object)] +#[pyclass(subclass, frozen, str, skip_from_py_object, module = "wreq")] pub struct StatusCode(pub wreq::StatusCode); impl<'a, 'py> FromPyObject<'a, 'py> for StatusCode { @@ -41,6 +44,18 @@ impl<'a, 'py> FromPyObject<'a, 'py> for StatusCode { #[pymethods] impl StatusCode { + /// Create a status code from an integer in 100-999. + #[new] + fn new(code: u16) -> PyResult { + wreq::StatusCode::from_u16(code) + .map(Self) + .map_err(|err| PyValueError::new_err(err.to_string())) + } + + fn __reduce__<'py>(slf: &Bound<'py, Self>) -> (Bound<'py, PyType>, (u16,)) { + (slf.get_type(), (slf.get().as_int(),)) + } + /// Return the status code as an integer. pub const fn as_int(&self) -> u16 { self.0.as_u16() diff --git a/src/lib.rs b/src/lib.rs index 6ec5ee70..5f6bc267 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,7 +30,6 @@ use client::{ use cookie::{Cookie, Jar, SameSite}; use dns::{DnsOptions, LookupIpStrategy}; use emulate::{Emulation, Platform, Profile}; -use error::*; use header::{HeaderMap, OrigHeaderMap}; use http::{Method, StatusCode, Version}; use http1::Http1Options; @@ -349,7 +348,6 @@ fn wreq(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_wrapped(wrap_pymodule!(emulation_module))?; m.add_wrapped(wrap_pymodule!(redirect_module))?; m.add_wrapped(wrap_pymodule!(blocking_module))?; - m.add_wrapped(wrap_pymodule!(exceptions_module))?; let sys = PyModule::import(py, intern!(py, "sys"))?; let sys_modules: Bound<'_, PyDict> = sys.getattr(intern!(py, "modules"))?.cast_into()?; @@ -382,10 +380,6 @@ fn wreq(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { intern!(py, "wreq.blocking"), m.getattr(intern!(py, "blocking"))?, )?; - sys_modules.set_item( - intern!(py, "wreq.exceptions"), - m.getattr(intern!(py, "exceptions"))?, - )?; Ok(()) } @@ -497,34 +491,3 @@ fn blocking_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; Ok(()) } - -#[pymodule(gil_used = false, name = "exceptions")] -fn exceptions_module(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add(intern!(py, "TlsError"), py.get_type::())?; - m.add(intern!(py, "BodyError"), py.get_type::())?; - m.add(intern!(py, "BuilderError"), py.get_type::())?; - m.add( - intern!(py, "ConnectionError"), - py.get_type::(), - )?; - m.add( - intern!(py, "ProxyConnectionError"), - py.get_type::(), - )?; - m.add( - intern!(py, "ConnectionResetError"), - py.get_type::(), - )?; - m.add(intern!(py, "DecodingError"), py.get_type::())?; - m.add(intern!(py, "RedirectError"), py.get_type::())?; - m.add(intern!(py, "TimeoutError"), py.get_type::())?; - m.add(intern!(py, "StatusError"), py.get_type::())?; - m.add(intern!(py, "RequestError"), py.get_type::())?; - m.add(intern!(py, "UpgradeError"), py.get_type::())?; - m.add( - intern!(py, "WebSocketError"), - py.get_type::(), - )?; - m.add(intern!(py, "RustPanic"), py.get_type::())?; - Ok(()) -} diff --git a/tests/error_test.py b/tests/error_test.py index 70f4e168..2b426557 100644 --- a/tests/error_test.py +++ b/tests/error_test.py @@ -1,7 +1,15 @@ +import asyncio +import builtins +import pickle +import socket +from datetime import timedelta + import pytest import wreq import wreq.exceptions as exceptions +from cancellation_test import local_server + @pytest.mark.asyncio @pytest.mark.flaky(reruns=3, reruns_delay=2) @@ -19,3 +27,59 @@ async def test_proxy_connection_error(): for url in target_urls: with pytest.raises(exceptions.ProxyConnectionError): await wreq.get(url, proxy=wreq.Proxy.all(proxy)) + + +@pytest.mark.asyncio +async def test_errors_share_one_hierarchy(): + # Every exception is exported from the package root and derives from `Error`. + for name in exceptions.__all__: + assert getattr(wreq, name) is getattr(exceptions, name) + assert issubclass(getattr(exceptions, name), wreq.Error) + + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + refused = f"http://127.0.0.1:{sock.getsockname()[1]}/" + + async with wreq.Client(proxies=[]) as client: + # A refused connection is also a builtin `ConnectionError`. Its URL stays out + # of the message, and the predicates and URL survive pickling. + with pytest.raises(wreq.ConnectionError) as caught: + await client.get(refused) + error = caught.value + assert isinstance(error, wreq.RequestError) + assert isinstance(error, builtins.ConnectionError) + assert error.is_connect() and error.is_request() and not error.is_timeout() + assert error.url == refused and refused not in str(error) + copy = pickle.loads(pickle.dumps(error)) + assert type(copy) is type(error) and str(copy) == str(error) + assert copy.is_connect() and copy.url == refused + + # An error the binding raises matches the predicate of its class. + with pytest.raises(wreq.BuilderError, match="Invalid header name") as caught: + await client.get(refused, headers={"bad name": "v"}) + assert caught.value.is_builder() and caught.value.url is None + + async with local_server() as (url, connections): + # A timeout while reading the body is a `TimeoutError` that is also + # `is_body()`. + task = asyncio.create_task( + client.get(url, read_timeout=timedelta(seconds=0.2)) + ) + _, writer = await asyncio.wait_for(connections.get(), 5) + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nhello") + response = await asyncio.wait_for(task, 5) + with pytest.raises(wreq.TimeoutError) as caught: + await asyncio.wait_for(response.bytes(), 5) + assert isinstance(caught.value, builtins.TimeoutError) + assert caught.value.is_timeout() and caught.value.is_body() + + # A `StatusError` carries the response status, which pickles too. + task = asyncio.create_task(client.get(url)) + _, writer = await asyncio.wait_for(connections.get(), 5) + writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n") + response = await asyncio.wait_for(task, 5) + with pytest.raises(wreq.StatusError) as caught: + response.raise_for_status() + copy = pickle.loads(pickle.dumps(caught.value)) + assert copy.status == 404 and copy.status.is_client_error() + assert copy.is_status() and copy.url == url diff --git a/tests/multipart_test.py b/tests/multipart_test.py index 144ba359..031c08c0 100644 --- a/tests/multipart_test.py +++ b/tests/multipart_test.py @@ -107,9 +107,9 @@ async def test_file_parts_open_when_the_request_is_built(tmp_path): # A missing file fails the request rather than building the form. form = Multipart(Part(name="f", value=tmp_path / "missing.txt")) - with pytest.raises(RuntimeError, match="IO error"): + with pytest.raises(FileNotFoundError): blocking.post(url, multipart=form) - with pytest.raises(RuntimeError, match="IO error"): + with pytest.raises(FileNotFoundError): await client.post(url, multipart=form) # A closed client rejects the request before building the form, so it never opens. diff --git a/tests/websocket_test.py b/tests/websocket_test.py index 2d5e0654..be2039e3 100644 --- a/tests/websocket_test.py +++ b/tests/websocket_test.py @@ -75,6 +75,8 @@ async def test_websocket_close_frame(code, reason, expected): async with client.websocket(url) as ws: assert await ws.close(code, reason) is None assert await asyncio.wait_for(frames.get(), 5) == expected + with pytest.raises(wreq.WebSocketError, match="disconnected"): + await ws.recv() finally: server.close() await server.wait_closed() @@ -149,3 +151,27 @@ async def send_later(): release.set() server.close() await server.wait_closed() + + +@pytest.mark.asyncio +async def test_websocket_protocol_error_is_websocket_error(): + async def bad_frame(reader, writer): + try: + await handshake(reader, writer) + # A frame with the reserved opcode 0x3. + writer.write(b"\x83\x00") + await writer.drain() + await reader.read() + finally: + writer.close() + + server = await asyncio.start_server(bad_frame, "127.0.0.1", 0) + url = f"ws://127.0.0.1:{server.sockets[0].getsockname()[1]}/" + try: + async with wreq.Client(proxies=[]) as client: + async with client.websocket(url) as ws: + with pytest.raises(wreq.WebSocketError): + await asyncio.wait_for(ws.recv(), 5) + finally: + server.close() + await server.wait_closed()