coding-client 1.21.2__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.
- coding_client/__init__.py +1 -0
- coding_client/api/__init__.py +19 -0
- coding_client/api/exception_handlers.py +158 -0
- coding_client/api/logging_setup.py +44 -0
- coding_client/api/metrics.py +40 -0
- coding_client/api/openapi_config.py +124 -0
- coding_client/api/request_logging.py +60 -0
- coding_client/api/validation.py +150 -0
- coding_client/auth/__init__.py +78 -0
- coding_client/auth/_default_tenant.py +132 -0
- coding_client/auth/_header_trust.py +98 -0
- coding_client/auth/_onbehalf.py +315 -0
- coding_client/auth/_trusted_networks.py +94 -0
- coding_client/auth/dependencies.py +61 -0
- coding_client/auth/middleware.py +445 -0
- coding_client/auth/models.py +83 -0
- coding_client/auth/outbound.py +137 -0
- coding_client/auth/rbac.py +21 -0
- coding_client/auth/rbac_coding.py +5 -0
- coding_client/auth/tenant.py +650 -0
- coding_client/auth/websocket.py +148 -0
- coding_client/clients/__init__.py +24 -0
- coding_client/clients/base.py +131 -0
- coding_client/clients/coding_ai.py +33 -0
- coding_client/clients/coding_analysis.py +36 -0
- coding_client/clients/coding_containers.py +45 -0
- coding_client/clients/coding_github.py +127 -0
- coding_client/clients/coding_realtime.py +127 -0
- coding_client/clients/coding_security.py +51 -0
- coding_client/clients/coding_testing.py +31 -0
- coding_client/clients/coding_workspaces.py +265 -0
- coding_client/config/__init__.py +12 -0
- coding_client/config/base_settings.py +61 -0
- coding_client/config/yaml_config_loader.py +172 -0
- coding_client/db/__init__.py +42 -0
- coding_client/db/db_types.py +122 -0
- coding_client/db/exceptions.py +59 -0
- coding_client/db/postgres_connection.py +572 -0
- coding_client/db/tenant_query_helper.py +417 -0
- coding_client/db_init.py +49 -0
- coding_client/exceptions/__init__.py +67 -0
- coding_client/exceptions/api_exceptions.py +395 -0
- coding_client/exceptions/pydantic_ai_exceptions.py +533 -0
- coding_client/interfaces/__init__.py +22 -0
- coding_client/interfaces/cache_interface.py +179 -0
- coding_client/interfaces/container_interface.py +49 -0
- coding_client/interfaces/docker_interface.py +283 -0
- coding_client/interfaces/graph_interface.py +28 -0
- coding_client/interfaces/security_interface.py +72 -0
- coding_client/interfaces/webhook_interface.py +67 -0
- coding_client/llm/__init__.py +54 -0
- coding_client/llm/base_agent.py +195 -0
- coding_client/llm/context.py +121 -0
- coding_client/llm/responses.py +102 -0
- coding_client/llm/routing.py +82 -0
- coding_client/llm/settings.py +102 -0
- coding_client/models/__init__.py +62 -0
- coding_client/models/agent.py +38 -0
- coding_client/models/ai_test_failure.py +745 -0
- coding_client/models/analysis.py +330 -0
- coding_client/models/architecture.py +685 -0
- coding_client/models/bug.py +128 -0
- coding_client/models/code_graph.py +317 -0
- coding_client/models/container.py +241 -0
- coding_client/models/core.py +878 -0
- coding_client/models/docker_container.py +104 -0
- coding_client/models/github.py +293 -0
- coding_client/models/logging.py +29 -0
- coding_client/models/package_manager.py +257 -0
- coding_client/models/pr_validation.py +440 -0
- coding_client/models/pydantic_ai.py +395 -0
- coding_client/models/repo_scanner.py +249 -0
- coding_client/models/responses.py +431 -0
- coding_client/models/security.py +185 -0
- coding_client/models/static_analysis.py +508 -0
- coding_client/models/system.py +48 -0
- coding_client/models/test_executor.py +204 -0
- coding_client/models/testing.py +673 -0
- coding_client/models/testing_config.py +200 -0
- coding_client/models/validation.py +127 -0
- coding_client/models_init.py +272 -0
- coding_client/telemetry.py +195 -0
- coding_client/testing.py +180 -0
- coding_client/types/__init__.py +130 -0
- coding_client/types/constraint_types.py +276 -0
- coding_client/types/core_types.py +112 -0
- coding_client/types/state_machine.py +443 -0
- coding_client/utils/__init__.py +86 -0
- coding_client/utils/archive_utils.py +387 -0
- coding_client/utils/ast_debug.py +45 -0
- coding_client/utils/async_timeout.py +364 -0
- coding_client/utils/constants.py +518 -0
- coding_client/utils/content_processing.py +326 -0
- coding_client/utils/database.py +916 -0
- coding_client/utils/encoding_helper.py +222 -0
- coding_client/utils/enum_helpers.py +166 -0
- coding_client/utils/file_operations.py +177 -0
- coding_client/utils/filesystem_ops.py +528 -0
- coding_client/utils/http_client.py +562 -0
- coding_client/utils/path_utils.py +471 -0
- coding_client/utils/profiling.py +421 -0
- coding_client/utils/response_builder.py +240 -0
- coding_client/utils/retry_helper.py +712 -0
- coding_client/utils/security_validation.py +423 -0
- coding_client-1.21.2.dist-info/METADATA +234 -0
- coding_client-1.21.2.dist-info/RECORD +109 -0
- coding_client-1.21.2.dist-info/WHEEL +5 -0
- coding_client-1.21.2.dist-info/licenses/LICENSE +201 -0
- coding_client-1.21.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""Unified HTTP client wrapper with retry logic, error handling, and timeout management.
|
|
2
|
+
|
|
3
|
+
This module provides a standardized HTTP client that handles common patterns like
|
|
4
|
+
retries, timeouts, error handling, and logging across the application.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import inspect
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from functools import wraps
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urljoin
|
|
16
|
+
|
|
17
|
+
import httpx as requests # httpx as drop-in replacement for requests (CVE-2026-25645)
|
|
18
|
+
|
|
19
|
+
# Shims for requests API compatibility
|
|
20
|
+
HTTPAdapter = None
|
|
21
|
+
Retry = None
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import aiohttp # noqa: F401 — used by ``from aiohttp import`` below
|
|
25
|
+
|
|
26
|
+
HAS_AIOHTTP = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
HAS_AIOHTTP = False
|
|
29
|
+
|
|
30
|
+
if HAS_AIOHTTP:
|
|
31
|
+
from aiohttp import ClientError, ClientSession, ClientTimeout
|
|
32
|
+
else:
|
|
33
|
+
ClientTimeout = None
|
|
34
|
+
ClientSession = None
|
|
35
|
+
ClientError = None
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HTTPClientError(Exception):
|
|
41
|
+
"""Base exception for HTTP client errors."""
|
|
42
|
+
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HTTPTimeoutError(HTTPClientError):
|
|
47
|
+
"""Raised when an HTTP request times out."""
|
|
48
|
+
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HTTPConnectionError(HTTPClientError):
|
|
53
|
+
"""Raised when connection to server fails."""
|
|
54
|
+
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class HTTPResponseError(HTTPClientError):
|
|
59
|
+
"""Raised when server returns an error response."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, message: str, status_code: int, response_text: str = ""):
|
|
62
|
+
super().__init__(message)
|
|
63
|
+
self.status_code = status_code
|
|
64
|
+
self.response_text = response_text
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RetryConfig:
|
|
68
|
+
"""Configuration for retry behavior."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
total: int = 3,
|
|
73
|
+
backoff_factor: float = 0.3,
|
|
74
|
+
status_forcelist: list[int] | None = None,
|
|
75
|
+
method_whitelist: list[str] | None = None,
|
|
76
|
+
):
|
|
77
|
+
self.total = total
|
|
78
|
+
self.backoff_factor = backoff_factor
|
|
79
|
+
self.status_forcelist = status_forcelist or [429, 500, 502, 503, 504]
|
|
80
|
+
self.method_whitelist = method_whitelist or [
|
|
81
|
+
"GET",
|
|
82
|
+
"PUT",
|
|
83
|
+
"DELETE",
|
|
84
|
+
"OPTIONS",
|
|
85
|
+
"TRACE",
|
|
86
|
+
"HEAD",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class HTTPClient:
|
|
91
|
+
"""Unified HTTP client with retry logic and error handling."""
|
|
92
|
+
|
|
93
|
+
DEFAULT_TIMEOUT = 30.0
|
|
94
|
+
DEFAULT_RETRY_CONFIG = RetryConfig()
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
base_url: str | None = None,
|
|
99
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
100
|
+
retry_config: RetryConfig | None = None,
|
|
101
|
+
headers: dict[str, str] | None = None,
|
|
102
|
+
):
|
|
103
|
+
"""Initialize HTTP client.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
base_url: Optional base URL for all requests
|
|
107
|
+
timeout: Default timeout in seconds
|
|
108
|
+
retry_config: Retry configuration
|
|
109
|
+
headers: Default headers to include in all requests
|
|
110
|
+
"""
|
|
111
|
+
self.base_url = base_url
|
|
112
|
+
self.timeout = timeout
|
|
113
|
+
self.retry_config = retry_config or self.DEFAULT_RETRY_CONFIG
|
|
114
|
+
self.default_headers = headers or {}
|
|
115
|
+
|
|
116
|
+
# Setup sync session with retry adapter
|
|
117
|
+
self._sync_session = self._create_sync_session()
|
|
118
|
+
|
|
119
|
+
# Async session will be created on demand
|
|
120
|
+
self._async_session: ClientSession | None = None
|
|
121
|
+
|
|
122
|
+
def _create_sync_session(self) -> requests.Client:
|
|
123
|
+
"""Create an httpx client with timeout configuration."""
|
|
124
|
+
transport = requests.HTTPTransport(retries=self.retry_config.total)
|
|
125
|
+
session = requests.Client(
|
|
126
|
+
transport=transport,
|
|
127
|
+
timeout=self.timeout,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Set default headers
|
|
131
|
+
session.headers.update(self.default_headers)
|
|
132
|
+
|
|
133
|
+
return session
|
|
134
|
+
|
|
135
|
+
async def _ensure_async_session(self) -> ClientSession:
|
|
136
|
+
"""Ensure async session is created."""
|
|
137
|
+
if not HAS_AIOHTTP:
|
|
138
|
+
raise ImportError("aiohttp module not available - install with 'pip install aiohttp'")
|
|
139
|
+
|
|
140
|
+
if self._async_session is None or self._async_session.closed:
|
|
141
|
+
assert ClientTimeout is not None, "aiohttp must be installed"
|
|
142
|
+
assert ClientSession is not None, "aiohttp must be installed"
|
|
143
|
+
timeout = ClientTimeout(total=self.timeout)
|
|
144
|
+
self._async_session = ClientSession(
|
|
145
|
+
timeout=timeout,
|
|
146
|
+
headers=self.default_headers,
|
|
147
|
+
)
|
|
148
|
+
return self._async_session
|
|
149
|
+
|
|
150
|
+
def _build_url(self, endpoint: str) -> str:
|
|
151
|
+
"""Build full URL from endpoint."""
|
|
152
|
+
if self.base_url:
|
|
153
|
+
return urljoin(self.base_url, endpoint)
|
|
154
|
+
return endpoint
|
|
155
|
+
|
|
156
|
+
def _log_request(self, method: str, url: str, **kwargs):
|
|
157
|
+
"""Log HTTP request details."""
|
|
158
|
+
logger.debug(
|
|
159
|
+
f"HTTP {method} {url}",
|
|
160
|
+
extra={
|
|
161
|
+
"http_method": method,
|
|
162
|
+
"http_url": url,
|
|
163
|
+
"http_params": kwargs.get("params"),
|
|
164
|
+
"http_headers": kwargs.get("headers"),
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _log_response(self, method: str, url: str, status_code: int, duration: float):
|
|
169
|
+
"""Log HTTP response details."""
|
|
170
|
+
logger.debug(
|
|
171
|
+
f"HTTP {method} {url} - {status_code} ({duration:.2f}s)",
|
|
172
|
+
extra={
|
|
173
|
+
"http_method": method,
|
|
174
|
+
"http_url": url,
|
|
175
|
+
"http_status_code": status_code,
|
|
176
|
+
"http_duration": duration,
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def _handle_response_error(self, response: requests.Response | Any, method: str, url: str):
|
|
181
|
+
"""Handle HTTP error responses."""
|
|
182
|
+
status_code: int = (
|
|
183
|
+
response.status_code if hasattr(response, "status_code") else response.status
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if 400 <= status_code < 500:
|
|
187
|
+
error_msg = f"Client error on {method} {url}: {status_code}"
|
|
188
|
+
response_text = ""
|
|
189
|
+
try:
|
|
190
|
+
if hasattr(response, "text"):
|
|
191
|
+
response_text = response.text
|
|
192
|
+
else:
|
|
193
|
+
# For async response, we'd need to await response.text()
|
|
194
|
+
# This is handled in the async methods
|
|
195
|
+
pass
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
logger.error(
|
|
200
|
+
error_msg,
|
|
201
|
+
extra={
|
|
202
|
+
"http_status_code": status_code,
|
|
203
|
+
"http_response": response_text[:500] if response_text else "",
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
raise HTTPResponseError(error_msg, status_code, response_text)
|
|
207
|
+
|
|
208
|
+
elif status_code >= 500:
|
|
209
|
+
error_msg = f"Server error on {method} {url}: {status_code}"
|
|
210
|
+
logger.error(error_msg, extra={"http_status_code": status_code})
|
|
211
|
+
raise HTTPResponseError(error_msg, status_code)
|
|
212
|
+
|
|
213
|
+
# Synchronous methods
|
|
214
|
+
|
|
215
|
+
def get(self, endpoint: str, **kwargs) -> requests.Response:
|
|
216
|
+
"""Perform GET request."""
|
|
217
|
+
return self.request("GET", endpoint, **kwargs)
|
|
218
|
+
|
|
219
|
+
def post(self, endpoint: str, **kwargs) -> requests.Response:
|
|
220
|
+
"""Perform POST request."""
|
|
221
|
+
return self.request("POST", endpoint, **kwargs)
|
|
222
|
+
|
|
223
|
+
def put(self, endpoint: str, **kwargs) -> requests.Response:
|
|
224
|
+
"""Perform PUT request."""
|
|
225
|
+
return self.request("PUT", endpoint, **kwargs)
|
|
226
|
+
|
|
227
|
+
def delete(self, endpoint: str, **kwargs) -> requests.Response:
|
|
228
|
+
"""Perform DELETE request."""
|
|
229
|
+
return self.request("DELETE", endpoint, **kwargs)
|
|
230
|
+
|
|
231
|
+
def patch(self, endpoint: str, **kwargs) -> requests.Response:
|
|
232
|
+
"""Perform PATCH request."""
|
|
233
|
+
return self.request("PATCH", endpoint, **kwargs)
|
|
234
|
+
|
|
235
|
+
def request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
|
236
|
+
"""Perform synchronous HTTP request with error handling.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
method: HTTP method
|
|
240
|
+
endpoint: URL endpoint
|
|
241
|
+
**kwargs: Additional arguments passed to requests
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Response object
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
HTTPTimeoutError: On timeout
|
|
248
|
+
HTTPConnectionError: On connection failure
|
|
249
|
+
HTTPResponseError: On error response
|
|
250
|
+
"""
|
|
251
|
+
url = self._build_url(endpoint)
|
|
252
|
+
|
|
253
|
+
# Set default timeout if not provided
|
|
254
|
+
if "timeout" not in kwargs:
|
|
255
|
+
kwargs["timeout"] = self.timeout
|
|
256
|
+
|
|
257
|
+
# Merge headers
|
|
258
|
+
if "headers" in kwargs:
|
|
259
|
+
headers = {**self.default_headers, **kwargs["headers"]}
|
|
260
|
+
kwargs["headers"] = headers
|
|
261
|
+
|
|
262
|
+
self._log_request(method, url, **kwargs)
|
|
263
|
+
start_time = time.time()
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
response = self._sync_session.request(method, url, **kwargs)
|
|
267
|
+
duration = time.time() - start_time
|
|
268
|
+
self._log_response(method, url, response.status_code, duration)
|
|
269
|
+
|
|
270
|
+
# Check for HTTP errors
|
|
271
|
+
response.raise_for_status()
|
|
272
|
+
|
|
273
|
+
return response
|
|
274
|
+
|
|
275
|
+
except requests.TimeoutException as e:
|
|
276
|
+
logger.error(f"Timeout on {method} {url}: {e}")
|
|
277
|
+
raise HTTPTimeoutError(f"Request timeout on {method} {url}") from e
|
|
278
|
+
|
|
279
|
+
except requests.ConnectError as e:
|
|
280
|
+
logger.error(f"Connection error on {method} {url}: {e}")
|
|
281
|
+
raise HTTPConnectionError(f"Connection failed on {method} {url}") from e
|
|
282
|
+
|
|
283
|
+
except requests.HTTPStatusError as e:
|
|
284
|
+
# Handle error responses
|
|
285
|
+
if e.response is not None:
|
|
286
|
+
self._handle_response_error(e.response, method, url)
|
|
287
|
+
raise
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.error(f"Unexpected error on {method} {url}: {e}")
|
|
291
|
+
raise HTTPClientError(f"Unexpected error on {method} {url}: {e}") from e
|
|
292
|
+
|
|
293
|
+
# Asynchronous methods
|
|
294
|
+
|
|
295
|
+
async def async_get(self, endpoint: str, **kwargs) -> Any:
|
|
296
|
+
"""Perform async GET request."""
|
|
297
|
+
return await self.async_request("GET", endpoint, **kwargs)
|
|
298
|
+
|
|
299
|
+
async def async_post(self, endpoint: str, **kwargs) -> Any:
|
|
300
|
+
"""Perform async POST request."""
|
|
301
|
+
return await self.async_request("POST", endpoint, **kwargs)
|
|
302
|
+
|
|
303
|
+
async def async_put(self, endpoint: str, **kwargs) -> Any:
|
|
304
|
+
"""Perform async PUT request."""
|
|
305
|
+
return await self.async_request("PUT", endpoint, **kwargs)
|
|
306
|
+
|
|
307
|
+
async def async_delete(self, endpoint: str, **kwargs) -> Any:
|
|
308
|
+
"""Perform async DELETE request."""
|
|
309
|
+
return await self.async_request("DELETE", endpoint, **kwargs)
|
|
310
|
+
|
|
311
|
+
async def async_patch(self, endpoint: str, **kwargs) -> Any:
|
|
312
|
+
"""Perform async PATCH request."""
|
|
313
|
+
return await self.async_request("PATCH", endpoint, **kwargs)
|
|
314
|
+
|
|
315
|
+
async def async_request(self, method: str, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
316
|
+
"""Perform asynchronous HTTP request with error handling and retries.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
method: HTTP method
|
|
320
|
+
endpoint: URL endpoint
|
|
321
|
+
**kwargs: Additional arguments passed to aiohttp
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Response data as dictionary
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
HTTPTimeoutError: On timeout
|
|
328
|
+
HTTPConnectionError: On connection failure
|
|
329
|
+
HTTPResponseError: On error response
|
|
330
|
+
"""
|
|
331
|
+
url = self._build_url(endpoint)
|
|
332
|
+
session = await self._ensure_async_session()
|
|
333
|
+
assert session is not None, "HTTP session must be available"
|
|
334
|
+
|
|
335
|
+
# Merge headers
|
|
336
|
+
headers = {**self.default_headers}
|
|
337
|
+
if "headers" in kwargs:
|
|
338
|
+
headers.update(kwargs["headers"])
|
|
339
|
+
kwargs["headers"] = headers
|
|
340
|
+
else:
|
|
341
|
+
kwargs["headers"] = headers
|
|
342
|
+
|
|
343
|
+
self._log_request(method, url, **kwargs)
|
|
344
|
+
|
|
345
|
+
# Implement retry logic
|
|
346
|
+
last_exception = None
|
|
347
|
+
for attempt in range(self.retry_config.total + 1):
|
|
348
|
+
if attempt > 0:
|
|
349
|
+
# Exponential backoff
|
|
350
|
+
sleep_time = self.retry_config.backoff_factor * (2 ** (attempt - 1))
|
|
351
|
+
logger.debug(f"Retrying {method} {url} after {sleep_time}s (attempt {attempt + 1})")
|
|
352
|
+
await asyncio.sleep(sleep_time)
|
|
353
|
+
|
|
354
|
+
start_time = time.time()
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
async with session.request(method, url, **kwargs) as response:
|
|
358
|
+
duration = time.time() - start_time
|
|
359
|
+
self._log_response(method, url, response.status, duration)
|
|
360
|
+
|
|
361
|
+
# Check status code
|
|
362
|
+
if response.status >= 400:
|
|
363
|
+
response_text = await response.text()
|
|
364
|
+
if (
|
|
365
|
+
response.status in self.retry_config.status_forcelist
|
|
366
|
+
and attempt < self.retry_config.total
|
|
367
|
+
):
|
|
368
|
+
# Retry on specific status codes
|
|
369
|
+
last_exception = HTTPResponseError(
|
|
370
|
+
f"Server error on {method} {url}: {response.status}",
|
|
371
|
+
response.status,
|
|
372
|
+
response_text,
|
|
373
|
+
)
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
# Non-retryable error
|
|
377
|
+
error_msg = f"{'Client' if response.status < 500 else 'Server'} error on {method} {url}: {response.status}"
|
|
378
|
+
logger.error(
|
|
379
|
+
error_msg,
|
|
380
|
+
extra={
|
|
381
|
+
"http_status_code": response.status,
|
|
382
|
+
"http_response": (response_text[:500] if response_text else ""),
|
|
383
|
+
},
|
|
384
|
+
)
|
|
385
|
+
raise HTTPResponseError(error_msg, response.status, response_text)
|
|
386
|
+
|
|
387
|
+
# Success - return response data
|
|
388
|
+
try:
|
|
389
|
+
return await response.json()
|
|
390
|
+
except Exception:
|
|
391
|
+
# If JSON parsing fails, return text
|
|
392
|
+
text = await response.text()
|
|
393
|
+
return {"text": text, "status": response.status}
|
|
394
|
+
|
|
395
|
+
except TimeoutError as e:
|
|
396
|
+
logger.error(f"Timeout on {method} {url}: {e}")
|
|
397
|
+
last_exception = HTTPTimeoutError(f"Request timeout on {method} {url}")
|
|
398
|
+
|
|
399
|
+
except Exception as e:
|
|
400
|
+
if HAS_AIOHTTP and ClientError and isinstance(e, ClientError):
|
|
401
|
+
logger.error(f"Client error on {method} {url}: {e}")
|
|
402
|
+
last_exception = HTTPConnectionError(
|
|
403
|
+
f"Connection failed on {method} {url}: {e}"
|
|
404
|
+
)
|
|
405
|
+
else:
|
|
406
|
+
logger.error(f"Unexpected error on {method} {url}: {e}")
|
|
407
|
+
last_exception = HTTPClientError(f"Unexpected error on {method} {url}: {e}")
|
|
408
|
+
|
|
409
|
+
# All retries failed
|
|
410
|
+
if last_exception:
|
|
411
|
+
raise last_exception
|
|
412
|
+
else:
|
|
413
|
+
raise HTTPClientError(
|
|
414
|
+
f"Failed to complete request after {self.retry_config.total + 1} attempts"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
def close(self):
|
|
418
|
+
"""Close all sessions.
|
|
419
|
+
|
|
420
|
+
For async sessions, attempts to close properly by detecting the event loop.
|
|
421
|
+
For guaranteed async cleanup, use aclose() instead from async context.
|
|
422
|
+
"""
|
|
423
|
+
self._sync_session.close()
|
|
424
|
+
if self._async_session:
|
|
425
|
+
try:
|
|
426
|
+
# Try to get running loop and schedule proper cleanup
|
|
427
|
+
loop = asyncio.get_running_loop()
|
|
428
|
+
# Schedule cleanup task - will be awaited by event loop
|
|
429
|
+
loop.create_task(self._async_session.close())
|
|
430
|
+
except RuntimeError:
|
|
431
|
+
# No running loop - try synchronous close via new loop
|
|
432
|
+
try:
|
|
433
|
+
asyncio.run(self._async_session.close())
|
|
434
|
+
except RuntimeError:
|
|
435
|
+
# Loop already running in another thread or closed
|
|
436
|
+
# Last resort: mark session as needing cleanup
|
|
437
|
+
logger.warning(
|
|
438
|
+
"Could not properly close async session - prefer using aclose() from async context"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
async def aclose(self):
|
|
442
|
+
"""Async close all sessions."""
|
|
443
|
+
self._sync_session.close()
|
|
444
|
+
if self._async_session:
|
|
445
|
+
await self._async_session.close()
|
|
446
|
+
|
|
447
|
+
def __enter__(self):
|
|
448
|
+
"""Context manager entry."""
|
|
449
|
+
return self
|
|
450
|
+
|
|
451
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
452
|
+
"""Context manager exit."""
|
|
453
|
+
self.close()
|
|
454
|
+
|
|
455
|
+
async def __aenter__(self):
|
|
456
|
+
"""Async context manager entry."""
|
|
457
|
+
return self
|
|
458
|
+
|
|
459
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
460
|
+
"""Async context manager exit."""
|
|
461
|
+
await self.aclose()
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# Convenience functions for one-off requests
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def get(url: str, **kwargs) -> requests.Response:
|
|
468
|
+
"""Perform one-off GET request with default retry logic."""
|
|
469
|
+
with HTTPClient() as client:
|
|
470
|
+
return client.get(url, **kwargs)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def post(url: str, **kwargs) -> requests.Response:
|
|
474
|
+
"""Perform one-off POST request with default retry logic."""
|
|
475
|
+
with HTTPClient() as client:
|
|
476
|
+
return client.post(url, **kwargs)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
async def async_get(url: str, **kwargs) -> dict[str, Any]:
|
|
480
|
+
"""Perform one-off async GET request with default retry logic."""
|
|
481
|
+
async with HTTPClient() as client:
|
|
482
|
+
return await client.async_get(url, **kwargs)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
async def async_post(url: str, **kwargs) -> dict[str, Any]:
|
|
486
|
+
"""Perform one-off async POST request with default retry logic."""
|
|
487
|
+
async with HTTPClient() as client:
|
|
488
|
+
return await client.async_post(url, **kwargs)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# Decorator for automatic retry on specific functions
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def with_http_retry(
|
|
495
|
+
retries: int = 3,
|
|
496
|
+
backoff_factor: float = 0.3,
|
|
497
|
+
exceptions: tuple = (HTTPTimeoutError, HTTPConnectionError),
|
|
498
|
+
):
|
|
499
|
+
"""Decorator to add retry logic to functions making HTTP calls.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
retries: Number of retry attempts
|
|
503
|
+
backoff_factor: Backoff multiplier between retries
|
|
504
|
+
exceptions: Tuple of exceptions to retry on
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
def decorator(func):
|
|
508
|
+
@wraps(func)
|
|
509
|
+
def wrapper(*args, **kwargs):
|
|
510
|
+
last_exception = None
|
|
511
|
+
for attempt in range(retries + 1):
|
|
512
|
+
if attempt > 0:
|
|
513
|
+
sleep_time = backoff_factor * (2 ** (attempt - 1))
|
|
514
|
+
logger.debug(
|
|
515
|
+
f"Retrying {func.__name__} after {sleep_time}s (attempt {attempt + 1})"
|
|
516
|
+
)
|
|
517
|
+
time.sleep(sleep_time)
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
return func(*args, **kwargs)
|
|
521
|
+
except exceptions as e:
|
|
522
|
+
logger.warning(f"Retry-able error in {func.__name__}: {e}")
|
|
523
|
+
last_exception = e
|
|
524
|
+
except Exception:
|
|
525
|
+
# Non-retryable exception
|
|
526
|
+
raise
|
|
527
|
+
|
|
528
|
+
# All retries exhausted
|
|
529
|
+
if last_exception:
|
|
530
|
+
raise last_exception
|
|
531
|
+
|
|
532
|
+
@wraps(func)
|
|
533
|
+
async def async_wrapper(*args, **kwargs):
|
|
534
|
+
last_exception = None
|
|
535
|
+
for attempt in range(retries + 1):
|
|
536
|
+
if attempt > 0:
|
|
537
|
+
sleep_time = backoff_factor * (2 ** (attempt - 1))
|
|
538
|
+
logger.debug(
|
|
539
|
+
f"Retrying {func.__name__} after {sleep_time}s (attempt {attempt + 1})"
|
|
540
|
+
)
|
|
541
|
+
await asyncio.sleep(sleep_time)
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
return await func(*args, **kwargs)
|
|
545
|
+
except exceptions as e:
|
|
546
|
+
logger.warning(f"Retry-able error in {func.__name__}: {e}")
|
|
547
|
+
last_exception = e
|
|
548
|
+
except Exception:
|
|
549
|
+
# Non-retryable exception
|
|
550
|
+
raise
|
|
551
|
+
|
|
552
|
+
# All retries exhausted
|
|
553
|
+
if last_exception:
|
|
554
|
+
raise last_exception
|
|
555
|
+
|
|
556
|
+
# Return appropriate wrapper based on function type
|
|
557
|
+
if inspect.iscoroutinefunction(func):
|
|
558
|
+
return async_wrapper
|
|
559
|
+
else:
|
|
560
|
+
return wrapper
|
|
561
|
+
|
|
562
|
+
return decorator
|