Skip to content

Commit 4b04f66

Browse files
authored
make ws recv timeout configurable (#517)
Signed-off-by: Jessie Frazelle <github@jessfraz.com>
1 parent fef0964 commit 4b04f66

8 files changed

Lines changed: 177 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to the KittyCAD Python SDK will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## v1.2.5
9+
10+
### Added
11+
12+
- WebSocket wrappers now honor configurable receive timeouts. Set a client-wide default with `KittyCAD(..., websocket_recv_timeout=120)` or override per connection via `client.modeling.modeling_commands_ws(recv_timeout=300)`. Existing code keeps the previous 60 second timeout without changes.
13+
14+
### Migration
15+
16+
- No action required unless you wish to change the timeout. If you relied on the old hardcoded 60 second timeout, explicitly pass `recv_timeout=60` to preserve that behavior when using a client configured with a different default.
17+
818
## v1.1.2
919

1020
### Changed - WebSocket message typing
@@ -720,6 +730,7 @@ Example exception message:
720730
- **Easier Debugging**: No need to dig into Error objects
721731
- **Cleaner Code**: No error checking boilerplate needed
722732
- **Better IDE Support**: Improved autocomplete and type checking
733+
723734
## v1.1.3
724735

725736
### Fixed – Typed responses instead of raw dicts

generate/templates/__init__.py.jinja2

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""The KittyCAD Python SDK"""
22

33
import os
4-
from typing import Any, Dict, List, Optional, Union
4+
from typing import Any, Callable, Dict, List, Optional, Union
55
import httpx
66

77
{% if has_websockets %}
@@ -51,12 +51,12 @@ class {{ tag_pascal }}API:
5151

5252
{% for func_name, func_info in functions.items() %}
5353
{% if func_info.is_websocket and func_info.has_websocket_class %}
54-
def {{ func_name }}(self{{ func_info.websocket_params if func_info.websocket_params else '' }}) -> "WebSocket{{ func_name|to_pascal_case }}":
54+
def {{ func_name }}(self{{ func_info.websocket_params if func_info.websocket_params else '' }}, recv_timeout: Optional[float] = None, ws_factory: Optional[Callable[..., ClientConnectionSync]] = None) -> "WebSocket{{ func_name|to_pascal_case }}":
5555
"""{{ func_info.description }}
5656

5757
Returns a WebSocket wrapper with methods for sending/receiving data.
5858
"""
59-
return WebSocket{{ func_name|to_pascal_case }}({{ func_info.websocket_call_args if func_info.websocket_call_args else '' }}{{ ', ' if func_info.websocket_call_args else '' }}client=self.client)
59+
return WebSocket{{ func_name|to_pascal_case }}({{ func_info.websocket_call_args if func_info.websocket_call_args else '' }}{{ ', ' if func_info.websocket_call_args else '' }}recv_timeout=recv_timeout, ws_factory=ws_factory, client=self.client)
6060
{% else %}
6161
{{ func_info.sync_implementation }}
6262
{% endif %}
@@ -93,7 +93,7 @@ class WebSocket{{ func_name|to_pascal_case }}:
9393

9494
ws: ClientConnectionSync
9595

96-
def __init__(self{{ func_info.websocket_params if func_info.websocket_params else '' }}, *, client: Client):
96+
def __init__(self{{ func_info.websocket_params if func_info.websocket_params else '' }}, recv_timeout: Optional[float] = None, ws_factory: Optional[Callable[..., ClientConnectionSync]] = None, *, client: Client):
9797
# Inline WebSocket connection logic
9898
{% set path_args = func_info.ws_args|selectattr("in_url")|list %}
9999
url = ("{}" + "{{ func_info.path }}").format(client.base_url{% for arg in path_args %}, {{ arg.name }}={{ arg.name }}{% endfor %})
@@ -114,7 +114,11 @@ class WebSocket{{ func_name|to_pascal_case }}:
114114
{% endif %}
115115
{% endfor %}
116116
headers = client.get_headers()
117-
self.ws = ws_connect(url.replace("http", "ws"), additional_headers=headers, close_timeout=120, max_size=None)
117+
factory = ws_factory or ws_connect
118+
self.ws = factory(url.replace("http", "ws"), additional_headers=headers, close_timeout=120, max_size=None)
119+
self._recv_timeout = (
120+
client.get_websocket_recv_timeout() if recv_timeout is None else recv_timeout
121+
)
118122

119123
def __enter__(self):
120124
return self
@@ -156,7 +160,7 @@ class WebSocket{{ func_name|to_pascal_case }}:
156160

157161
def recv(self) -> {{ func_info.ws_response_type }}:
158162
"""Receive data from the websocket."""
159-
message = self.ws.recv(timeout=60)
163+
message = self.ws.recv(timeout=self._recv_timeout)
160164
{% if func_info.ws_response_is_dict %}
161165
return json.loads(message)
162166
{% else %}

kittycad/__init__.py

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import json
44
import os
5-
from typing import Any, Dict, List, Optional, Union
5+
from typing import Any, Callable, Dict, List, Optional, Union
66

77
import bson
88
import httpx
@@ -1274,22 +1274,37 @@ def create_text_to_cad_part_feedback(
12741274
return response.json() if response.content else None
12751275

12761276
def ml_copilot_ws(
1277-
self, conversation_id: Optional[str] = None, replay: Optional[bool] = None
1277+
self,
1278+
conversation_id: Optional[str] = None,
1279+
replay: Optional[bool] = None,
1280+
recv_timeout: Optional[float] = None,
1281+
ws_factory: Optional[Callable[..., ClientConnectionSync]] = None,
12781282
) -> "WebSocketMlCopilotWs":
12791283
"""Open a websocket to prompt the ML copilot.
12801284
12811285
Returns a WebSocket wrapper with methods for sending/receiving data.
12821286
"""
12831287
return WebSocketMlCopilotWs(
1284-
conversation_id=conversation_id, replay=replay, client=self.client
1288+
conversation_id=conversation_id,
1289+
replay=replay,
1290+
recv_timeout=recv_timeout,
1291+
ws_factory=ws_factory,
1292+
client=self.client,
12851293
)
12861294

1287-
def ml_reasoning_ws(self, id: str) -> "WebSocketMlReasoningWs":
1295+
def ml_reasoning_ws(
1296+
self,
1297+
id: str,
1298+
recv_timeout: Optional[float] = None,
1299+
ws_factory: Optional[Callable[..., ClientConnectionSync]] = None,
1300+
) -> "WebSocketMlReasoningWs":
12881301
"""Open a websocket to prompt the ML copilot.
12891302
12901303
Returns a WebSocket wrapper with methods for sending/receiving data.
12911304
"""
1292-
return WebSocketMlReasoningWs(id=id, client=self.client)
1305+
return WebSocketMlReasoningWs(
1306+
id=id, recv_timeout=recv_timeout, ws_factory=ws_factory, client=self.client
1307+
)
12931308

12941309

12951310
class AsyncMlAPI:
@@ -4768,12 +4783,18 @@ def create_file_execution(
47684783
# Validate into a Pydantic model (works for BaseModel and RootModel)
47694784
return CodeOutput.model_validate(json_data)
47704785

4771-
def create_executor_term(self) -> "WebSocketCreateExecutorTerm":
4786+
def create_executor_term(
4787+
self,
4788+
recv_timeout: Optional[float] = None,
4789+
ws_factory: Optional[Callable[..., ClientConnectionSync]] = None,
4790+
) -> "WebSocketCreateExecutorTerm":
47724791
"""Create a terminal.
47734792
47744793
Returns a WebSocket wrapper with methods for sending/receiving data.
47754794
"""
4776-
return WebSocketCreateExecutorTerm(client=self.client)
4795+
return WebSocketCreateExecutorTerm(
4796+
recv_timeout=recv_timeout, ws_factory=ws_factory, client=self.client
4797+
)
47774798

47784799

47794800
class AsyncExecutorAPI:
@@ -11973,6 +11994,8 @@ def modeling_commands_ws(
1197311994
video_res_height: Optional[int] = None,
1197411995
video_res_width: Optional[int] = None,
1197511996
webrtc: Optional[bool] = None,
11997+
recv_timeout: Optional[float] = None,
11998+
ws_factory: Optional[Callable[..., ClientConnectionSync]] = None,
1197611999
) -> "WebSocketModelingCommandsWs":
1197712000
"""Open a websocket which accepts modeling commands.
1197812001
@@ -11989,6 +12012,8 @@ def modeling_commands_ws(
1198912012
video_res_height=video_res_height,
1199012013
video_res_width=video_res_width,
1199112014
webrtc=webrtc,
12015+
recv_timeout=recv_timeout,
12016+
ws_factory=ws_factory,
1199212017
client=self.client,
1199312018
)
1199412019

@@ -12114,6 +12139,8 @@ def __init__(
1211412139
self,
1211512140
conversation_id: Optional[str] = None,
1211612141
replay: Optional[bool] = None,
12142+
recv_timeout: Optional[float] = None,
12143+
ws_factory: Optional[Callable[..., ClientConnectionSync]] = None,
1211712144
*,
1211812145
client: Client,
1211912146
):
@@ -12134,12 +12161,18 @@ def __init__(
1213412161
url = url + "?replay=" + str(replay).lower()
1213512162

1213612163
headers = client.get_headers()
12137-
self.ws = ws_connect(
12164+
factory = ws_factory or ws_connect
12165+
self.ws = factory(
1213812166
url.replace("http", "ws"),
1213912167
additional_headers=headers,
1214012168
close_timeout=120,
1214112169
max_size=None,
1214212170
)
12171+
self._recv_timeout = (
12172+
client.get_websocket_recv_timeout()
12173+
if recv_timeout is None
12174+
else recv_timeout
12175+
)
1214312176

1214412177
def __enter__(self):
1214512178
return self
@@ -12171,7 +12204,7 @@ def send_binary(self, data: MlCopilotClientMessage):
1217112204

1217212205
def recv(self) -> MlCopilotServerMessage:
1217312206
"""Receive data from the websocket."""
12174-
message = self.ws.recv(timeout=60)
12207+
message = self.ws.recv(timeout=self._recv_timeout)
1217512208

1217612209
return MlCopilotServerMessage.model_validate_json(message)
1217712210

@@ -12185,18 +12218,31 @@ class WebSocketMlReasoningWs:
1218512218

1218612219
ws: ClientConnectionSync
1218712220

12188-
def __init__(self, id: str, *, client: Client):
12221+
def __init__(
12222+
self,
12223+
id: str,
12224+
recv_timeout: Optional[float] = None,
12225+
ws_factory: Optional[Callable[..., ClientConnectionSync]] = None,
12226+
*,
12227+
client: Client,
12228+
):
1218912229
# Inline WebSocket connection logic
1219012230

1219112231
url = ("{}" + "/ws/ml/reasoning/{id}").format(client.base_url, id=id)
1219212232

1219312233
headers = client.get_headers()
12194-
self.ws = ws_connect(
12234+
factory = ws_factory or ws_connect
12235+
self.ws = factory(
1219512236
url.replace("http", "ws"),
1219612237
additional_headers=headers,
1219712238
close_timeout=120,
1219812239
max_size=None,
1219912240
)
12241+
self._recv_timeout = (
12242+
client.get_websocket_recv_timeout()
12243+
if recv_timeout is None
12244+
else recv_timeout
12245+
)
1220012246

1220112247
def __enter__(self):
1220212248
return self
@@ -12228,7 +12274,7 @@ def send_binary(self, data: MlCopilotClientMessage):
1222812274

1222912275
def recv(self) -> MlCopilotServerMessage:
1223012276
"""Receive data from the websocket."""
12231-
message = self.ws.recv(timeout=60)
12277+
message = self.ws.recv(timeout=self._recv_timeout)
1223212278

1223312279
return MlCopilotServerMessage.model_validate_json(message)
1223412280

@@ -12242,18 +12288,30 @@ class WebSocketCreateExecutorTerm:
1224212288

1224312289
ws: ClientConnectionSync
1224412290

12245-
def __init__(self, *, client: Client):
12291+
def __init__(
12292+
self,
12293+
recv_timeout: Optional[float] = None,
12294+
ws_factory: Optional[Callable[..., ClientConnectionSync]] = None,
12295+
*,
12296+
client: Client,
12297+
):
1224612298
# Inline WebSocket connection logic
1224712299

1224812300
url = ("{}" + "/ws/executor/term").format(client.base_url)
1224912301

1225012302
headers = client.get_headers()
12251-
self.ws = ws_connect(
12303+
factory = ws_factory or ws_connect
12304+
self.ws = factory(
1225212305
url.replace("http", "ws"),
1225312306
additional_headers=headers,
1225412307
close_timeout=120,
1225512308
max_size=None,
1225612309
)
12310+
self._recv_timeout = (
12311+
client.get_websocket_recv_timeout()
12312+
if recv_timeout is None
12313+
else recv_timeout
12314+
)
1225712315

1225812316
def __enter__(self):
1225912317
return self
@@ -12285,7 +12343,7 @@ def send_binary(self, data: Dict[str, Any]):
1228512343

1228612344
def recv(self) -> Dict[str, Any]:
1228712345
"""Receive data from the websocket."""
12288-
message = self.ws.recv(timeout=60)
12346+
message = self.ws.recv(timeout=self._recv_timeout)
1228912347

1229012348
return json.loads(message)
1229112349

@@ -12311,6 +12369,8 @@ def __init__(
1231112369
video_res_height: Optional[int] = None,
1231212370
video_res_width: Optional[int] = None,
1231312371
webrtc: Optional[bool] = None,
12372+
recv_timeout: Optional[float] = None,
12373+
ws_factory: Optional[Callable[..., ClientConnectionSync]] = None,
1231412374
*,
1231512375
client: Client,
1231612376
):
@@ -12379,12 +12439,18 @@ def __init__(
1237912439
url = url + "?webrtc=" + str(webrtc).lower()
1238012440

1238112441
headers = client.get_headers()
12382-
self.ws = ws_connect(
12442+
factory = ws_factory or ws_connect
12443+
self.ws = factory(
1238312444
url.replace("http", "ws"),
1238412445
additional_headers=headers,
1238512446
close_timeout=120,
1238612447
max_size=None,
1238712448
)
12449+
self._recv_timeout = (
12450+
client.get_websocket_recv_timeout()
12451+
if recv_timeout is None
12452+
else recv_timeout
12453+
)
1238812454

1238912455
def __enter__(self):
1239012456
return self
@@ -12416,7 +12482,7 @@ def send_binary(self, data: WebSocketRequest):
1241612482

1241712483
def recv(self) -> WebSocketResponse:
1241812484
"""Receive data from the websocket."""
12419-
message = self.ws.recv(timeout=60)
12485+
message = self.ws.recv(timeout=self._recv_timeout)
1242012486

1242112487
return WebSocketResponse.model_validate_json(message)
1242212488

kittycad/client.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Client:
1717
cookies: Dict[str, str] = attr.ib(factory=dict, kw_only=True)
1818
headers: Dict[str, str] = attr.ib(factory=dict, kw_only=True)
1919
timeout: float = attr.ib(120.0, kw_only=True)
20+
websocket_recv_timeout: Optional[float] = attr.ib(60.0, kw_only=True)
2021
verify_ssl: Union[str, bool, ssl.SSLContext, truststore.SSLContext] = attr.ib(
2122
True, kw_only=True
2223
)
@@ -47,6 +48,13 @@ def with_timeout(self, timeout: float) -> "Client":
4748
"""Get a new client matching this one with a new timeout (in seconds)"""
4849
return attr.evolve(self, timeout=timeout)
4950

51+
def get_websocket_recv_timeout(self) -> Optional[float]:
52+
return self.websocket_recv_timeout
53+
54+
def with_websocket_recv_timeout(self, timeout: Optional[float]) -> "Client":
55+
"""Get a new client matching this one with a new websocket recv timeout"""
56+
return attr.evolve(self, websocket_recv_timeout=timeout)
57+
5058
def with_base_url(self, url: str) -> "Client":
5159
"""Get a new client matching this one with a new base url"""
5260
return attr.evolve(self, base_url=url)
@@ -85,6 +93,7 @@ class AsyncClient:
8593
cookies: Dict[str, str] = attr.ib(factory=dict, kw_only=True)
8694
headers: Dict[str, str] = attr.ib(factory=dict, kw_only=True)
8795
timeout: float = attr.ib(120.0, kw_only=True)
96+
websocket_recv_timeout: Optional[float] = attr.ib(60.0, kw_only=True)
8897
verify_ssl: Union[str, bool, ssl.SSLContext, truststore.SSLContext] = attr.ib(
8998
True, kw_only=True
9099
)
@@ -115,6 +124,13 @@ def with_timeout(self, timeout: float) -> "AsyncClient":
115124
"""Get a new client matching this one with a new timeout (in seconds)"""
116125
return attr.evolve(self, timeout=timeout)
117126

127+
def get_websocket_recv_timeout(self) -> Optional[float]:
128+
return self.websocket_recv_timeout
129+
130+
def with_websocket_recv_timeout(self, timeout: Optional[float]) -> "AsyncClient":
131+
"""Get a new client matching this one with a new websocket recv timeout"""
132+
return attr.evolve(self, websocket_recv_timeout=timeout)
133+
118134
def with_base_url(self, url: str) -> "AsyncClient":
119135
"""Get a new client matching this one with a new base url"""
120136
return attr.evolve(self, base_url=url)

0 commit comments

Comments
 (0)