diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34589883..9f4e9cfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -304,9 +304,10 @@ jobs: python-version: 3.x architecture: ${{ matrix.platform.python-architecture || 'x64' }} - name: Install NASM - run: | - choco install nasm -y - shell: cmd + uses: ilammy/setup-nasm@v1 + with: + version: "2.16.01" + from-source: "false" - name: Build wheels uses: PyO3/maturin-action@v1 with: diff --git a/Cargo.lock b/Cargo.lock index 0a73473e..36b5c055 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2597,7 +2597,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", @@ -2668,7 +2668,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 a7c5fe1c..5106357d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,7 +79,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/RELEASE.md b/RELEASE.md index 8604b96b..6aa8ad63 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,6 +5,11 @@ - Unknown keyword arguments now raise `TypeError` when calling async or blocking client constructors, HTTP or WebSocket request methods and functions, `Http1Options`, `Http2Options`, `TlsOptions`, or `Proxy` factories. These arguments were previously ignored. - Set client-only options such as `connect_timeout` and `proxies` on `Client`; passing them to a request now raises `TypeError`. Use `proxy` to select a proxy for one request. - Async request calls check keyword names before returning a coroutine. Values for supported request keywords are still converted on first `await` or entry with `async with`. +- Every wreq exception now derives from `wreq.exceptions.Error`. `ConnectionError`, `ProxyConnectionError`, `ConnectionResetError` and `TimeoutError` derive from `RequestError`, and `UpgradeError` from `WebSocketError`. `ConnectionError`, `ConnectionResetError` and `TimeoutError` also derive from the builtins of the same name, so `except TimeoutError` and `except OSError` now catch them. +- A timeout raises `TimeoutError` even while streaming a body, where it raised `BodyError`. A connection reset raises `ConnectionResetError` even while streaming a body or a WebSocket, a WebSocket read failure raises `WebSocketError` rather than `BodyError`, and an error wreq leaves unclassified raises `Error` rather than `UpgradeError`. +- Invalid header names or values and form or JSON bodies that cannot be encoded raise `BuilderError`, and a disconnected WebSocket raises `WebSocketError`; all raised `RuntimeError`. A multipart file that cannot be opened raises the matching `OSError` subclass, such as `FileNotFoundError`. +- Error messages no longer include the request URL; read it from `error.url`. Message text is unspecified, so match on exception classes and `is_*` methods instead. +- `RustPanic`, which was never raised, is removed. ## [0.11.0](https://github.com/0x676e67/wreq-python/compare/v0.10.2..v0.11.0) - 2026-04-13 diff --git a/docs/source/guide/basic.md b/docs/source/guide/basic.md index cf03e13b..20e91373 100644 --- a/docs/source/guide/basic.md +++ b/docs/source/guide/basic.md @@ -188,8 +188,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..20792dab 100644 --- a/python/wreq/exceptions.py +++ b/python/wreq/exceptions.py @@ -1,206 +1,294 @@ """ 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, +and a local file that cannot be opened, such as a multipart file, raises the +matching `OSError` subclass. + + 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 + +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()`. -class TlsError(Exception): - r""" - An error occurred in the TLS security layer. + 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. - This exception covers TLS/SSL related issues such as: - - Certificate verification failures - - TLS handshake failures - - Protocol version mismatches - - Cipher suite negotiations + wreq fills in the predicates, `url` and `status` of the errors it raises. + An error constructed directly, for example in a test, holds only its + message. """ - -class ConnectionError(Exception): + url: "str | None" = None r""" - An error occurred while establishing a connection. + The request URL, if known. The message leaves it out because it may hold + credentials. + """ - 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 + status: "StatusCode | None" = None + r""" + The response status of a `StatusError`, otherwise None. """ + _predicates: frozenset[str] = frozenset() + + 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 wreq reports the failure against a request or response body; + currently only for a timeout while streaming one. + """ + 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 ProxyConnectionError(Exception): +# ======================================== +# Configuration Errors +# ======================================== + + +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. + wreq reported a request or response body failure without a more specific + cause. - 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 + wreq currently reports body failures through other classes: a timeout while + streaming a body raises `TimeoutError` with `is_body()`, a cut-off or corrupt + response raises `DecodingError`, and a failing upload stream raises + `RequestError`. """ -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 a truncated body, 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 4662ea41..cf8663bd 100644 --- a/python/wreq/wreq.py +++ b/python/wreq/wreq.py @@ -68,6 +68,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/client/resp/ws/cmd.rs b/src/client/resp/ws/cmd.rs index fcfb013d..2b6c9955 100644 --- a/src/client/resp/ws/cmd.rs +++ b/src/client/resp/ws/cmd.rs @@ -82,7 +82,7 @@ async fn read( match timeout { Some(timeout) => time::timeout(timeout, reader.try_next()) .await - .map_err(Error::Timeout), + .map_err(Error::WebSocketTimeout), None => Ok(reader.try_next().await), } }; diff --git a/src/error.rs b/src/error.rs index 9420d35b..64a57465 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,13 +1,21 @@ -use std::io; +use std::{error::Error as StdError, fmt, io, iter}; use hickory_resolver::net::NetError; use pyo3::{ - PyErr, Python, create_exception, - exceptions::{PyException, PyRuntimeError, PyStopAsyncIteration, PyStopIteration}, + PyErrArguments, + exceptions::{ + PyImportError, PyRuntimeError, PyStopAsyncIteration, PyStopIteration, PyTypeError, + PyValueError, + }, + intern, + prelude::*, + types::{PyFrozenSet, PyType}, }; 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,51 +30,308 @@ 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; + + 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); +} + +/// Load the exception classes and check that each [`Kind`] reaches the `is_*` method +/// that reads it, so a `wreq/exceptions.py` that does not match this extension fails +/// the import with `ImportError` instead of the first error. +pub fn init(py: Python<'_>) -> PyResult<()> { + check_exceptions(py).map_err(|err| { + let error = PyImportError::new_err("wreq.exceptions does not match the wreq extension"); + error.set_cause(py, Some(err)); + error + }) +} + +fn check_exceptions(py: Python<'_>) -> PyResult<()> { + // Resolve every class fallibly first: `import_exception!` panics on a missing one. + let module = py.import(intern!(py, "wreq.exceptions"))?; + let base = module + .getattr(intern!(py, "Error"))? + .cast_into::()?; + for name in Kind::CLASSES { + if !module + .getattr(name)? + .cast_into::()? + .is_subclass(&base)? + { + return Err(PyTypeError::new_err(format!( + "{name} does not derive from Error" + ))); + } + } + for kind in Kind::ALL { + let method = format!("is_{}", kind.name()); + let error = Details::new(kind, String::new()).instantiate(py)?; + if !error.bind(py).call_method0(method.as_str())?.is_truthy()? { + return Err(PyValueError::new_err(format!( + "Error.{method}() does not read {:?}", + kind.name() + ))); + } + } + Ok(()) +} + +// ===== impl Kind ===== -// Network connection errors -create_exception!(exceptions, ConnectionError, PyException); -create_exception!(exceptions, ProxyConnectionError, PyException); -create_exception!(exceptions, ConnectionResetError, PyException); -create_exception!(exceptions, TlsError, PyException); +/// Defines [`Kind`] from a table of the name an `is_*` method of `wreq.exceptions.Error` +/// reads, the [`wreq::Error`] predicate behind it, and the exception class it selects. +macro_rules! define_kinds { + ($($kind:ident($name:literal, $predicate:ident) $(=> $exception:ident)?),* $(,)?) => { + /// A fact about a failure, one per `is_*` method of `wreq.exceptions.Error`. + #[derive(Clone, Copy)] + enum Kind { + $($kind),* + } -// 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); + impl Kind { + /// Every kind, in the order that selects the exception class. + const ALL: [Kind; [$($name),*].len()] = [$(Kind::$kind),*]; -// Data processing and encoding errors -create_exception!(exceptions, BodyError, PyException); -create_exception!(exceptions, DecodingError, PyException); + /// The names of the exception classes the kinds select. + const CLASSES: &[&str] = &[$($(stringify!($exception),)?)*]; -// Configuration and builder errors -create_exception!(exceptions, BuilderError, PyException); + /// The name the `is_*` method reads. + const fn name(self) -> &'static str { + match self { + $(Kind::$kind => $name),* + } + } -// Protocol upgrade and WebSocket errors -create_exception!(exceptions, UpgradeError, PyException); -create_exception!(exceptions, WebSocketError, PyException); + /// Whether wreq reports this kind for `error`. + fn matches(self, error: &wreq::Error) -> bool { + match self { + $(Kind::$kind => error.$predicate()),* + } + } -macro_rules! wrap_error { - ($error:expr, $($variant:ident => $exception:ident),*) => { - { - $( - if $error.$variant() { - return $exception::new_err(format_library_error(&$error, concat!(stringify!($variant), " error"))); + /// The exception class this kind selects, if any. + fn exception(self, py: Python<'_>) -> Option> { + match self { + $(Kind::$kind => define_kinds!(@exception py $($exception)?)),* } - )* - UpgradeError::new_err(format_library_error(&$error, "error")) + } } }; + (@exception $py:ident $exception:ident) => { + Some($py.get_type::()) + }; + (@exception $py:ident) => { + None + }; +} + +// Causes found in the source chain come before error kinds, so a body read that timed +// out raises `TimeoutError`, not `BodyError`. +define_kinds! { + Timeout("timeout", is_timeout) => TimeoutError, + ProxyConnect("proxy_connect", is_proxy_connect) => ProxyConnectionError, + ConnectionReset("connection_reset", is_connection_reset) => ConnectionResetError, + Connect("connect", is_connect) => ConnectionError, + Dns("dns", is_dns), + Tls("tls", is_tls) => TlsError, + Body("body", is_body) => BodyError, + Decode("decode", is_decode) => DecodingError, + Redirect("redirect", is_redirect) => RedirectError, + Status("status", is_status) => StatusError, + Upgrade("upgrade", is_upgrade) => UpgradeError, + WebSocket("websocket", is_websocket) => WebSocketError, + Builder("builder", is_builder) => BuilderError, + Request("request", is_request) => RequestError, +} + +const _: () = assert!(Kind::ALL.len() <= u16::BITS as usize); + +impl Kind { + const fn bit(self) -> u16 { + 1 << self as u16 + } +} + +// ===== impl Kinds ===== + +/// A set of [`Kind`]s. +#[derive(Clone, Copy)] +struct Kinds(u16); + +impl Kinds { + /// Every kind wreq reports for `error`. + fn of(error: &wreq::Error) -> Self { + Kind::ALL + .into_iter() + .filter(|kind| kind.matches(error)) + .collect() + } + + /// The kinds in this set, in class selection order. + fn iter(self) -> impl Iterator { + Kind::ALL + .into_iter() + .filter(move |kind| self.0 & kind.bit() != 0) + } } -/// 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")) +impl From for Kinds { + fn from(kind: Kind) -> Self { + Kinds(kind.bit()) + } } +impl From<[Kind; N]> for Kinds { + fn from(kinds: [Kind; N]) -> Self { + kinds.into_iter().collect() + } +} + +impl FromIterator for Kinds { + fn from_iter>(kinds: I) -> Self { + Kinds(kinds.into_iter().fold(0, |bits, kind| bits | kind.bit())) + } +} + +// ===== impl Details ===== + +/// What a wreq exception carries. PyO3 builds it into the exception instance only when +/// raising it, which keeps these fields out of the exception's constructor. +struct Details { + kinds: Kinds, + message: String, + url: Option, + status: Option, + cause: Option, +} + +impl Details { + /// Details for a failure detected here rather than by wreq. + fn new(kinds: impl Into, message: String) -> Self { + Details { + kinds: kinds.into(), + message, + url: None, + status: None, + cause: None, + } + } + + /// Details for a library error. The URL may hold credentials, so it stays out of + /// the message. + fn library(error: wreq::Error) -> Self { + let kinds = Kinds::of(&error); + let url = error.uri().map(ToString::to_string); + let status = error.status().map(StatusCode); + let (message, cause) = describe(&error.without_uri()); + Details { + kinds, + message, + url, + status, + cause, + } + } + + /// Create the exception of the first kind that selects a class. + fn instantiate(self, py: Python<'_>) -> PyResult> { + let class = self + .kinds + .iter() + .find_map(|kind| kind.exception(py)) + .unwrap_or_else(|| py.get_type::()); + let error = class.call1((self.message,))?; + let names = PyFrozenSet::new(py, self.kinds.iter().map(Kind::name))?; + error.setattr(intern!(py, "_predicates"), names)?; + // The class supplies the defaults for anything left unset. + if let Some(url) = self.url { + error.setattr(intern!(py, "url"), url)?; + } + if let Some(status) = self.status { + error.setattr(intern!(py, "status"), status)?; + } + if let Some(cause) = self.cause { + error.setattr(intern!(py, "__cause__"), cause.into_value(py))?; + } + Ok(error.unbind()) + } +} + +impl PyErrArguments for Details { + // PyO3 raises a returned exception instance as it is. Should building it fail, the + // base `Error` is raised with that failure as its message. + fn arguments(self, py: Python<'_>) -> Py { + self.instantiate(py) + .unwrap_or_else(|err| err.into_value(py).into_any()) + } +} + +impl From
for PyErr { + fn from(details: Details) -> Self { + PyErr::new::(details) + } +} + +/// Format `error` with its source chain and take the first Python exception in the +/// chain as its cause. Both need the interpreter, so neither happens without it. +fn describe(error: &wreq::Error) -> (String, Option) { + Python::try_attach(|py| { + let cause = iter::successors(error.source(), |&err| err.source()) + .find_map(|err| err.downcast_ref::()) + .map(|err| err.clone_ref(py)); + (SourceChain(error).to_string(), cause) + }) + .unwrap_or_else(|| ("The Python interpreter is not available".to_owned(), None)) +} + +// ===== impl SourceChain ===== + +/// 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(()) + } +} + +// ===== impl Error ===== + /// Unified error enum #[derive(Debug)] pub enum Error { @@ -74,13 +339,11 @@ pub enum Error { StopIteration, StopAsyncIteration, WebSocketDisconnected, + WebSocketTimeout(Elapsed), InvalidHeaderName(header::InvalidHeaderName), InvalidHeaderValue(header::InvalidHeaderValue), - Timeout(Elapsed), - Builder(http::Error), Dns(NetError), IO(io::Error), - Decode(cookie::ParseError), Json(serde_json::Error), Form(serde_urlencoded::ser::Error), Library(wreq::Error), @@ -94,36 +357,26 @@ 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 => Details::new( + Kind::WebSocket, + "The WebSocket has been disconnected".to_owned(), + ) + .into(), + Error::WebSocketTimeout(err) => Details::new( + [Kind::Timeout, Kind::WebSocket], + format!("WebSocket receive timed out: {err}"), + ) + .into(), + Error::InvalidHeaderName(err) => Details::new(Kind::Builder, err.to_string()).into(), + Error::InvalidHeaderValue(err) => Details::new(Kind::Builder, err.to_string()).into(), + Error::Dns(err) => { + Details::new(Kind::Builder, format!("DNS resolver error: {err}")).into() } - Error::InvalidHeaderValue(err) => { - PyRuntimeError::new_err(format!("Invalid header value: {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 - ), + // PyO3 raises the matching `OSError` subclass, such as `FileNotFoundError`. + Error::IO(err) => err.into(), + Error::Json(err) => Details::new(Kind::Builder, format!("JSON error: {err}")).into(), + Error::Form(err) => Details::new(Kind::Builder, format!("Form error: {err}")).into(), + Error::Library(err) => Details::library(err).into(), } } } @@ -152,12 +405,6 @@ impl From for Error { } } -impl From for Error { - fn from(err: Elapsed) -> Self { - Error::Timeout(err) - } -} - #[cfg(test)] mod tests { use std::{fmt, process::Command}; @@ -213,10 +460,9 @@ 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" - ); + let (message, cause) = describe(&error); + assert_eq!(message, "The Python interpreter is not available"); + assert!(cause.is_none()); // 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..f1c18c93 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,4 +1,11 @@ -use pyo3::{class::basic::CompareOp, prelude::*, pyclass::PyClassGuardError}; +use pyo3::{ + class::basic::CompareOp, + exceptions::PyValueError, + intern, + prelude::*, + pyclass::PyClassGuardError, + types::{PyBool, PyInt, PyType}, +}; define_enum!( /// An HTTP version. @@ -28,7 +35,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 +48,24 @@ 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: &Bound<'_, PyInt>) -> PyResult { + code.extract::() + .ok() + .and_then(|code| wreq::StatusCode::from_u16(code).ok()) + .map(Self) + .ok_or_else(|| PyValueError::new_err(format!("invalid status code: {code}"))) + } + + fn __reduce__<'py>( + slf: &Bound<'py, Self>, + ) -> (Bound<'py, PyType>, (u16,), Option>) { + // Instances of a Python subclass keep their attributes in `__dict__`. + let state = slf.getattr(intern!(slf.py(), "__dict__")).ok(); + (slf.get_type(), (slf.get().as_int(),), state) + } + /// Return the status code as an integer. pub const fn as_int(&self) -> u16 { self.0.as_u16() @@ -71,20 +96,25 @@ impl StatusCode { self.0.is_server_error() } - /// Rich comparison with integers. - fn __richcmp__(&self, other: &Bound, op: CompareOp) -> PyResult { - // Try to extract an integer from other - let other_int: u16 = other.extract()?; - let self_int = self.as_int(); - - Ok(match op { - CompareOp::Lt => self_int < other_int, - CompareOp::Le => self_int <= other_int, - CompareOp::Eq => self_int == other_int, - CompareOp::Ne => self_int != other_int, - CompareOp::Gt => self_int > other_int, - CompareOp::Ge => self_int >= other_int, - }) + /// Compare with another status code or an integer. + fn __richcmp__(&self, other: &Bound<'_, PyAny>, op: CompareOp) -> Py { + let py = other.py(); + let other = match other.extract::() { + Ok(code) => code.as_int(), + Err(_) => match other.extract::() { + Ok(code) => code, + Err(_) => return py.NotImplemented(), + }, + }; + PyBool::new(py, op.matches(self.as_int().cmp(&other))) + .to_owned() + .into_any() + .unbind() + } + + // Equal to the integer of the same value, so it hashes like one. + fn __hash__(&self) -> u64 { + u64::from(self.as_int()) } } diff --git a/src/lib.rs b/src/lib.rs index 796ab7d6..71a04281 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; @@ -319,6 +318,7 @@ mod blocking { fn wreq(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { use r#async::{delete, get, head, options, patch, post, put, request, trace, websocket}; + error::init(py)?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -356,7 +356,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()?; @@ -389,10 +388,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(()) } @@ -504,34 +499,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..6362b9d3 100644 --- a/tests/error_test.py +++ b/tests/error_test.py @@ -1,7 +1,21 @@ +import asyncio +import builtins +import pickle +import socket +import subprocess +import sys +from datetime import timedelta + import pytest import wreq import wreq.exceptions as exceptions +from cancellation_test import local_server + + +class Teapot(wreq.StatusCode): + pass + @pytest.mark.asyncio @pytest.mark.flaky(reruns=3, reruns_delay=2) @@ -19,3 +33,105 @@ 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 HTTP header name") as caught: + await client.get(refused, headers={"bad name": "v"}) + assert caught.value.is_builder() and caught.value.url is None + + # A constructed error holds only its message. + error = wreq.TimeoutError("timed out") + assert error.args == ("timed out",) and str(error) == "timed out" + assert error.url is None and error.status is None and not error.is_timeout() + + 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.status == caught.value.status and copy.status != None + assert copy.status in {404} and copy.is_status() and copy.url == url + for code in (-1, 1000, 70000): + with pytest.raises(ValueError): + wreq.StatusCode(code) + + teapot = Teapot(418) + teapot.note = "short and stout" + assert pickle.loads(pickle.dumps(teapot)).note == "short and stout" + + # A Python error that fails an upload is the cause of the wreq error. + def chunks(): + yield b"first" + raise ValueError("upload exploded") + + with pytest.raises(wreq.RequestError) as caught: + await asyncio.wait_for(client.post(url, body=chunks()), 5) + assert isinstance(caught.value.__cause__, ValueError) + assert str(caught.value.__cause__) == "upload exploded" + + +def test_mismatched_exceptions_module_fails_import(): + # A class the extension raises is missing, as with a stale `wreq/exceptions.py`. + script = """ +import importlib.util, os, sys +package = os.path.dirname(importlib.util.find_spec("wreq").origin) +spec = importlib.util.spec_from_file_location( + "wreq.exceptions", os.path.join(package, "exceptions.py") +) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +del module.UpgradeError +sys.modules["wreq.exceptions"] = module +try: + import wreq +except ImportError as error: + print(type(error.__cause__).__name__) +""" + result = subprocess.run( + [sys.executable, "-c", script], capture_output=True, text=True, timeout=60 + ) + assert result.stdout.strip() == "AttributeError", result.stderr 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/shutdown_test.py b/tests/shutdown_test.py index 54efdc1a..1f3e0ae6 100644 --- a/tests/shutdown_test.py +++ b/tests/shutdown_test.py @@ -11,7 +11,7 @@ import os import socket import sys -import threading +import time from types import FunctionType import wreq @@ -23,19 +23,16 @@ url = f"http://127.0.0.1:{listener.getsockname()[1]}/" -wait_lock = threading.Lock() -wait_lock.acquire() - - class HoldTeardown: def __init__(self, peer, task): self.peer = peer self.task = task - def __del__(self, write=os.write, wait=wait_lock.acquire): + # Python 3.15 refuses lock waits during finalization, so sleep instead. + def __del__(self, write=os.write, sleep=time.sleep): self.peer.close() write(2, b"teardown: connection closed\\n") - wait(timeout=1) + sleep(1) write(2, b"teardown: wait complete\\n") diff --git a/tests/websocket_test.py b/tests/websocket_test.py index 2d5e0654..bb2ce3dc 100644 --- a/tests/websocket_test.py +++ b/tests/websocket_test.py @@ -3,6 +3,7 @@ import hashlib import struct import threading +from datetime import timedelta import pytest import wreq @@ -75,6 +76,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 +152,37 @@ async def send_later(): release.set() server.close() await server.wait_closed() + + +@pytest.mark.asyncio +async def test_websocket_receive_errors(): + release = asyncio.Event() + + async def bad_frame(reader, writer): + try: + await handshake(reader, writer) + await release.wait() + # 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: + # A receive timeout is both a timeout and a WebSocket failure. + with pytest.raises(wreq.TimeoutError) as caught: + await ws.recv(timeout=timedelta(seconds=0.1)) + assert caught.value.is_timeout() and caught.value.is_websocket() + + release.set() + with pytest.raises(wreq.WebSocketError): + await asyncio.wait_for(ws.recv(), 5) + finally: + release.set() + server.close() + await server.wait_closed()