1 # Copyright 2013 The Swarming Authors. All rights reserved.
2 # Use of this source code is governed under the Apache License, Version 2.0 that
3 # can be found in the LICENSE file.
5 """Classes and functions for generic network communication over HTTP."""
8 import cStringIO as StringIO
26 from third_party import requests
27 from third_party.requests import adapters
28 from third_party.requests import structures
29 from third_party.rietveld import upload
31 from utils import oauth
32 from utils import tools
33 from utils import zip_package
35 # Hack out upload logging.info()
36 upload.logging = logging.getLogger('upload')
37 # Mac pylint choke on this line.
38 upload.logging.setLevel(logging.WARNING) # pylint: disable=E1103
41 # TODO(vadimsh): Remove this once we don't have to support python 2.6 anymore.
42 def monkey_patch_httplib():
43 """Patch httplib.HTTPConnection to have '_tunnel_host' attribute.
45 'requests' library (>= v2) accesses 'HTTPConnection._tunnel_host' attribute
46 added only in python 2.6.3. This function patches HTTPConnection to have it
47 on python 2.6.2 as well.
49 conn = httplib.HTTPConnection('example.com')
50 if not hasattr(conn, '_tunnel_host'):
51 httplib.HTTPConnection._tunnel_host = None
52 monkey_patch_httplib()
55 # The name of the key to store the count of url attempts.
56 COUNT_KEY = 'UrlOpenAttempt'
58 # Default maximum number of attempts to trying opening a url before aborting.
59 URL_OPEN_MAX_ATTEMPTS = 30
61 # Default timeout when retrying.
62 URL_OPEN_TIMEOUT = 6*60.
64 # Default timeout when reading from open HTTP connection.
67 # Content type for url encoded POST body.
68 URL_ENCODED_FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded'
69 # Content type for JSON body.
70 JSON_CONTENT_TYPE = 'application/json; charset=UTF-8'
71 # Default content type for POST body.
72 DEFAULT_CONTENT_TYPE = URL_ENCODED_FORM_CONTENT_TYPE
74 # Content type -> function that encodes a request body.
76 URL_ENCODED_FORM_CONTENT_TYPE:
79 lambda x: json.dumps(x, sort_keys=True, separators=(',', ':')),
82 # File to use to store all auth cookies.
83 COOKIE_FILE = os.path.join(os.path.expanduser('~'), '.isolated_cookies')
85 # Google Storage URL regular expression.
86 GS_STORAGE_HOST_URL_RE = re.compile(r'https://.*\.storage\.googleapis\.com')
88 # All possible authentication methods with corresponding types of method config
89 # object (or None if method is not configurable). See configure_auth.
90 # Order is important: it's visible in commands --help output.
92 ('oauth', oauth.OAuthConfig),
99 # Global (for now) map: server URL (http://example.com) -> HttpService instance.
100 # Used by get_http_service to cache HttpService instances.
102 _http_services_lock = threading.Lock()
104 # CookieJar reused by all services + lock that protects its instantiation.
106 _cookie_jar_lock = threading.Lock()
108 # Path to cacert.pem bundle file reused by all services.
110 _ca_certs_lock = threading.Lock()
112 # This lock ensures that user won't be confused with multiple concurrent
114 _auth_lock = threading.Lock()
116 # Set in 'configure_auth'. If configure_auth is not called before first request,
117 # will be set to defaults generated by 'get_default_auth_config'.
119 _auth_method_config = None
122 class NetError(IOError):
123 """Generic network related error."""
125 def __init__(self, inner_exc=None):
126 super(NetError, self).__init__(str(inner_exc or self.__doc__))
127 self.inner_exc = inner_exc
129 def format(self, verbose=False):
130 """Human readable description with detailed information about the error."""
131 out = [str(self.inner_exc)]
135 if isinstance(self.inner_exc, urllib2.HTTPError):
136 headers = self.inner_exc.hdrs.items()
137 body = self.inner_exc.read()
138 elif isinstance(self.inner_exc, requests.HTTPError):
139 headers = self.inner_exc.response.headers.items()
140 body = self.inner_exc.response.content
142 out.append('----------')
144 for header, value in headers:
145 if not header.startswith('x-'):
146 out.append('%s: %s' % (header.capitalize(), value))
148 out.append(body or '<empty body>')
149 out.append('----------')
150 return '\n'.join(out)
153 class TimeoutError(NetError):
154 """Timeout while reading HTTP response."""
157 class ConnectionError(NetError):
158 """Failed to connect to the server."""
161 class HttpError(NetError):
162 """Server returned HTTP error code."""
164 def __init__(self, code, inner_exc=None):
165 super(HttpError, self).__init__(inner_exc)
169 def url_open(url, **kwargs):
170 """Attempts to open the given url multiple times.
172 |data| can be either:
173 - None for a GET request
174 - str for pre-encoded data
175 - list for data to be encoded
176 - dict for data to be encoded
178 See HttpService.request for a full list of arguments.
180 Returns HttpResponse object, where the response may be read from, or None
181 if it was unable to connect.
183 urlhost, urlpath = split_server_request_url(url)
184 service = get_http_service(urlhost)
185 return service.request(urlpath, **kwargs)
188 def url_read(url, **kwargs):
189 """Attempts to open the given url multiple times and read all data from it.
191 Accepts same arguments as url_open function.
193 Returns all data read or None if it was unable to connect or read the data.
195 kwargs['stream'] = False
196 response = url_open(url, **kwargs)
200 return response.read()
205 def url_retrieve(filepath, url, **kwargs):
206 """Downloads an URL to a file. Returns True on success."""
207 response = url_open(url, **kwargs)
211 with open(filepath, 'wb') as f:
213 buf = response.read(65536)
217 except (IOError, OSError, TimeoutError):
225 def split_server_request_url(url):
226 """Splits the url into scheme+netloc and path+params+query+fragment."""
227 url_parts = list(urlparse.urlparse(url))
228 urlhost = '%s://%s' % (url_parts[0], url_parts[1])
229 urlpath = urlparse.urlunparse(['', ''] + url_parts[2:])
230 return urlhost, urlpath
233 def get_http_service(urlhost):
234 """Returns existing or creates new instance of HttpService that can send
235 requests to given base urlhost.
237 # Ensure consistency in url naming.
238 urlhost = str(urlhost).lower().rstrip('/')
239 # Do not use COUNT_KEY with Google Storage (since it breaks a signature).
240 use_count_key = not GS_STORAGE_HOST_URL_RE.match(urlhost)
241 with _http_services_lock:
242 service = _http_services.get(urlhost)
244 service = HttpService(
246 engine=RequestsLibEngine(get_cacerts_bundle()),
247 authenticator=create_authenticator(urlhost),
248 use_count_key=use_count_key)
249 _http_services[urlhost] = service
253 def get_cookie_jar():
254 """Returns global CoookieJar object that stores cookies in the file."""
256 with _cookie_jar_lock:
257 if _cookie_jar is not None:
259 jar = ThreadSafeCookieJar(COOKIE_FILE)
265 def get_cacerts_bundle():
266 """Returns path to a file with CA root certificates bundle."""
269 if _ca_certs is not None and os.path.exists(_ca_certs):
271 _ca_certs = zip_package.extract_resource(requests, 'cacert.pem')
275 def get_default_auth_config():
276 """Returns auth configuration used by default if configure_auth is not called.
278 If running in a headless mode on bots, will use 'bot' auth, otherwise
279 'oauth' with default oauth config.
281 Returns pair (auth method name, auth method config).
283 if tools.is_headless():
286 return 'oauth', oauth.make_oauth_config()
289 def configure_auth(method, config=None):
290 """Defines what authentication methods to use.
292 Possible authentication methods are:
293 'bot' - use HMAC authentication based on a secret key.
294 'cookie' - use cookie-based authentication.
295 'none' - do not use authentication.
296 'oauth' - use oauth-based authentication.
299 method: what method to use.
300 config: object that holds configuration for authentication method.
301 Concrete type depends on a method used (see AUTH_METHODS for expected
302 type). Passed to corresponding authenticator instance.
305 global _auth_method_config
306 assert method in dict(AUTH_METHODS), method
307 config_type = dict(AUTH_METHODS)[method]
308 if config_type and not isinstance(config, config_type):
310 'Expecting \'%s\' auth config to be of type %s. Got %s instead.' %
311 (method, config_type, type(config)))
312 elif not config_type and config is not None:
313 raise TypeError('Auth method \'%s\' is not configurable.' % method)
315 _auth_method = method
316 _auth_method_config = config
319 def create_authenticator(urlhost):
320 """Makes Authenticator instance used by HttpService to access |urlhost|."""
321 # We use signed URL for Google Storage, no need for special authentication.
322 if GS_STORAGE_HOST_URL_RE.match(urlhost):
325 # Lazy initialize auth config with defaults.
327 default_method, default_config = get_default_auth_config()
328 configure_auth(default_method, default_config)
330 # Use configuration set with 'configure_auth'.
332 if _auth_method == 'bot':
333 # TODO(vadimsh): Implement it. Use IP whitelist (that doesn't require
334 # any authenticator instance) for now.
336 elif _auth_method == 'cookie':
337 return CookieBasedAuthenticator(urlhost, get_cookie_jar())
338 elif _auth_method == 'none':
340 elif _auth_method == 'oauth':
341 return OAuthAuthenticator(urlhost, _auth_method_config)
342 raise AssertionError('Invalid auth method: %s' % _auth_method)
345 def get_case_insensitive_dict(original):
346 """Given a dict with string keys returns new CaseInsensitiveDict.
348 Raises ValueError if there are duplicate keys.
350 normalized = structures.CaseInsensitiveDict(original or {})
351 if len(normalized) != len(original):
352 raise ValueError('Duplicate keys in: %s' % repr(original))
356 class HttpService(object):
357 """Base class for a class that provides an API to HTTP based service:
358 - Provides 'request' method.
359 - Supports automatic request retries.
363 def __init__(self, urlhost, engine, authenticator=None, use_count_key=True):
364 self.urlhost = urlhost
366 self.authenticator = authenticator
367 self.use_count_key = use_count_key
370 def is_transient_http_error(code, retry_404, retry_50x):
371 """Returns True if given HTTP response code is a transient error."""
372 # Google Storage can return this and it should be retried.
375 # Retry 404 only if allowed by the caller.
378 # All other 4** errors are fatal.
381 # Retry >= 500 error only if allowed by the caller.
385 def encode_request_body(body, content_type):
386 """Returns request body encoded according to its content type."""
387 # No body or it is already encoded.
388 if body is None or isinstance(body, str):
390 # Any body should have content type set.
391 assert content_type, 'Request has body, but no content type'
392 encoder = CONTENT_ENCODERS.get(content_type)
393 assert encoder, ('Unknown content type %s' % content_type)
396 def login(self, allow_user_interaction):
397 """Runs authentication flow to refresh short lived access token.
399 Authentication flow may need to interact with the user (read username from
400 stdin, open local browser for OAuth2, etc.). If interaction is required and
401 |allow_user_interaction| is False, the login will silently be considered
402 failed (i.e. this function returns False).
404 'request' method always uses non-interactive login, so long-lived
405 authentication tokens (cookie, OAuth2 refresh token, etc) have to be set up
406 manually by developer (by calling 'auth.py login' perhaps) prior running
407 any swarming or isolate scripts.
409 # Use global lock to ensure two authentication flows never run in parallel.
411 if self.authenticator:
412 return self.authenticator.login(allow_user_interaction)
416 """Purges access credentials from local cache."""
417 if self.authenticator:
418 self.authenticator.logout()
425 max_attempts=URL_OPEN_MAX_ATTEMPTS,
428 timeout=URL_OPEN_TIMEOUT,
429 read_timeout=URL_READ_TIMEOUT,
433 """Attempts to open the given url multiple times.
435 |urlpath| is relative to the server root, i.e. '/some/request?param=1'.
437 |data| can be either:
438 - None for a GET request
439 - str for pre-encoded data
440 - list for data to be form-encoded
441 - dict for data to be form-encoded
443 - Optionally retries HTTP 404 and 50x.
444 - Retries up to |max_attempts| times. If None or 0, there's no limit in the
446 - Retries up to |timeout| duration in seconds. If None or 0, there's no
447 limit in the time taken to do retries.
448 - If both |max_attempts| and |timeout| are None or 0, this functions retries
451 If |method| is given it can be 'GET', 'POST' or 'PUT' and it will be used
452 when performing the request. By default it's GET if |data| is None and POST
453 if |data| is not None.
455 If |headers| is given, it should be a dict with HTTP headers to append
456 to request. Caller is responsible for providing headers that make sense.
458 If |read_timeout| is not None will configure underlying socket to
459 raise TimeoutError exception whenever there's no response from the server
460 for more than |read_timeout| seconds. It can happen during any read
461 operation so once you pass non-None |read_timeout| be prepared to handle
462 these exceptions in subsequent reads from the stream.
464 Returns a file-like object, where the response may be read from, or None
465 if it was unable to connect. If |stream| is False will read whole response
466 into memory buffer before returning file-like object that reads from this
469 assert urlpath and urlpath[0] == '/', urlpath
472 assert method in (None, 'POST', 'PUT')
473 method = method or 'POST'
474 content_type = content_type or DEFAULT_CONTENT_TYPE
475 body = self.encode_request_body(data, content_type)
477 assert method in (None, 'GET')
478 method = method or 'GET'
480 assert not content_type, 'Can\'t use content_type on GET'
482 # Prepare request info.
483 parsed = urlparse.urlparse('/' + urlpath.lstrip('/'))
484 resource_url = urlparse.urljoin(self.urlhost, parsed.path)
485 query_params = urlparse.parse_qsl(parsed.query)
488 headers = get_case_insensitive_dict(headers or {})
490 headers['Content-Length'] = len(body)
492 headers['Content-Type'] = content_type
495 auth_attempted = False
497 for attempt in retry_loop(max_attempts, timeout):
498 # Log non-first attempt.
501 'Retrying request %s, attempt %d/%d...',
502 resource_url, attempt.attempt, max_attempts)
505 # Prepare and send a new request.
506 request = HttpRequest(method, resource_url, query_params, body,
507 headers, read_timeout, stream)
508 self.prepare_request(request, attempt.attempt)
509 if self.authenticator:
510 self.authenticator.authorize(request)
511 response = self.engine.perform_request(request)
512 logging.debug('Request %s succeeded', request.get_full_url())
515 except (ConnectionError, TimeoutError) as e:
518 'Unable to open url %s on attempt %d.\n%s',
519 request.get_full_url(), attempt.attempt, e.format())
522 except HttpError as e:
525 # Access denied -> authenticate.
526 if e.code in (401, 403):
528 'Authentication is required for %s on attempt %d.\n%s',
529 request.get_full_url(), attempt.attempt, e.format())
530 # Try to authenticate only once. If it doesn't help, then server does
531 # not support authentication or user doesn't have required access.
532 if not auth_attempted:
533 auth_attempted = True
534 if self.login(allow_user_interaction=False):
535 # Success! Run request again immediately.
536 attempt.skip_sleep = True
538 # Authentication attempt was unsuccessful.
540 'Unable to authenticate to %s (%s). Use auth.py to login: '
541 'python auth.py login --service=%s',
542 self.urlhost, e.format(), self.urlhost)
545 # Hit a error that can not be retried -> stop retry loop.
546 if not self.is_transient_http_error(e.code, retry_404, retry_50x):
547 # This HttpError means we reached the server and there was a problem
548 # with the request, so don't retry.
550 'Able to connect to %s but an exception was thrown.\n%s',
551 request.get_full_url(), e.format(verbose=True))
554 # Retry all other errors.
556 'Server responded with error on %s on attempt %d.\n%s',
557 request.get_full_url(), attempt.attempt, e.format())
561 'Unable to open given url, %s, after %d attempts.\n%s',
562 request.get_full_url(), max_attempts, last_error.format(verbose=True))
570 max_attempts=URL_OPEN_MAX_ATTEMPTS,
571 timeout=URL_OPEN_TIMEOUT,
573 """Sends JSON request to the server and parses JSON response it get back.
576 method: HTTP method to use ('GET', 'POST', ...).
577 urlpath: relative request path (e.g. '/auth/v1/...').
578 body: object to serialize to JSON and sent in the request.
579 max_attempts: how many times to retry 50x errors.
580 timeout: how long to wait for a response (including all retries).
581 headers: dict with additional request headers.
584 Deserialized JSON response on success, None on error or timeout.
586 response = self.request(
588 content_type=JSON_CONTENT_TYPE if body is not None else None,
591 max_attempts=max_attempts,
600 text = response.read()
606 return json.loads(text)
608 logging.error('Not a JSON response when calling %s: %s', urlpath, text)
611 def prepare_request(self, request, attempt): # pylint: disable=R0201
612 """Modify HttpRequest before sending it by adding COUNT_KEY parameter."""
613 # Add COUNT_KEY only on retries.
614 if self.use_count_key and attempt:
615 request.params += [(COUNT_KEY, attempt)]
618 class HttpRequest(object):
619 """Request to HttpService."""
621 def __init__(self, method, url, params, body, headers, timeout, stream):
623 |method| - HTTP method to use
624 |url| - relative URL to the resource, without query parameters
625 |params| - list of (key, value) pairs to put into GET parameters
626 |body| - encoded body of the request (None or str)
627 |headers| - dict with request headers
628 |timeout| - socket read timeout (None to disable)
629 |stream| - True to stream response from socket
633 self.params = params[:]
635 self.headers = headers.copy()
636 self.timeout = timeout
642 """CookieJar object that will be used for cookies in this request."""
643 if self._cookies is None:
644 self._cookies = cookielib.CookieJar()
647 def get_full_url(self):
648 """Resource URL with url-encoded GET parameters."""
652 return '%s?%s' % (self.url, urllib.urlencode(self.params))
654 def make_fake_response(self, content='', headers=None):
655 """Makes new fake HttpResponse to this request, useful in tests."""
656 return HttpResponse.get_fake_response(content, self.get_full_url(), headers)
659 class HttpResponse(object):
660 """Response from HttpService."""
662 def __init__(self, stream, url, headers):
663 self._stream = stream
665 self._headers = get_case_insensitive_dict(headers)
669 def content_length(self):
670 """Total length to the response or None if not known in advance."""
671 length = self.get_header('Content-Length')
672 return int(length) if length is not None else None
674 def get_header(self, header):
675 """Returns response header (as str) or None if no such header."""
676 return self._headers.get(header)
678 def read(self, size=None):
679 """Reads up to |size| bytes from the stream and returns them.
681 If |size| is None reads all available bytes.
683 Raises TimeoutError on read timeout.
686 # cStringIO has a bug: stream.read(None) is not the same as stream.read().
687 data = self._stream.read() if size is None else self._stream.read(size)
688 self._read += len(data)
690 except (socket.timeout, ssl.SSLError, requests.Timeout) as e:
691 logging.error('Timeout while reading from %s, read %d of %s: %s',
692 self._url, self._read, self.content_length, e)
693 raise TimeoutError(e)
696 def get_fake_response(cls, content, url, headers=None):
697 """Returns HttpResponse with predefined content, useful in tests."""
698 headers = dict(headers or {})
699 headers['Content-Length'] = len(content)
700 return cls(StringIO.StringIO(content), url, headers)
703 class Authenticator(object):
704 """Base class for objects that know how to authenticate into http services."""
706 def authorize(self, request):
707 """Add authentication information to the request."""
709 def login(self, allow_user_interaction):
710 """Run interactive authentication flow."""
711 raise NotImplementedError()
714 """Purges access credentials from local cache."""
717 class RequestsLibEngine(object):
718 """Class that knows how to execute HttpRequests via requests library."""
720 # Preferred number of connections in a connection pool.
721 CONNECTION_POOL_SIZE = 64
722 # If True will not open more than CONNECTION_POOL_SIZE connections.
723 CONNECTION_POOL_BLOCK = False
724 # Maximum number of internal connection retries in a connection pool.
725 CONNECTION_RETRIES = 0
727 def __init__(self, ca_certs):
728 super(RequestsLibEngine, self).__init__()
729 self.session = requests.Session()
731 self.session.trust_env = False
732 self.session.verify = ca_certs
733 # Configure connection pools.
734 for protocol in ('https://', 'http://'):
735 self.session.mount(protocol, adapters.HTTPAdapter(
736 pool_connections=self.CONNECTION_POOL_SIZE,
737 pool_maxsize=self.CONNECTION_POOL_SIZE,
738 max_retries=self.CONNECTION_RETRIES,
739 pool_block=self.CONNECTION_POOL_BLOCK))
741 def perform_request(self, request):
742 """Sends a HttpRequest to the server and reads back the response.
744 Returns HttpResponse.
747 ConnectionError - failed to establish connection to the server.
748 TimeoutError - timeout while connecting or reading response.
749 HttpError - server responded with >= 400 error code.
752 response = self.session.request(
753 method=request.method,
755 params=request.params,
757 headers=request.headers,
758 cookies=request.cookies,
759 timeout=request.timeout,
760 stream=request.stream)
761 response.raise_for_status()
763 stream = response.raw
765 stream = StringIO.StringIO(response.content)
766 return HttpResponse(stream, request.get_full_url(), response.headers)
767 except requests.Timeout as e:
768 raise TimeoutError(e)
769 except requests.HTTPError as e:
770 raise HttpError(e.response.status_code, e)
771 except (requests.ConnectionError, socket.timeout, ssl.SSLError) as e:
772 raise ConnectionError(e)
775 # TODO(vadimsh): Remove once everything is using OAuth or HMAC-based auth.
776 class CookieBasedAuthenticator(Authenticator):
777 """Uses cookies (that AppEngine recognizes) to authenticate to |urlhost|."""
779 def __init__(self, urlhost, cookie_jar):
780 super(CookieBasedAuthenticator, self).__init__()
781 self.urlhost = urlhost
782 self.cookie_jar = cookie_jar
786 self._lock = threading.Lock()
788 def authorize(self, request):
789 # Copy all cookies from authenticator cookie jar to request cookie jar.
791 with self.cookie_jar:
792 for cookie in self.cookie_jar:
793 request.cookies.set_cookie(cookie)
795 def login(self, allow_user_interaction):
796 # Cookie authentication is always interactive (it asks for user name).
797 if not allow_user_interaction:
798 print >> sys.stderr, 'Cookie authentication requires interactive login'
800 # To be used from inside AuthServer.
801 cookie_jar = self.cookie_jar
802 # RPC server that uses AuthenticationSupport's cookie jar.
803 class AuthServer(upload.AbstractRpcServer):
804 def _GetOpener(self):
805 # Authentication code needs to know about 302 response.
806 # So make OpenerDirector without HTTPRedirectHandler.
807 opener = urllib2.OpenerDirector()
808 opener.add_handler(urllib2.ProxyHandler())
809 opener.add_handler(urllib2.UnknownHandler())
810 opener.add_handler(urllib2.HTTPHandler())
811 opener.add_handler(urllib2.HTTPDefaultErrorHandler())
812 opener.add_handler(urllib2.HTTPSHandler())
813 opener.add_handler(urllib2.HTTPErrorProcessor())
814 opener.add_handler(urllib2.HTTPCookieProcessor(cookie_jar))
816 def PerformAuthentication(self):
818 return self.authenticated
821 rpc_server = AuthServer(self.urlhost, self.get_credentials)
822 return rpc_server.PerformAuthentication()
825 domain = urlparse.urlparse(self.urlhost).netloc
827 with self.cookie_jar:
828 self.cookie_jar.clear(domain)
832 def get_credentials(self):
833 """Called during authentication process to get the credentials.
835 May be called multiple times if authentication fails.
837 Returns tuple (email, password).
839 if self.email and self.password:
840 return (self.email, self.password)
841 self._keyring = self._keyring or upload.KeyringCreds(self.urlhost,
842 self.urlhost, self.email)
843 return self._keyring.GetUserCredentials()
846 # TODO(vadimsh): Remove once everything is using OAuth or HMAC-based auth.
847 class ThreadSafeCookieJar(cookielib.MozillaCookieJar):
848 """MozillaCookieJar with thread safe load and save."""
851 """Context manager interface."""
854 def __exit__(self, *_args):
855 """Saves cookie jar when exiting the block."""
859 def load(self, filename=None, ignore_discard=False, ignore_expires=False):
860 """Loads cookies from the file if it exists."""
861 filename = os.path.expanduser(filename or self.filename)
862 with self._cookies_lock:
863 if os.path.exists(filename):
865 cookielib.MozillaCookieJar.load(
866 self, filename, ignore_discard, ignore_expires)
867 logging.debug('Loaded cookies from %s', filename)
868 except (cookielib.LoadError, IOError):
872 fd = os.open(filename, os.O_CREAT, 0600)
875 logging.debug('Failed to create %s', filename)
877 os.chmod(filename, 0600)
879 logging.debug('Failed to fix mode for %s', filename)
881 def save(self, filename=None, ignore_discard=False, ignore_expires=False):
882 """Saves cookies to the file, completely overwriting it."""
883 logging.debug('Saving cookies to %s', filename or self.filename)
884 with self._cookies_lock:
886 cookielib.MozillaCookieJar.save(
887 self, filename, ignore_discard, ignore_expires)
889 logging.error('Failed to save %s', filename)
892 class OAuthAuthenticator(Authenticator):
893 """Uses OAuth Authorization header to authenticate requests."""
895 def __init__(self, urlhost, config):
896 super(OAuthAuthenticator, self).__init__()
897 assert isinstance(config, oauth.OAuthConfig)
898 self.urlhost = urlhost
900 self._lock = threading.Lock()
901 self._access_token = oauth.load_access_token(self.urlhost, self.config)
903 def authorize(self, request):
905 if self._access_token:
906 request.headers['Authorization'] = 'Bearer %s' % self._access_token
908 def login(self, allow_user_interaction):
910 self._access_token = oauth.create_access_token(
911 self.urlhost, self.config, allow_user_interaction)
912 return self._access_token is not None
916 self._access_token = None
917 oauth.purge_access_token(self.urlhost, self.config)
920 class RetryAttempt(object):
921 """Contains information about current retry attempt.
923 Yielded from retry_loop.
926 def __init__(self, attempt, remaining):
927 """Information about current attempt in retry loop:
928 |attempt| - zero based index of attempt.
929 |remaining| - how much time is left before retry loop finishes retries.
931 self.attempt = attempt
932 self.remaining = remaining
933 self.skip_sleep = False
936 def calculate_sleep_before_retry(attempt, max_duration):
937 """How long to sleep before retrying an attempt in retry_loop."""
938 # Maximum sleeping time. We're hammering a cloud-distributed service, it'll
941 # random.random() returns [0.0, 1.0). Starts with relatively short waiting
942 # time by starting with 1.5/2+1.5^-1 median offset.
943 duration = (random.random() * 1.5) + math.pow(1.5, (attempt - 1))
944 assert duration > 0.1
945 duration = min(MAX_SLEEP, duration)
947 duration = min(max_duration, duration)
951 def sleep_before_retry(attempt, max_duration):
952 """Sleeps for some amount of time when retrying the attempt in retry_loop.
954 To be mocked in tests.
956 time.sleep(calculate_sleep_before_retry(attempt, max_duration))
960 """Used by retry loop to get current time.
962 To be mocked in tests.
967 def retry_loop(max_attempts=None, timeout=None):
968 """Yields whenever new attempt to perform some action is needed.
970 Yields instances of RetryAttempt class that contains information about current
971 attempt. Setting |skip_sleep| attribute of RetryAttempt to True will cause
972 retry loop to run next attempt immediately.
974 start = current_time()
975 for attempt in itertools.count():
977 if max_attempts and attempt == max_attempts:
979 # Retried for too long?
980 remaining = (timeout - (current_time() - start)) if timeout else None
981 if remaining is not None and remaining < 0:
983 # Kick next iteration.
984 attemp_obj = RetryAttempt(attempt, remaining)
986 if attemp_obj.skip_sleep:
988 # Only sleep if we are going to try again.
989 if max_attempts and attempt != max_attempts - 1:
990 remaining = (timeout - (current_time() - start)) if timeout else None
991 if remaining is not None and remaining < 0:
993 sleep_before_retry(attempt, remaining)