hopx-ai 0.1.11__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.
@@ -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
+ BunnyshellError: 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
+
hopx_ai/_client.py ADDED
@@ -0,0 +1,230 @@
1
+ """Internal HTTP client with retry logic."""
2
+
3
+ import os
4
+ import time
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 HTTPClient:
24
+ """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
+ import socket
47
+ self._client = httpx.Client(
48
+ base_url=self.base_url,
49
+ timeout=timeout,
50
+ headers=self._default_headers(),
51
+ transport=httpx.HTTPTransport(
52
+ local_address="0.0.0.0", # Force IPv4
53
+ retries=0 # We handle retries ourselves
54
+ ),
55
+ )
56
+
57
+ def _default_headers(self) -> Dict[str, str]:
58
+ """Get default headers for all requests."""
59
+ return {
60
+ "X-API-Key": self.api_key,
61
+ "Content-Type": "application/json",
62
+ "User-Agent": "bunnyshell-python/0.1.0",
63
+ }
64
+
65
+ def _should_retry(self, status_code: int, attempt: int) -> bool:
66
+ """Determine if request should be retried."""
67
+ if attempt >= self.max_retries:
68
+ return False
69
+
70
+ # Retry on server errors and rate limits
71
+ return status_code in (429, 500, 502, 503, 504)
72
+
73
+ def _get_retry_delay(self, attempt: int, retry_after: Optional[int] = None) -> float:
74
+ """Calculate retry delay with exponential backoff."""
75
+ if retry_after:
76
+ return float(retry_after)
77
+
78
+ # Exponential backoff: 1s, 2s, 4s, 8s...
79
+ return min(2 ** attempt, 60)
80
+
81
+ def _handle_error(self, response: httpx.Response) -> None:
82
+ """Convert HTTP errors to appropriate exceptions."""
83
+ try:
84
+ error_data = response.json().get("error", {})
85
+ message = error_data.get("message", response.text)
86
+ code = error_data.get("code")
87
+ request_id = error_data.get("request_id")
88
+ details = error_data.get("details", {})
89
+ except Exception:
90
+ message = response.text or f"HTTP {response.status_code}"
91
+ code = None
92
+ request_id = response.headers.get("X-Request-ID")
93
+ details = {}
94
+
95
+ kwargs = {
96
+ "code": code,
97
+ "request_id": request_id,
98
+ "details": details,
99
+ "status_code": response.status_code,
100
+ }
101
+
102
+ if response.status_code == 401:
103
+ raise AuthenticationError(message, **kwargs)
104
+ elif response.status_code == 404:
105
+ raise NotFoundError(message, **kwargs)
106
+ elif response.status_code == 400:
107
+ raise ValidationError(message, **kwargs)
108
+ elif response.status_code == 429:
109
+ retry_after = details.get("retry_after_seconds")
110
+ raise RateLimitError(message, retry_after=retry_after, **kwargs)
111
+ elif response.status_code == 403 and "limit" in message.lower():
112
+ raise ResourceLimitError(
113
+ message,
114
+ limit=details.get("limit"),
115
+ current=details.get("current"),
116
+ available=details.get("available"),
117
+ upgrade_url=details.get("upgrade_url"),
118
+ **kwargs
119
+ )
120
+ elif response.status_code >= 500:
121
+ raise ServerError(message, **kwargs)
122
+ else:
123
+ raise APIError(message, **kwargs)
124
+
125
+ def request(
126
+ self,
127
+ method: str,
128
+ path: str,
129
+ *,
130
+ params: Optional[Dict[str, Any]] = None,
131
+ json: Optional[Dict[str, Any]] = None,
132
+ ) -> Dict[str, Any]:
133
+ """
134
+ Make an HTTP request with automatic retries.
135
+
136
+ Args:
137
+ method: HTTP method (GET, POST, DELETE, etc.)
138
+ path: API endpoint path (without base URL)
139
+ params: Query parameters
140
+ json: JSON request body
141
+
142
+ Returns:
143
+ Response JSON data
144
+
145
+ Raises:
146
+ BunnyshellError: On API errors
147
+ NetworkError: On network errors
148
+ TimeoutError: On timeout
149
+ """
150
+ url = f"{self.base_url}/{path.lstrip('/')}"
151
+
152
+ # Debug logging
153
+ logger.debug(f"{method} {url}")
154
+ if json:
155
+ logger.debug(f"Request body: {json}")
156
+ if params:
157
+ logger.debug(f"Query params: {params}")
158
+
159
+ for attempt in range(self.max_retries + 1):
160
+ try:
161
+ start_time = time.time()
162
+
163
+ response = 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
+ time.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
+ time.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
+ time.sleep(delay)
210
+ continue
211
+ raise NetworkError(f"Network error: {e}") from e
212
+
213
+ raise ServerError("Max retries exceeded")
214
+
215
+ def get(self, path: str, **kwargs) -> Dict[str, Any]:
216
+ """GET request."""
217
+ return self.request("GET", path, **kwargs)
218
+
219
+ def post(self, path: str, **kwargs) -> Dict[str, Any]:
220
+ """POST request."""
221
+ return self.request("POST", path, **kwargs)
222
+
223
+ def delete(self, path: str, **kwargs) -> Dict[str, Any]:
224
+ """DELETE request."""
225
+ return self.request("DELETE", path, **kwargs)
226
+
227
+ def close(self) -> None:
228
+ """Close the HTTP client."""
229
+ self._client.close()
230
+
@@ -0,0 +1,22 @@
1
+ """
2
+ Auto-generated models from OpenAPI spec v3.1.2.
3
+
4
+ These models are automatically generated from the HOPX VM Agent API OpenAPI specification.
5
+ DO NOT EDIT MANUALLY - regenerate using scripts/generate_models.sh
6
+
7
+ All models are type-safe Pydantic v2 models with built-in validation.
8
+
9
+ Usage:
10
+ from bunnyshell._generated import ExecuteRequest, ExecuteResponse
11
+
12
+ request = ExecuteRequest(code="print('hello')", language="python")
13
+ # Pydantic validates automatically!
14
+ """
15
+
16
+ # Re-export all models from models.py
17
+ from .models import *
18
+
19
+ __all__ = [
20
+ # Export everything from models
21
+ # This is populated automatically from models.py
22
+ ]