Merge branch '17171-pysdk-timeout-fix'
[arvados.git] / sdk / python / arvados / api.py
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: Apache-2.0
4
5 from __future__ import absolute_import
6 from future import standard_library
7 standard_library.install_aliases()
8 from builtins import range
9 import collections
10 import http.client
11 import httplib2
12 import json
13 import logging
14 import os
15 import re
16 import socket
17 import sys
18 import time
19 import types
20
21 import apiclient
22 from apiclient import discovery as apiclient_discovery
23 from apiclient import errors as apiclient_errors
24 from . import config
25 from . import errors
26 from . import util
27 from . import cache
28
29 _logger = logging.getLogger('arvados.api')
30
31 MAX_IDLE_CONNECTION_DURATION = 30
32 RETRY_DELAY_INITIAL = 2
33 RETRY_DELAY_BACKOFF = 2
34 RETRY_COUNT = 2
35
36 if sys.version_info >= (3,):
37     httplib2.SSLHandshakeError = None
38
39 class OrderedJsonModel(apiclient.model.JsonModel):
40     """Model class for JSON that preserves the contents' order.
41
42     API clients that care about preserving the order of fields in API
43     server responses can use this model to do so, like this::
44
45         from arvados.api import OrderedJsonModel
46         client = arvados.api('v1', ..., model=OrderedJsonModel())
47     """
48
49     def deserialize(self, content):
50         # This is a very slightly modified version of the parent class'
51         # implementation.  Copyright (c) 2010 Google.
52         content = content.decode('utf-8')
53         body = json.loads(content, object_pairs_hook=collections.OrderedDict)
54         if self._data_wrapper and isinstance(body, dict) and 'data' in body:
55             body = body['data']
56         return body
57
58
59 def _intercept_http_request(self, uri, method="GET", headers={}, **kwargs):
60     if (self.max_request_size and
61         kwargs.get('body') and
62         self.max_request_size < len(kwargs['body'])):
63         raise apiclient_errors.MediaUploadSizeError("Request size %i bytes exceeds published limit of %i bytes" % (len(kwargs['body']), self.max_request_size))
64
65     if config.get("ARVADOS_EXTERNAL_CLIENT", "") == "true":
66         headers['X-External-Client'] = '1'
67
68     headers['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
69     if not headers.get('X-Request-Id'):
70         headers['X-Request-Id'] = self._request_id()
71
72     retryable = method in [
73         'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
74     retry_count = self._retry_count if retryable else 0
75
76     if (not retryable and
77         time.time() - self._last_request_time > self._max_keepalive_idle):
78         # High probability of failure due to connection atrophy. Make
79         # sure this request [re]opens a new connection by closing and
80         # forgetting all cached connections first.
81         for conn in self.connections.values():
82             conn.close()
83         self.connections.clear()
84
85     delay = self._retry_delay_initial
86     for _ in range(retry_count):
87         self._last_request_time = time.time()
88         try:
89             return self.orig_http_request(uri, method, headers=headers, **kwargs)
90         except http.client.HTTPException:
91             _logger.debug("Retrying API request in %d s after HTTP error",
92                           delay, exc_info=True)
93         except socket.error:
94             # This is the one case where httplib2 doesn't close the
95             # underlying connection first.  Close all open
96             # connections, expecting this object only has the one
97             # connection to the API server.  This is safe because
98             # httplib2 reopens connections when needed.
99             _logger.debug("Retrying API request in %d s after socket error",
100                           delay, exc_info=True)
101             for conn in self.connections.values():
102                 conn.close()
103         except httplib2.SSLHandshakeError as e:
104             # Intercept and re-raise with a better error message.
105             raise httplib2.SSLHandshakeError("Could not connect to %s\n%s\nPossible causes: remote SSL/TLS certificate expired, or was issued by an untrusted certificate authority." % (uri, e))
106
107         time.sleep(delay)
108         delay = delay * self._retry_delay_backoff
109
110     self._last_request_time = time.time()
111     return self.orig_http_request(uri, method, headers=headers, **kwargs)
112
113 def _patch_http_request(http, api_token):
114     http.arvados_api_token = api_token
115     http.max_request_size = 0
116     http.orig_http_request = http.request
117     http.request = types.MethodType(_intercept_http_request, http)
118     http._last_request_time = 0
119     http._max_keepalive_idle = MAX_IDLE_CONNECTION_DURATION
120     http._retry_delay_initial = RETRY_DELAY_INITIAL
121     http._retry_delay_backoff = RETRY_DELAY_BACKOFF
122     http._retry_count = RETRY_COUNT
123     http._request_id = util.new_request_id
124     return http
125
126 # Monkey patch discovery._cast() so objects and arrays get serialized
127 # with json.dumps() instead of str().
128 _cast_orig = apiclient_discovery._cast
129 def _cast_objects_too(value, schema_type):
130     global _cast_orig
131     if (type(value) != type('') and
132         type(value) != type(b'') and
133         (schema_type == 'object' or schema_type == 'array')):
134         return json.dumps(value)
135     else:
136         return _cast_orig(value, schema_type)
137 apiclient_discovery._cast = _cast_objects_too
138
139 # Convert apiclient's HttpErrors into our own API error subclass for better
140 # error reporting.
141 # Reassigning apiclient_errors.HttpError is not sufficient because most of the
142 # apiclient submodules import the class into their own namespace.
143 def _new_http_error(cls, *args, **kwargs):
144     return super(apiclient_errors.HttpError, cls).__new__(
145         errors.ApiError, *args, **kwargs)
146 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
147
148 def http_cache(data_type):
149     homedir = os.environ.get('HOME')
150     if not homedir or len(homedir) == 0:
151         return None
152     path = homedir + '/.cache/arvados/' + data_type
153     try:
154         util.mkdir_dash_p(path)
155     except OSError:
156         return None
157     return cache.SafeHTTPCache(path, max_age=60*60*24*2)
158
159 def api(version=None, cache=True, host=None, token=None, insecure=False,
160         request_id=None, timeout=10, **kwargs):
161     """Return an apiclient Resources object for an Arvados instance.
162
163     :version:
164       A string naming the version of the Arvados API to use (for
165       example, 'v1').
166
167     :cache:
168       Use a cache (~/.cache/arvados/discovery) for the discovery
169       document.
170
171     :host:
172       The Arvados API server host (and optional :port) to connect to.
173
174     :token:
175       The authentication token to send with each API call.
176
177     :insecure:
178       If True, ignore SSL certificate validation errors.
179
180     :timeout:
181       A timeout value for http requests.
182
183     :request_id:
184       Default X-Request-Id header value for outgoing requests that
185       don't already provide one. If None or omitted, generate a random
186       ID. When retrying failed requests, the same ID is used on all
187       attempts.
188
189     Additional keyword arguments will be passed directly to
190     `apiclient_discovery.build` if a new Resource object is created.
191     If the `discoveryServiceUrl` or `http` keyword arguments are
192     missing, this function will set default values for them, based on
193     the current Arvados configuration settings.
194
195     """
196
197     if not version:
198         version = 'v1'
199         _logger.info("Using default API version. " +
200                      "Call arvados.api('%s') instead." %
201                      version)
202     if 'discoveryServiceUrl' in kwargs:
203         if host:
204             raise ValueError("both discoveryServiceUrl and host provided")
205         # Here we can't use a token from environment, config file,
206         # etc. Those probably have nothing to do with the host
207         # provided by the caller.
208         if not token:
209             raise ValueError("discoveryServiceUrl provided, but token missing")
210     elif host and token:
211         pass
212     elif not host and not token:
213         return api_from_config(
214             version=version, cache=cache, request_id=request_id, **kwargs)
215     else:
216         # Caller provided one but not the other
217         if not host:
218             raise ValueError("token argument provided, but host missing.")
219         else:
220             raise ValueError("host argument provided, but token missing.")
221
222     if host:
223         # Caller wants us to build the discoveryServiceUrl
224         kwargs['discoveryServiceUrl'] = (
225             'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
226
227     if 'http' not in kwargs:
228         http_kwargs = {'ca_certs': util.ca_certs_path()}
229         if cache:
230             http_kwargs['cache'] = http_cache('discovery')
231         if insecure:
232             http_kwargs['disable_ssl_certificate_validation'] = True
233         kwargs['http'] = httplib2.Http(**http_kwargs)
234
235     if kwargs['http'].timeout is None:
236         kwargs['http'].timeout = timeout
237
238     kwargs['http'] = _patch_http_request(kwargs['http'], token)
239
240     svc = apiclient_discovery.build('arvados', version, cache_discovery=False, **kwargs)
241     svc.api_token = token
242     svc.insecure = insecure
243     svc.request_id = request_id
244     svc.config = lambda: util.get_config_once(svc)
245     kwargs['http'].max_request_size = svc._rootDesc.get('maxRequestSize', 0)
246     kwargs['http'].cache = None
247     kwargs['http']._request_id = lambda: svc.request_id or util.new_request_id()
248     return svc
249
250 def api_from_config(version=None, apiconfig=None, **kwargs):
251     """Return an apiclient Resources object enabling access to an Arvados server
252     instance.
253
254     :version:
255       A string naming the version of the Arvados REST API to use (for
256       example, 'v1').
257
258     :apiconfig:
259       If provided, this should be a dict-like object (must support the get()
260       method) with entries for ARVADOS_API_HOST, ARVADOS_API_TOKEN, and
261       optionally ARVADOS_API_HOST_INSECURE.  If not provided, use
262       arvados.config (which gets these parameters from the environment by
263       default.)
264
265     Other keyword arguments such as `cache` will be passed along `api()`
266
267     """
268     # Load from user configuration or environment
269     if apiconfig is None:
270         apiconfig = config.settings()
271
272     errors = []
273     for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
274         if x not in apiconfig:
275             errors.append(x)
276     if errors:
277         raise ValueError(" and ".join(errors)+" not set.\nPlease set in %s or export environment variable." % config.default_config_file)
278     host = apiconfig.get('ARVADOS_API_HOST')
279     token = apiconfig.get('ARVADOS_API_TOKEN')
280     insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig)
281
282     return api(version=version, host=host, token=token, insecure=insecure, **kwargs)