1 # Copyright (C) The Arvados Authors. All rights reserved.
3 # SPDX-License-Identifier: Apache-2.0
5 from __future__ import absolute_import
6 from future import standard_library
7 standard_library.install_aliases()
8 from builtins import range
23 from apiclient import discovery as apiclient_discovery
24 from apiclient import errors as apiclient_errors
30 _logger = logging.getLogger('arvados.api')
32 MAX_IDLE_CONNECTION_DURATION = 30
33 RETRY_DELAY_INITIAL = 2
34 RETRY_DELAY_BACKOFF = 2
37 if sys.version_info >= (3,):
38 httplib2.SSLHandshakeError = None
40 class OrderedJsonModel(apiclient.model.JsonModel):
41 """Model class for JSON that preserves the contents' order.
43 API clients that care about preserving the order of fields in API
44 server responses can use this model to do so, like this::
46 from arvados.api import OrderedJsonModel
47 client = arvados.api('v1', ..., model=OrderedJsonModel())
50 def deserialize(self, content):
51 # This is a very slightly modified version of the parent class'
52 # implementation. Copyright (c) 2010 Google.
53 content = content.decode('utf-8')
54 body = json.loads(content, object_pairs_hook=collections.OrderedDict)
55 if self._data_wrapper and isinstance(body, dict) and 'data' in body:
60 def _intercept_http_request(self, uri, method="GET", headers={}, **kwargs):
61 if not headers.get('X-Request-Id'):
62 headers['X-Request-Id'] = self._request_id()
64 if (self.max_request_size and
65 kwargs.get('body') and
66 self.max_request_size < len(kwargs['body'])):
67 raise apiclient_errors.MediaUploadSizeError("Request size %i bytes exceeds published limit of %i bytes" % (len(kwargs['body']), self.max_request_size))
69 headers['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
71 retryable = method in [
72 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
73 retry_count = self._retry_count if retryable else 0
76 time.time() - self._last_request_time > self._max_keepalive_idle):
77 # High probability of failure due to connection atrophy. Make
78 # sure this request [re]opens a new connection by closing and
79 # forgetting all cached connections first.
80 for conn in self.connections.values():
82 self.connections.clear()
84 delay = self._retry_delay_initial
85 for _ in range(retry_count):
86 self._last_request_time = time.time()
88 return self.orig_http_request(uri, method, headers=headers, **kwargs)
89 except http.client.HTTPException:
90 _logger.debug("[%s] Retrying API request in %d s after HTTP error",
91 headers['X-Request-Id'], delay, exc_info=True)
92 except ssl.SSLCertVerificationError as e:
93 raise ssl.SSLCertVerificationError(e.args[0], "Could not connect to %s\n%s\nPossible causes: remote SSL/TLS certificate expired, or was issued by an untrusted certificate authority." % (uri, e)) from None
95 # This is the one case where httplib2 doesn't close the
96 # underlying connection first. Close all open
97 # connections, expecting this object only has the one
98 # connection to the API server. This is safe because
99 # httplib2 reopens connections when needed.
100 _logger.debug("[%s] Retrying API request in %d s after socket error",
101 headers['X-Request-Id'], delay, exc_info=True)
102 for conn in self.connections.values():
106 delay = delay * self._retry_delay_backoff
108 self._last_request_time = time.time()
109 return self.orig_http_request(uri, method, headers=headers, **kwargs)
110 except Exception as e:
111 # Prepend "[request_id] " to the error message, which we
112 # assume is the first string argument passed to the exception
114 for i in range(len(e.args or ())):
115 if type(e.args[i]) == type(""):
116 e.args = e.args[:i] + ("[{}] {}".format(headers['X-Request-Id'], e.args[i]),) + e.args[i+1:]
117 raise type(e)(*e.args)
120 def _patch_http_request(http, api_token):
121 http.arvados_api_token = api_token
122 http.max_request_size = 0
123 http.orig_http_request = http.request
124 http.request = types.MethodType(_intercept_http_request, http)
125 http._last_request_time = 0
126 http._max_keepalive_idle = MAX_IDLE_CONNECTION_DURATION
127 http._retry_delay_initial = RETRY_DELAY_INITIAL
128 http._retry_delay_backoff = RETRY_DELAY_BACKOFF
129 http._retry_count = RETRY_COUNT
130 http._request_id = util.new_request_id
133 def _close_connections(self):
134 for conn in self._http.connections.values():
137 # Monkey patch discovery._cast() so objects and arrays get serialized
138 # with json.dumps() instead of str().
139 _cast_orig = apiclient_discovery._cast
140 def _cast_objects_too(value, schema_type):
142 if (type(value) != type('') and
143 type(value) != type(b'') and
144 (schema_type == 'object' or schema_type == 'array')):
145 return json.dumps(value)
147 return _cast_orig(value, schema_type)
148 apiclient_discovery._cast = _cast_objects_too
150 # Convert apiclient's HttpErrors into our own API error subclass for better
152 # Reassigning apiclient_errors.HttpError is not sufficient because most of the
153 # apiclient submodules import the class into their own namespace.
154 def _new_http_error(cls, *args, **kwargs):
155 return super(apiclient_errors.HttpError, cls).__new__(
156 errors.ApiError, *args, **kwargs)
157 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
159 def http_cache(data_type):
160 homedir = os.environ.get('HOME')
161 if not homedir or len(homedir) == 0:
163 path = homedir + '/.cache/arvados/' + data_type
165 util.mkdir_dash_p(path)
168 return cache.SafeHTTPCache(path, max_age=60*60*24*2)
170 def api(version=None, cache=True, host=None, token=None, insecure=False,
171 request_id=None, timeout=5*60, **kwargs):
172 """Return an apiclient Resources object for an Arvados instance.
175 A string naming the version of the Arvados API to use (for
179 Use a cache (~/.cache/arvados/discovery) for the discovery
183 The Arvados API server host (and optional :port) to connect to.
186 The authentication token to send with each API call.
189 If True, ignore SSL certificate validation errors.
192 A timeout value for http requests.
195 Default X-Request-Id header value for outgoing requests that
196 don't already provide one. If None or omitted, generate a random
197 ID. When retrying failed requests, the same ID is used on all
200 Additional keyword arguments will be passed directly to
201 `apiclient_discovery.build` if a new Resource object is created.
202 If the `discoveryServiceUrl` or `http` keyword arguments are
203 missing, this function will set default values for them, based on
204 the current Arvados configuration settings.
210 _logger.info("Using default API version. " +
211 "Call arvados.api('%s') instead." %
213 if 'discoveryServiceUrl' in kwargs:
215 raise ValueError("both discoveryServiceUrl and host provided")
216 # Here we can't use a token from environment, config file,
217 # etc. Those probably have nothing to do with the host
218 # provided by the caller.
220 raise ValueError("discoveryServiceUrl provided, but token missing")
223 elif not host and not token:
224 return api_from_config(
225 version=version, cache=cache, timeout=timeout,
226 request_id=request_id, **kwargs)
228 # Caller provided one but not the other
230 raise ValueError("token argument provided, but host missing.")
232 raise ValueError("host argument provided, but token missing.")
235 # Caller wants us to build the discoveryServiceUrl
236 kwargs['discoveryServiceUrl'] = (
237 'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
239 if 'http' not in kwargs:
240 http_kwargs = {'ca_certs': util.ca_certs_path()}
242 http_kwargs['cache'] = http_cache('discovery')
244 http_kwargs['disable_ssl_certificate_validation'] = True
245 kwargs['http'] = httplib2.Http(**http_kwargs)
247 if kwargs['http'].timeout is None:
248 kwargs['http'].timeout = timeout
250 kwargs['http'] = _patch_http_request(kwargs['http'], token)
252 svc = apiclient_discovery.build('arvados', version, cache_discovery=False, **kwargs)
253 svc.api_token = token
254 svc.insecure = insecure
255 svc.request_id = request_id
256 svc.config = lambda: util.get_config_once(svc)
257 svc.vocabulary = lambda: util.get_vocabulary_once(svc)
258 svc.close_connections = types.MethodType(_close_connections, svc)
259 kwargs['http'].max_request_size = svc._rootDesc.get('maxRequestSize', 0)
260 kwargs['http'].cache = None
261 kwargs['http']._request_id = lambda: svc.request_id or util.new_request_id()
264 def api_from_config(version=None, apiconfig=None, **kwargs):
265 """Return an apiclient Resources object enabling access to an Arvados server
269 A string naming the version of the Arvados REST API to use (for
273 If provided, this should be a dict-like object (must support the get()
274 method) with entries for ARVADOS_API_HOST, ARVADOS_API_TOKEN, and
275 optionally ARVADOS_API_HOST_INSECURE. If not provided, use
276 arvados.config (which gets these parameters from the environment by
279 Other keyword arguments such as `cache` will be passed along `api()`
282 # Load from user configuration or environment
283 if apiconfig is None:
284 apiconfig = config.settings()
287 for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
288 if x not in apiconfig:
291 raise ValueError(" and ".join(errors)+" not set.\nPlease set in %s or export environment variable." % config.default_config_file)
292 host = apiconfig.get('ARVADOS_API_HOST')
293 token = apiconfig.get('ARVADOS_API_TOKEN')
294 insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig)
296 return api(version=version, host=host, token=token, insecure=insecure, **kwargs)