toolplane-python-client 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toolplane/__init__.py +106 -0
- toolplane/common/__init__.py +93 -0
- toolplane/common/base_config.py +129 -0
- toolplane/common/base_connection_manager.py +171 -0
- toolplane/common/base_session_manager.py +321 -0
- toolplane/common/base_tool_manager.py +347 -0
- toolplane/common/constants.py +47 -0
- toolplane/common/utils.py +310 -0
- toolplane/core/__init__.py +67 -0
- toolplane/core/config.py +107 -0
- toolplane/core/connection.py +285 -0
- toolplane/core/errors.py +298 -0
- toolplane/core/machine.py +480 -0
- toolplane/core/request.py +775 -0
- toolplane/core/session.py +332 -0
- toolplane/core/session_context.py +514 -0
- toolplane/core/task.py +130 -0
- toolplane/core/tool.py +329 -0
- toolplane/http_core/__init__.py +37 -0
- toolplane/http_core/http_config.py +97 -0
- toolplane/http_core/http_connection.py +409 -0
- toolplane/http_core/http_machine.py +298 -0
- toolplane/http_core/http_request.py +748 -0
- toolplane/http_core/http_session.py +348 -0
- toolplane/http_core/http_session_context.py +491 -0
- toolplane/http_core/http_task.py +101 -0
- toolplane/http_core/http_tool.py +400 -0
- toolplane/interfaces/__init__.py +27 -0
- toolplane/interfaces/client_interface.py +122 -0
- toolplane/interfaces/connection_interface.py +193 -0
- toolplane/interfaces/event_interface.py +290 -0
- toolplane/interfaces/request_interface.py +439 -0
- toolplane/interfaces/session_interface.py +288 -0
- toolplane/interfaces/tool_interface.py +441 -0
- toolplane/proto/__init__.py +0 -0
- toolplane/proto/service_pb2.py +315 -0
- toolplane/proto/service_pb2_grpc.py +2240 -0
- toolplane/provider_cli.py +268 -0
- toolplane/provider_registry.py +77 -0
- toolplane/provider_runtime.py +302 -0
- toolplane/toolkits/__init__.py +0 -0
- toolplane/toolkits/standalone_tools/__init__.py +0 -0
- toolplane/toolkits/standalone_tools/create_directory.py +94 -0
- toolplane/toolkits/standalone_tools/create_file.py +124 -0
- toolplane/toolkits/standalone_tools/file_search.py +229 -0
- toolplane/toolkits/standalone_tools/grep_search.py +372 -0
- toolplane/toolkits/standalone_tools/launcher.py +146 -0
- toolplane/toolkits/standalone_tools/list_dir.py +395 -0
- toolplane/toolkits/standalone_tools/read_file.py +346 -0
- toolplane/toolkits/standalone_tools/replace_string_in_file.py +407 -0
- toolplane/toolkits/standalone_tools/run_tests.py +66 -0
- toolplane/toolkits/standalone_tools/semantic_search.py +485 -0
- toolplane/toolkits/standalone_tools/standalone_toolkit.py +979 -0
- toolplane/toolkits/standalone_tools/test_failure_analysis.py +618 -0
- toolplane/toolkits/standalone_tools/test_standalone_toolkit.py +517 -0
- toolplane/toolkits/swe/__init__.py +35 -0
- toolplane/toolkits/swe/create_directory.py +15 -0
- toolplane/toolkits/swe/create_file.py +15 -0
- toolplane/toolkits/swe/descriptions.py +273 -0
- toolplane/toolkits/swe/execute_bash.py +93 -0
- toolplane/toolkits/swe/file_editor.py +775 -0
- toolplane/toolkits/swe/file_search.py +16 -0
- toolplane/toolkits/swe/finish.py +50 -0
- toolplane/toolkits/swe/grep_search.py +19 -0
- toolplane/toolkits/swe/list_dir.py +407 -0
- toolplane/toolkits/swe/read_file.py +18 -0
- toolplane/toolkits/swe/replace_string_in_file.py +17 -0
- toolplane/toolkits/swe/search.py +260 -0
- toolplane/toolkits/swe/semantic_search.py +20 -0
- toolplane/toolkits/swe/str_replace_editor.py +647 -0
- toolplane/toolkits/swe/submit.py +29 -0
- toolplane/toolkits/swe/swe_toolkit.py +1296 -0
- toolplane/toolplane_client.py +686 -0
- toolplane/toolplane_http_client.py +681 -0
- toolplane/utils/__init__.py +3 -0
- toolplane/utils/schema.py +146 -0
- toolplane_python_client-0.1.0.dist-info/METADATA +543 -0
- toolplane_python_client-0.1.0.dist-info/RECORD +81 -0
- toolplane_python_client-0.1.0.dist-info/WHEEL +5 -0
- toolplane_python_client-0.1.0.dist-info/entry_points.txt +2 -0
- toolplane_python_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Connection management for Toolplane client."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import random
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import grpc
|
|
11
|
+
|
|
12
|
+
from toolplane.proto.service_pb2_grpc import (
|
|
13
|
+
MachinesServiceStub,
|
|
14
|
+
RequestsServiceStub,
|
|
15
|
+
SessionsServiceStub,
|
|
16
|
+
TasksServiceStub,
|
|
17
|
+
ToolServiceStub,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from ..common.constants import (
|
|
21
|
+
GRPC_MAX_RECEIVE_MESSAGE_LENGTH,
|
|
22
|
+
GRPC_MAX_SEND_MESSAGE_LENGTH,
|
|
23
|
+
)
|
|
24
|
+
from .config import ClientConfig
|
|
25
|
+
from .errors import ConnectionError, ToolplaneConnectionError
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConnectionManager:
|
|
31
|
+
"""Manages gRPC connections and stubs."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, config: ClientConfig):
|
|
34
|
+
"""Initialize connection manager with configuration."""
|
|
35
|
+
self.config = config
|
|
36
|
+
self.channel: Optional[grpc.Channel] = None
|
|
37
|
+
self.tool_stub: Optional[ToolServiceStub] = None
|
|
38
|
+
self.session_stub: Optional[SessionsServiceStub] = None
|
|
39
|
+
self.machine_stub: Optional[MachinesServiceStub] = None
|
|
40
|
+
self.requests_stub: Optional[RequestsServiceStub] = None
|
|
41
|
+
self.tasks_stub: Optional[TasksServiceStub] = None
|
|
42
|
+
self.connected = False
|
|
43
|
+
self._connect_lock = threading.RLock()
|
|
44
|
+
|
|
45
|
+
# Retry configuration
|
|
46
|
+
self.max_retries = getattr(config, "max_retries", 3)
|
|
47
|
+
self.retry_base_delay = getattr(config, "retry_base_delay", 1.0)
|
|
48
|
+
self.retry_max_delay = getattr(config, "retry_max_delay", 60.0)
|
|
49
|
+
self.retry_backoff_factor = getattr(config, "retry_backoff_factor", 2.0)
|
|
50
|
+
self.connection_state = (
|
|
51
|
+
"disconnected" # disconnected, connecting, connected, failed
|
|
52
|
+
)
|
|
53
|
+
self.consecutive_failures = 0
|
|
54
|
+
|
|
55
|
+
def _cleanup_failed_connection(self):
|
|
56
|
+
"""Reset stubs and close any existing channel after a failed attempt."""
|
|
57
|
+
if self.channel is not None:
|
|
58
|
+
try:
|
|
59
|
+
self.channel.close()
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.debug("Error closing channel: %s", e)
|
|
62
|
+
self._reset_stubs()
|
|
63
|
+
self.connected = False
|
|
64
|
+
self.connection_state = "failed"
|
|
65
|
+
|
|
66
|
+
def _calculate_delay(self, attempt: int) -> float:
|
|
67
|
+
"""Calculate delay with exponential backoff and jitter."""
|
|
68
|
+
delay = min(
|
|
69
|
+
self.retry_base_delay * (self.retry_backoff_factor**attempt),
|
|
70
|
+
self.retry_max_delay,
|
|
71
|
+
)
|
|
72
|
+
# Add jitter to prevent thundering herd
|
|
73
|
+
jitter = random.uniform(0, 0.1 * delay)
|
|
74
|
+
return delay + jitter
|
|
75
|
+
|
|
76
|
+
def _should_retry(self, attempt: int, exception: Exception) -> bool:
|
|
77
|
+
"""Determine if we should retry based on exception type and attempt count."""
|
|
78
|
+
if attempt >= self.max_retries:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
# Retry on specific gRPC status codes
|
|
82
|
+
if isinstance(exception, grpc.RpcError):
|
|
83
|
+
retryable_codes = [
|
|
84
|
+
grpc.StatusCode.UNAVAILABLE,
|
|
85
|
+
grpc.StatusCode.DEADLINE_EXCEEDED,
|
|
86
|
+
grpc.StatusCode.RESOURCE_EXHAUSTED,
|
|
87
|
+
]
|
|
88
|
+
return exception.code() in retryable_codes
|
|
89
|
+
|
|
90
|
+
# Retry on network-related exceptions
|
|
91
|
+
if isinstance(
|
|
92
|
+
exception, (ToolplaneConnectionError, ConnectionError, TimeoutError)
|
|
93
|
+
):
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
def _load_tls_bytes(self, path_value: Optional[str], label: str) -> Optional[bytes]:
|
|
99
|
+
"""Load TLS material from disk when configured."""
|
|
100
|
+
if not path_value:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
return Path(path_value).expanduser().read_bytes()
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
raise ToolplaneConnectionError(f"Failed to read {label}: {exc}") from exc
|
|
107
|
+
|
|
108
|
+
def _channel_options(self) -> list[tuple[str, object]]:
|
|
109
|
+
"""Build gRPC channel options: the message-size ladder plus TLS
|
|
110
|
+
overrides.
|
|
111
|
+
|
|
112
|
+
The size options lift the client above the gRPC 4 MiB receive
|
|
113
|
+
default, which rejects a full 8 MiB replay-window read at the
|
|
114
|
+
client before it reaches application code.
|
|
115
|
+
"""
|
|
116
|
+
options: list[tuple[str, object]] = [
|
|
117
|
+
(
|
|
118
|
+
"grpc.max_receive_message_length",
|
|
119
|
+
GRPC_MAX_RECEIVE_MESSAGE_LENGTH,
|
|
120
|
+
),
|
|
121
|
+
("grpc.max_send_message_length", GRPC_MAX_SEND_MESSAGE_LENGTH),
|
|
122
|
+
]
|
|
123
|
+
server_name = getattr(self.config, "tls_server_name", None)
|
|
124
|
+
if server_name:
|
|
125
|
+
options.extend(
|
|
126
|
+
[
|
|
127
|
+
("grpc.ssl_target_name_override", server_name),
|
|
128
|
+
("grpc.default_authority", server_name),
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
return options
|
|
132
|
+
|
|
133
|
+
def _create_channel(self, target: str) -> grpc.Channel:
|
|
134
|
+
"""Create a secure or insecure gRPC channel from client configuration."""
|
|
135
|
+
use_tls = bool(
|
|
136
|
+
getattr(self.config, "use_tls", False)
|
|
137
|
+
or getattr(self.config, "tls_ca_cert_path", None)
|
|
138
|
+
or getattr(self.config, "tls_server_name", None)
|
|
139
|
+
or getattr(self.config, "tls_cert_path", None)
|
|
140
|
+
or getattr(self.config, "tls_key_path", None)
|
|
141
|
+
)
|
|
142
|
+
options = self._channel_options()
|
|
143
|
+
if not use_tls:
|
|
144
|
+
return grpc.insecure_channel(target, options=options)
|
|
145
|
+
|
|
146
|
+
certificate_chain = self._load_tls_bytes(
|
|
147
|
+
getattr(self.config, "tls_cert_path", None),
|
|
148
|
+
"TLS client certificate",
|
|
149
|
+
)
|
|
150
|
+
private_key = self._load_tls_bytes(
|
|
151
|
+
getattr(self.config, "tls_key_path", None),
|
|
152
|
+
"TLS client key",
|
|
153
|
+
)
|
|
154
|
+
if bool(certificate_chain) != bool(private_key):
|
|
155
|
+
raise ToolplaneConnectionError(
|
|
156
|
+
"TLS client authentication requires both certificate and key files"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
root_certificates = self._load_tls_bytes(
|
|
160
|
+
getattr(self.config, "tls_ca_cert_path", None),
|
|
161
|
+
"TLS CA certificate",
|
|
162
|
+
)
|
|
163
|
+
credentials = grpc.ssl_channel_credentials(
|
|
164
|
+
root_certificates=root_certificates,
|
|
165
|
+
private_key=private_key,
|
|
166
|
+
certificate_chain=certificate_chain,
|
|
167
|
+
)
|
|
168
|
+
return grpc.secure_channel(target, credentials, options=options)
|
|
169
|
+
|
|
170
|
+
def connect(self) -> bool:
|
|
171
|
+
"""Establish connection to gRPC server with retry logic."""
|
|
172
|
+
with self._connect_lock:
|
|
173
|
+
if self.connected and self.channel is not None:
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
last_exception = None
|
|
177
|
+
ready_timeout = max(1, min(self.config.request_timeout, 10))
|
|
178
|
+
|
|
179
|
+
for attempt in range(self.max_retries + 1):
|
|
180
|
+
try:
|
|
181
|
+
if attempt > 0:
|
|
182
|
+
delay = self._calculate_delay(attempt - 1)
|
|
183
|
+
time.sleep(delay)
|
|
184
|
+
logger.debug(
|
|
185
|
+
"Retry attempt %d/%d after %.2fs delay",
|
|
186
|
+
attempt,
|
|
187
|
+
self.max_retries,
|
|
188
|
+
delay,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
self.connection_state = "connecting"
|
|
192
|
+
target = self.config.get_server_address()
|
|
193
|
+
self.channel = self._create_channel(target)
|
|
194
|
+
|
|
195
|
+
# Wait until the channel reports ready or timeout occurs
|
|
196
|
+
grpc.channel_ready_future(self.channel).result(
|
|
197
|
+
timeout=ready_timeout
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
self._initialize_stubs()
|
|
201
|
+
self.connected = True
|
|
202
|
+
self.connection_state = "connected"
|
|
203
|
+
self.consecutive_failures = 0
|
|
204
|
+
logger.info("Successfully connected to %s", target)
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
last_exception = e
|
|
209
|
+
self.consecutive_failures += 1
|
|
210
|
+
logger.warning("Connection attempt %d failed: %s", attempt + 1, e)
|
|
211
|
+
self._cleanup_failed_connection()
|
|
212
|
+
|
|
213
|
+
if attempt < self.max_retries and self._should_retry(attempt, e):
|
|
214
|
+
continue
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
raise ToolplaneConnectionError(
|
|
218
|
+
f"Failed to connect after {self.max_retries + 1} attempts. Last error: {last_exception}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def disconnect(self):
|
|
222
|
+
"""Close connection to gRPC server."""
|
|
223
|
+
with self._connect_lock:
|
|
224
|
+
if self.channel is not None:
|
|
225
|
+
try:
|
|
226
|
+
self.channel.close()
|
|
227
|
+
except Exception as e:
|
|
228
|
+
logger.debug("Error closing channel: %s", e)
|
|
229
|
+
self._reset_stubs()
|
|
230
|
+
self.connected = False
|
|
231
|
+
self.connection_state = "disconnected"
|
|
232
|
+
|
|
233
|
+
def _initialize_stubs(self):
|
|
234
|
+
"""Initialize all gRPC service stubs."""
|
|
235
|
+
if self.channel is None:
|
|
236
|
+
raise ToolplaneConnectionError("Channel is not initialized")
|
|
237
|
+
|
|
238
|
+
self.tool_stub = ToolServiceStub(self.channel)
|
|
239
|
+
self.session_stub = SessionsServiceStub(self.channel)
|
|
240
|
+
self.machine_stub = MachinesServiceStub(self.channel)
|
|
241
|
+
self.requests_stub = RequestsServiceStub(self.channel)
|
|
242
|
+
self.tasks_stub = TasksServiceStub(self.channel)
|
|
243
|
+
|
|
244
|
+
def _reset_stubs(self):
|
|
245
|
+
"""Reset all service stubs."""
|
|
246
|
+
self.tool_stub = None
|
|
247
|
+
self.session_stub = None
|
|
248
|
+
self.machine_stub = None
|
|
249
|
+
self.requests_stub = None
|
|
250
|
+
self.tasks_stub = None
|
|
251
|
+
self.channel = None
|
|
252
|
+
|
|
253
|
+
def ensure_connected(self):
|
|
254
|
+
"""Ensure connection is established."""
|
|
255
|
+
if not self.connected or self.channel is None:
|
|
256
|
+
if not self.connect():
|
|
257
|
+
raise ToolplaneConnectionError("Failed to establish connection")
|
|
258
|
+
|
|
259
|
+
def get_metadata(self):
|
|
260
|
+
"""Get metadata for gRPC calls."""
|
|
261
|
+
metadata = self.config.get_metadata()
|
|
262
|
+
# Per-machine credential for provide-scoped RPCs (set by the machine
|
|
263
|
+
# registration that minted it). Sent on every call; the server only
|
|
264
|
+
# consults it on provider operations.
|
|
265
|
+
if getattr(self, "machine_token", ""):
|
|
266
|
+
metadata = list(metadata) + [
|
|
267
|
+
("x-toolplane-machine-token", self.machine_token)
|
|
268
|
+
]
|
|
269
|
+
return metadata
|
|
270
|
+
|
|
271
|
+
def set_machine_token(self, token: str):
|
|
272
|
+
"""Store the per-machine credential minted at registration."""
|
|
273
|
+
self.machine_token = token
|
|
274
|
+
|
|
275
|
+
def mark_unhealthy(self):
|
|
276
|
+
"""Mark the current channel as unhealthy so the next call reconnects."""
|
|
277
|
+
with self._connect_lock:
|
|
278
|
+
if self.channel is not None:
|
|
279
|
+
try:
|
|
280
|
+
self.channel.close()
|
|
281
|
+
except Exception as e:
|
|
282
|
+
logger.debug("Error closing unhealthy channel: %s", e)
|
|
283
|
+
self._reset_stubs()
|
|
284
|
+
self.connected = False
|
|
285
|
+
self.connection_state = "disconnected"
|
toolplane/core/errors.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Error handling for Toolplane client."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from grpc import StatusCode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ToolplaneError(Exception):
|
|
10
|
+
"""Base exception for Toolplane client errors."""
|
|
11
|
+
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToolplaneConnectionError(ToolplaneError):
|
|
16
|
+
"""Error related to gRPC connection.
|
|
17
|
+
|
|
18
|
+
Named to avoid shadowing the builtin ConnectionError: modules that
|
|
19
|
+
imported the old name silently lost the ability to catch (or retry on)
|
|
20
|
+
real socket connection errors.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Backwards-compatible alias for the pre-1.0 name. New code should prefer
|
|
27
|
+
# ToolplaneConnectionError.
|
|
28
|
+
ConnectionError = ToolplaneConnectionError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ToolError(ToolplaneError):
|
|
32
|
+
"""Error related to tool operations."""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SessionError(ToolplaneError):
|
|
38
|
+
"""Error related to session operations."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MachineError(ToolplaneError):
|
|
44
|
+
"""Error related to machine operations."""
|
|
45
|
+
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RequestError(ToolplaneError):
|
|
50
|
+
"""Error related to request operations."""
|
|
51
|
+
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TaskError(ToolplaneError):
|
|
56
|
+
"""Error related to task operations."""
|
|
57
|
+
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# gRPC codes where re-issuing the same call can plausibly succeed (the server
|
|
62
|
+
# or a proxy was momentarily unavailable, or a capacity limit will clear).
|
|
63
|
+
# Everything else — bad credentials, bad arguments, state conflicts,
|
|
64
|
+
# missing entities — is deterministic: retrying re-runs the same failure.
|
|
65
|
+
RETRYABLE_GRPC_CODES = frozenset(
|
|
66
|
+
{StatusCode.UNAVAILABLE, StatusCode.RESOURCE_EXHAUSTED}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# The JSON gateway translates gRPC codes to HTTP statuses and carries the
|
|
70
|
+
# numeric gRPC code in the error body; this table covers responses whose body
|
|
71
|
+
# is not that envelope (bare proxies, load balancers).
|
|
72
|
+
_HTTP_STATUS_FALLBACK = {
|
|
73
|
+
400: StatusCode.INVALID_ARGUMENT,
|
|
74
|
+
401: StatusCode.UNAUTHENTICATED,
|
|
75
|
+
403: StatusCode.PERMISSION_DENIED,
|
|
76
|
+
404: StatusCode.NOT_FOUND,
|
|
77
|
+
409: StatusCode.ALREADY_EXISTS,
|
|
78
|
+
422: StatusCode.INVALID_ARGUMENT,
|
|
79
|
+
429: StatusCode.RESOURCE_EXHAUSTED,
|
|
80
|
+
500: StatusCode.INTERNAL,
|
|
81
|
+
502: StatusCode.UNAVAILABLE,
|
|
82
|
+
503: StatusCode.UNAVAILABLE,
|
|
83
|
+
504: StatusCode.DEADLINE_EXCEEDED,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# grpc.StatusCode members are keyed by their wire strings ("failed
|
|
87
|
+
# precondition"), not by the protocol's numeric codes, so the numbers from
|
|
88
|
+
# the gateway error envelope are mapped explicitly.
|
|
89
|
+
_GRPC_NUMERIC_CODE = {
|
|
90
|
+
0: StatusCode.OK,
|
|
91
|
+
1: StatusCode.CANCELLED,
|
|
92
|
+
2: StatusCode.UNKNOWN,
|
|
93
|
+
3: StatusCode.INVALID_ARGUMENT,
|
|
94
|
+
4: StatusCode.DEADLINE_EXCEEDED,
|
|
95
|
+
5: StatusCode.NOT_FOUND,
|
|
96
|
+
6: StatusCode.ALREADY_EXISTS,
|
|
97
|
+
7: StatusCode.PERMISSION_DENIED,
|
|
98
|
+
8: StatusCode.RESOURCE_EXHAUSTED,
|
|
99
|
+
9: StatusCode.FAILED_PRECONDITION,
|
|
100
|
+
10: StatusCode.ABORTED,
|
|
101
|
+
11: StatusCode.OUT_OF_RANGE,
|
|
102
|
+
12: StatusCode.UNIMPLEMENTED,
|
|
103
|
+
13: StatusCode.INTERNAL,
|
|
104
|
+
14: StatusCode.UNAVAILABLE,
|
|
105
|
+
15: StatusCode.DATA_LOSS,
|
|
106
|
+
16: StatusCode.UNAUTHENTICATED,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ToolplaneAPIError(ToolplaneError):
|
|
111
|
+
"""A server-reported failure carrying a machine-readable status code.
|
|
112
|
+
|
|
113
|
+
Attributes:
|
|
114
|
+
code: gRPC status name, e.g. "NOT_FOUND" or "FAILED_PRECONDITION".
|
|
115
|
+
retryable: True when re-issuing the call can plausibly succeed
|
|
116
|
+
(UNAVAILABLE or RESOURCE_EXHAUSTED).
|
|
117
|
+
request_id: The request the failing call operated on, when known.
|
|
118
|
+
status: Last-known request status for poll-style calls, when known.
|
|
119
|
+
details: Raw transport error (gRPC trailing status or HTTP body).
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
message: str,
|
|
125
|
+
*,
|
|
126
|
+
code: str,
|
|
127
|
+
retryable: bool,
|
|
128
|
+
request_id: Optional[str] = None,
|
|
129
|
+
status: Optional[str] = None,
|
|
130
|
+
details: Any = None,
|
|
131
|
+
):
|
|
132
|
+
super().__init__(message)
|
|
133
|
+
self.code = code
|
|
134
|
+
self.retryable = retryable
|
|
135
|
+
self.request_id = request_id
|
|
136
|
+
self.status = status
|
|
137
|
+
self.details = details
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ToolplaneNotFoundError(ToolplaneAPIError):
|
|
141
|
+
"""The targeted session, request, machine, tool, key, or task is missing."""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ToolplaneInvalidArgumentError(ToolplaneAPIError):
|
|
145
|
+
"""The server rejected the request payload (INVALID_ARGUMENT)."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ToolplaneFailedPreconditionError(ToolplaneAPIError):
|
|
149
|
+
"""The operation conflicts with server state: a lost claim race, a
|
|
150
|
+
stale lease, a draining machine, a terminal request, or no provider
|
|
151
|
+
registered for the tool (FAILED_PRECONDITION)."""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ToolplaneResourceExhaustedError(ToolplaneAPIError):
|
|
155
|
+
"""A capacity limit was hit (RESOURCE_EXHAUSTED); retryable with backoff."""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ToolplaneUnauthenticatedError(ToolplaneAPIError):
|
|
159
|
+
"""The presented API key is missing, unknown, or revoked
|
|
160
|
+
(UNAUTHENTICATED). Retrying cannot succeed."""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ToolplanePermissionDeniedError(ToolplaneAPIError):
|
|
164
|
+
"""The caller lacks the required capability, or the per-machine
|
|
165
|
+
credential check failed (PERMISSION_DENIED)."""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class ToolplaneAlreadyExistsError(ToolplaneAPIError):
|
|
169
|
+
"""The created entity already exists (ALREADY_EXISTS)."""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ToolplaneTimeoutError(ToolplaneAPIError):
|
|
173
|
+
"""The call exceeded its deadline (DEADLINE_EXCEEDED)."""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ToolplaneUnavailableError(ToolplaneAPIError):
|
|
177
|
+
"""The server or a proxy was momentarily unreachable (UNAVAILABLE);
|
|
178
|
+
retryable with backoff."""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ToolplaneCancelledError(ToolplaneAPIError):
|
|
182
|
+
"""The call was cancelled before completing (CANCELLED)."""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class ToolplaneInternalError(ToolplaneAPIError):
|
|
186
|
+
"""An unexpected server-side failure, or an unrecognized status code."""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
_GRPC_CODE_TO_ERROR = {
|
|
190
|
+
StatusCode.NOT_FOUND: ToolplaneNotFoundError,
|
|
191
|
+
StatusCode.INVALID_ARGUMENT: ToolplaneInvalidArgumentError,
|
|
192
|
+
StatusCode.FAILED_PRECONDITION: ToolplaneFailedPreconditionError,
|
|
193
|
+
StatusCode.OUT_OF_RANGE: ToolplaneInvalidArgumentError,
|
|
194
|
+
StatusCode.RESOURCE_EXHAUSTED: ToolplaneResourceExhaustedError,
|
|
195
|
+
StatusCode.UNAUTHENTICATED: ToolplaneUnauthenticatedError,
|
|
196
|
+
StatusCode.PERMISSION_DENIED: ToolplanePermissionDeniedError,
|
|
197
|
+
StatusCode.ALREADY_EXISTS: ToolplaneAlreadyExistsError,
|
|
198
|
+
StatusCode.DEADLINE_EXCEEDED: ToolplaneTimeoutError,
|
|
199
|
+
StatusCode.UNAVAILABLE: ToolplaneUnavailableError,
|
|
200
|
+
StatusCode.CANCELLED: ToolplaneCancelledError,
|
|
201
|
+
StatusCode.INTERNAL: ToolplaneInternalError,
|
|
202
|
+
StatusCode.UNKNOWN: ToolplaneInternalError,
|
|
203
|
+
StatusCode.UNIMPLEMENTED: ToolplaneInternalError,
|
|
204
|
+
StatusCode.ABORTED: ToolplaneFailedPreconditionError,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def api_error_from_rpc_error(
|
|
209
|
+
rpc_error: Any,
|
|
210
|
+
context: str = "",
|
|
211
|
+
request_id: Optional[str] = None,
|
|
212
|
+
status: Optional[str] = None,
|
|
213
|
+
) -> ToolplaneAPIError:
|
|
214
|
+
"""Translate a grpc.RpcError into a typed ToolplaneAPIError subclass."""
|
|
215
|
+
code = rpc_error.code()
|
|
216
|
+
message = rpc_error.details() or str(rpc_error)
|
|
217
|
+
if context:
|
|
218
|
+
message = f"{context}: {message}"
|
|
219
|
+
error_class = _GRPC_CODE_TO_ERROR.get(code, ToolplaneInternalError)
|
|
220
|
+
return error_class(
|
|
221
|
+
message,
|
|
222
|
+
code=code.name,
|
|
223
|
+
retryable=code in RETRYABLE_GRPC_CODES,
|
|
224
|
+
request_id=request_id,
|
|
225
|
+
status=status,
|
|
226
|
+
details=rpc_error,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def api_error_from_http_response(
|
|
231
|
+
http_status: int,
|
|
232
|
+
body: str,
|
|
233
|
+
context: str = "",
|
|
234
|
+
request_id: Optional[str] = None,
|
|
235
|
+
) -> ToolplaneAPIError:
|
|
236
|
+
"""Translate an HTTP error response into a typed ToolplaneAPIError.
|
|
237
|
+
|
|
238
|
+
The JSON gateway emits ``{"code": <grpc code number>, "message": ...}``
|
|
239
|
+
for unary endpoints and wraps the status in an ``{"error": {...}}``
|
|
240
|
+
frame for streaming endpoints; the numeric code is authoritative when
|
|
241
|
+
present in either shape. Otherwise the HTTP status is mapped through
|
|
242
|
+
_HTTP_STATUS_FALLBACK.
|
|
243
|
+
"""
|
|
244
|
+
message = ""
|
|
245
|
+
code: Optional[StatusCode] = None
|
|
246
|
+
try:
|
|
247
|
+
parsed = json.loads(body) if body else None
|
|
248
|
+
if isinstance(parsed, dict):
|
|
249
|
+
envelope = parsed
|
|
250
|
+
nested = parsed.get("error")
|
|
251
|
+
if isinstance(nested, dict):
|
|
252
|
+
envelope = nested
|
|
253
|
+
message = str(envelope.get("message") or "")
|
|
254
|
+
raw_code = envelope.get("code")
|
|
255
|
+
if raw_code is None:
|
|
256
|
+
raw_code = parsed.get("code")
|
|
257
|
+
if isinstance(raw_code, int):
|
|
258
|
+
code = _GRPC_NUMERIC_CODE.get(raw_code)
|
|
259
|
+
except (ValueError, TypeError):
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
if code is None:
|
|
263
|
+
code = _HTTP_STATUS_FALLBACK.get(http_status, StatusCode.UNKNOWN)
|
|
264
|
+
if not message:
|
|
265
|
+
message = f"HTTP {http_status} {body or ''}".strip()
|
|
266
|
+
if context:
|
|
267
|
+
message = f"{context}: {message}"
|
|
268
|
+
|
|
269
|
+
error_class = _GRPC_CODE_TO_ERROR.get(code, ToolplaneInternalError)
|
|
270
|
+
return error_class(
|
|
271
|
+
message,
|
|
272
|
+
code=code.name,
|
|
273
|
+
retryable=code in RETRYABLE_GRPC_CODES,
|
|
274
|
+
request_id=request_id,
|
|
275
|
+
details=body,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def status_for_wire(name):
|
|
280
|
+
"""Map a friendly status ("done", "pending") onto the v1 enum name
|
|
281
|
+
("REQUEST_STATUS_DONE") used on requests with status filters. An empty
|
|
282
|
+
value maps to 0 (UNSPECIFIED), which gRPC accepts and means "no filter".
|
|
283
|
+
"""
|
|
284
|
+
if not isinstance(name, str) or not name:
|
|
285
|
+
return 0
|
|
286
|
+
return "REQUEST_STATUS_" + name.upper()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def normalize_status_name(status):
|
|
290
|
+
"""Map a v1 enum status name (e.g. "REQUEST_STATUS_DONE") onto the
|
|
291
|
+
friendly lowercase form ("done") clients have always seen. Passes
|
|
292
|
+
through anything else unchanged."""
|
|
293
|
+
if not isinstance(status, str):
|
|
294
|
+
return status
|
|
295
|
+
for prefix in ("REQUEST_STATUS_", "TASK_STATUS_"):
|
|
296
|
+
if status.startswith(prefix):
|
|
297
|
+
return status[len(prefix) :].lower()
|
|
298
|
+
return status
|