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, allow_cached=False, use_count_key=None):
234 """Returns existing or creates new instance of HttpService that can send
235 requests to given base urlhost.
240 engine=RequestsLibEngine(get_cacerts_bundle()),
241 authenticator=create_authenticator(urlhost),
242 use_count_key=use_count_key)
244 # Ensure consistency in url naming.
245 urlhost = str(urlhost).lower().rstrip('/')
247 # Do not use COUNT_KEY with Google Storage (since it breaks a signature).
248 if use_count_key is None:
249 use_count_key = not GS_STORAGE_HOST_URL_RE.match(urlhost)
253 with _http_services_lock:
254 service = _http_services.get(urlhost)
256 service = new_service()
257 _http_services[urlhost] = service
261 def get_cookie_jar():
262 """Returns global CoookieJar object that stores cookies in the file."""
264 with _cookie_jar_lock:
265 if _cookie_jar is not None:
267 jar = ThreadSafeCookieJar(COOKIE_FILE)
273 def get_cacerts_bundle():
274 """Returns path to a file with CA root certificates bundle."""
277 if _ca_certs is not None and os.path.exists(_ca_certs):
279 _ca_certs = zip_package.extract_resource(requests, 'cacert.pem')
283 def get_default_auth_config():
284 """Returns auth configuration used by default if configure_auth is not called.
286 If running in a headless mode on bots, will use 'bot' auth, otherwise
287 'oauth' with default oauth config.
289 Returns pair (auth method name, auth method config).
291 if tools.is_headless():
294 return 'oauth', oauth.make_oauth_config()
297 def configure_auth(method, config=None):
298 """Defines what authentication methods to use.
300 Possible authentication methods are:
301 'bot' - use HMAC authentication based on a secret key.
302 'cookie' - use cookie-based authentication.
303 'none' - do not use authentication.
304 'oauth' - use oauth-based authentication.
307 method: what method to use.
308 config: object that holds configuration for authentication method.
309 Concrete type depends on a method used (see AUTH_METHODS for expected
310 type). Passed to corresponding authenticator instance.
313 global _auth_method_config
314 assert method in dict(AUTH_METHODS), method
315 config_type = dict(AUTH_METHODS)[method]
316 if config_type and not isinstance(config, config_type):
318 'Expecting \'%s\' auth config to be of type %s. Got %s instead.' %
319 (method, config_type, type(config)))
320 elif not config_type and config is not None:
321 raise TypeError('Auth method \'%s\' is not configurable.' % method)
323 _auth_method = method
324 _auth_method_config = config
327 def get_auth_method():
328 """Returns authentication method used by default.
330 Set with 'configure_auth'. See 'configure_auth' doc string for existing
336 def create_authenticator(urlhost):
337 """Makes Authenticator instance used by HttpService to access |urlhost|."""
338 # We use signed URL for Google Storage, no need for special authentication.
339 if GS_STORAGE_HOST_URL_RE.match(urlhost):
342 # Lazy initialize auth config with defaults.
344 default_method, default_config = get_default_auth_config()
345 configure_auth(default_method, default_config)
347 # Use configuration set with 'configure_auth'.
349 if _auth_method == 'bot':
350 # TODO(vadimsh): Implement it. Use IP whitelist (that doesn't require
351 # any authenticator instance) for now.
353 elif _auth_method == 'cookie':
354 return CookieBasedAuthenticator(urlhost, get_cookie_jar())
355 elif _auth_method == 'none':
357 elif _auth_method == 'oauth':
358 return OAuthAuthenticator(urlhost, _auth_method_config)
359 raise AssertionError('Invalid auth method: %s' % _auth_method)
362 def get_case_insensitive_dict(original):
363 """Given a dict with string keys returns new CaseInsensitiveDict.
365 Raises ValueError if there are duplicate keys.
367 normalized = structures.CaseInsensitiveDict(original or {})
368 if len(normalized) != len(original):
369 raise ValueError('Duplicate keys in: %s' % repr(original))
373 class HttpService(object):
374 """Base class for a class that provides an API to HTTP based service:
375 - Provides 'request' method.
376 - Supports automatic request retries.
380 def __init__(self, urlhost, engine, authenticator=None, use_count_key=True):
381 self.urlhost = urlhost
383 self.authenticator = authenticator
384 self.use_count_key = use_count_key
387 def is_transient_http_error(code, retry_404, retry_50x):
388 """Returns True if given HTTP response code is a transient error."""
389 # Google Storage can return this and it should be retried.
392 # Retry 404 only if allowed by the caller.
395 # All other 4** errors are fatal.
398 # Retry >= 500 error only if allowed by the caller.
402 def encode_request_body(body, content_type):
403 """Returns request body encoded according to its content type."""
404 # No body or it is already encoded.
405 if body is None or isinstance(body, str):
407 # Any body should have content type set.
408 assert content_type, 'Request has body, but no content type'
409 encoder = CONTENT_ENCODERS.get(content_type)
410 assert encoder, ('Unknown content type %s' % content_type)
413 def login(self, allow_user_interaction):
414 """Runs authentication flow to refresh short lived access token.
416 Authentication flow may need to interact with the user (read username from
417 stdin, open local browser for OAuth2, etc.). If interaction is required and
418 |allow_user_interaction| is False, the login will silently be considered
419 failed (i.e. this function returns False).
421 'request' method always uses non-interactive login, so long-lived
422 authentication tokens (cookie, OAuth2 refresh token, etc) have to be set up
423 manually by developer (by calling 'auth.py login' perhaps) prior running
424 any swarming or isolate scripts.
426 # Use global lock to ensure two authentication flows never run in parallel.
428 if self.authenticator:
429 return self.authenticator.login(allow_user_interaction)
433 """Purges access credentials from local cache."""
434 if self.authenticator:
435 self.authenticator.logout()
442 max_attempts=URL_OPEN_MAX_ATTEMPTS,
445 timeout=URL_OPEN_TIMEOUT,
446 read_timeout=URL_READ_TIMEOUT,
450 """Attempts to open the given url multiple times.
452 |urlpath| is relative to the server root, i.e. '/some/request?param=1'.
454 |data| can be either:
455 - None for a GET request
456 - str for pre-encoded data
457 - list for data to be form-encoded
458 - dict for data to be form-encoded
460 - Optionally retries HTTP 404 and 50x.
461 - Retries up to |max_attempts| times. If None or 0, there's no limit in the
463 - Retries up to |timeout| duration in seconds. If None or 0, there's no
464 limit in the time taken to do retries.
465 - If both |max_attempts| and |timeout| are None or 0, this functions retries
468 If |method| is given it can be 'GET', 'POST' or 'PUT' and it will be used
469 when performing the request. By default it's GET if |data| is None and POST
470 if |data| is not None.
472 If |headers| is given, it should be a dict with HTTP headers to append
473 to request. Caller is responsible for providing headers that make sense.
475 If |read_timeout| is not None will configure underlying socket to
476 raise TimeoutError exception whenever there's no response from the server
477 for more than |read_timeout| seconds. It can happen during any read
478 operation so once you pass non-None |read_timeout| be prepared to handle
479 these exceptions in subsequent reads from the stream.
481 Returns a file-like object, where the response may be read from, or None
482 if it was unable to connect. If |stream| is False will read whole response
483 into memory buffer before returning file-like object that reads from this
486 assert urlpath and urlpath[0] == '/', urlpath
489 assert method in (None, 'POST', 'PUT')
490 method = method or 'POST'
491 content_type = content_type or DEFAULT_CONTENT_TYPE
492 body = self.encode_request_body(data, content_type)
494 assert method in (None, 'GET')
495 method = method or 'GET'
497 assert not content_type, 'Can\'t use content_type on GET'
499 # Prepare request info.
500 parsed = urlparse.urlparse('/' + urlpath.lstrip('/'))
501 resource_url = urlparse.urljoin(self.urlhost, parsed.path)
502 query_params = urlparse.parse_qsl(parsed.query)
505 headers = get_case_insensitive_dict(headers or {})
507 headers['Content-Length'] = len(body)
509 headers['Content-Type'] = content_type
512 auth_attempted = False
514 for attempt in retry_loop(max_attempts, timeout):
515 # Log non-first attempt.
518 'Retrying request %s, attempt %d/%d...',
519 resource_url, attempt.attempt, max_attempts)
522 # Prepare and send a new request.
523 request = HttpRequest(method, resource_url, query_params, body,
524 headers, read_timeout, stream)
525 self.prepare_request(request, attempt.attempt)
526 if self.authenticator:
527 self.authenticator.authorize(request)
528 response = self.engine.perform_request(request)
529 logging.debug('Request %s succeeded', request.get_full_url())
532 except (ConnectionError, TimeoutError) as e:
535 'Unable to open url %s on attempt %d.\n%s',
536 request.get_full_url(), attempt.attempt, e.format())
539 except HttpError as e:
542 # Access denied -> authenticate.
543 if e.code in (401, 403):
545 'Authentication is required for %s on attempt %d.\n%s',
546 request.get_full_url(), attempt.attempt, e.format())
547 # Try to authenticate only once. If it doesn't help, then server does
548 # not support authentication or user doesn't have required access.
549 if not auth_attempted:
550 auth_attempted = True
551 if self.login(allow_user_interaction=False):
552 # Success! Run request again immediately.
553 attempt.skip_sleep = True
555 # Authentication attempt was unsuccessful.
557 'Unable to authenticate to %s (%s). Use auth.py to login: '
558 'python auth.py login --service=%s',
559 self.urlhost, e.format(), self.urlhost)
562 # Hit a error that can not be retried -> stop retry loop.
563 if not self.is_transient_http_error(e.code, retry_404, retry_50x):
564 # This HttpError means we reached the server and there was a problem
565 # with the request, so don't retry.
567 'Able to connect to %s but an exception was thrown.\n%s',
568 request.get_full_url(), e.format(verbose=True))
571 # Retry all other errors.
573 'Server responded with error on %s on attempt %d.\n%s',
574 request.get_full_url(), attempt.attempt, e.format())
578 'Unable to open given url, %s, after %d attempts.\n%s',
579 request.get_full_url(), max_attempts, last_error.format(verbose=True))
587 max_attempts=URL_OPEN_MAX_ATTEMPTS,
588 timeout=URL_OPEN_TIMEOUT,
590 """Sends JSON request to the server and parses JSON response it get back.
593 method: HTTP method to use ('GET', 'POST', ...).
594 urlpath: relative request path (e.g. '/auth/v1/...').
595 body: object to serialize to JSON and sent in the request.
596 max_attempts: how many times to retry 50x errors.
597 timeout: how long to wait for a response (including all retries).
598 headers: dict with additional request headers.
601 Deserialized JSON response on success, None on error or timeout.
603 response = self.request(
605 content_type=JSON_CONTENT_TYPE if body is not None else None,
608 max_attempts=max_attempts,
617 text = response.read()
623 return json.loads(text)
625 logging.error('Not a JSON response when calling %s: %s', urlpath, text)
628 def prepare_request(self, request, attempt): # pylint: disable=R0201
629 """Modify HttpRequest before sending it by adding COUNT_KEY parameter."""
630 # Add COUNT_KEY only on retries.
631 if self.use_count_key and attempt:
632 request.params += [(COUNT_KEY, attempt)]
635 class HttpRequest(object):
636 """Request to HttpService."""
638 def __init__(self, method, url, params, body, headers, timeout, stream):
640 |method| - HTTP method to use
641 |url| - relative URL to the resource, without query parameters
642 |params| - list of (key, value) pairs to put into GET parameters
643 |body| - encoded body of the request (None or str)
644 |headers| - dict with request headers
645 |timeout| - socket read timeout (None to disable)
646 |stream| - True to stream response from socket
650 self.params = params[:]
652 self.headers = headers.copy()
653 self.timeout = timeout
659 """CookieJar object that will be used for cookies in this request."""
660 if self._cookies is None:
661 self._cookies = cookielib.CookieJar()
664 def get_full_url(self):
665 """Resource URL with url-encoded GET parameters."""
669 return '%s?%s' % (self.url, urllib.urlencode(self.params))
671 def make_fake_response(self, content='', headers=None):
672 """Makes new fake HttpResponse to this request, useful in tests."""
673 return HttpResponse.get_fake_response(content, self.get_full_url(), headers)
676 class HttpResponse(object):
677 """Response from HttpService."""
679 def __init__(self, stream, url, headers):
680 self._stream = stream
682 self._headers = get_case_insensitive_dict(headers)
686 def content_length(self):
687 """Total length to the response or None if not known in advance."""
688 length = self.get_header('Content-Length')
689 return int(length) if length is not None else None
691 def get_header(self, header):
692 """Returns response header (as str) or None if no such header."""
693 return self._headers.get(header)
695 def read(self, size=None):
696 """Reads up to |size| bytes from the stream and returns them.
698 If |size| is None reads all available bytes.
700 Raises TimeoutError on read timeout.
703 # cStringIO has a bug: stream.read(None) is not the same as stream.read().
704 data = self._stream.read() if size is None else self._stream.read(size)
705 self._read += len(data)
707 except (socket.timeout, ssl.SSLError, requests.Timeout) as e:
708 logging.error('Timeout while reading from %s, read %d of %s: %s',
709 self._url, self._read, self.content_length, e)
710 raise TimeoutError(e)
713 def get_fake_response(cls, content, url, headers=None):
714 """Returns HttpResponse with predefined content, useful in tests."""
715 headers = dict(headers or {})
716 headers['Content-Length'] = len(content)
717 return cls(StringIO.StringIO(content), url, headers)
720 class Authenticator(object):
721 """Base class for objects that know how to authenticate into http services."""
723 def authorize(self, request):
724 """Add authentication information to the request."""
726 def login(self, allow_user_interaction):
727 """Run interactive authentication flow."""
728 raise NotImplementedError()
731 """Purges access credentials from local cache."""
734 class RequestsLibEngine(object):
735 """Class that knows how to execute HttpRequests via requests library."""
737 # Preferred number of connections in a connection pool.
738 CONNECTION_POOL_SIZE = 64
739 # If True will not open more than CONNECTION_POOL_SIZE connections.
740 CONNECTION_POOL_BLOCK = False
741 # Maximum number of internal connection retries in a connection pool.
742 CONNECTION_RETRIES = 0
744 def __init__(self, ca_certs):
745 super(RequestsLibEngine, self).__init__()
746 self.session = requests.Session()
748 self.session.trust_env = False
749 self.session.verify = ca_certs
750 # Configure connection pools.
751 for protocol in ('https://', 'http://'):
752 self.session.mount(protocol, adapters.HTTPAdapter(
753 pool_connections=self.CONNECTION_POOL_SIZE,
754 pool_maxsize=self.CONNECTION_POOL_SIZE,
755 max_retries=self.CONNECTION_RETRIES,
756 pool_block=self.CONNECTION_POOL_BLOCK))
758 def perform_request(self, request):
759 """Sends a HttpRequest to the server and reads back the response.
761 Returns HttpResponse.
764 ConnectionError - failed to establish connection to the server.
765 TimeoutError - timeout while connecting or reading response.
766 HttpError - server responded with >= 400 error code.
769 response = self.session.request(
770 method=request.method,
772 params=request.params,
774 headers=request.headers,
775 cookies=request.cookies,
776 timeout=request.timeout,
777 stream=request.stream)
778 response.raise_for_status()
780 stream = response.raw
782 stream = StringIO.StringIO(response.content)
783 return HttpResponse(stream, request.get_full_url(), response.headers)
784 except requests.Timeout as e:
785 raise TimeoutError(e)
786 except requests.HTTPError as e:
787 raise HttpError(e.response.status_code, e)
788 except (requests.ConnectionError, socket.timeout, ssl.SSLError) as e:
789 raise ConnectionError(e)
792 # TODO(vadimsh): Remove once everything is using OAuth or HMAC-based auth.
793 class CookieBasedAuthenticator(Authenticator):
794 """Uses cookies (that AppEngine recognizes) to authenticate to |urlhost|."""
796 def __init__(self, urlhost, cookie_jar):
797 super(CookieBasedAuthenticator, self).__init__()
798 self.urlhost = urlhost
799 self.cookie_jar = cookie_jar
803 self._lock = threading.Lock()
805 def authorize(self, request):
806 # Copy all cookies from authenticator cookie jar to request cookie jar.
808 with self.cookie_jar:
809 for cookie in self.cookie_jar:
810 request.cookies.set_cookie(cookie)
812 def login(self, allow_user_interaction):
813 # Cookie authentication is always interactive (it asks for user name).
814 if not allow_user_interaction:
815 print >> sys.stderr, 'Cookie authentication requires interactive login'
817 # To be used from inside AuthServer.
818 cookie_jar = self.cookie_jar
819 # RPC server that uses AuthenticationSupport's cookie jar.
820 class AuthServer(upload.AbstractRpcServer):
821 def _GetOpener(self):
822 # Authentication code needs to know about 302 response.
823 # So make OpenerDirector without HTTPRedirectHandler.
824 opener = urllib2.OpenerDirector()
825 opener.add_handler(urllib2.ProxyHandler())
826 opener.add_handler(urllib2.UnknownHandler())
827 opener.add_handler(urllib2.HTTPHandler())
828 opener.add_handler(urllib2.HTTPDefaultErrorHandler())
829 opener.add_handler(urllib2.HTTPSHandler())
830 opener.add_handler(urllib2.HTTPErrorProcessor())
831 opener.add_handler(urllib2.HTTPCookieProcessor(cookie_jar))
833 def PerformAuthentication(self):
835 return self.authenticated
838 rpc_server = AuthServer(self.urlhost, self.get_credentials)
839 return rpc_server.PerformAuthentication()
842 domain = urlparse.urlparse(self.urlhost).netloc
844 with self.cookie_jar:
845 self.cookie_jar.clear(domain)
849 def get_credentials(self):
850 """Called during authentication process to get the credentials.
852 May be called multiple times if authentication fails.
854 Returns tuple (email, password).
856 if self.email and self.password:
857 return (self.email, self.password)
858 self._keyring = self._keyring or upload.KeyringCreds(self.urlhost,
859 self.urlhost, self.email)
860 return self._keyring.GetUserCredentials()
863 # TODO(vadimsh): Remove once everything is using OAuth or HMAC-based auth.
864 class ThreadSafeCookieJar(cookielib.MozillaCookieJar):
865 """MozillaCookieJar with thread safe load and save."""
868 """Context manager interface."""
871 def __exit__(self, *_args):
872 """Saves cookie jar when exiting the block."""
876 def load(self, filename=None, ignore_discard=False, ignore_expires=False):
877 """Loads cookies from the file if it exists."""
878 filename = os.path.expanduser(filename or self.filename)
879 with self._cookies_lock:
880 if os.path.exists(filename):
882 cookielib.MozillaCookieJar.load(
883 self, filename, ignore_discard, ignore_expires)
884 logging.debug('Loaded cookies from %s', filename)
885 except (cookielib.LoadError, IOError):
889 fd = os.open(filename, os.O_CREAT, 0600)
892 logging.debug('Failed to create %s', filename)
894 os.chmod(filename, 0600)
896 logging.debug('Failed to fix mode for %s', filename)
898 def save(self, filename=None, ignore_discard=False, ignore_expires=False):
899 """Saves cookies to the file, completely overwriting it."""
900 logging.debug('Saving cookies to %s', filename or self.filename)
901 with self._cookies_lock:
903 cookielib.MozillaCookieJar.save(
904 self, filename, ignore_discard, ignore_expires)
906 logging.error('Failed to save %s', filename)
909 class OAuthAuthenticator(Authenticator):
910 """Uses OAuth Authorization header to authenticate requests."""
912 def __init__(self, urlhost, config):
913 super(OAuthAuthenticator, self).__init__()
914 assert isinstance(config, oauth.OAuthConfig)
915 self.urlhost = urlhost
917 self._lock = threading.Lock()
918 self._access_token_known = False
919 self._access_token = None
921 def authorize(self, request):
923 if not self._access_token_known:
924 self._access_token = oauth.load_access_token(self.urlhost, self.config)
925 self._access_token_known = True
926 if self._access_token:
927 request.headers['Authorization'] = 'Bearer %s' % self._access_token
929 def login(self, allow_user_interaction):
931 self._access_token = oauth.create_access_token(
932 self.urlhost, self.config, allow_user_interaction)
933 self._access_token_known = True
934 return self._access_token is not None
938 self._access_token = None
939 self._access_token_known = True
940 oauth.purge_access_token(self.urlhost, self.config)
943 class RetryAttempt(object):
944 """Contains information about current retry attempt.
946 Yielded from retry_loop.
949 def __init__(self, attempt, remaining):
950 """Information about current attempt in retry loop:
951 |attempt| - zero based index of attempt.
952 |remaining| - how much time is left before retry loop finishes retries.
954 self.attempt = attempt
955 self.remaining = remaining
956 self.skip_sleep = False
959 def calculate_sleep_before_retry(attempt, max_duration):
960 """How long to sleep before retrying an attempt in retry_loop."""
961 # Maximum sleeping time. We're hammering a cloud-distributed service, it'll
964 # random.random() returns [0.0, 1.0). Starts with relatively short waiting
965 # time by starting with 1.5/2+1.5^-1 median offset.
966 duration = (random.random() * 1.5) + math.pow(1.5, (attempt - 1))
967 assert duration > 0.1
968 duration = min(MAX_SLEEP, duration)
970 duration = min(max_duration, duration)
974 def sleep_before_retry(attempt, max_duration):
975 """Sleeps for some amount of time when retrying the attempt in retry_loop.
977 To be mocked in tests.
979 time.sleep(calculate_sleep_before_retry(attempt, max_duration))
983 """Used by retry loop to get current time.
985 To be mocked in tests.
990 def retry_loop(max_attempts=None, timeout=None):
991 """Yields whenever new attempt to perform some action is needed.
993 Yields instances of RetryAttempt class that contains information about current
994 attempt. Setting |skip_sleep| attribute of RetryAttempt to True will cause
995 retry loop to run next attempt immediately.
997 start = current_time()
998 for attempt in itertools.count():
1000 if max_attempts and attempt == max_attempts:
1002 # Retried for too long?
1003 remaining = (timeout - (current_time() - start)) if timeout else None
1004 if remaining is not None and remaining < 0:
1006 # Kick next iteration.
1007 attemp_obj = RetryAttempt(attempt, remaining)
1009 if attemp_obj.skip_sleep:
1011 # Only sleep if we are going to try again.
1012 if max_attempts and attempt != max_attempts - 1:
1013 remaining = (timeout - (current_time() - start)) if timeout else None
1014 if remaining is not None and remaining < 0:
1016 sleep_before_retry(attempt, remaining)