33from email .message import Message
44from http .client import HTTPResponse
55from http .client import responses
6- from typing import Any , Optional , Dict
6+ from typing import Any , List , Optional , Dict , Union
77from urllib import request
88from urllib .error import HTTPError , URLError
99import urllib .parse as urlparse
1010from urllib .parse import urlencode
1111
1212from racetrack_client .log .context_error import ContextError
1313
14+ DEBUG_MODE = False # print the actual bytes sent in requests and responses
15+
1416
1517class RequestError (ContextError ):
1618 """HTTP Request error due to network error, bad URL, unreachable address"""
@@ -27,13 +29,16 @@ def __init__(self, error_context: str, status_code: int):
2729
2830class Response :
2931
30- def __init__ (self ,
32+ def __init__ (
33+ self ,
3134 url : str ,
35+ method : str ,
3236 status_code : int ,
3337 content : bytes ,
3438 headers : Message ,
3539 ):
3640 self ._url : str = url
41+ self ._method : str = method .upper ()
3742 self ._status_code : int = status_code
3843 self ._content : bytes = content
3944 self ._headers : Message = headers
@@ -62,7 +67,7 @@ def content(self) -> bytes:
6267 def text (self ) -> str :
6368 return self ._content .decode ('utf8' )
6469
65- def json (self ) -> Dict :
70+ def json (self ) -> Union [ Dict , List ] :
6671 response_data = self ._content .decode ('utf8' )
6772 if not response_data :
6873 raise RuntimeError ('no content in response to decode as JSON' )
@@ -76,82 +81,96 @@ def is_json(self) -> bool:
7681 def url (self ) -> str :
7782 return self ._url
7883
84+ @property
85+ def method (self ) -> str :
86+ return self ._method
87+
7988 @property
8089 def headers (self ) -> Message :
8190 return self ._headers
8291
8392 def header (self , name : str ) -> Optional [str ]:
8493 return self ._headers [name ]
8594
95+ @property
96+ def error_details (self ) -> str :
97+ return f'{ self .status_code } { self .status_reason } for url: { self .method } { self .url } '
98+
8699
87100class Requests :
88101
89102 insecure : bool = True # whether to verify SSL certificates
90103
91104 @classmethod
92- def get (cls ,
105+ def get (
106+ cls ,
93107 url : str ,
94108 params : Optional [Dict [str , Any ]] = None ,
95109 headers : Optional [Dict [str , str ]] = None ,
96- timeout : float = None ,
97- ) -> Response :
110+ timeout : Optional [ float ] = None ,
111+ ) -> Response :
98112 return cls ._make_request ('GET' , url , None , None , params , headers , timeout )
99113
100114 @classmethod
101- def post (cls ,
115+ def post (
116+ cls ,
102117 url : str ,
103118 json : Optional [Any ] = None ,
104119 data : Optional [bytes ] = None ,
105120 params : Optional [Dict [str , Any ]] = None ,
106121 headers : Optional [Dict [str , str ]] = None ,
107- timeout : float = None ,
108- ) -> Response :
122+ timeout : Optional [ float ] = None ,
123+ ) -> Response :
109124 return cls ._make_request ('POST' , url , json , data , params , headers , timeout )
110125
111126 @classmethod
112- def put (cls ,
127+ def put (
128+ cls ,
113129 url : str ,
114130 json : Optional [Any ] = None ,
115131 data : Optional [bytes ] = None ,
116132 params : Optional [Dict [str , Any ]] = None ,
117133 headers : Optional [Dict [str , str ]] = None ,
118- timeout : float = None ,
119- ) -> Response :
134+ timeout : Optional [ float ] = None ,
135+ ) -> Response :
120136 return cls ._make_request ('PUT' , url , json , data , params , headers , timeout )
121137
122138 @classmethod
123- def delete (cls ,
139+ def delete (
140+ cls ,
124141 url : str ,
125142 json : Optional [Any ] = None ,
126143 data : Optional [bytes ] = None ,
127144 params : Optional [Dict [str , Any ]] = None ,
128145 headers : Optional [Dict [str , str ]] = None ,
129- timeout : float = None ,
130- ) -> Response :
146+ timeout : Optional [ float ] = None ,
147+ ) -> Response :
131148 return cls ._make_request ('DELETE' , url , json , data , params , headers , timeout )
132149
133150 @classmethod
134- def request (cls ,
151+ def request (
152+ cls ,
135153 method : str ,
136154 url : str ,
137155 json : Optional [Any ] = None ,
138156 data : Optional [bytes ] = None ,
139157 params : Optional [Dict [str , Any ]] = None ,
140158 headers : Optional [Dict [str , str ]] = None ,
141- timeout : float = None ,
142- ) -> Response :
159+ timeout : Optional [ float ] = None ,
160+ ) -> Response :
143161 return cls ._make_request (method .upper (), url , json , data , params , headers , timeout )
144162
145163 @classmethod
146- def _make_request (cls ,
164+ def _make_request (
165+ cls ,
147166 method : str ,
148167 url : str ,
149168 jsondata : Optional [Any ] = None ,
150169 data : Optional [bytes ] = None ,
151170 params : Optional [Dict [str , Any ]] = None ,
152171 headers : Optional [Dict [str , str ]] = None ,
153- timeout : float = None ,
154- ) -> Response :
172+ timeout : Optional [ float ] = None ,
173+ ) -> Response :
155174 """
156175 Make HTTP request and return response object.
157176 :param method: HTTP method: GET, POST, PUT, DELETE
@@ -190,19 +209,33 @@ def _make_request(cls,
190209 if timeout is not None :
191210 kwargs ['timeout' ] = timeout
192211
212+ if not req .has_header ('User-Agent' ):
213+ req .add_header ('User-Agent' , 'request' )
214+
193215 kwargs ['context' ] = cls ._get_ssl_context ()
194216
217+ if DEBUG_MODE :
218+ http_handler = request .HTTPHandler (debuglevel = 2 )
219+ https_handler = request .HTTPSHandler (context = kwargs ['context' ], debuglevel = 2 )
220+ opener = request .build_opener (http_handler , https_handler )
221+ request .install_opener (opener )
222+ # Passing 'context' argument to urllib.request.urlopen rebuilds the URL opener,
223+ # not giving a chance to turn the debug mode on.
224+ del kwargs ['context' ]
225+
195226 try :
196227 http_response : HTTPResponse = request .urlopen (req , ** kwargs )
197228 return Response (
198229 url = url ,
230+ method = method ,
199231 status_code = http_response .status ,
200232 content = http_response .read (),
201233 headers = http_response .headers ,
202234 )
203235 except HTTPError as e :
204236 return Response (
205237 url = url ,
238+ method = method ,
206239 status_code = e .code ,
207240 content = e .read (),
208241 headers = e .headers ,
@@ -232,7 +265,7 @@ def build_url_with_params(
232265 return urlparse .urlunparse (url_parts )
233266
234267
235- def parse_response (response : Response , error_context : str ) -> Optional [Dict ]:
268+ def parse_response (response : Response , error_context : str ) -> Optional [Union [ Dict , List ] ]:
236269 """
237270 Ensure response was successful. If not, try to extract error message from it.
238271 :return: response parsed as JSON object
@@ -248,34 +281,53 @@ def parse_response(response: Response, error_context: str) -> Optional[Dict]:
248281 raise "Deployment error: 500 Internal Server Error: you have no power here"
249282 """
250283 try :
251- result : Optional [Dict ] = None
284+ result : Optional [Union [ Dict , List ] ] = None
252285 if 'application/json' in response .headers ['content-type' ]:
253286 result = response .json ()
254287
255288 if response .ok :
256289 return result
257290
258- if result is not None and 'error' in result :
259- raise RuntimeError (f'{ response .status_reason } : { result .get ("error" )} ' )
260- response .raise_for_status ()
261- return result
291+ if result is not None and isinstance (result , dict ) and 'error' in result :
292+ raise ContextError (response .status_reason , RuntimeError (result .get ("error" )))
293+ raise ResponseError (response .error_details , response .status_code )
262294 except Exception as e :
263295 raise ResponseError (error_context , response .status_code ) from e
264296
265297
266298def parse_response_object (response : Response , error_context : str ) -> Dict :
267299 try :
268300 if 'application/json' not in response .headers ['content-type' ]:
269- raise RuntimeError ('expected JSON response' )
301+ raise RuntimeError (f'expected JSON response, got "{ response .headers ["content-type" ]} ", '
302+ f'{ response .error_details } ' )
303+
304+ result = response .json ()
305+
306+ if response .ok :
307+ assert isinstance (result , dict ), f'response JSON expected to be a dictionary, got { type (result )} '
308+ return result
309+
310+ if result is not None and isinstance (result , dict ) and 'error' in result :
311+ raise ContextError (response .status_reason , RuntimeError (result .get ("error" )))
312+ raise ResponseError (response .error_details , response .status_code )
313+ except Exception as e :
314+ raise ResponseError (error_context , response .status_code ) from e
315+
316+
317+ def parse_response_list (response : Response , error_context : str ) -> List :
318+ try :
319+ if 'application/json' not in response .headers ['content-type' ]:
320+ raise RuntimeError (f'expected JSON response, got "{ response .headers ["content-type" ]} ", '
321+ f'{ response .error_details } ' )
270322
271323 result = response .json ()
272324
273325 if response .ok :
326+ assert isinstance (result , list ), f'response JSON expected to be a list, got { type (result )} '
274327 return result
275328
276- if result is not None and 'error' in result :
277- raise RuntimeError (f'{ response .status_reason } : { result .get ("error" )} ' )
278- response .raise_for_status ()
279- return result
329+ if result is not None and isinstance (result , dict ) and 'error' in result :
330+ raise ContextError (response .status_reason , RuntimeError (result .get ("error" )))
331+ raise ResponseError (response .error_details , response .status_code )
280332 except Exception as e :
281333 raise ResponseError (error_context , response .status_code ) from e
0 commit comments