Skip to content

Commit e236553

Browse files
committed
feat: Add HTTP client with connection pooling and retry logic
- Implement HTTPClient with automatic retries for transient errors - Add connection pooling for performance optimization - Handle all API error responses with appropriate exceptions - Support multipart/form-data for file uploads and JSON actions - Add comprehensive unit tests with mocked responses - Include context manager support for proper resource cleanup
1 parent d06d5b1 commit e236553

2 files changed

Lines changed: 361 additions & 18 deletions

File tree

src/nutrient/http_client.py

Lines changed: 117 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
"""HTTP client abstraction for API communication."""
22

3+
import json
34
import logging
4-
from typing import Any, Dict, Optional
5+
from typing import Any, Dict, Optional, Tuple, Union
56

67
import requests
78
from requests.adapters import HTTPAdapter
89
from urllib3.util.retry import Retry
910

11+
from nutrient.exceptions import APIError, AuthenticationError, TimeoutError, ValidationError
12+
1013
logger = logging.getLogger(__name__)
1114

1215

1316
class HTTPClient:
1417
"""HTTP client with connection pooling and retry logic."""
1518

16-
def __init__(self, api_key: str, timeout: int = 300) -> None:
17-
"""Initialize HTTP client with authentication."""
19+
def __init__(self, api_key: Optional[str], timeout: int = 300) -> None:
20+
"""Initialize HTTP client with authentication.
21+
22+
Args:
23+
api_key: API key for authentication.
24+
timeout: Request timeout in seconds.
25+
"""
1826
self._api_key = api_key
1927
self._timeout = timeout
2028
self._session = self._create_session()
@@ -24,54 +32,145 @@ def _create_session(self) -> requests.Session:
2432
"""Create requests session with retry logic."""
2533
session = requests.Session()
2634

27-
# Configure retries
35+
# Configure retries with exponential backoff
2836
retry_strategy = Retry(
2937
total=3,
3038
backoff_factor=1,
3139
status_forcelist=[429, 500, 502, 503, 504],
3240
allowed_methods=["GET", "POST"],
41+
raise_on_status=False, # We'll handle status codes ourselves
42+
)
43+
adapter = HTTPAdapter(
44+
max_retries=retry_strategy,
45+
pool_connections=10,
46+
pool_maxsize=10,
3347
)
34-
adapter = HTTPAdapter(max_retries=retry_strategy)
3548
session.mount("http://", adapter)
3649
session.mount("https://", adapter)
3750

3851
# Set default headers
39-
session.headers.update({
40-
"X-Api-Key": self._api_key,
52+
headers = {
4153
"User-Agent": "nutrient-python-client/0.1.0",
42-
})
54+
}
55+
if self._api_key:
56+
headers["X-Api-Key"] = self._api_key
57+
58+
session.headers.update(headers)
4359

4460
return session
4561

62+
def _handle_response(self, response: requests.Response) -> bytes:
63+
"""Handle API response and raise appropriate exceptions.
64+
65+
Args:
66+
response: Response from the API.
67+
68+
Returns:
69+
Response content as bytes.
70+
71+
Raises:
72+
AuthenticationError: For 401/403 responses.
73+
ValidationError: For 422 responses.
74+
APIError: For other error responses.
75+
"""
76+
# Extract request ID if available
77+
request_id = response.headers.get("X-Request-Id")
78+
79+
try:
80+
response.raise_for_status()
81+
except requests.exceptions.HTTPError:
82+
# Try to parse error message from response
83+
error_message = f"HTTP {response.status_code}"
84+
error_details = None
85+
86+
try:
87+
error_data = response.json()
88+
error_message = error_data.get("message", error_message)
89+
error_details = error_data.get("errors", error_data.get("details"))
90+
except (json.JSONDecodeError, requests.exceptions.JSONDecodeError):
91+
# If response is not JSON, use text content
92+
if response.text:
93+
error_message = f"{error_message}: {response.text[:200]}"
94+
95+
# Handle specific status codes
96+
if response.status_code in (401, 403):
97+
raise AuthenticationError(
98+
error_message or "Authentication failed. Check your API key."
99+
)
100+
elif response.status_code == 422:
101+
raise ValidationError(
102+
error_message or "Request validation failed",
103+
errors=error_details,
104+
)
105+
else:
106+
raise APIError(
107+
error_message,
108+
status_code=response.status_code,
109+
response_body=response.text,
110+
request_id=request_id,
111+
)
112+
113+
return response.content
114+
46115
def post(
47116
self,
48117
endpoint: str,
49118
files: Optional[Dict[str, Any]] = None,
50119
data: Optional[Dict[str, Any]] = None,
51-
) -> requests.Response:
120+
json_data: Optional[Dict[str, Any]] = None,
121+
) -> bytes:
52122
"""Make POST request to API.
53123
54124
Args:
55125
endpoint: API endpoint path.
56126
files: Files to upload.
57127
data: Form data.
128+
json_data: JSON data (for multipart requests).
58129
59130
Returns:
60-
Response object.
131+
Response content as bytes.
132+
133+
Raises:
134+
AuthenticationError: If API key is missing or invalid.
135+
TimeoutError: If request times out.
136+
APIError: For other API errors.
61137
"""
138+
if not self._api_key:
139+
raise AuthenticationError("API key is required but not provided")
140+
62141
url = f"{self._base_url}{endpoint}"
63142
logger.debug(f"POST {url}")
64143

65-
response = self._session.post(
66-
url,
67-
files=files,
68-
data=data,
69-
timeout=self._timeout,
70-
)
144+
# Prepare multipart data if json_data is provided
145+
prepared_data = data or {}
146+
if json_data is not None:
147+
prepared_data["actions"] = (None, json.dumps(json_data), "application/json")
148+
149+
try:
150+
response = self._session.post(
151+
url,
152+
files=files,
153+
data=prepared_data,
154+
timeout=self._timeout,
155+
)
156+
except requests.exceptions.Timeout as e:
157+
raise TimeoutError(f"Request timed out after {self._timeout} seconds") from e
158+
except requests.exceptions.ConnectionError as e:
159+
raise APIError(f"Connection error: {str(e)}") from e
160+
except requests.exceptions.RequestException as e:
161+
raise APIError(f"Request failed: {str(e)}") from e
71162

72163
logger.debug(f"Response: {response.status_code}")
73-
return response
164+
return self._handle_response(response)
74165

75166
def close(self) -> None:
76167
"""Close the session."""
77-
self._session.close()
168+
self._session.close()
169+
170+
def __enter__(self) -> "HTTPClient":
171+
"""Context manager entry."""
172+
return self
173+
174+
def __exit__(self, *args: Any) -> None:
175+
"""Context manager exit."""
176+
self.close()

0 commit comments

Comments
 (0)