diff --git a/Cargo.lock b/Cargo.lock index 1a14cbf5..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" @@ -1271,6 +1277,7 @@ version = "0.0.0" dependencies = [ "bytes", "cookie", + "either", "encoding", "futures", "h2", @@ -1768,6 +1775,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-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/Cargo.toml b/impit-python/Cargo.toml index e49fb0da..f0d3e36a 100644 --- a/impit-python/Cargo.toml +++ b/impit-python/Cargo.toml @@ -15,9 +15,10 @@ 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"] } 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/python/impit/__init__.py b/impit-python/python/impit/__init__.py index d809788e..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, @@ -48,6 +49,7 @@ __version__ = metadata.version('impit') __all__ = [ + 'USE_CLIENT_DEFAULT', 'AsyncClient', 'Browser', 'Client', diff --git a/impit-python/python/impit/impit.pyi b/impit-python/python/impit/impit.pyi index f769b347..f66df365 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.""" @@ -397,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: @@ -459,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, @@ -475,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. @@ -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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 """ @@ -739,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: @@ -800,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, @@ -816,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. @@ -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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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 | str | 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..13a55698 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}, @@ -8,8 +9,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, parse_timeout, RequestBody, USE_CLIENT_DEFAULT_SENTINEL}, + response::ImpitPyResponse, }; #[pyclass] @@ -38,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, @@ -107,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, }; @@ -160,7 +164,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn get<'python>( &self, py: Python<'python>, @@ -168,7 +172,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -184,7 +188,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn head<'python>( &self, py: Python<'python>, @@ -192,7 +196,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -208,7 +212,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn post<'python>( &self, py: Python<'python>, @@ -216,7 +220,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -232,7 +236,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn patch<'python>( &self, py: Python<'python>, @@ -240,7 +244,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -256,7 +260,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn put<'python>( &self, py: Python<'python>, @@ -264,7 +268,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -280,7 +284,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn delete<'python>( &self, py: Python<'python>, @@ -288,7 +292,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -304,7 +308,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn options<'python>( &self, py: Python<'python>, @@ -312,7 +316,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -328,7 +332,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn trace<'python>( &self, py: Python<'python>, @@ -336,7 +340,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { self.request( @@ -352,7 +356,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn stream<'python>( &self, py: Python<'python>, @@ -361,7 +365,7 @@ impl AsyncClient { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { let response = self.request( @@ -398,7 +402,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, stream=false))] pub fn request<'python>( &self, py: Python<'python>, @@ -407,7 +411,7 @@ impl AsyncClient { content: Option>, mut data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, stream: Option, ) -> Result, PyErr> { @@ -434,13 +438,15 @@ impl AsyncClient { None => Ok(Vec::new()), }?; + let timeout = parse_timeout(timeout)?; + 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..d5192f2d 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, RequestBody}, + request::{form_to_bytes, parse_timeout, RequestBody, USE_CLIENT_DEFAULT_SENTINEL}, response::{self, ImpitPyResponse}, }; @@ -35,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, @@ -98,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, }; @@ -151,7 +155,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn get( &self, py: Python<'_>, @@ -159,7 +163,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -175,7 +179,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn head( &self, py: Python<'_>, @@ -183,7 +187,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -199,7 +203,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn post( &self, py: Python<'_>, @@ -207,7 +211,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -223,7 +227,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn patch( &self, py: Python<'_>, @@ -231,7 +235,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -247,7 +251,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn put( &self, py: Python<'_>, @@ -255,7 +259,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -271,7 +275,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn delete( &self, py: Python<'_>, @@ -279,7 +283,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -295,7 +299,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn options( &self, py: Python<'_>, @@ -303,7 +307,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -319,7 +323,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn trace( &self, py: Python<'_>, @@ -327,7 +331,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result { self.request( @@ -343,7 +347,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false))] pub fn stream<'python>( &self, py: Python<'python>, @@ -352,7 +356,7 @@ impl Client { content: Option>, data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, ) -> Result, PyErr> { let response = self.request( @@ -389,7 +393,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=Some(Right(USE_CLIENT_DEFAULT_SENTINEL)), force_http3=false, stream=false))] pub fn request( &self, py: Python<'_>, @@ -398,7 +402,7 @@ impl Client { content: Option>, mut data: Option, headers: Option>, - timeout: Option, + timeout: Option>, force_http3: Option, stream: Option, ) -> Result { @@ -425,13 +429,16 @@ impl Client { None => Ok(Vec::new()), }?; + let timeout = parse_timeout(timeout) + .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..21ff3f1b 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; @@ -9,7 +10,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 +88,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=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: Option, + timeout: Option>, force_http3: Option, cookie_jar: Option>, cookies: Option>, @@ -102,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) } @@ -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=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=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: Option, + timeout: Option>, force_http3: Option, cookie_jar: Option>, cookies: Option>, @@ -136,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, @@ -161,5 +162,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..07a26da6 100644 --- a/impit-python/src/request.rs +++ b/impit-python/src/request.rs @@ -1,6 +1,39 @@ -use std::collections::HashMap; +use std::{collections::HashMap, time::Duration}; -use pyo3::{Bound, FromPyObject, 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 = "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 parse_timeout( + timeout: Option>, +) -> pyo3::PyResult>> { + 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) + } + } + } +} + +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..635738d0 100644 --- a/impit-python/test/basic_client_test.py +++ b/impit-python/test/basic_client_test.py @@ -7,7 +7,18 @@ import pytest -from impit import Browser, Client, Cookies, StreamClosed, StreamConsumed, TooManyRedirects +from impit import ( + USE_CLIENT_DEFAULT, + Browser, + Client, + ConnectTimeout, + Cookies, + ReadTimeout, + StreamClosed, + StreamConsumed, + TimeoutException, + TooManyRedirects, +) from .httpbin import get_httpbin_url from .setup_proxy import start_proxy_server @@ -599,3 +610,84 @@ 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) -> 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)) + port_holder[0] = server.getsockname()[1] + server.listen(1) + + def _serve() -> None: + 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() + + threading.Thread(target=_serve, daemon=True).start() + + +class TestTimeoutBehaviour: + def test_timeout_none_disables_timeout(self) -> None: + """timeout=None should disable the timeout entirely, not use the client default.""" + + port_holder = [0] + # Start a slow server (responds after 1.5s) + make_slow_server(port_holder, delay=1.5) + time.sleep(0.05) + + impit = Client(timeout=0.1) + + # Without explicit timeout override the client default (0.1s) should fire. + 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. + port_holder2 = [0] + make_slow_server(port_holder2, delay=1.5) + time.sleep(0.05) + + # 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: + """USE_CLIENT_DEFAULT should use the client-level timeout.""" + + 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 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) diff --git a/impit/src/impit.rs b/impit/src/impit.rs index d48f48a2..6e6cb9f8 100644 --- a/impit/src/impit.rs +++ b/impit/src/impit.rs @@ -506,7 +506,13 @@ 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, + // 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), + }; 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.