Repository: tensorzero/tensorzero (language: rust)

Issue:
Regression: `timeout` in new Python client doesn't accepts floats
```
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[20], [line 1](vscode-notebook-cell:?execution_count=20&line=1)
----> [1](vscode-notebook-cell:?execution_count=20&line=1) async with await AsyncTensorZeroGateway.build_http(
      [2](vscode-notebook-cell:?execution_count=20&line=2)     gateway_url="http://localhost:3000/", timeout=180.0
      [3](vscode-notebook-cell:?execution_count=20&line=3) ) as client:
      [4](vscode-notebook-cell:?execution_count=20&line=4)     results = await run_puzzles(
      [5](vscode-notebook-cell:?execution_count=20&line=5)         TensorZeroPlayer(client, variant_name),
      [6](vscode-notebook-cell:?execution_count=20&line=6)         puzzle_df,
   (...)
      [9](vscode-notebook-cell:?execution_count=20&line=9)         client,
     [10](vscode-notebook-cell:?execution_count=20&line=10)     )

TypeError: argument 'timeout': 'float' object cannot be interpreted as an integer
```

We used to accept either ints or floats.


Discussion / hints:

Fix patch:
diff --git a/clients/python-pyo3/src/lib.rs b/clients/python-pyo3/src/lib.rs
index 15400fb3f..600646509 100644
--- a/clients/python-pyo3/src/lib.rs
+++ b/clients/python-pyo3/src/lib.rs
@@ -138,7 +138,7 @@ impl BaseTensorZeroGateway {
     fn new(
         py: Python<'_>,
         base_url: &str,
-        timeout: Option<u64>,
+        timeout: Option<f64>,
         verbose_errors: bool,
     ) -> PyResult<Self> {
         let mut client_builder = ClientBuilder::new(ClientBuilderMode::HTTPGateway {
@@ -148,7 +148,10 @@ impl BaseTensorZeroGateway {
         .with_verbose_errors(verbose_errors);
         if let Some(timeout) = timeout {
             let http_client = reqwest::Client::builder()
-                .timeout(Duration::from_secs(timeout))
+                .timeout(
+                    Duration::try_from_secs_f64(timeout)
+                        .map_err(|e| PyValueError::new_err(format!("Invalid timeout: {e}")))?,
+                )
                 .build()
                 .map_err(|e| {
                     PyValueError::new_err(format!("Failed to build HTTP client: {e:?}"))
@@ -354,7 +357,7 @@ impl TensorZeroGateway {
     fn new(
         py: Python<'_>,
         base_url: &str,
-        timeout: Option<u64>,
+        timeout: Option<f64>,
     ) -> PyResult<(Self, BaseTensorZeroGateway)> {
         tracing::warn!("TensorZeroGateway.__init__ is deprecated. Use TensorZeroGateway.build_http or TensorZeroGateway.build_embedded instead.");
         Ok((
@@ -373,7 +376,7 @@ impl TensorZeroGateway {
     fn build_http(
         cls: &Bound<'_, PyType>,
         gateway_url: &str,
-        timeout: Option<u64>,
+        timeout: Option<f64>,
         verbose_errors: bool,
     ) -> PyResult<Py<TensorZeroGateway>> {
         let client = BaseTensorZeroGateway::new(cls.py(), gateway_url, timeout, verbose_errors)?;
@@ -388,7 +391,7 @@ impl TensorZeroGateway {
     /// :param timeout: The timeout for the HTTP client in seconds. If not provided, no timeout will be set.
     #[allow(unused_variables)]
     #[pyo3(signature = (base_url, *, timeout=None))]
-    fn __init__(this: Py<Self>, base_url: &str, timeout: Option<u64>) -> Py<Self> {
+    fn __init__(this: Py<Self>, base_url: &str, timeout: Option<f64>) -> Py<Self> {
         // The actual logic is in the 'new' method - this method just exists to generate a docstring
         this
     }
@@ -599,7 +602,7 @@ impl AsyncTensorZeroGateway {
     fn new(
         py: Python<'_>,
         base_url: &str,
-        timeout: Option<u64>,
+        timeout: Option<f64>,
     ) -> PyResult<(Self, BaseTensorZeroGateway)> {
         tracing::warn!("AsyncTensorZeroGateway.__init__ is deprecated. Use AsyncTensorZeroGateway.build_http or AsyncTensorZeroGateway.build_embedded instead.");
         Ok((
@@ -618,7 +621,7 @@ impl AsyncTensorZeroGateway {
     fn build_http<'a>(
         cls: &Bound<'a, PyType>,
         gateway_url: &str,
-        timeout: Option<u64>,
+        timeout: Option<f64>,
         verbose_errors: bool,
     ) -> PyResult<Bound<'a, PyAny>> {
         let gateway_url = gateway_url.to_string();
@@ -639,7 +642,7 @@ impl AsyncTensorZeroGateway {
     /// :param timeout: The timeout for the HTTP client in seconds. If not provided, no timeout will be set.
     #[allow(unused_variables)]
     #[pyo3(signature = (base_url, *, timeout=None))]
-    fn __init__(this: Py<Self>, base_url: &str, timeout: Option<u64>) -> Py<Self> {
+    fn __init__(this: Py<Self>, base_url: &str, timeout: Option<f64>) -> Py<Self> {
         // The actual logic is in the 'new' method - this method just exists to generate a docstring
         this
     }


Test patch:
diff --git a/clients/python-pyo3/tests/test_client.py b/clients/python-pyo3/tests/test_client.py
index 834f5047e..75a6219bb 100644
--- a/clients/python-pyo3/tests/test_client.py
+++ b/clients/python-pyo3/tests/test_client.py
@@ -1692,7 +1692,7 @@ async def test_async_err_in_stream(async_client):
 
 
 @pytest.mark.asyncio
-async def test_async_timeout():
+async def test_async_timeout_int():
     async with await AsyncTensorZeroGateway.build_http(
         gateway_url="http://localhost:3000",
         timeout=1,
@@ -1709,7 +1709,25 @@ async def test_async_timeout():
         assert "HTTP request timed out" in str(exc_info.value)
 
 
-def test_sync_timeout():
+@pytest.mark.asyncio
+async def test_async_timeout_float():
+    async with await AsyncTensorZeroGateway.build_http(
+        gateway_url="http://localhost:3000",
+        timeout=0.1,
+    ) as async_client:
+        with pytest.raises(TensorZeroInternalError) as exc_info:
+            await async_client.inference(
+                function_name="basic_test",
+                variant_name="slow",
+                input={
+                    "system": {"assistant_name": "TensorZero bot"},
+                    "messages": [{"role": "user", "content": "Hello"}],
+                },
+            )
+        assert "HTTP request timed out" in str(exc_info.value)
+
+
+def test_sync_timeout_int():
     with TensorZeroGateway.build_http(
         gateway_url="http://localhost:3000", timeout=1
     ) as sync_client:
@@ -1725,6 +1743,31 @@ def test_sync_timeout():
         assert "HTTP request timed out" in str(exc_info.value)
 
 
+def test_sync_timeout_float():
+    with TensorZeroGateway.build_http(
+        gateway_url="http://localhost:3000", timeout=0.1
+    ) as sync_client:
+        with pytest.raises(TensorZeroInternalError) as exc_info:
+            sync_client.inference(
+                function_name="basic_test",
+                variant_name="slow",
+                input={
+                    "system": {"assistant_name": "TensorZero bot"},
+                    "messages": [{"role": "user", "content": "Hello"}],
+                },
+            )
+        assert "HTTP request timed out" in str(exc_info.value)
+
+
+def test_sync_timeout_invalid():
+    with pytest.raises(ValueError) as exc_info:
+        TensorZeroGateway.build_http(gateway_url="http://localhost:3000", timeout=-1)
+    assert (
+        "Invalid timeout: cannot convert float seconds to Duration: value is negative"
+        == str(exc_info.value)
+    )
+
+
 @pytest.mark.asyncio
 async def test_async_non_verbose_errors():
     async with await AsyncTensorZeroGateway.build_http(