hud-python 0.2.1__py3-none-any.whl → 0.2.3__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 hud-python might be problematic. Click here for more details.

Files changed (59) hide show
  1. hud/__init__.py +5 -3
  2. hud/adapters/__init__.py +2 -1
  3. hud/adapters/claude/adapter.py +13 -17
  4. hud/adapters/common/adapter.py +3 -3
  5. hud/adapters/common/tests/__init__.py +0 -0
  6. hud/adapters/common/tests/test_adapter.py +277 -0
  7. hud/adapters/common/types.py +3 -6
  8. hud/adapters/operator/adapter.py +22 -29
  9. hud/agent/__init__.py +9 -1
  10. hud/agent/base.py +28 -28
  11. hud/agent/claude.py +69 -60
  12. hud/agent/langchain.py +204 -0
  13. hud/agent/operator.py +75 -67
  14. hud/env/__init__.py +5 -5
  15. hud/env/client.py +2 -2
  16. hud/env/docker_client.py +37 -39
  17. hud/env/environment.py +91 -66
  18. hud/env/local_docker_client.py +5 -7
  19. hud/env/remote_client.py +40 -29
  20. hud/env/remote_docker_client.py +13 -3
  21. hud/evaluators/__init__.py +2 -3
  22. hud/evaluators/base.py +4 -3
  23. hud/evaluators/inspect.py +3 -8
  24. hud/evaluators/judge.py +34 -58
  25. hud/evaluators/match.py +42 -49
  26. hud/evaluators/remote.py +13 -26
  27. hud/evaluators/tests/__init__.py +0 -0
  28. hud/evaluators/tests/test_inspect.py +12 -0
  29. hud/evaluators/tests/test_judge.py +231 -0
  30. hud/evaluators/tests/test_match.py +115 -0
  31. hud/evaluators/tests/test_remote.py +98 -0
  32. hud/exceptions.py +167 -0
  33. hud/gym.py +12 -10
  34. hud/job.py +525 -47
  35. hud/server/__init__.py +2 -2
  36. hud/server/requests.py +148 -186
  37. hud/server/tests/__init__.py +0 -0
  38. hud/server/tests/test_requests.py +275 -0
  39. hud/settings.py +3 -2
  40. hud/task.py +12 -22
  41. hud/taskset.py +44 -11
  42. hud/trajectory.py +6 -9
  43. hud/types.py +14 -9
  44. hud/utils/__init__.py +2 -2
  45. hud/utils/common.py +37 -13
  46. hud/utils/config.py +44 -29
  47. hud/utils/progress.py +149 -0
  48. hud/utils/telemetry.py +10 -11
  49. hud/utils/tests/__init__.py +0 -0
  50. hud/utils/tests/test_common.py +52 -0
  51. hud/utils/tests/test_config.py +129 -0
  52. hud/utils/tests/test_progress.py +225 -0
  53. hud/utils/tests/test_telemetry.py +37 -0
  54. hud/utils/tests/test_version.py +8 -0
  55. {hud_python-0.2.1.dist-info → hud_python-0.2.3.dist-info}/METADATA +44 -21
  56. hud_python-0.2.3.dist-info/RECORD +62 -0
  57. hud_python-0.2.1.dist-info/RECORD +0 -44
  58. {hud_python-0.2.1.dist-info → hud_python-0.2.3.dist-info}/WHEEL +0 -0
  59. {hud_python-0.2.1.dist-info → hud_python-0.2.3.dist-info}/licenses/LICENSE +0 -0
hud/server/requests.py CHANGED
@@ -11,95 +11,25 @@ from typing import Any
11
11
 
12
12
  import httpx
13
13
 
14
+ from hud.exceptions import (
15
+ HudAuthenticationError,
16
+ HudNetworkError,
17
+ HudRequestError,
18
+ HudTimeoutError,
19
+ )
20
+
14
21
  # Set up logger
15
22
  logger = logging.getLogger("hud.http")
16
23
  logger.setLevel(logging.DEBUG)
17
24
 
18
25
 
19
- class RequestError(Exception):
20
- """Custom exception for API request errors"""
21
-
22
- def __init__(
23
- self,
24
- message: str,
25
- status_code: int | None = None,
26
- response_text: str | None = None,
27
- response_json: dict[str, Any] | None = None,
28
- response_headers: dict[str, str] | None = None,
29
- ) -> None:
30
- self.message = message
31
- self.status_code = status_code
32
- self.response_text = response_text
33
- self.response_json = response_json
34
- self.response_headers = response_headers
35
- super().__init__(message)
36
-
37
- def __str__(self) -> str:
38
- parts = [self.message]
39
-
40
- if self.status_code:
41
- parts.append(f"Status: {self.status_code}")
42
-
43
- if self.response_text:
44
- parts.append(f"Response Text: {self.response_text}")
45
-
46
- if self.response_json:
47
- parts.append(f"Response JSON: {self.response_json}")
48
-
49
- if self.response_headers:
50
- parts.append(f"Headers: {self.response_headers}")
51
-
52
- return " | ".join(parts)
53
-
54
- @classmethod
55
- def from_http_error(
56
- cls, error: httpx.HTTPStatusError, context: str = ""
57
- ) -> RequestError:
58
- """Create a RequestError from an HTTP error response"""
59
- response = error.response
60
- status_code = response.status_code
61
- response_text = response.text
62
- response_headers = dict(response.headers)
63
-
64
- # Try to get detailed error info from JSON if available
65
- response_json = None
66
- try:
67
- response_json = response.json()
68
- detail = response_json.get("detail")
69
- if detail:
70
- message = f"Request failed: {detail}"
71
- else:
72
- # If no detail field but we have JSON, include a summary
73
- message = f"Request failed with status {status_code}"
74
- if (
75
- len(response_json) <= 5
76
- ): # If it's a small object, include it in the message
77
- message += f" - JSON response: {response_json}"
78
- except Exception:
79
- # Fallback to simple message if JSON parsing fails
80
- message = f"Request failed with status {status_code}"
81
-
82
- # Add context if provided
83
- if context:
84
- message = f"{context}: {message}"
85
-
86
- # Log the error details
87
- logger.error(
88
- "HTTP error from HUD SDK: %s | URL: %s | Status: %s | Response: %s%s",
89
- message,
90
- response.url,
91
- status_code,
92
- response_text[:500],
93
- "..." if len(response_text) > 500 else "",
94
- )
95
-
96
- return cls(
97
- message=message,
98
- status_code=status_code,
99
- response_text=response_text,
100
- response_json=response_json,
101
- response_headers=response_headers,
102
- )
26
+ # Long running requests can take up to 10 minutes.
27
+ _DEFAULT_TIMEOUT = 600.0
28
+ _DEFAULT_LIMITS = httpx.Limits(
29
+ max_connections=1000,
30
+ max_keepalive_connections=1000,
31
+ keepalive_expiry=10.0,
32
+ )
103
33
 
104
34
 
105
35
  async def _handle_retry(
@@ -118,6 +48,22 @@ async def _handle_retry(
118
48
  await asyncio.sleep(retry_time)
119
49
 
120
50
 
51
+ def _create_default_async_client() -> httpx.AsyncClient:
52
+ """Create a default httpx AsyncClient with standard configuration."""
53
+ return httpx.AsyncClient(
54
+ timeout=_DEFAULT_TIMEOUT,
55
+ limits=_DEFAULT_LIMITS,
56
+ )
57
+
58
+
59
+ def _create_default_sync_client() -> httpx.Client:
60
+ """Create a default httpx Client with standard configuration."""
61
+ return httpx.Client(
62
+ timeout=_DEFAULT_TIMEOUT,
63
+ limits=_DEFAULT_LIMITS,
64
+ )
65
+
66
+
121
67
  async def make_request(
122
68
  method: str,
123
69
  url: str,
@@ -125,6 +71,7 @@ async def make_request(
125
71
  api_key: str | None = None,
126
72
  max_retries: int = 4,
127
73
  retry_delay: float = 2.0,
74
+ client: httpx.AsyncClient | None = None,
128
75
  ) -> dict[str, Any]:
129
76
  """
130
77
  Make an asynchronous HTTP request to the HUD API.
@@ -136,62 +83,69 @@ async def make_request(
136
83
  api_key: API key for authentication
137
84
  max_retries: Maximum number of retries
138
85
  retry_delay: Delay between retries
86
+ *,
87
+ client: Optional custom httpx.AsyncClient
88
+
139
89
  Returns:
140
90
  dict: JSON response from the server
141
91
 
142
92
  Raises:
143
- RequestError: If API key is missing or request fails
93
+ HudAuthenticationError: If API key is missing or invalid.
94
+ HudRequestError: If the request fails with a non-retryable status code.
95
+ HudNetworkError: If there are network-related issues.
96
+ HudTimeoutError: If the request times out.
144
97
  """
145
98
  if not api_key:
146
- raise RequestError("API key is required but not provided")
99
+ raise HudAuthenticationError("API key is required but not provided")
147
100
 
148
101
  headers = {"Authorization": f"Bearer {api_key}"}
149
102
  retry_status_codes = [502, 503, 504]
150
103
  attempt = 0
151
-
152
- while attempt <= max_retries:
153
- attempt += 1
154
-
155
- try:
156
- async with httpx.AsyncClient(
157
- timeout=600.0, # Long running requests can take up to 10 minutes
158
- limits=httpx.Limits(
159
- max_connections=1000,
160
- max_keepalive_connections=1000,
161
- keepalive_expiry=10.0,
162
- ),
163
- ) as client:
164
- response = await client.request(
165
- method=method, url=url, json=json, headers=headers
166
- )
167
-
168
- # Check if we got a retriable status code
169
- if response.status_code in retry_status_codes and attempt <= max_retries:
170
- await _handle_retry(
171
- attempt,
172
- max_retries,
173
- retry_delay,
174
- url,
175
- f"Received status {response.status_code}",
176
- )
177
- continue
178
-
179
- response.raise_for_status()
180
- result = response.json()
181
- return result
182
- except httpx.HTTPStatusError as e:
183
- raise RequestError.from_http_error(e) from None
184
- except httpx.RequestError as e:
185
- if attempt <= max_retries:
186
- await _handle_retry(
187
- attempt, max_retries, retry_delay, url, f"Network error: {e}"
188
- )
189
- continue
190
- else:
191
- raise RequestError(f"Network error: {e!s}") from None
192
- except Exception as e:
193
- raise RequestError(f"Unexpected error: {e!s}") from None
194
- raise RequestError(f"Request failed after {max_retries} retries with unknown error")
104
+ should_close_client = False
105
+
106
+ if client is None:
107
+ client = _create_default_async_client()
108
+ should_close_client = True
109
+
110
+ try:
111
+ while attempt <= max_retries:
112
+ attempt += 1
113
+
114
+ try:
115
+ response = await client.request(method=method, url=url, json=json, headers=headers)
116
+
117
+ # Check if we got a retriable status code
118
+ if response.status_code in retry_status_codes and attempt <= max_retries:
119
+ await _handle_retry(
120
+ attempt,
121
+ max_retries,
122
+ retry_delay,
123
+ url,
124
+ f"Received status {response.status_code}",
125
+ )
126
+ continue
127
+
128
+ response.raise_for_status()
129
+ result = response.json()
130
+ return result
131
+ except httpx.TimeoutException as e:
132
+ raise HudTimeoutError(f"Request timed out: {e!s}") from None
133
+ except httpx.HTTPStatusError as e:
134
+ raise HudRequestError.from_httpx_error(e) from None
135
+ except httpx.RequestError as e:
136
+ if attempt <= max_retries:
137
+ await _handle_retry(
138
+ attempt, max_retries, retry_delay, url, f"Network error: {e}"
139
+ )
140
+ continue
141
+ else:
142
+ raise HudNetworkError(f"Network error: {e!s}") from None
143
+ except Exception as e:
144
+ raise HudRequestError(f"Unexpected error: {e!s}") from None
145
+ raise HudRequestError(f"Request failed after {max_retries} retries with unknown error")
146
+ finally:
147
+ if should_close_client:
148
+ await client.aclose()
195
149
 
196
150
 
197
151
  def make_request_sync(
@@ -201,6 +155,8 @@ def make_request_sync(
201
155
  api_key: str | None = None,
202
156
  max_retries: int = 4,
203
157
  retry_delay: float = 2.0,
158
+ *,
159
+ client: httpx.Client | None = None,
204
160
  ) -> dict[str, Any]:
205
161
  """
206
162
  Make a synchronous HTTP request to the HUD API.
@@ -212,69 +168,75 @@ def make_request_sync(
212
168
  api_key: API key for authentication
213
169
  max_retries: Maximum number of retries
214
170
  retry_delay: Delay between retries
171
+ client: Optional custom httpx.Client
172
+
215
173
  Returns:
216
174
  dict: JSON response from the server
217
175
 
218
176
  Raises:
219
- RequestError: If API key is missing or request fails
177
+ HudAuthenticationError: If API key is missing or invalid.
178
+ HudRequestError: If the request fails with a non-retryable status code.
179
+ HudNetworkError: If there are network-related issues.
180
+ HudTimeoutError: If the request times out.
220
181
  """
221
182
  if not api_key:
222
- raise RequestError("API key is required but not provided")
183
+ raise HudAuthenticationError("API key is required but not provided")
223
184
 
224
185
  headers = {"Authorization": f"Bearer {api_key}"}
225
186
  retry_status_codes = [502, 503, 504]
226
187
  attempt = 0
227
-
228
- while attempt <= max_retries:
229
- attempt += 1
230
-
231
- try:
232
- with httpx.Client(
233
- timeout=600.0, # Long running requests can take up to 10 minutes
234
- limits=httpx.Limits(
235
- max_connections=1000,
236
- max_keepalive_connections=1000,
237
- keepalive_expiry=10.0,
238
- ),
239
- ) as client:
240
- response = client.request(
241
- method=method, url=url, json=json, headers=headers
242
- )
243
-
244
- # Check if we got a retriable status code
245
- if response.status_code in retry_status_codes and attempt <= max_retries:
246
- retry_time = retry_delay * (2 ** (attempt - 1)) # Exponential backoff
247
- logger.warning(
248
- "Received status %d from %s, retrying in %.2f seconds (attempt %d/%d)",
249
- response.status_code,
250
- url,
251
- retry_time,
252
- attempt,
253
- max_retries,
254
- )
255
- time.sleep(retry_time)
256
- continue
257
-
258
- response.raise_for_status()
259
- result = response.json()
260
- return result
261
- except httpx.HTTPStatusError as e:
262
- raise RequestError.from_http_error(e) from None
263
- except httpx.RequestError as e:
264
- if attempt <= max_retries:
265
- retry_time = retry_delay * (2 ** (attempt - 1))
266
- logger.warning(
267
- "Network error %s from %s, retrying in %.2f seconds (attempt %d/%d)",
268
- str(e),
269
- url,
270
- retry_time,
271
- attempt,
272
- max_retries,
273
- )
274
- time.sleep(retry_time)
275
- continue
276
- else:
277
- raise RequestError(f"Network error: {e!s}") from None
278
- except Exception as e:
279
- raise RequestError(f"Unexpected error: {e!s}") from None
280
- raise RequestError(f"Request failed after {max_retries} retries with unknown error")
188
+ should_close_client = False
189
+
190
+ if client is None:
191
+ client = _create_default_sync_client()
192
+ should_close_client = True
193
+
194
+ try:
195
+ while attempt <= max_retries:
196
+ attempt += 1
197
+
198
+ try:
199
+ response = client.request(method=method, url=url, json=json, headers=headers)
200
+
201
+ # Check if we got a retriable status code
202
+ if response.status_code in retry_status_codes and attempt <= max_retries:
203
+ retry_time = retry_delay * (2 ** (attempt - 1)) # Exponential backoff
204
+ logger.warning(
205
+ "Received status %d from %s, retrying in %.2f seconds (attempt %d/%d)",
206
+ response.status_code,
207
+ url,
208
+ retry_time,
209
+ attempt,
210
+ max_retries,
211
+ )
212
+ time.sleep(retry_time)
213
+ continue
214
+
215
+ response.raise_for_status()
216
+ result = response.json()
217
+ return result
218
+ except httpx.TimeoutException as e:
219
+ raise HudTimeoutError(f"Request timed out: {e!s}") from None
220
+ except httpx.HTTPStatusError as e:
221
+ raise HudRequestError.from_httpx_error(e) from None
222
+ except httpx.RequestError as e:
223
+ if attempt <= max_retries:
224
+ retry_time = retry_delay * (2 ** (attempt - 1))
225
+ logger.warning(
226
+ "Network error %s from %s, retrying in %.2f seconds (attempt %d/%d)",
227
+ str(e),
228
+ url,
229
+ retry_time,
230
+ attempt,
231
+ max_retries,
232
+ )
233
+ time.sleep(retry_time)
234
+ continue
235
+ else:
236
+ raise HudNetworkError(f"Network error: {e!s}") from None
237
+ except Exception as e:
238
+ raise HudRequestError(f"Unexpected error: {e!s}") from None
239
+ raise HudRequestError(f"Request failed after {max_retries} retries with unknown error")
240
+ finally:
241
+ if should_close_client:
242
+ client.close()
File without changes
@@ -0,0 +1,275 @@
1
+ """Tests for the HTTP request utilities in the HUD API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from http import HTTPStatus
6
+ from typing import TYPE_CHECKING, Any
7
+ from unittest.mock import AsyncMock, Mock, patch
8
+
9
+ import httpx
10
+ import pytest
11
+
12
+ from hud.exceptions import (
13
+ HudAuthenticationError,
14
+ HudNetworkError,
15
+ HudRequestError,
16
+ HudTimeoutError,
17
+ )
18
+ from hud.server.requests import (
19
+ _handle_retry,
20
+ make_request,
21
+ make_request_sync,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable
26
+
27
+
28
+ def _create_mock_response(
29
+ status_code: int = 200,
30
+ json_data: dict[str, Any] | None = None,
31
+ raise_exception: Exception | None = None,
32
+ ) -> Callable[[httpx.Request], httpx.Response]:
33
+ """Create a mock response handler for httpx.MockTransport."""
34
+
35
+ def handler(request: httpx.Request) -> httpx.Response:
36
+ if "Authorization" not in request.headers:
37
+ return httpx.Response(HTTPStatus.UNAUTHORIZED, json={"error": "Unauthorized"})
38
+
39
+ if raise_exception:
40
+ raise raise_exception
41
+
42
+ return httpx.Response(status_code, json=json_data or {"result": "success"}, request=request)
43
+
44
+ return handler
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_handle_retry():
49
+ """Test the retry handler."""
50
+ with patch("asyncio.sleep") as mock_sleep:
51
+ mock_sleep.return_value = None
52
+ await _handle_retry(
53
+ attempt=2,
54
+ max_retries=3,
55
+ retry_delay=1.0,
56
+ url="https://example.com",
57
+ error_msg="Test error",
58
+ )
59
+
60
+ # Check exponential backoff formula: delay * (2 ^ (attempt - 1))
61
+ mock_sleep.assert_awaited_once_with(2.0)
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_make_request_success():
66
+ """Test successful async request."""
67
+ expected_data = {"id": "123", "name": "test"}
68
+ async_client = httpx.AsyncClient(
69
+ transport=httpx.MockTransport(_create_mock_response(200, expected_data))
70
+ )
71
+ result = await make_request(
72
+ "GET", "https://api.test.com/data", api_key="test-key", client=async_client
73
+ )
74
+ assert result == expected_data
75
+
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_make_request_no_api_key():
79
+ """Test request without API key."""
80
+ with pytest.raises(HudAuthenticationError):
81
+ await make_request("GET", "https://api.test.com/data", api_key=None)
82
+
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_make_request_http_error():
86
+ """Test HTTP error handling."""
87
+ async_client = httpx.AsyncClient(
88
+ transport=httpx.MockTransport(_create_mock_response(404, {"error": "Not found"}))
89
+ )
90
+
91
+ with pytest.raises(HudRequestError) as excinfo:
92
+ await make_request(
93
+ "GET", "https://api.test.com/data", api_key="test-key", client=async_client
94
+ )
95
+
96
+ assert "404" in str(excinfo.value)
97
+
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_make_request_network_error():
101
+ """Test network error handling with retry exhaustion."""
102
+ request_error = httpx.RequestError(
103
+ "Connection error", request=httpx.Request("GET", "https://api.test.com")
104
+ )
105
+ async_client = httpx.AsyncClient(
106
+ transport=httpx.MockTransport(_create_mock_response(raise_exception=request_error))
107
+ )
108
+
109
+ # Replace handle_retry to avoid sleep
110
+ with patch("hud.server.requests._handle_retry", AsyncMock()) as mock_retry:
111
+ mock_retry.return_value = None
112
+
113
+ with pytest.raises(HudNetworkError) as excinfo:
114
+ await make_request(
115
+ "GET",
116
+ "https://api.test.com/data",
117
+ api_key="test-key",
118
+ max_retries=2,
119
+ retry_delay=0.01,
120
+ client=async_client,
121
+ )
122
+
123
+ assert "Connection error" in str(excinfo.value)
124
+
125
+
126
+ @pytest.mark.asyncio
127
+ async def test_make_request_timeout():
128
+ """Test timeout error handling."""
129
+ timeout_error = httpx.TimeoutException(
130
+ "Request timed out", request=httpx.Request("GET", "https://api.test.com")
131
+ )
132
+ async_client = httpx.AsyncClient(
133
+ transport=httpx.MockTransport(_create_mock_response(raise_exception=timeout_error))
134
+ )
135
+
136
+ with pytest.raises(HudTimeoutError) as excinfo:
137
+ await make_request(
138
+ "GET", "https://api.test.com/data", api_key="test-key", client=async_client
139
+ )
140
+
141
+ assert "timed out" in str(excinfo.value)
142
+
143
+
144
+ @pytest.mark.asyncio
145
+ async def test_make_request_unexpected_error():
146
+ """Test handling of unexpected errors."""
147
+ unexpected_error = ValueError("Unexpected error")
148
+ async_client = httpx.AsyncClient(
149
+ transport=httpx.MockTransport(_create_mock_response(raise_exception=unexpected_error))
150
+ )
151
+ with pytest.raises(HudRequestError) as excinfo:
152
+ await make_request(
153
+ "GET", "https://api.test.com/data", api_key="test-key", client=async_client
154
+ )
155
+
156
+ assert "Unexpected error" in str(excinfo.value)
157
+
158
+
159
+ @pytest.mark.asyncio
160
+ async def test_make_request_auto_client_creation(mocker):
161
+ """Test automatic client creation when not provided."""
162
+ mock_create_client = mocker.patch("hud.server.requests._create_default_async_client")
163
+ mock_client = AsyncMock()
164
+ mock_client.request.return_value = httpx.Response(
165
+ 200, json={"result": "success"}, request=httpx.Request("GET", "https://api.test.com")
166
+ )
167
+ mock_client.aclose = AsyncMock()
168
+ mock_create_client.return_value = mock_client
169
+
170
+ result = await make_request("GET", "https://api.test.com/data", api_key="test-key")
171
+
172
+ assert result == {"result": "success"}
173
+ mock_client.aclose.assert_awaited_once()
174
+
175
+
176
+ def test_make_request_sync_success():
177
+ """Test successful sync request."""
178
+ expected_data = {"id": "123", "name": "test"}
179
+ sync_client = httpx.Client(
180
+ transport=httpx.MockTransport(_create_mock_response(200, expected_data))
181
+ )
182
+
183
+ result = make_request_sync(
184
+ "GET", "https://api.test.com/data", api_key="test-key", client=sync_client
185
+ )
186
+
187
+ assert result == expected_data
188
+
189
+
190
+ def test_make_request_sync_no_api_key():
191
+ """Test sync request without API key."""
192
+ with pytest.raises(HudAuthenticationError):
193
+ make_request_sync("GET", "https://api.test.com/data", api_key=None)
194
+
195
+
196
+ def test_make_request_sync_http_error():
197
+ """Test HTTP error handling."""
198
+ sync_client = httpx.Client(
199
+ transport=httpx.MockTransport(_create_mock_response(404, {"error": "Not found"}))
200
+ )
201
+ with pytest.raises(HudRequestError) as excinfo:
202
+ make_request_sync(
203
+ "GET", "https://api.test.com/data", api_key="test-key", client=sync_client
204
+ )
205
+
206
+ assert "404" in str(excinfo.value)
207
+
208
+
209
+ def test_make_request_sync_network_error():
210
+ """Test network error handling with retry exhaustion."""
211
+ request_error = httpx.RequestError(
212
+ "Connection error", request=httpx.Request("GET", "https://api.test.com")
213
+ )
214
+ sync_client = httpx.Client(
215
+ transport=httpx.MockTransport(_create_mock_response(raise_exception=request_error))
216
+ )
217
+ with patch("time.sleep", lambda _: None):
218
+ with pytest.raises(HudNetworkError) as excinfo:
219
+ make_request_sync(
220
+ "GET",
221
+ "https://api.test.com/data",
222
+ api_key="test-key",
223
+ max_retries=2,
224
+ retry_delay=0.01,
225
+ client=sync_client,
226
+ )
227
+
228
+ assert "Connection error" in str(excinfo.value)
229
+
230
+
231
+ def test_make_request_sync_timeout():
232
+ """Test timeout error handling."""
233
+ timeout_error = httpx.TimeoutException(
234
+ "Request timed out", request=httpx.Request("GET", "https://api.test.com")
235
+ )
236
+ sync_client = httpx.Client(
237
+ transport=httpx.MockTransport(_create_mock_response(raise_exception=timeout_error))
238
+ )
239
+ with pytest.raises(HudTimeoutError) as excinfo:
240
+ make_request_sync(
241
+ "GET", "https://api.test.com/data", api_key="test-key", client=sync_client
242
+ )
243
+
244
+ assert "timed out" in str(excinfo.value)
245
+
246
+
247
+ def test_make_request_sync_unexpected_error():
248
+ """Test handling of unexpected errors."""
249
+ unexpected_error = ValueError("Unexpected error")
250
+ sync_client = httpx.Client(
251
+ transport=httpx.MockTransport(_create_mock_response(raise_exception=unexpected_error))
252
+ )
253
+
254
+ with pytest.raises(HudRequestError) as excinfo:
255
+ make_request_sync(
256
+ "GET", "https://api.test.com/data", api_key="test-key", client=sync_client
257
+ )
258
+
259
+ assert "Unexpected error" in str(excinfo.value)
260
+
261
+
262
+ def test_make_request_sync_auto_client_creation():
263
+ """Test automatic client creation when not provided."""
264
+ with patch("hud.server.requests._create_default_sync_client") as mock_create_client:
265
+ mock_client = Mock()
266
+ mock_client.request.return_value = httpx.Response(
267
+ 200, json={"result": "success"}, request=httpx.Request("GET", "https://api.test.com")
268
+ )
269
+ mock_client.close = Mock()
270
+ mock_create_client.return_value = mock_client
271
+
272
+ result = make_request_sync("GET", "https://api.test.com/data", api_key="test-key")
273
+
274
+ assert result == {"result": "success"}
275
+ mock_client.close.assert_called_once()