4 # Copyright (C) 2007, 2008, 2010 Google Inc.
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
11 # This program is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14 # General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
21 """HTTP client module.
27 from cStringIO import StringIO
29 from ganeti import http
30 from ganeti import compat
31 from ganeti import netutils
34 class HttpClientRequest(object):
35 def __init__(self, host, port, method, path, headers=None, post_data=None,
36 read_timeout=None, curl_config_fn=None):
37 """Describes an HTTP request.
44 @param method: Method name
46 @param path: Request path
47 @type headers: list or None
48 @param headers: Additional headers to send, list of strings
49 @type post_data: string or None
50 @param post_data: Additional data to send
51 @type read_timeout: int
52 @param read_timeout: if passed, it will be used as the read
53 timeout while reading the response from the server
54 @type curl_config_fn: callable
55 @param curl_config_fn: Function to configure cURL object before request
56 (Note: if the function configures the connection in
57 a way where it wouldn't be efficient to reuse them,
58 a "identity" property should be defined, see
59 L{HttpClientRequest.identity})
62 assert path.startswith("/"), "Path must start with slash (/)"
63 assert curl_config_fn is None or callable(curl_config_fn)
70 self.read_timeout = read_timeout
71 self.curl_config_fn = curl_config_fn
76 self.post_data = post_data
80 elif isinstance(headers, dict):
81 # Support for old interface
82 self.headers = ["%s: %s" % (name, value)
83 for name, value in headers.items()]
85 self.headers = headers
92 self.resp_status_code = None
96 status = ["%s.%s" % (self.__class__.__module__, self.__class__.__name__),
97 "%s:%s" % (self.host, self.port),
101 return "<%s at %#x>" % (" ".join(status), id(self))
105 """Returns the full URL for this requests.
108 if netutils.IPAddress.IsValid(self.host):
109 family = netutils.IPAddress.GetAddressFamily(self.host)
110 address = netutils.FormatAddress(family, (self.host, self.port))
112 address = "%s:%s" % (self.host, self.port)
113 # TODO: Support for non-SSL requests
114 return "https://%s%s" % (address, self.path)
118 """Returns identifier for retrieving a pooled connection for this request.
120 This allows cURL client objects to be re-used and to cache information
121 (e.g. SSL session IDs or connections).
124 parts = [self.host, self.port]
126 if self.curl_config_fn:
128 parts.append(self.curl_config_fn.identity)
129 except AttributeError:
132 return "/".join(str(i) for i in parts)
135 class _HttpClient(object):
136 def __init__(self, curl_config_fn):
137 """Initializes this class.
139 @type curl_config_fn: callable
140 @param curl_config_fn: Function to configure cURL object after
146 curl = self._CreateCurlHandle()
147 curl.setopt(pycurl.VERBOSE, False)
148 curl.setopt(pycurl.NOSIGNAL, True)
149 curl.setopt(pycurl.USERAGENT, http.HTTP_GANETI_VERSION)
150 curl.setopt(pycurl.PROXY, "")
152 # Pass cURL object to external config function
159 def _CreateCurlHandle():
160 """Returns a new cURL object.
165 def GetCurlHandle(self):
166 """Returns the cURL object.
171 def GetCurrentRequest(self):
172 """Returns the current request.
174 @rtype: L{HttpClientRequest} or None
179 def StartRequest(self, req):
180 """Starts a request on this client.
182 @type req: L{HttpClientRequest}
183 @param req: HTTP request
186 assert not self._req, "Another request is already started"
189 self._resp_buffer = StringIO()
193 post_data = req.post_data
194 headers = req.headers
196 # PycURL requires strings to be non-unicode
197 assert isinstance(method, str)
198 assert isinstance(url, str)
199 assert isinstance(post_data, str)
200 assert compat.all(isinstance(i, str) for i in headers)
202 # Configure cURL object for request
204 curl.setopt(pycurl.CUSTOMREQUEST, str(method))
205 curl.setopt(pycurl.URL, url)
206 curl.setopt(pycurl.POSTFIELDS, post_data)
207 curl.setopt(pycurl.WRITEFUNCTION, self._resp_buffer.write)
208 curl.setopt(pycurl.HTTPHEADER, headers)
210 if req.read_timeout is None:
211 curl.setopt(pycurl.TIMEOUT, 0)
213 curl.setopt(pycurl.TIMEOUT, int(req.read_timeout))
215 # Pass cURL object to external config function
216 if req.curl_config_fn:
217 req.curl_config_fn(curl)
219 def Done(self, errmsg):
220 """Finishes a request.
222 @type errmsg: string or None
223 @param errmsg: Error message if request failed
227 assert req, "No request"
229 logging.debug("Request %s finished, errmsg=%s", req, errmsg)
233 req.success = not bool(errmsg)
236 # Get HTTP response code
237 req.resp_status_code = curl.getinfo(pycurl.RESPONSE_CODE)
238 req.resp_body = self._resp_buffer.getvalue()
240 # Reset client object
242 self._resp_buffer = None
244 # Ensure no potentially large variables are referenced
245 curl.setopt(pycurl.POSTFIELDS, "")
246 curl.setopt(pycurl.WRITEFUNCTION, lambda _: None)
249 class _PooledHttpClient:
250 """Data structure for HTTP client pool.
253 def __init__(self, identity, client):
254 """Initializes this class.
256 @type identity: string
257 @param identity: Client identifier for pool
258 @type client: L{_HttpClient}
259 @param client: HTTP client
262 self.identity = identity
267 status = ["%s.%s" % (self.__class__.__module__, self.__class__.__name__),
268 "id=%s" % self.identity,
269 "lastuse=%s" % self.lastused,
272 return "<%s at %#x>" % (" ".join(status), id(self))
275 class HttpClientPool:
276 """A simple HTTP client pool.
278 Supports one pooled connection per identity (see
279 L{HttpClientRequest.identity}).
282 #: After how many generations to drop unused clients
283 _MAX_GENERATIONS_DROP = 25
285 def __init__(self, curl_config_fn):
286 """Initializes this class.
288 @type curl_config_fn: callable
289 @param curl_config_fn: Function to configure cURL object after
293 self._curl_config_fn = curl_config_fn
298 def _GetHttpClientCreator():
299 """Returns callable to create HTTP client.
304 def _Get(self, identity):
305 """Gets an HTTP client from the pool.
307 @type identity: string
308 @param identity: Client identifier
312 pclient = self._pool.pop(identity)
314 # Need to create new client
315 client = self._GetHttpClientCreator()(self._curl_config_fn)
316 pclient = _PooledHttpClient(identity, client)
317 logging.debug("Created new client %s", pclient)
319 logging.debug("Reusing client %s", pclient)
321 assert pclient.identity == identity
325 def _StartRequest(self, req):
328 @type req: L{HttpClientRequest}
329 @param req: HTTP request
332 logging.debug("Starting request %r", req)
333 pclient = self._Get(req.identity)
335 assert req.identity not in self._pool
337 pclient.client.StartRequest(req)
338 pclient.lastused = self._generation
342 def _Return(self, pclients):
343 """Returns HTTP clients to the pool.
347 logging.debug("Returning client %s to pool", pc)
348 assert pc.identity not in self._pool
349 assert pc not in self._pool.values()
350 self._pool[pc.identity] = pc
352 # Check for unused clients
353 for pc in self._pool.values():
354 if (pc.lastused + self._MAX_GENERATIONS_DROP) < self._generation:
355 logging.debug("Removing client %s which hasn't been used"
356 " for %s generations",
357 pc, self._MAX_GENERATIONS_DROP)
358 self._pool.pop(pc.identity, None)
360 assert compat.all(pc.lastused >= (self._generation -
361 self._MAX_GENERATIONS_DROP)
362 for pc in self._pool.values())
365 def _CreateCurlMultiHandle():
366 """Creates new cURL multi handle.
369 return pycurl.CurlMulti()
371 def ProcessRequests(self, requests):
372 """Processes any number of HTTP client requests using pooled objects.
374 @type requests: list of L{HttpClientRequest}
375 @param requests: List of all requests
378 multi = self._CreateCurlMultiHandle()
381 self._generation += 1
383 assert compat.all((req.error is None and
384 req.success is None and
385 req.resp_status_code is None and
386 req.resp_body is None)
391 pclient = self._StartRequest(req)
392 curl = pclient.client.GetCurlHandle()
393 curl_to_pclient[curl] = pclient
394 multi.add_handle(curl)
395 assert pclient.client.GetCurrentRequest() == req
396 assert pclient.lastused >= 0
398 assert len(curl_to_pclient) == len(requests)
402 (ret, _) = multi.perform()
403 assert ret in (pycurl.E_MULTI_OK, pycurl.E_CALL_MULTI_PERFORM)
405 if ret == pycurl.E_CALL_MULTI_PERFORM:
406 # cURL wants to be called again
410 (remaining_messages, successful, failed) = multi.info_read()
412 for curl in successful:
413 multi.remove_handle(curl)
415 pclient = curl_to_pclient[curl]
416 req = pclient.client.GetCurrentRequest()
417 pclient.client.Done(None)
419 assert not pclient.client.GetCurrentRequest()
421 for curl, errnum, errmsg in failed:
422 multi.remove_handle(curl)
424 pclient = curl_to_pclient[curl]
425 req = pclient.client.GetCurrentRequest()
426 pclient.client.Done("Error %s: %s" % (errnum, errmsg))
428 assert not pclient.client.GetCurrentRequest()
430 if remaining_messages == 0:
433 assert done_count <= len(requests)
435 if done_count == len(requests):
438 # Wait for I/O. The I/O timeout shouldn't be too long so that HTTP
439 # timeouts, which are only evaluated in multi.perform, aren't
440 # unnecessarily delayed.
443 assert compat.all(pclient.client.GetCurrentRequest() is None
444 for pclient in curl_to_pclient.values())
446 # Return clients to pool
447 self._Return(curl_to_pclient.values())
449 assert done_count == len(requests)
450 assert compat.all(req.error is not None or
452 req.resp_status_code is not None and
453 req.resp_body is not None)