hopx-ai 0.1.15__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.

Potentially problematic release.


This version of hopx-ai might be problematic. Click here for more details.

@@ -0,0 +1,223 @@
1
+ """Async HTTP client for agent operations with retry logic."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Optional, Dict, Any
6
+ import httpx
7
+ from .errors import (
8
+ FileNotFoundError,
9
+ FileOperationError,
10
+ CodeExecutionError,
11
+ CommandExecutionError,
12
+ DesktopNotAvailableError,
13
+ AgentError,
14
+ NetworkError,
15
+ TimeoutError as HopxTimeoutError,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class AsyncAgentHTTPClient:
22
+ """
23
+ Async HTTP client for agent operations with retry logic and error handling.
24
+
25
+ Features:
26
+ - Connection pooling (reuses TCP connections)
27
+ - Automatic retries with exponential backoff
28
+ - Proper error wrapping to HOPX exceptions
29
+ - Configurable timeouts
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ agent_url: str,
35
+ *,
36
+ jwt_token: Optional[str] = None,
37
+ timeout: int = 30,
38
+ max_retries: int = 3,
39
+ token_refresh_callback: Optional[callable] = None,
40
+ ):
41
+ """
42
+ Initialize async agent HTTP client.
43
+
44
+ Args:
45
+ agent_url: Agent base URL (e.g., https://7777-{id}.domain)
46
+ jwt_token: JWT token for agent authentication
47
+ timeout: Default timeout in seconds
48
+ max_retries: Maximum retry attempts
49
+ token_refresh_callback: Async callback to refresh token on 401
50
+ """
51
+ self._agent_url = agent_url.rstrip('/')
52
+ self._jwt_token = jwt_token
53
+ self._timeout = timeout
54
+ self._max_retries = max_retries
55
+ self._token_refresh_callback = token_refresh_callback
56
+
57
+ # Build headers
58
+ headers = {}
59
+ if self._jwt_token:
60
+ headers['Authorization'] = f'Bearer {self._jwt_token}'
61
+
62
+ # Create reusable async HTTP client with connection pooling
63
+ self._client = httpx.AsyncClient(
64
+ timeout=httpx.Timeout(timeout),
65
+ limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
66
+ headers=headers,
67
+ transport=httpx.AsyncHTTPTransport(
68
+ local_address="0.0.0.0", # Force IPv4
69
+ )
70
+ )
71
+
72
+ logger.debug(f"Async agent client initialized: {self._agent_url}")
73
+
74
+ async def close(self) -> None:
75
+ """Close the HTTP client."""
76
+ await self._client.aclose()
77
+
78
+ def update_jwt_token(self, token: str) -> None:
79
+ """Update JWT token for agent authentication."""
80
+ self._jwt_token = token
81
+ self._client.headers['Authorization'] = f'Bearer {token}'
82
+
83
+ def _should_retry(self, status_code: int) -> bool:
84
+ """Check if request should be retried."""
85
+ return status_code in {429, 500, 502, 503, 504}
86
+
87
+ def _get_retry_delay(self, attempt: int) -> float:
88
+ """Calculate retry delay with exponential backoff."""
89
+ return min(2 ** attempt, 10) # Max 10 seconds
90
+
91
+ def _wrap_error(
92
+ self,
93
+ error: Exception,
94
+ operation: str,
95
+ context: Optional[Dict[str, Any]] = None
96
+ ) -> AgentError:
97
+ """Wrap httpx errors into HOPX exceptions."""
98
+ context = context or {}
99
+
100
+ if isinstance(error, httpx.HTTPStatusError):
101
+ status_code = error.response.status_code
102
+
103
+ try:
104
+ error_data = error.response.json()
105
+ error_code = error_data.get('code', '').upper()
106
+ error_message = error_data.get('message', str(error))
107
+ except:
108
+ error_code = None
109
+ error_message = str(error)
110
+
111
+ # Map error codes to specific exceptions
112
+ if error_code == 'FILE_NOT_FOUND':
113
+ return FileNotFoundError(
114
+ error_message,
115
+ path=context.get('path'),
116
+ code='file_not_found'
117
+ )
118
+ elif error_code in ('PATH_NOT_ALLOWED', 'PERMISSION_DENIED'):
119
+ return FileOperationError(
120
+ error_message,
121
+ path=context.get('path'),
122
+ operation=operation,
123
+ code='file_operation_failed'
124
+ )
125
+ elif error_code in ('EXECUTION_FAILED', 'EXECUTION_TIMEOUT', 'INVALID_TOKEN'):
126
+ return CodeExecutionError(
127
+ error_message,
128
+ exit_code=error_data.get('exit_code'),
129
+ code='execution_failed'
130
+ )
131
+ elif error_code == 'COMMAND_FAILED':
132
+ return CommandExecutionError(
133
+ error_message,
134
+ exit_code=error_data.get('exit_code'),
135
+ command=context.get('command'),
136
+ code='command_failed'
137
+ )
138
+ elif error_code == 'DESKTOP_NOT_AVAILABLE':
139
+ return DesktopNotAvailableError(
140
+ error_message,
141
+ missing_dependencies=error_data.get('missing_dependencies', []),
142
+ code='desktop_not_available'
143
+ )
144
+
145
+ elif isinstance(error, httpx.TimeoutException):
146
+ return HopxTimeoutError(f"{operation} timed out after {self._timeout}s")
147
+ elif isinstance(error, httpx.NetworkError):
148
+ return NetworkError(f"{operation} failed: {error}")
149
+
150
+ return AgentError(f"{operation} failed: {error}")
151
+
152
+ async def _request(
153
+ self,
154
+ method: str,
155
+ endpoint: str,
156
+ operation: str = "request",
157
+ context: Optional[Dict[str, Any]] = None,
158
+ **kwargs
159
+ ) -> Dict[str, Any]:
160
+ """Make HTTP request with retry logic."""
161
+ url = f"{self._agent_url}{endpoint}"
162
+
163
+ for attempt in range(self._max_retries):
164
+ try:
165
+ response = await self._client.request(method, url, **kwargs)
166
+ response.raise_for_status()
167
+
168
+ # Handle empty responses (204 No Content)
169
+ if response.status_code == 204 or not response.content:
170
+ return {}
171
+
172
+ if response.headers.get('content-type', '').startswith('application/json'):
173
+ return response.json()
174
+ return {"content": response.content}
175
+
176
+ except httpx.HTTPStatusError as e:
177
+ # Handle 401 Unauthorized - try to refresh token
178
+ if e.response.status_code == 401 and self._token_refresh_callback and attempt == 0:
179
+ logger.info(f"{operation} got 401 Unauthorized, attempting token refresh...")
180
+ try:
181
+ # Call async refresh callback to get new token
182
+ new_token = await self._token_refresh_callback()
183
+ if new_token:
184
+ # Update token and retry request
185
+ self.update_jwt_token(new_token)
186
+ logger.info(f"{operation} token refreshed, retrying request...")
187
+ continue
188
+ except Exception as refresh_error:
189
+ logger.error(f"Token refresh failed: {refresh_error}")
190
+ # Fall through to raise original error
191
+
192
+ if not self._should_retry(e.response.status_code) or attempt == self._max_retries - 1:
193
+ raise self._wrap_error(e, operation, context)
194
+
195
+ await asyncio.sleep(self._get_retry_delay(attempt))
196
+
197
+ except (httpx.TimeoutException, httpx.NetworkError) as e:
198
+ if attempt == self._max_retries - 1:
199
+ raise self._wrap_error(e, operation, context)
200
+
201
+ await asyncio.sleep(self._get_retry_delay(attempt))
202
+
203
+ raise AgentError(f"{operation} failed after {self._max_retries} retries")
204
+
205
+ async def get(self, endpoint: str, operation: str = "GET request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
206
+ """Make GET request."""
207
+ return await self._request("GET", endpoint, operation=operation, context=context, **kwargs)
208
+
209
+ async def post(self, endpoint: str, operation: str = "POST request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
210
+ """Make POST request."""
211
+ return await self._request("POST", endpoint, operation=operation, context=context, **kwargs)
212
+
213
+ async def put(self, endpoint: str, operation: str = "PUT request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
214
+ """Make PUT request."""
215
+ return await self._request("PUT", endpoint, operation=operation, context=context, **kwargs)
216
+
217
+ async def delete(self, endpoint: str, operation: str = "DELETE request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
218
+ """Make DELETE request."""
219
+ return await self._request("DELETE", endpoint, operation=operation, context=context, **kwargs)
220
+
221
+ async def patch(self, endpoint: str, operation: str = "PATCH request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
222
+ """Make PATCH request."""
223
+ return await self._request("PATCH", endpoint, operation=operation, context=context, **kwargs)
@@ -0,0 +1,38 @@
1
+ """Async cache operations for sandboxes."""
2
+
3
+ from typing import Dict, Any
4
+ import logging
5
+ from ._async_agent_client import AsyncAgentHTTPClient
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class AsyncCache:
11
+ """Async cache operations."""
12
+
13
+ def __init__(self, sandbox):
14
+ """Initialize with sandbox reference."""
15
+ self._sandbox = sandbox
16
+ logger.debug("AsyncCache initialized")
17
+
18
+ async def _get_client(self) -> AsyncAgentHTTPClient:
19
+ """Get agent client from sandbox."""
20
+ await self._sandbox._ensure_agent_client()
21
+ return self._sandbox._agent_client
22
+
23
+ async def stats(self) -> Dict[str, Any]:
24
+ """Get cache statistics."""
25
+ client = await self._get_client()
26
+ response = await client.get(
27
+ "/cache/stats",
28
+ operation="get cache stats"
29
+ )
30
+ return response
31
+
32
+ async def clear(self) -> None:
33
+ """Clear cache."""
34
+ client = await self._get_client()
35
+ await client.post(
36
+ "/cache/clear",
37
+ operation="clear cache"
38
+ )
@@ -0,0 +1,230 @@
1
+ """Async HTTP client with retry logic."""
2
+
3
+ import os
4
+ import asyncio
5
+ import logging
6
+ from typing import Optional, Dict, Any
7
+ import httpx
8
+ from .errors import (
9
+ APIError,
10
+ AuthenticationError,
11
+ NotFoundError,
12
+ ValidationError,
13
+ RateLimitError,
14
+ ResourceLimitError,
15
+ ServerError,
16
+ NetworkError,
17
+ TimeoutError,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class AsyncHTTPClient:
24
+ """Async HTTP client with automatic retries and error handling."""
25
+
26
+ def __init__(
27
+ self,
28
+ api_key: Optional[str] = None,
29
+ base_url: str = "https://api.hopx.dev",
30
+ timeout: int = 60,
31
+ max_retries: int = 3,
32
+ ):
33
+ # API key priority: param > env var > error
34
+ self.api_key = api_key or os.environ.get("HOPX_API_KEY")
35
+ if not self.api_key:
36
+ raise ValueError(
37
+ "API key required. Pass api_key parameter or set HOPX_API_KEY environment variable.\n"
38
+ "Get your API key at: https://hopx.ai"
39
+ )
40
+
41
+ self.base_url = base_url.rstrip("/")
42
+ self.timeout = timeout
43
+ self.max_retries = max_retries
44
+
45
+ # Force IPv4 to avoid IPv6 timeout issues (270s delay)
46
+ self._client = httpx.AsyncClient(
47
+ base_url=self.base_url,
48
+ timeout=timeout,
49
+ headers=self._default_headers(),
50
+ transport=httpx.AsyncHTTPTransport(
51
+ local_address="0.0.0.0", # Force IPv4
52
+ retries=0 # We handle retries ourselves
53
+ ),
54
+ )
55
+
56
+ def _default_headers(self) -> Dict[str, str]:
57
+ """Get default headers for all requests."""
58
+ return {
59
+ "X-API-Key": self.api_key,
60
+ "Content-Type": "application/json",
61
+ "User-Agent": "bunnyshell-python/0.1.0",
62
+ }
63
+
64
+ def _should_retry(self, status_code: int, attempt: int) -> bool:
65
+ """Determine if request should be retried."""
66
+ if attempt >= self.max_retries:
67
+ return False
68
+
69
+ # Retry on server errors and rate limits
70
+ return status_code in (429, 500, 502, 503, 504)
71
+
72
+ def _get_retry_delay(self, attempt: int, retry_after: Optional[int] = None) -> float:
73
+ """Calculate retry delay with exponential backoff."""
74
+ if retry_after:
75
+ return float(retry_after)
76
+
77
+ # Exponential backoff: 1s, 2s, 4s, 8s...
78
+ return min(2 ** attempt, 60)
79
+
80
+ def _handle_error(self, response: httpx.Response) -> None:
81
+ """Convert HTTP errors to appropriate exceptions."""
82
+ try:
83
+ error_data = response.json().get("error", {})
84
+ message = error_data.get("message", response.text)
85
+ code = error_data.get("code")
86
+ request_id = error_data.get("request_id")
87
+ details = error_data.get("details", {})
88
+ except Exception:
89
+ message = response.text or f"HTTP {response.status_code}"
90
+ code = None
91
+ request_id = response.headers.get("X-Request-ID")
92
+ details = {}
93
+
94
+ kwargs = {
95
+ "code": code,
96
+ "request_id": request_id,
97
+ "details": details,
98
+ "status_code": response.status_code,
99
+ }
100
+
101
+ if response.status_code == 401:
102
+ raise AuthenticationError(message, **kwargs)
103
+ elif response.status_code == 404:
104
+ raise NotFoundError(message, **kwargs)
105
+ elif response.status_code == 400:
106
+ raise ValidationError(message, **kwargs)
107
+ elif response.status_code == 429:
108
+ retry_after = details.get("retry_after_seconds")
109
+ raise RateLimitError(message, retry_after=retry_after, **kwargs)
110
+ elif response.status_code == 403 and "limit" in message.lower():
111
+ raise ResourceLimitError(
112
+ message,
113
+ limit=details.get("limit"),
114
+ current=details.get("current"),
115
+ available=details.get("available"),
116
+ upgrade_url=details.get("upgrade_url"),
117
+ **kwargs
118
+ )
119
+ elif response.status_code >= 500:
120
+ raise ServerError(message, **kwargs)
121
+ else:
122
+ raise APIError(message, **kwargs)
123
+
124
+ async def request(
125
+ self,
126
+ method: str,
127
+ path: str,
128
+ *,
129
+ params: Optional[Dict[str, Any]] = None,
130
+ json: Optional[Dict[str, Any]] = None,
131
+ ) -> Dict[str, Any]:
132
+ """
133
+ Make an async HTTP request with automatic retries.
134
+
135
+ Args:
136
+ method: HTTP method (GET, POST, DELETE, etc.)
137
+ path: API endpoint path (without base URL)
138
+ params: Query parameters
139
+ json: JSON request body
140
+
141
+ Returns:
142
+ Response JSON data
143
+
144
+ Raises:
145
+ HopxError: On API errors
146
+ NetworkError: On network errors
147
+ TimeoutError: On timeout
148
+ """
149
+ url = f"{self.base_url}/{path.lstrip('/')}"
150
+
151
+ # Debug logging
152
+ logger.debug(f"{method} {url}")
153
+ if json:
154
+ logger.debug(f"Request body: {json}")
155
+ if params:
156
+ logger.debug(f"Query params: {params}")
157
+
158
+ for attempt in range(self.max_retries + 1):
159
+ try:
160
+ import time
161
+ start_time = time.time()
162
+
163
+ response = await self._client.request(
164
+ method=method,
165
+ url=url,
166
+ params=params,
167
+ json=json,
168
+ )
169
+
170
+ elapsed = time.time() - start_time
171
+ logger.debug(f"Response: {response.status_code} ({elapsed:.3f}s)")
172
+
173
+ # Success
174
+ if response.status_code < 400:
175
+ result = response.json()
176
+ if logger.isEnabledFor(logging.DEBUG):
177
+ logger.debug(f"Response body: {result}")
178
+ return result
179
+
180
+ # Should we retry?
181
+ if self._should_retry(response.status_code, attempt):
182
+ retry_after = None
183
+ if response.status_code == 429:
184
+ try:
185
+ retry_after = response.json().get("error", {}).get("details", {}).get("retry_after_seconds")
186
+ except Exception:
187
+ pass
188
+
189
+ delay = self._get_retry_delay(attempt, retry_after)
190
+ logger.debug(f"Retrying in {delay}s (attempt {attempt + 1}/{self.max_retries})")
191
+ await asyncio.sleep(delay)
192
+ continue
193
+
194
+ # Error - no retry
195
+ self._handle_error(response)
196
+
197
+ except httpx.TimeoutException as e:
198
+ if attempt < self.max_retries:
199
+ delay = self._get_retry_delay(attempt)
200
+ logger.debug(f"Timeout, retrying in {delay}s")
201
+ await asyncio.sleep(delay)
202
+ continue
203
+ raise TimeoutError(f"Request timed out after {self.timeout}s") from e
204
+
205
+ except httpx.NetworkError as e:
206
+ if attempt < self.max_retries:
207
+ delay = self._get_retry_delay(attempt)
208
+ logger.debug(f"Network error, retrying in {delay}s")
209
+ await asyncio.sleep(delay)
210
+ continue
211
+ raise NetworkError(f"Network error: {e}") from e
212
+
213
+ raise ServerError("Max retries exceeded")
214
+
215
+ async def get(self, path: str, **kwargs) -> Dict[str, Any]:
216
+ """GET request."""
217
+ return await self.request("GET", path, **kwargs)
218
+
219
+ async def post(self, path: str, **kwargs) -> Dict[str, Any]:
220
+ """POST request."""
221
+ return await self.request("POST", path, **kwargs)
222
+
223
+ async def delete(self, path: str, **kwargs) -> Dict[str, Any]:
224
+ """DELETE request."""
225
+ return await self.request("DELETE", path, **kwargs)
226
+
227
+ async def close(self) -> None:
228
+ """Close the HTTP client."""
229
+ await self._client.aclose()
230
+
@@ -0,0 +1,58 @@
1
+ """Async command execution for sandboxes."""
2
+
3
+ from typing import Optional, Dict, Any
4
+ import logging
5
+ from ._async_agent_client import AsyncAgentHTTPClient
6
+ from .models import ExecutionResult
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class AsyncCommands:
12
+ """Async command execution for sandboxes."""
13
+
14
+ def __init__(self, sandbox):
15
+ """Initialize with sandbox reference."""
16
+ self._sandbox = sandbox
17
+ logger.debug("AsyncCommands initialized")
18
+
19
+ async def _get_client(self) -> AsyncAgentHTTPClient:
20
+ """Get agent client from sandbox."""
21
+ await self._sandbox._ensure_agent_client()
22
+ return self._sandbox._agent_client
23
+
24
+ async def run(
25
+ self,
26
+ command: str,
27
+ *,
28
+ timeout_seconds: int = 60,
29
+ env: Optional[Dict[str, str]] = None,
30
+ working_dir: str = "/workspace"
31
+ ) -> ExecutionResult:
32
+ """Run shell command."""
33
+ client = await self._get_client()
34
+
35
+ payload = {
36
+ "command": command,
37
+ "timeout": timeout_seconds,
38
+ "working_dir": working_dir
39
+ }
40
+
41
+ if env:
42
+ payload["env"] = env
43
+
44
+ response = await client.post(
45
+ "/commands/run",
46
+ json=payload,
47
+ operation="run command",
48
+ context={"command": command}
49
+ )
50
+
51
+ return ExecutionResult(
52
+ success=response.get("success", True),
53
+ stdout=response.get("stdout", ""),
54
+ stderr=response.get("stderr", ""),
55
+ exit_code=response.get("exit_code", 0),
56
+ execution_time=response.get("execution_time", 0.0),
57
+ rich_outputs=[]
58
+ )