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
22 from apiclient import discovery as apiclient_discovery
23 from apiclient import errors as apiclient_errors
29 _logger = logging.getLogger('arvados.api')
31 MAX_IDLE_CONNECTION_DURATION = 30
32 RETRY_DELAY_INITIAL = 2
33 RETRY_DELAY_BACKOFF = 2
36 if sys.version_info >= (3,):
37 httplib2.SSLHandshakeError = None
39 class OrderedJsonModel(apiclient.model.JsonModel):
40 """Model class for JSON that preserves the contents' order.
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::
45 from arvados.api import OrderedJsonModel
46 client = arvados.api('v1', ..., model=OrderedJsonModel())
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:
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))
65 if config.get("ARVADOS_EXTERNAL_CLIENT", "") == "true":
66 headers['X-External-Client'] = '1'
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()
72 retryable = method in [
73 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
74 retry_count = self._retry_count if retryable else 0
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():
83 self.connections.clear()
85 delay = self._retry_delay_initial
86 for _ in range(retry_count):
87 self._last_request_time = time.time()
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",
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():
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))
108 delay = delay * self._retry_delay_backoff
110 self._last_request_time = time.time()
111 return self.orig_http_request(uri, method, headers=headers, **kwargs)
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
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):
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)
136 return _cast_orig(value, schema_type)
137 apiclient_discovery._cast = _cast_objects_too
139 # Convert apiclient's HttpErrors into our own API error subclass for better
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)
148 def http_cache(data_type):
149 homedir = os.environ.get('HOME')
150 if not homedir or len(homedir) == 0:
152 path = homedir + '/.cache/arvados/' + data_type
154 util.mkdir_dash_p(path)
157 return cache.SafeHTTPCache(path, max_age=60*60*24*2)
159 def api(version=None, cache=True, host=None, token=None, insecure=False,
160 request_id=None, timeout=5*60, **kwargs):
161 """Return an apiclient Resources object for an Arvados instance.
164 A string naming the version of the Arvados API to use (for
168 Use a cache (~/.cache/arvados/discovery) for the discovery
172 The Arvados API server host (and optional :port) to connect to.
175 The authentication token to send with each API call.
178 If True, ignore SSL certificate validation errors.
181 A timeout value for http requests.
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
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.
199 _logger.info("Using default API version. " +
200 "Call arvados.api('%s') instead." %
202 if 'discoveryServiceUrl' in kwargs:
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.
209 raise ValueError("discoveryServiceUrl provided, but token missing")
212 elif not host and not token:
213 return api_from_config(
214 version=version, cache=cache, timeout=timeout,
215 request_id=request_id, **kwargs)
217 # Caller provided one but not the other
219 raise ValueError("token argument provided, but host missing.")
221 raise ValueError("host argument provided, but token missing.")
224 # Caller wants us to build the discoveryServiceUrl
225 kwargs['discoveryServiceUrl'] = (
226 'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
228 if 'http' not in kwargs:
229 http_kwargs = {'ca_certs': util.ca_certs_path()}
231 http_kwargs['cache'] = http_cache('discovery')
233 http_kwargs['disable_ssl_certificate_validation'] = True
234 kwargs['http'] = httplib2.Http(**http_kwargs)
236 if kwargs['http'].timeout is None:
237 kwargs['http'].timeout = timeout
239 kwargs['http'] = _patch_http_request(kwargs['http'], token)
241 svc = apiclient_discovery.build('arvados', version, cache_discovery=False, **kwargs)
242 svc.api_token = token
243 svc.insecure = insecure
244 svc.request_id = request_id
245 svc.config = lambda: util.get_config_once(svc)
246 kwargs['http'].max_request_size = svc._rootDesc.get('maxRequestSize', 0)
247 kwargs['http'].cache = None
248 kwargs['http']._request_id = lambda: svc.request_id or util.new_request_id()
251 def api_from_config(version=None, apiconfig=None, **kwargs):
252 """Return an apiclient Resources object enabling access to an Arvados server
256 A string naming the version of the Arvados REST API to use (for
260 If provided, this should be a dict-like object (must support the get()
261 method) with entries for ARVADOS_API_HOST, ARVADOS_API_TOKEN, and
262 optionally ARVADOS_API_HOST_INSECURE. If not provided, use
263 arvados.config (which gets these parameters from the environment by
266 Other keyword arguments such as `cache` will be passed along `api()`
269 # Load from user configuration or environment
270 if apiconfig is None:
271 apiconfig = config.settings()
274 for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
275 if x not in apiconfig:
278 raise ValueError(" and ".join(errors)+" not set.\nPlease set in %s or export environment variable." % config.default_config_file)
279 host = apiconfig.get('ARVADOS_API_HOST')
280 token = apiconfig.get('ARVADOS_API_TOKEN')
281 insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig)
283 return api(version=version, host=host, token=token, insecure=insecure, **kwargs)