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 if config.get("ARVADOS_EXTERNAL_CLIENT", "") == "true":
70 headers['X-External-Client'] = '1'
72 headers['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
74 retryable = method in [
75 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
76 retry_count = self._retry_count if retryable else 0
79 time.time() - self._last_request_time > self._max_keepalive_idle):
80 # High probability of failure due to connection atrophy. Make
81 # sure this request [re]opens a new connection by closing and
82 # forgetting all cached connections first.
83 for conn in self.connections.values():
85 self.connections.clear()
87 delay = self._retry_delay_initial
88 for _ in range(retry_count):
89 self._last_request_time = time.time()
91 return self.orig_http_request(uri, method, headers=headers, **kwargs)
92 except http.client.HTTPException:
93 _logger.debug("[%s] Retrying API request in %d s after HTTP error",
94 headers['X-Request-Id'], delay, exc_info=True)
95 except ssl.SSLCertVerificationError as e:
96 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
98 # This is the one case where httplib2 doesn't close the
99 # underlying connection first. Close all open
100 # connections, expecting this object only has the one
101 # connection to the API server. This is safe because
102 # httplib2 reopens connections when needed.
103 _logger.debug("[%s] Retrying API request in %d s after socket error",
104 headers['X-Request-Id'], delay, exc_info=True)
105 for conn in self.connections.values():
109 delay = delay * self._retry_delay_backoff
111 self._last_request_time = time.time()
112 return self.orig_http_request(uri, method, headers=headers, **kwargs)
113 except Exception as e:
114 # Prepend "[request_id] " to the error message, which we
115 # assume is the first string argument passed to the exception
117 for i in range(len(e.args or ())):
118 if type(e.args[i]) == type(""):
119 e.args = e.args[:i] + ("[{}] {}".format(headers['X-Request-Id'], e.args[i]),) + e.args[i+1:]
120 raise type(e)(*e.args)
123 def _patch_http_request(http, api_token):
124 http.arvados_api_token = api_token
125 http.max_request_size = 0
126 http.orig_http_request = http.request
127 http.request = types.MethodType(_intercept_http_request, http)
128 http._last_request_time = 0
129 http._max_keepalive_idle = MAX_IDLE_CONNECTION_DURATION
130 http._retry_delay_initial = RETRY_DELAY_INITIAL
131 http._retry_delay_backoff = RETRY_DELAY_BACKOFF
132 http._retry_count = RETRY_COUNT
133 http._request_id = util.new_request_id
136 def _close_connections(self):
137 for conn in self._http.connections.values():
140 # Monkey patch discovery._cast() so objects and arrays get serialized
141 # with json.dumps() instead of str().
142 _cast_orig = apiclient_discovery._cast
143 def _cast_objects_too(value, schema_type):
145 if (type(value) != type('') and
146 type(value) != type(b'') and
147 (schema_type == 'object' or schema_type == 'array')):
148 return json.dumps(value)
150 return _cast_orig(value, schema_type)
151 apiclient_discovery._cast = _cast_objects_too
153 # Convert apiclient's HttpErrors into our own API error subclass for better
155 # Reassigning apiclient_errors.HttpError is not sufficient because most of the
156 # apiclient submodules import the class into their own namespace.
157 def _new_http_error(cls, *args, **kwargs):
158 return super(apiclient_errors.HttpError, cls).__new__(
159 errors.ApiError, *args, **kwargs)
160 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
162 def http_cache(data_type):
163 homedir = os.environ.get('HOME')
164 if not homedir or len(homedir) == 0:
166 path = homedir + '/.cache/arvados/' + data_type
168 util.mkdir_dash_p(path)
171 return cache.SafeHTTPCache(path, max_age=60*60*24*2)
173 def api(version=None, cache=True, host=None, token=None, insecure=False,
174 request_id=None, timeout=5*60, **kwargs):
175 """Return an apiclient Resources object for an Arvados instance.
178 A string naming the version of the Arvados API to use (for
182 Use a cache (~/.cache/arvados/discovery) for the discovery
186 The Arvados API server host (and optional :port) to connect to.
189 The authentication token to send with each API call.
192 If True, ignore SSL certificate validation errors.
195 A timeout value for http requests.
198 Default X-Request-Id header value for outgoing requests that
199 don't already provide one. If None or omitted, generate a random
200 ID. When retrying failed requests, the same ID is used on all
203 Additional keyword arguments will be passed directly to
204 `apiclient_discovery.build` if a new Resource object is created.
205 If the `discoveryServiceUrl` or `http` keyword arguments are
206 missing, this function will set default values for them, based on
207 the current Arvados configuration settings.
213 _logger.info("Using default API version. " +
214 "Call arvados.api('%s') instead." %
216 if 'discoveryServiceUrl' in kwargs:
218 raise ValueError("both discoveryServiceUrl and host provided")
219 # Here we can't use a token from environment, config file,
220 # etc. Those probably have nothing to do with the host
221 # provided by the caller.
223 raise ValueError("discoveryServiceUrl provided, but token missing")
226 elif not host and not token:
227 return api_from_config(
228 version=version, cache=cache, timeout=timeout,
229 request_id=request_id, **kwargs)
231 # Caller provided one but not the other
233 raise ValueError("token argument provided, but host missing.")
235 raise ValueError("host argument provided, but token missing.")
238 # Caller wants us to build the discoveryServiceUrl
239 kwargs['discoveryServiceUrl'] = (
240 'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
242 if 'http' not in kwargs:
243 http_kwargs = {'ca_certs': util.ca_certs_path()}
245 http_kwargs['cache'] = http_cache('discovery')
247 http_kwargs['disable_ssl_certificate_validation'] = True
248 kwargs['http'] = httplib2.Http(**http_kwargs)
250 if kwargs['http'].timeout is None:
251 kwargs['http'].timeout = timeout
253 kwargs['http'] = _patch_http_request(kwargs['http'], token)
255 svc = apiclient_discovery.build('arvados', version, cache_discovery=False, **kwargs)
256 svc.api_token = token
257 svc.insecure = insecure
258 svc.request_id = request_id
259 svc.config = lambda: util.get_config_once(svc)
260 svc.vocabulary = lambda: util.get_vocabulary_once(svc)
261 svc.close_connections = types.MethodType(_close_connections, svc)
262 kwargs['http'].max_request_size = svc._rootDesc.get('maxRequestSize', 0)
263 kwargs['http'].cache = None
264 kwargs['http']._request_id = lambda: svc.request_id or util.new_request_id()
267 def api_from_config(version=None, apiconfig=None, **kwargs):
268 """Return an apiclient Resources object enabling access to an Arvados server
272 A string naming the version of the Arvados REST API to use (for
276 If provided, this should be a dict-like object (must support the get()
277 method) with entries for ARVADOS_API_HOST, ARVADOS_API_TOKEN, and
278 optionally ARVADOS_API_HOST_INSECURE. If not provided, use
279 arvados.config (which gets these parameters from the environment by
282 Other keyword arguments such as `cache` will be passed along `api()`
285 # Load from user configuration or environment
286 if apiconfig is None:
287 apiconfig = config.settings()
290 for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
291 if x not in apiconfig:
294 raise ValueError(" and ".join(errors)+" not set.\nPlease set in %s or export environment variable." % config.default_config_file)
295 host = apiconfig.get('ARVADOS_API_HOST')
296 token = apiconfig.get('ARVADOS_API_TOKEN')
297 insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig)
299 return api(version=version, host=host, token=token, insecure=insecure, **kwargs)