11"""HTTP client abstraction for API communication."""
22
3+ import json
34import logging
4- from typing import Any , Dict , Optional
5+ from typing import Any , Dict , Optional , Tuple , Union
56
67import requests
78from requests .adapters import HTTPAdapter
89from urllib3 .util .retry import Retry
910
11+ from nutrient .exceptions import APIError , AuthenticationError , TimeoutError , ValidationError
12+
1013logger = logging .getLogger (__name__ )
1114
1215
1316class 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