From a62855128cc4f6581e719386eb0288bbe045b310 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:47:25 +0000 Subject: [PATCH 01/10] Initial plan From 31fa76cf9f915f68dd486934d3edca18c7a51b83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:20:39 +0000 Subject: [PATCH 02/10] feat(python): support timeout=None to disable timeout per-request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `timeout=None` is explicitly passed to any per-request method, the timeout is now actively disabled (wait indefinitely), matching HTTPX behaviour. A `USE_CLIENT_DEFAULT` sentinel constant is introduced so users can also explicitly say "use the client-level timeout" when needed. Changes: - `impit/src/request.rs`: `RequestOptions.timeout` is now `Option>` (None = use default, Some(None) = disabled, Some(Some(d)) = specific value) - `impit/src/impit.rs`: translates the three-state timeout before passing to reqwest - `impit-node/src/lib.rs`: trivially wraps the existing timeout value in `Some(…)` to match the new type - `impit-python/src/request.rs`: adds `USE_CLIENT_DEFAULT_SENTINEL`, `default_timeout()` and `timeout_from_pyobj()` helpers - `impit-python/src/client.rs` / `async_client.rs` / `lib.rs`: per-request `timeout` parameter changed from `Option` to `Py` with the sentinel as default; parsed via `timeout_from_pyobj` - `impit-python/python/impit/__init__.py`: exports `USE_CLIENT_DEFAULT` - `impit-python/python/impit/impit.pyi`: updated type stubs and docs - `impit-python/test/basic_client_test.py`: new `TestTimeoutBehaviour` class Co-authored-by: barjin <61918049+barjin@users.noreply.github.com> --- impit-node/src/lib.rs | 2 +- impit-python/python/impit/__init__.py | 2 + impit-python/python/impit/impit.pyi | 105 +++++++++++++------------ impit-python/src/async_client.rs | 50 ++++++------ impit-python/src/client.rs | 48 +++++------ impit-python/src/lib.rs | 12 +-- impit-python/src/request.rs | 45 ++++++++++- impit-python/test/basic_client_test.py | 63 +++++++++++++++ impit/src/impit.rs | 6 +- impit/src/request.rs | 8 +- 10 files changed, 236 insertions(+), 105 deletions(-) diff --git a/impit-node/src/lib.rs b/impit-node/src/lib.rs index 8657d537..456ae6fc 100644 --- a/impit-node/src/lib.rs +++ b/impit-node/src/lib.rs @@ -120,7 +120,7 @@ impl ImpitWrapper { timeout: request_init .as_ref() .and_then(|init| init.timeout) - .map(|timeout| Duration::from_millis(timeout.into())), + .map(|timeout| Some(Duration::from_millis(timeout.into()))), http3_prior_knowledge: request_init .as_ref() .and_then(|init| init.force_http3) diff --git a/impit-python/python/impit/__init__.py b/impit-python/python/impit/__init__.py index d809788e..261b38d1 100644 --- a/impit-python/python/impit/__init__.py +++ b/impit-python/python/impit/__init__.py @@ -32,6 +32,7 @@ TooManyRedirects, TransportError, UnsupportedProtocol, + USE_CLIENT_DEFAULT, WriteError, WriteTimeout, delete, @@ -79,6 +80,7 @@ 'TooManyRedirects', 'TransportError', 'UnsupportedProtocol', + 'USE_CLIENT_DEFAULT', 'WriteError', 'WriteTimeout', 'delete', diff --git a/impit-python/python/impit/impit.pyi b/impit-python/python/impit/impit.pyi index f769b347..5ddbc554 100644 --- a/impit-python/python/impit/impit.pyi +++ b/impit-python/python/impit/impit.pyi @@ -9,6 +9,13 @@ from contextlib import AbstractAsyncContextManager, AbstractContextManager Browser = Literal['chrome', 'firefox', 'chrome125', 'chrome100', 'chrome101', 'chrome104', 'chrome107', 'chrome110', 'chrome116', 'chrome131', 'chrome136', 'chrome142', 'firefox128', 'firefox133', 'firefox135', 'firefox144'] +USE_CLIENT_DEFAULT: str +"""Sentinel that, when passed as a per-request ``timeout``, causes the client-level default timeout to be used. + +This is the default value for the ``timeout`` parameter in per-request methods. +Pass ``None`` instead to disable the timeout entirely. +""" + class HTTPError(Exception): """Represents an HTTP-related error.""" @@ -495,7 +502,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a GET request. @@ -505,7 +512,7 @@ class Client: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -515,7 +522,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a POST request. @@ -525,7 +532,7 @@ class Client: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -536,7 +543,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a PUT request. @@ -546,7 +553,7 @@ class Client: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -556,7 +563,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a PATCH request. @@ -566,7 +573,7 @@ class Client: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -576,7 +583,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a DELETE request. @@ -586,7 +593,7 @@ class Client: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -596,7 +603,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a HEAD request. @@ -606,7 +613,7 @@ class Client: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -616,7 +623,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an OPTIONS request. @@ -626,7 +633,7 @@ class Client: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -636,7 +643,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a TRACE request. @@ -646,7 +653,7 @@ class Client: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -657,7 +664,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, stream: bool = False, ) -> Response: @@ -669,7 +676,7 @@ class Client: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol stream: Whether to return a streaming response (default: False) """ @@ -681,7 +688,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> AbstractContextManager[Response]: """Make a streaming request with the specified method. @@ -703,7 +710,7 @@ class Client: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -836,7 +843,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous GET request. @@ -846,7 +853,7 @@ class AsyncClient: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -856,7 +863,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous POST request. @@ -866,7 +873,7 @@ class AsyncClient: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -877,7 +884,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous PUT request. @@ -887,7 +894,7 @@ class AsyncClient: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -897,7 +904,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous PATCH request. @@ -907,7 +914,7 @@ class AsyncClient: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -917,7 +924,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous DELETE request. @@ -927,7 +934,7 @@ class AsyncClient: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -937,7 +944,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous HEAD request. @@ -947,7 +954,7 @@ class AsyncClient: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -957,7 +964,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous OPTIONS request. @@ -967,7 +974,7 @@ class AsyncClient: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -977,7 +984,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous TRACE request. @@ -987,7 +994,7 @@ class AsyncClient: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -998,7 +1005,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, stream: bool = False, ) -> Response: @@ -1010,7 +1017,7 @@ class AsyncClient: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol stream: Whether to return a streaming response (default: False) """ @@ -1022,7 +1029,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> AbstractAsyncContextManager[Response]: """Make an asynchronous streaming request with the specified method. @@ -1044,7 +1051,7 @@ class AsyncClient: content: Raw content to send data: Form data to send in request body headers: HTTP headers for this request. Override both client-level and impersonation headers (case-insensitive). To remove an impersonated header, pass an empty string as the value - timeout: Request timeout in seconds (overrides default timeout) + timeout: Per-request timeout in seconds. Pass ``None`` to disable the timeout entirely. Defaults to ``USE_CLIENT_DEFAULT`` (inherits the client-level timeout). force_http3: Force HTTP/3 protocol """ @@ -1055,7 +1062,7 @@ def stream( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1088,7 +1095,7 @@ def get( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1121,7 +1128,7 @@ def post( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1154,7 +1161,7 @@ def put( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1187,7 +1194,7 @@ def patch( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1220,7 +1227,7 @@ def delete( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1253,7 +1260,7 @@ def head( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1286,7 +1293,7 @@ def options( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1316,7 +1323,7 @@ def trace( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = None, + timeout: float | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, diff --git a/impit-python/src/async_client.rs b/impit-python/src/async_client.rs index 422f8cd5..1ddc2ec9 100644 --- a/impit-python/src/async_client.rs +++ b/impit-python/src/async_client.rs @@ -8,8 +8,10 @@ use impit::{ use pyo3::{exceptions::PyTypeError, ffi::c_str, prelude::*}; use crate::{ - cookies::PythonCookieJar, errors::ImpitPyError, request::form_to_bytes, - response::ImpitPyResponse, RequestBody, + cookies::PythonCookieJar, + errors::ImpitPyError, + request::{form_to_bytes, timeout_from_pyobj, RequestBody}, + response::ImpitPyResponse, }; #[pyclass] @@ -160,7 +162,7 @@ impl AsyncClient { }) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn get<'python>( &self, py: Python<'python>, @@ -168,7 +170,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -184,7 +186,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn head<'python>( &self, py: Python<'python>, @@ -192,7 +194,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -208,7 +210,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn post<'python>( &self, py: Python<'python>, @@ -216,7 +218,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -232,7 +234,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn patch<'python>( &self, py: Python<'python>, @@ -240,7 +242,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -256,7 +258,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn put<'python>( &self, py: Python<'python>, @@ -264,7 +266,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -280,7 +282,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn delete<'python>( &self, py: Python<'python>, @@ -288,7 +290,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -304,7 +306,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn options<'python>( &self, py: Python<'python>, @@ -312,7 +314,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -328,7 +330,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn trace<'python>( &self, py: Python<'python>, @@ -336,7 +338,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -352,7 +354,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn stream<'python>( &self, py: Python<'python>, @@ -361,7 +363,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result, PyErr> { let response = self.request( @@ -398,7 +400,7 @@ impl AsyncClient { Ok(wrapped_response.into_bound(py)) } - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=None, force_http3=false, stream=false))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false, stream=false))] pub fn request<'python>( &self, py: Python<'python>, @@ -407,7 +409,7 @@ impl AsyncClient { content: Option>, mut data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, stream: Option, ) -> Result, PyErr> { @@ -434,13 +436,15 @@ impl AsyncClient { None => Ok(Vec::new()), }?; + let timeout = timeout_from_pyobj(timeout.bind(py))?; + let options = RequestOptions { headers: headers .unwrap_or_default() .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(), - timeout: timeout.map(Duration::from_secs_f64), + timeout, http3_prior_knowledge: force_http3.unwrap_or(false), }; diff --git a/impit-python/src/client.rs b/impit-python/src/client.rs index ef90352a..c9378bd8 100644 --- a/impit-python/src/client.rs +++ b/impit-python/src/client.rs @@ -10,7 +10,7 @@ use pyo3::{ffi::c_str, prelude::*}; use crate::{ cookies::PythonCookieJar, errors::ImpitPyError, - request::{form_to_bytes, RequestBody}, + request::{form_to_bytes, timeout_from_pyobj, RequestBody}, response::{self, ImpitPyResponse}, }; @@ -151,7 +151,7 @@ impl Client { }) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn get( &self, py: Python<'_>, @@ -159,7 +159,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result { self.request( @@ -175,7 +175,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn head( &self, py: Python<'_>, @@ -183,7 +183,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result { self.request( @@ -199,7 +199,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn post( &self, py: Python<'_>, @@ -207,7 +207,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result { self.request( @@ -223,7 +223,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn patch( &self, py: Python<'_>, @@ -231,7 +231,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result { self.request( @@ -247,7 +247,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn put( &self, py: Python<'_>, @@ -255,7 +255,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result { self.request( @@ -271,7 +271,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn delete( &self, py: Python<'_>, @@ -279,7 +279,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result { self.request( @@ -295,7 +295,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn options( &self, py: Python<'_>, @@ -303,7 +303,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result { self.request( @@ -319,7 +319,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn trace( &self, py: Python<'_>, @@ -327,7 +327,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result { self.request( @@ -343,7 +343,7 @@ impl Client { ) } - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=None, force_http3=false))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] pub fn stream<'python>( &self, py: Python<'python>, @@ -352,7 +352,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, ) -> Result, PyErr> { let response = self.request( @@ -389,7 +389,7 @@ impl Client { Ok(wrapped_response.into_bound(py)) } - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=None, force_http3=false, stream=false))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false, stream=false))] pub fn request( &self, py: Python<'_>, @@ -398,7 +398,7 @@ impl Client { content: Option>, mut data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, stream: Option, ) -> Result { @@ -425,13 +425,17 @@ impl Client { None => Ok(Vec::new()), }?; + let timeout = timeout_from_pyobj(timeout.bind(py)).map_err(|e| { + ImpitPyError(ImpitError::BindingPassthroughError(e.to_string())) + })?; + let options = RequestOptions { headers: headers .unwrap_or_default() .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(), - timeout: timeout.map(Duration::from_secs_f64), + timeout, http3_prior_knowledge: force_http3.unwrap_or(false), }; diff --git a/impit-python/src/lib.rs b/impit-python/src/lib.rs index b6d2cf66..32073a3a 100644 --- a/impit-python/src/lib.rs +++ b/impit-python/src/lib.rs @@ -9,7 +9,7 @@ mod response; use async_client::AsyncClient; use client::Client; -use request::RequestBody; +use request::{RequestBody, USE_CLIENT_DEFAULT_SENTINEL}; use std::collections::HashMap; #[pymodule] @@ -87,14 +87,14 @@ fn impit(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { ($($name:ident),*) => { $( #[pyfunction] - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=None, force_http3=false, cookie_jar=None, cookies=None, follow_redirects=None, max_redirects=None, proxy=None))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false, cookie_jar=None, cookies=None, follow_redirects=None, max_redirects=None, proxy=None))] fn $name( _py: Python, url: String, content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, cookie_jar: Option>, cookies: Option>, @@ -115,7 +115,7 @@ fn impit(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { http_no_client!(get, post, put, head, patch, delete, options, trace); #[pyfunction] - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=None, force_http3=false, cookie_jar=None, cookies=None, follow_redirects=None, max_redirects=None, proxy=None))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false, cookie_jar=None, cookies=None, follow_redirects=None, max_redirects=None, proxy=None))] fn stream<'python>( _py: Python<'python>, method: &str, @@ -123,7 +123,7 @@ fn impit(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Py, force_http3: Option, cookie_jar: Option>, cookies: Option>, @@ -161,5 +161,7 @@ fn impit(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(stream, m)?)?; + m.add("USE_CLIENT_DEFAULT", USE_CLIENT_DEFAULT_SENTINEL)?; + Ok(()) } diff --git a/impit-python/src/request.rs b/impit-python/src/request.rs index 00e26221..ae326855 100644 --- a/impit-python/src/request.rs +++ b/impit-python/src/request.rs @@ -1,6 +1,47 @@ -use std::collections::HashMap; +use std::{collections::HashMap, time::Duration}; -use pyo3::{Bound, FromPyObject, PyAny}; +use pyo3::{prelude::*, types::PyAnyMethods, Bound, PyAny}; + +/// The sentinel string used as the Python default value for per-request `timeout` parameters. +/// +/// When a user does not explicitly supply a `timeout`, this string is received by the Rust +/// method and treated as "inherit the client-level default". It is exposed to Python as +/// the `USE_CLIENT_DEFAULT` module constant so that callers can also pass it explicitly. +pub(crate) const USE_CLIENT_DEFAULT_SENTINEL: &str = "__impit_use_client_default__"; + +/// Returns the sentinel value used as the Python default for per-request `timeout`. +/// +/// Calling convention: `#[pyo3(signature = (timeout=crate::request::default_timeout()))]`. +pub(crate) fn default_timeout() -> Py { + Python::attach(|py| pyo3::intern!(py, USE_CLIENT_DEFAULT_SENTINEL).clone().into()) +} + +/// Parse a Python `timeout` argument into `Option>`: +/// +/// - `USE_CLIENT_DEFAULT_SENTINEL` string (not provided / explicit sentinel) → `None` +/// (inherit client default) +/// - Python `None` → `Some(None)` (disable timeout) +/// - Python `float` → `Some(Some(Duration))` (specific timeout) +pub(crate) fn timeout_from_pyobj( + timeout: &Bound<'_, PyAny>, +) -> pyo3::PyResult>> { + if timeout.is_none() { + Ok(Some(None)) + } else if let Ok(s) = timeout.extract::<&str>() { + if s == USE_CLIENT_DEFAULT_SENTINEL { + Ok(None) + } else { + Err(pyo3::exceptions::PyValueError::new_err(format!( + "Invalid timeout value: {s:?}" + ))) + } + } else { + let secs = timeout.extract::()?; + Ok(Some(Some(Duration::from_secs_f64(secs)))) + } +} + +use pyo3::FromPyObject; #[derive(FromPyObject)] pub(crate) enum RequestBody<'py> { diff --git a/impit-python/test/basic_client_test.py b/impit-python/test/basic_client_test.py index 3f61accd..7a94adbc 100644 --- a/impit-python/test/basic_client_test.py +++ b/impit-python/test/basic_client_test.py @@ -599,3 +599,66 @@ def test_iter_bytes_without_consumed(self, browser: Browser) -> None: with pytest.raises(StreamClosed): _ = response.content + + +def make_slow_server(port_holder: list[int], delay: float = 2.0) -> socket.socket: + """Create a server that waits before responding.""" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(('127.0.0.1', 0)) + port_holder[0] = server.getsockname()[1] + server.listen(1) + + def _serve() -> None: + conn, _ = server.accept() + conn.recv(1024) + time.sleep(delay) + conn.send(b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK') + conn.close() + server.close() + + t = threading.Thread(target=_serve, daemon=True) + t.start() + return server + + +class TestTimeoutBehaviour: + def test_timeout_none_disables_timeout(self) -> None: + """timeout=None should disable the timeout entirely, not use the client default.""" + from impit import ConnectTimeout, ReadTimeout + + port_holder = [0] + # Start a slow server (responds after 1.5s) + make_slow_server(port_holder, delay=1.5) + + time.sleep(0.05) + + # Client has a very short default timeout + impit = Client(timeout=0.1) + + # Without explicit timeout, the client default (0.1s) should be used → timeout + with pytest.raises((ConnectTimeout, ReadTimeout)): + impit.get(f'http://127.0.0.1:{port_holder[0]}/') + + # Restart the slow server for the second request + port_holder2 = [0] + make_slow_server(port_holder2, delay=1.5) + time.sleep(0.05) + + # With timeout=None, the timeout should be disabled → request succeeds + response = Client(timeout=0.1).get(f'http://127.0.0.1:{port_holder2[0]}/', timeout=None) + assert response.status_code == 200 + + def test_use_client_default_uses_client_timeout(self) -> None: + """USE_CLIENT_DEFAULT should use the client-level timeout.""" + from impit import USE_CLIENT_DEFAULT, ConnectTimeout, ReadTimeout + + port_holder = [0] + make_slow_server(port_holder, delay=1.5) + time.sleep(0.05) + + impit = Client(timeout=0.1) + + # Explicitly passing USE_CLIENT_DEFAULT should behave same as not passing timeout + with pytest.raises((ConnectTimeout, ReadTimeout)): + impit.get(f'http://127.0.0.1:{port_holder[0]}/', timeout=USE_CLIENT_DEFAULT) diff --git a/impit/src/impit.rs b/impit/src/impit.rs index d48f48a2..5a0c48a6 100644 --- a/impit/src/impit.rs +++ b/impit/src/impit.rs @@ -506,7 +506,11 @@ impl Impit { let headers = request_options.headers; let request = self.build_request(method, url, body, headers); - let timeout = request_options.timeout; + let timeout = match request_options.timeout { + None => None, + Some(None) => Some(Duration::MAX), + Some(Some(d)) => Some(d), + }; let http3_prior_knowledge = request_options.http3_prior_knowledge; self.send(request, timeout, Some(http3_prior_knowledge)) .await diff --git a/impit/src/request.rs b/impit/src/request.rs index b15714dc..a0f1d0ad 100644 --- a/impit/src/request.rs +++ b/impit/src/request.rs @@ -12,8 +12,12 @@ pub struct RequestOptions { /// A `Vec` of string pairs that represent custom HTTP request headers. These take precedence over the headers set in [`ImpitBuilder`](crate::impit::ImpitBuilder) /// (both from the `with_headers` and the `with_browser` methods). pub headers: Vec<(String, String)>, - /// The timeout for the request. This option overrides the global [`Impit`] timeout. - pub timeout: Option, + /// The per-request timeout, with three possible states: + /// + /// - `None` — inherit the client-level default timeout set via [`ImpitBuilder::with_default_timeout`](crate::impit::ImpitBuilder::with_default_timeout). + /// - `Some(None)` — disable the timeout entirely for this request (wait indefinitely). + /// - `Some(Some(d))` — use the given duration, overriding the client-level default. + pub timeout: Option>, /// Enforce the use of HTTP/3 for this request. This will cause broken responses from servers that don't support HTTP/3. /// /// If [`ImpitBuilder::with_http3`](crate::impit::ImpitBuilder::with_http3) wasn't called, this option will cause [`ErrorType::Http3Disabled`](crate::impit::ErrorType::Http3Disabled) errors. From 24fbe890cd4279c1e3564ee4acf822741d381bf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:25:49 +0000 Subject: [PATCH 03/10] fix: address code review - add Duration::MAX comment and improve test quality Co-authored-by: barjin <61918049+barjin@users.noreply.github.com> --- impit-python/test/basic_client_test.py | 35 +++++++++++++------------- impit/src/impit.rs | 2 ++ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/impit-python/test/basic_client_test.py b/impit-python/test/basic_client_test.py index 7a94adbc..9fe2bb29 100644 --- a/impit-python/test/basic_client_test.py +++ b/impit-python/test/basic_client_test.py @@ -601,8 +601,9 @@ def test_iter_bytes_without_consumed(self, browser: Browser) -> None: _ = response.content -def make_slow_server(port_holder: list[int], delay: float = 2.0) -> socket.socket: - """Create a server that waits before responding.""" + +def make_slow_server(port_holder: list[int], delay: float = 2.0) -> None: + """Start a server in a daemon thread that waits `delay` seconds before responding.""" server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(('127.0.0.1', 0)) @@ -610,16 +611,16 @@ def make_slow_server(port_holder: list[int], delay: float = 2.0) -> socket.socke server.listen(1) def _serve() -> None: - conn, _ = server.accept() - conn.recv(1024) - time.sleep(delay) - conn.send(b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK') - conn.close() - server.close() + try: + conn, _ = server.accept() + conn.recv(1024) + time.sleep(delay) + conn.send(b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK') + conn.close() + finally: + server.close() - t = threading.Thread(target=_serve, daemon=True) - t.start() - return server + threading.Thread(target=_serve, daemon=True).start() class TestTimeoutBehaviour: @@ -630,23 +631,21 @@ def test_timeout_none_disables_timeout(self) -> None: port_holder = [0] # Start a slow server (responds after 1.5s) make_slow_server(port_holder, delay=1.5) - time.sleep(0.05) - # Client has a very short default timeout impit = Client(timeout=0.1) - # Without explicit timeout, the client default (0.1s) should be used → timeout + # Without explicit timeout override the client default (0.1s) should fire. with pytest.raises((ConnectTimeout, ReadTimeout)): impit.get(f'http://127.0.0.1:{port_holder[0]}/') - # Restart the slow server for the second request + # Restart the slow server for the second request. port_holder2 = [0] make_slow_server(port_holder2, delay=1.5) time.sleep(0.05) - # With timeout=None, the timeout should be disabled → request succeeds - response = Client(timeout=0.1).get(f'http://127.0.0.1:{port_holder2[0]}/', timeout=None) + # With timeout=None the timeout is disabled → request succeeds despite slow server. + response = impit.get(f'http://127.0.0.1:{port_holder2[0]}/', timeout=None) assert response.status_code == 200 def test_use_client_default_uses_client_timeout(self) -> None: @@ -659,6 +658,6 @@ def test_use_client_default_uses_client_timeout(self) -> None: impit = Client(timeout=0.1) - # Explicitly passing USE_CLIENT_DEFAULT should behave same as not passing timeout + # Explicitly passing USE_CLIENT_DEFAULT should behave the same as not passing timeout. with pytest.raises((ConnectTimeout, ReadTimeout)): impit.get(f'http://127.0.0.1:{port_holder[0]}/', timeout=USE_CLIENT_DEFAULT) diff --git a/impit/src/impit.rs b/impit/src/impit.rs index 5a0c48a6..6e6cb9f8 100644 --- a/impit/src/impit.rs +++ b/impit/src/impit.rs @@ -508,6 +508,8 @@ impl Impit { let timeout = match request_options.timeout { None => None, + // reqwest has no per-request "no timeout" API; overriding with Duration::MAX is the + // conventional way to disable a timeout without rebuilding the client. Some(None) => Some(Duration::MAX), Some(Some(d)) => Some(d), }; From 2c965e3b5e0135d38c274acd1fa17216623e76fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Fri, 6 Mar 2026 11:11:10 +0100 Subject: [PATCH 04/10] refactor(python): use pyo3 Either for timeout parameter instead of GIL-acquired sentinel Replace Py timeout parameters with Option> leveraging pyo3's built-in either feature for type extraction. This removes the default_timeout() function that acquired the GIL just to create a Python string. --- Cargo.lock | 2 ++ impit-python/Cargo.toml | 3 ++- impit-python/src/async_client.rs | 45 ++++++++++++++++---------------- impit-python/src/client.rs | 45 ++++++++++++++++---------------- impit-python/src/lib.rs | 9 ++++--- impit-python/src/request.rs | 40 ++++++++++++---------------- 6 files changed, 71 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ed763110..90999823 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1541,6 +1541,7 @@ version = "0.0.0" dependencies = [ "bytes", "cookie", + "either", "encoding", "futures", "h2", @@ -2183,6 +2184,7 @@ version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf85e27e86080aafd5a22eae58a162e133a589551542b3e5cee4beb27e54f8e1" dependencies = [ + "either", "libc", "once_cell", "portable-atomic", diff --git a/impit-python/Cargo.toml b/impit-python/Cargo.toml index 88842965..1c5faf2c 100644 --- a/impit-python/Cargo.toml +++ b/impit-python/Cargo.toml @@ -15,10 +15,11 @@ h2 = "0.4.7" reqwest = "0.13.1" tokio-stream = "0.1.17" bytes = "1.9.0" -pyo3 = { version = "0.28", features = ["extension-module", "auto-initialize"] } +pyo3 = { version = "0.28", features = ["extension-module", "auto-initialize", "either"] } pyo3-async-runtimes = { version = "0.28", features = ["attributes", "async-std-runtime", "tokio-runtime"] } openssl = { version = "*", features = ["vendored"] } urlencoding = "2.1.3" encoding = "0.2.33" cookie = "0.18.1" futures = "0.3.31" +either = "1.15.0" diff --git a/impit-python/src/async_client.rs b/impit-python/src/async_client.rs index 1ddc2ec9..d0b2da0c 100644 --- a/impit-python/src/async_client.rs +++ b/impit-python/src/async_client.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; +use either::{Either, Right}; use impit::{ errors::ImpitError, impit::{Impit, ImpitBuilder}, @@ -10,7 +11,7 @@ use pyo3::{exceptions::PyTypeError, ffi::c_str, prelude::*}; use crate::{ cookies::PythonCookieJar, errors::ImpitPyError, - request::{form_to_bytes, timeout_from_pyobj, RequestBody}, + request::{RequestBody, USE_CLIENT_DEFAULT_SENTINEL, form_to_bytes, parse_timeout}, response::ImpitPyResponse, }; @@ -162,7 +163,7 @@ impl AsyncClient { }) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn get<'python>( &self, py: Python<'python>, @@ -170,7 +171,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -186,7 +187,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn head<'python>( &self, py: Python<'python>, @@ -194,7 +195,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -210,7 +211,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn post<'python>( &self, py: Python<'python>, @@ -218,7 +219,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -234,7 +235,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn patch<'python>( &self, py: Python<'python>, @@ -242,7 +243,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -258,7 +259,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn put<'python>( &self, py: Python<'python>, @@ -266,7 +267,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -282,7 +283,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn delete<'python>( &self, py: Python<'python>, @@ -290,7 +291,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -306,7 +307,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn options<'python>( &self, py: Python<'python>, @@ -314,7 +315,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -330,7 +331,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn trace<'python>( &self, py: Python<'python>, @@ -338,7 +339,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -354,7 +355,7 @@ impl AsyncClient { ) } - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn stream<'python>( &self, py: Python<'python>, @@ -363,7 +364,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { let response = self.request( @@ -400,7 +401,7 @@ impl AsyncClient { Ok(wrapped_response.into_bound(py)) } - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false, stream=false))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, stream=false))] pub fn request<'python>( &self, py: Python<'python>, @@ -409,7 +410,7 @@ impl AsyncClient { content: Option>, mut data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, stream: Option, ) -> Result, PyErr> { @@ -436,7 +437,7 @@ impl AsyncClient { None => Ok(Vec::new()), }?; - let timeout = timeout_from_pyobj(timeout.bind(py))?; + let timeout = parse_timeout(timeout)?; let options = RequestOptions { headers: headers diff --git a/impit-python/src/client.rs b/impit-python/src/client.rs index c9378bd8..075b30d0 100644 --- a/impit-python/src/client.rs +++ b/impit-python/src/client.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, time::Duration}; +use either::{Either, Right}; use impit::{ errors::ImpitError, impit::{Impit, ImpitBuilder}, @@ -10,7 +11,7 @@ use pyo3::{ffi::c_str, prelude::*}; use crate::{ cookies::PythonCookieJar, errors::ImpitPyError, - request::{form_to_bytes, timeout_from_pyobj, RequestBody}, + request::{RequestBody, USE_CLIENT_DEFAULT_SENTINEL, form_to_bytes, parse_timeout}, response::{self, ImpitPyResponse}, }; @@ -151,7 +152,7 @@ impl Client { }) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn get( &self, py: Python<'_>, @@ -159,7 +160,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -175,7 +176,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn head( &self, py: Python<'_>, @@ -183,7 +184,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -199,7 +200,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn post( &self, py: Python<'_>, @@ -207,7 +208,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -223,7 +224,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn patch( &self, py: Python<'_>, @@ -231,7 +232,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -247,7 +248,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn put( &self, py: Python<'_>, @@ -255,7 +256,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -271,7 +272,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn delete( &self, py: Python<'_>, @@ -279,7 +280,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -295,7 +296,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn options( &self, py: Python<'_>, @@ -303,7 +304,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -319,7 +320,7 @@ impl Client { ) } - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn trace( &self, py: Python<'_>, @@ -327,7 +328,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -343,7 +344,7 @@ impl Client { ) } - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn stream<'python>( &self, py: Python<'python>, @@ -352,7 +353,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { let response = self.request( @@ -389,7 +390,7 @@ impl Client { Ok(wrapped_response.into_bound(py)) } - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false, stream=false))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, stream=false))] pub fn request( &self, py: Python<'_>, @@ -398,7 +399,7 @@ impl Client { content: Option>, mut data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, stream: Option, ) -> Result { @@ -425,7 +426,7 @@ impl Client { None => Ok(Vec::new()), }?; - let timeout = timeout_from_pyobj(timeout.bind(py)).map_err(|e| { + let timeout = parse_timeout(timeout).map_err(|e| { ImpitPyError(ImpitError::BindingPassthroughError(e.to_string())) })?; diff --git a/impit-python/src/lib.rs b/impit-python/src/lib.rs index 32073a3a..887254a0 100644 --- a/impit-python/src/lib.rs +++ b/impit-python/src/lib.rs @@ -1,3 +1,4 @@ +use either::{Either, Right}; use pyo3::prelude::*; mod async_client; @@ -87,14 +88,14 @@ fn impit(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { ($($name:ident),*) => { $( #[pyfunction] - #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false, cookie_jar=None, cookies=None, follow_redirects=None, max_redirects=None, proxy=None))] + #[pyo3(signature = (url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, cookie_jar=None, cookies=None, follow_redirects=None, max_redirects=None, proxy=None))] fn $name( _py: Python, url: String, content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, cookie_jar: Option>, cookies: Option>, @@ -115,7 +116,7 @@ fn impit(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { http_no_client!(get, post, put, head, patch, delete, options, trace); #[pyfunction] - #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=crate::request::default_timeout(), force_http3=false, cookie_jar=None, cookies=None, follow_redirects=None, max_redirects=None, proxy=None))] + #[pyo3(signature = (method, url, content=None, data=None, headers=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, cookie_jar=None, cookies=None, follow_redirects=None, max_redirects=None, proxy=None))] fn stream<'python>( _py: Python<'python>, method: &str, @@ -123,7 +124,7 @@ fn impit(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { content: Option>, data: Option, headers: Option>, - timeout: Py, + timeout: Option>, force_http3: Option, cookie_jar: Option>, cookies: Option>, diff --git a/impit-python/src/request.rs b/impit-python/src/request.rs index ae326855..07a26da6 100644 --- a/impit-python/src/request.rs +++ b/impit-python/src/request.rs @@ -1,43 +1,35 @@ use std::{collections::HashMap, time::Duration}; -use pyo3::{prelude::*, types::PyAnyMethods, Bound, PyAny}; +use either::{Either, Left, Right}; +use pyo3::{Bound, PyAny}; /// The sentinel string used as the Python default value for per-request `timeout` parameters. /// /// When a user does not explicitly supply a `timeout`, this string is received by the Rust /// method and treated as "inherit the client-level default". It is exposed to Python as /// the `USE_CLIENT_DEFAULT` module constant so that callers can also pass it explicitly. -pub(crate) const USE_CLIENT_DEFAULT_SENTINEL: &str = "__impit_use_client_default__"; - -/// Returns the sentinel value used as the Python default for per-request `timeout`. -/// -/// Calling convention: `#[pyo3(signature = (timeout=crate::request::default_timeout()))]`. -pub(crate) fn default_timeout() -> Py { - Python::attach(|py| pyo3::intern!(py, USE_CLIENT_DEFAULT_SENTINEL).clone().into()) -} - +pub(crate) const USE_CLIENT_DEFAULT_SENTINEL: &str = "USE_CLIENT_DEFAULT"; /// Parse a Python `timeout` argument into `Option>`: /// /// - `USE_CLIENT_DEFAULT_SENTINEL` string (not provided / explicit sentinel) → `None` /// (inherit client default) /// - Python `None` → `Some(None)` (disable timeout) /// - Python `float` → `Some(Some(Duration))` (specific timeout) -pub(crate) fn timeout_from_pyobj( - timeout: &Bound<'_, PyAny>, +pub(crate) fn parse_timeout( + timeout: Option>, ) -> pyo3::PyResult>> { - if timeout.is_none() { - Ok(Some(None)) - } else if let Ok(s) = timeout.extract::<&str>() { - if s == USE_CLIENT_DEFAULT_SENTINEL { - Ok(None) - } else { - Err(pyo3::exceptions::PyValueError::new_err(format!( - "Invalid timeout value: {s:?}" - ))) + match timeout { + None => Ok(Some(None)), + Some(Left(secs)) => Ok(Some(Some(Duration::from_secs_f64(secs)))), + Some(Right(s)) => { + if s != USE_CLIENT_DEFAULT_SENTINEL { + Err(pyo3::exceptions::PyValueError::new_err(format!( + "Invalid timeout value: {s:?}" + ))) + } else { + Ok(None) + } } - } else { - let secs = timeout.extract::()?; - Ok(Some(Some(Duration::from_secs_f64(secs)))) } } From 827e11004f76c8d2ee86bbd1c200e4837c9c723b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Fri, 6 Mar 2026 11:20:35 +0100 Subject: [PATCH 05/10] chore: run formatter + clippy fixes --- impit-cli/src/main.rs | 4 +++- impit-python/src/async_client.rs | 2 +- impit-python/src/client.rs | 7 +++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/impit-cli/src/main.rs b/impit-cli/src/main.rs index 8e601066..b3acdf52 100644 --- a/impit-cli/src/main.rs +++ b/impit-cli/src/main.rs @@ -122,7 +122,9 @@ async fn main() { let client = client.build().unwrap(); - let timeout = args.max_time.map(std::time::Duration::from_secs); + let timeout = args + .max_time + .map(|x| Some(std::time::Duration::from_secs(x))); let options = RequestOptions { headers: headers::process_headers(args.headers), diff --git a/impit-python/src/async_client.rs b/impit-python/src/async_client.rs index d0b2da0c..d2e50113 100644 --- a/impit-python/src/async_client.rs +++ b/impit-python/src/async_client.rs @@ -11,7 +11,7 @@ use pyo3::{exceptions::PyTypeError, ffi::c_str, prelude::*}; use crate::{ cookies::PythonCookieJar, errors::ImpitPyError, - request::{RequestBody, USE_CLIENT_DEFAULT_SENTINEL, form_to_bytes, parse_timeout}, + request::{form_to_bytes, parse_timeout, RequestBody, USE_CLIENT_DEFAULT_SENTINEL}, response::ImpitPyResponse, }; diff --git a/impit-python/src/client.rs b/impit-python/src/client.rs index 075b30d0..39fe1075 100644 --- a/impit-python/src/client.rs +++ b/impit-python/src/client.rs @@ -11,7 +11,7 @@ use pyo3::{ffi::c_str, prelude::*}; use crate::{ cookies::PythonCookieJar, errors::ImpitPyError, - request::{RequestBody, USE_CLIENT_DEFAULT_SENTINEL, form_to_bytes, parse_timeout}, + request::{form_to_bytes, parse_timeout, RequestBody, USE_CLIENT_DEFAULT_SENTINEL}, response::{self, ImpitPyResponse}, }; @@ -426,9 +426,8 @@ impl Client { None => Ok(Vec::new()), }?; - let timeout = parse_timeout(timeout).map_err(|e| { - ImpitPyError(ImpitError::BindingPassthroughError(e.to_string())) - })?; + let timeout = parse_timeout(timeout) + .map_err(|e| ImpitPyError(ImpitError::BindingPassthroughError(e.to_string())))?; let options = RequestOptions { headers: headers From 2eb3fd865755b703d72bed2fc0378c9dd64b983f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Fri, 6 Mar 2026 11:25:49 +0100 Subject: [PATCH 06/10] chore: run python formatter --- impit-python/python/impit/__init__.py | 4 ++-- impit-python/test/basic_client_test.py | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/impit-python/python/impit/__init__.py b/impit-python/python/impit/__init__.py index 261b38d1..de7b311c 100644 --- a/impit-python/python/impit/__init__.py +++ b/impit-python/python/impit/__init__.py @@ -3,6 +3,7 @@ from .cookies import Cookies from .impit import ( + USE_CLIENT_DEFAULT, AsyncClient, Client, CloseError, @@ -32,7 +33,6 @@ TooManyRedirects, TransportError, UnsupportedProtocol, - USE_CLIENT_DEFAULT, WriteError, WriteTimeout, delete, @@ -49,6 +49,7 @@ __version__ = metadata.version('impit') __all__ = [ + 'USE_CLIENT_DEFAULT', 'AsyncClient', 'Browser', 'Client', @@ -80,7 +81,6 @@ 'TooManyRedirects', 'TransportError', 'UnsupportedProtocol', - 'USE_CLIENT_DEFAULT', 'WriteError', 'WriteTimeout', 'delete', diff --git a/impit-python/test/basic_client_test.py b/impit-python/test/basic_client_test.py index 9fe2bb29..c4e1f73f 100644 --- a/impit-python/test/basic_client_test.py +++ b/impit-python/test/basic_client_test.py @@ -7,7 +7,17 @@ import pytest -from impit import Browser, Client, Cookies, StreamClosed, StreamConsumed, TooManyRedirects +from impit import ( + USE_CLIENT_DEFAULT, + Browser, + Client, + ConnectTimeout, + Cookies, + ReadTimeout, + StreamClosed, + StreamConsumed, + TooManyRedirects, +) from .httpbin import get_httpbin_url from .setup_proxy import start_proxy_server @@ -601,7 +611,6 @@ def test_iter_bytes_without_consumed(self, browser: Browser) -> None: _ = response.content - def make_slow_server(port_holder: list[int], delay: float = 2.0) -> None: """Start a server in a daemon thread that waits `delay` seconds before responding.""" server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -626,7 +635,6 @@ def _serve() -> None: class TestTimeoutBehaviour: def test_timeout_none_disables_timeout(self) -> None: """timeout=None should disable the timeout entirely, not use the client default.""" - from impit import ConnectTimeout, ReadTimeout port_holder = [0] # Start a slow server (responds after 1.5s) @@ -650,7 +658,6 @@ def test_timeout_none_disables_timeout(self) -> None: def test_use_client_default_uses_client_timeout(self) -> None: """USE_CLIENT_DEFAULT should use the client-level timeout.""" - from impit import USE_CLIENT_DEFAULT, ConnectTimeout, ReadTimeout port_holder = [0] make_slow_server(port_holder, delay=1.5) From 7a4c506f95e62488a003158e8fa0cfdd868dad8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Fri, 6 Mar 2026 11:40:16 +0100 Subject: [PATCH 07/10] fix(python): catch TimeoutException in timeout tests The error mapping produces the base TimeoutException, not the ConnectTimeout/ReadTimeout subclasses. --- impit-python/test/basic_client_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/impit-python/test/basic_client_test.py b/impit-python/test/basic_client_test.py index c4e1f73f..5a3fefe0 100644 --- a/impit-python/test/basic_client_test.py +++ b/impit-python/test/basic_client_test.py @@ -16,6 +16,7 @@ ReadTimeout, StreamClosed, StreamConsumed, + TimeoutException, TooManyRedirects, ) @@ -644,7 +645,7 @@ def test_timeout_none_disables_timeout(self) -> None: impit = Client(timeout=0.1) # Without explicit timeout override the client default (0.1s) should fire. - with pytest.raises((ConnectTimeout, ReadTimeout)): + with pytest.raises((TimeoutException, ConnectTimeout, ReadTimeout)): impit.get(f'http://127.0.0.1:{port_holder[0]}/') # Restart the slow server for the second request. @@ -666,5 +667,5 @@ def test_use_client_default_uses_client_timeout(self) -> None: impit = Client(timeout=0.1) # Explicitly passing USE_CLIENT_DEFAULT should behave the same as not passing timeout. - with pytest.raises((ConnectTimeout, ReadTimeout)): + with pytest.raises((TimeoutException, ConnectTimeout, ReadTimeout)): impit.get(f'http://127.0.0.1:{port_holder[0]}/', timeout=USE_CLIENT_DEFAULT) From 62d2dae81870eef05ef235eeb6b3b53242c478fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Fri, 6 Mar 2026 12:13:17 +0100 Subject: [PATCH 08/10] chore: update lockfile --- Cargo.lock | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index aeb644a9..b11e1b2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -588,6 +588,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encoding" version = "0.2.33" From 2dfe573990f3bc94293495585a86e8244edc5a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Fri, 6 Mar 2026 12:48:28 +0100 Subject: [PATCH 09/10] chore: fix mypy errors --- impit-python/python/impit/impit.pyi | 58 ++++++++++++++--------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/impit-python/python/impit/impit.pyi b/impit-python/python/impit/impit.pyi index 5ddbc554..4189742f 100644 --- a/impit-python/python/impit/impit.pyi +++ b/impit-python/python/impit/impit.pyi @@ -502,7 +502,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a GET request. @@ -522,7 +522,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a POST request. @@ -543,7 +543,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a PUT request. @@ -563,7 +563,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a PATCH request. @@ -583,7 +583,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a DELETE request. @@ -603,7 +603,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a HEAD request. @@ -623,7 +623,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an OPTIONS request. @@ -643,7 +643,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make a TRACE request. @@ -664,7 +664,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, stream: bool = False, ) -> Response: @@ -688,7 +688,7 @@ class Client: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> AbstractContextManager[Response]: """Make a streaming request with the specified method. @@ -843,7 +843,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous GET request. @@ -863,7 +863,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous POST request. @@ -884,7 +884,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous PUT request. @@ -904,7 +904,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous PATCH request. @@ -924,7 +924,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous DELETE request. @@ -944,7 +944,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous HEAD request. @@ -964,7 +964,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous OPTIONS request. @@ -984,7 +984,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> Response: """Make an asynchronous TRACE request. @@ -1005,7 +1005,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, stream: bool = False, ) -> Response: @@ -1029,7 +1029,7 @@ class AsyncClient: content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, ) -> AbstractAsyncContextManager[Response]: """Make an asynchronous streaming request with the specified method. @@ -1062,7 +1062,7 @@ def stream( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1095,7 +1095,7 @@ def get( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1128,7 +1128,7 @@ def post( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1161,7 +1161,7 @@ def put( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1194,7 +1194,7 @@ def patch( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1227,7 +1227,7 @@ def delete( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1260,7 +1260,7 @@ def head( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1293,7 +1293,7 @@ def options( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, @@ -1323,7 +1323,7 @@ def trace( content: bytes | bytearray | list[int] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, - timeout: float | None = USE_CLIENT_DEFAULT, + timeout: float | str | None = USE_CLIENT_DEFAULT, force_http3: bool | None = None, follow_redirects: bool | None = None, max_redirects: int | None = None, From 5cfad74ae998699bda687e90fd15ef1e6fad1aed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Fri, 6 Mar 2026 13:22:30 +0100 Subject: [PATCH 10/10] feat: allow disabling the client-wide timeout --- impit-python/python/impit/impit.pyi | 12 ++++++------ impit-python/src/async_client.rs | 9 +++++---- impit-python/src/client.rs | 11 +++++++---- impit-python/src/lib.rs | 4 ++-- impit-python/test/basic_client_test.py | 22 ++++++++++++++++++++++ 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/impit-python/python/impit/impit.pyi b/impit-python/python/impit/impit.pyi index 4189742f..f66df365 100644 --- a/impit-python/python/impit/impit.pyi +++ b/impit-python/python/impit/impit.pyi @@ -404,7 +404,7 @@ class Client: .. warning:: Not supported when HTTP/3 is enabled. timeout: - Default request timeout in seconds. + Default request timeout in seconds. Pass ``None`` to disable the timeout entirely. This value can be overridden for individual requests. verify: @@ -466,7 +466,7 @@ class Client: browser: Browser | None = None, http3: bool | None = None, proxy: str | None = None, - timeout: float | None = None, + timeout: float | None = ..., verify: bool | None = None, default_encoding: str | None = None, follow_redirects: bool | None = None, @@ -482,7 +482,7 @@ class Client: browser: Browser to impersonate ("chrome" or "firefox") http3: Enable HTTP/3 support proxy: Proxy URL to use - timeout: Default request timeout in seconds + timeout: Default request timeout in seconds. Pass ``None`` to disable the timeout entirely. verify: Verify SSL certificates (set to False to ignore TLS errors) default_encoding: Default encoding for response.text field (e.g., "utf-8", "cp1252"). Overrides `content-type` header and bytestream prescan. @@ -746,7 +746,7 @@ class AsyncClient: .. warning:: Not supported when HTTP/3 is enabled. timeout: - Default request timeout in seconds. + Default request timeout in seconds. Pass ``None`` to disable the timeout entirely. This value can be overridden for individual requests. verify: @@ -807,7 +807,7 @@ class AsyncClient: browser: Browser | None = None, http3: bool | None = None, proxy: str | None = None, - timeout: float | None = None, + timeout: float | None = ..., verify: bool | None = None, default_encoding: str | None = None, follow_redirects: bool | None = None, @@ -823,7 +823,7 @@ class AsyncClient: browser: Browser to impersonate ("chrome" or "firefox") http3: Enable HTTP/3 support proxy: Proxy URL to use - timeout: Default request timeout in seconds + timeout: Default request timeout in seconds. Pass ``None`` to disable the timeout entirely. verify: Verify SSL certificates (set to False to ignore TLS errors) default_encoding: Default encoding for response.text field (e.g., "utf-8", "cp1252"). Overrides `content-type` header and bytestream prescan. diff --git a/impit-python/src/async_client.rs b/impit-python/src/async_client.rs index d2e50113..13a55698 100644 --- a/impit-python/src/async_client.rs +++ b/impit-python/src/async_client.rs @@ -41,13 +41,13 @@ impl AsyncClient { } #[new] - #[pyo3(signature = (browser=None, http3=None, proxy=None, timeout=None, verify=None, default_encoding=None, follow_redirects=None, max_redirects=Some(20), cookie_jar=None, cookies=None, headers=None, local_address=None))] + #[pyo3(signature = (browser=None, http3=None, proxy=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), verify=None, default_encoding=None, follow_redirects=None, max_redirects=Some(20), cookie_jar=None, cookies=None, headers=None, local_address=None))] pub fn new( py: Python<'_>, browser: Option, http3: Option, proxy: Option, - timeout: Option, + timeout: Option>, verify: Option, default_encoding: Option, follow_redirects: Option, @@ -110,8 +110,9 @@ impl AsyncClient { None => builder, }; - let builder = match timeout { - Some(secs) => builder.with_default_timeout(Duration::from_secs_f64(secs)), + let builder = match parse_timeout(timeout)? { + Some(Some(d)) => builder.with_default_timeout(d), + Some(None) => builder.with_default_timeout(Duration::MAX), None => builder, }; diff --git a/impit-python/src/client.rs b/impit-python/src/client.rs index 39fe1075..d5192f2d 100644 --- a/impit-python/src/client.rs +++ b/impit-python/src/client.rs @@ -36,13 +36,13 @@ impl Client { } #[new] - #[pyo3(signature = (browser=None, http3=None, proxy=None, timeout=None, verify=None, default_encoding=None, follow_redirects=None, max_redirects=Some(20), cookie_jar=None, cookies=None, headers=None, local_address=None))] + #[pyo3(signature = (browser=None, http3=None, proxy=None, timeout=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), verify=None, default_encoding=None, follow_redirects=None, max_redirects=Some(20), cookie_jar=None, cookies=None, headers=None, local_address=None))] pub fn new( py: Python<'_>, browser: Option, http3: Option, proxy: Option, - timeout: Option, + timeout: Option>, verify: Option, default_encoding: Option, follow_redirects: Option, @@ -99,8 +99,11 @@ impl Client { None => builder, }; - let builder = match timeout { - Some(secs) => builder.with_default_timeout(Duration::from_secs_f64(secs)), + let builder = match parse_timeout(timeout) + .map_err(|e| ImpitPyError(ImpitError::BindingPassthroughError(e.to_string())))? + { + Some(Some(d)) => builder.with_default_timeout(d), + Some(None) => builder.with_default_timeout(Duration::MAX), None => builder, }; diff --git a/impit-python/src/lib.rs b/impit-python/src/lib.rs index 887254a0..21ff3f1b 100644 --- a/impit-python/src/lib.rs +++ b/impit-python/src/lib.rs @@ -103,7 +103,7 @@ fn impit(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { max_redirects: Option, proxy: Option, ) -> Result { - let client = Client::new(_py, None, None, proxy, None, None, None, follow_redirects, max_redirects, cookie_jar, cookies, None, None); + let client = Client::new(_py, None, None, proxy, Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), None, None, follow_redirects, max_redirects, cookie_jar, cookies, None, None); client?.$name(_py, url, content, data, headers, timeout, force_http3) } @@ -137,7 +137,7 @@ fn impit(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { None, None, proxy, - None, + Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), None, None, follow_redirects, diff --git a/impit-python/test/basic_client_test.py b/impit-python/test/basic_client_test.py index 5a3fefe0..635738d0 100644 --- a/impit-python/test/basic_client_test.py +++ b/impit-python/test/basic_client_test.py @@ -669,3 +669,25 @@ def test_use_client_default_uses_client_timeout(self) -> None: # Explicitly passing USE_CLIENT_DEFAULT should behave the same as not passing timeout. with pytest.raises((TimeoutException, ConnectTimeout, ReadTimeout)): impit.get(f'http://127.0.0.1:{port_holder[0]}/', timeout=USE_CLIENT_DEFAULT) + + def test_client_timeout_none_disables_default_timeout(self) -> None: + """Client(timeout=None) should disable the timeout for all requests by default.""" + + port_holder = [0] + make_slow_server(port_holder, delay=1.5) + time.sleep(0.05) + + impit = Client(timeout=None) + response = impit.get(f'http://127.0.0.1:{port_holder[0]}/') + assert response.status_code == 200 + + def test_client_timeout_none_overridden_by_per_request(self) -> None: + """A per-request timeout should override Client(timeout=None).""" + + port_holder = [0] + make_slow_server(port_holder, delay=1.5) + time.sleep(0.05) + + impit = Client(timeout=None) + with pytest.raises((TimeoutException, ConnectTimeout, ReadTimeout)): + impit.get(f'http://127.0.0.1:{port_holder[0]}/', timeout=0.1)