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 # Monkey patch discovery._cast() so objects and arrays get serialized
137 # with json.dumps() instead of str().
138 _cast_orig = apiclient_discovery._cast
139 def _cast_objects_too(value, schema_type):
141 if (type(value) != type('') and
142 type(value) != type(b'') and
143 (schema_type == 'object' or schema_type == 'array')):
144 return json.dumps(value)
146 return _cast_orig(value, schema_type)
147 apiclient_discovery._cast = _cast_objects_too
149 # Convert apiclient's HttpErrors into our own API error subclass for better
151 # Reassigning apiclient_errors.HttpError is not sufficient because most of the
152 # apiclient submodules import the class into their own namespace.
153 def _new_http_error(cls, *args, **kwargs):
154 return super(apiclient_errors.HttpError, cls).__new__(
155 errors.ApiError, *args, **kwargs)
156 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
158 def http_cache(data_type):
159 homedir = os.environ.get('HOME')
160 if not homedir or len(homedir) == 0:
162 path = homedir + '/.cache/arvados/' + data_type
164 util.mkdir_dash_p(path)
167 return cache.SafeHTTPCache(path, max_age=60*60*24*2)
169 def api(version=None, cache=True, host=None, token=None, insecure=False,
170 request_id=None, timeout=5*60, **kwargs):
171 """Return an apiclient Resources object for an Arvados instance.
174 A string naming the version of the Arvados API to use (for
178 Use a cache (~/.cache/arvados/discovery) for the discovery
182 The Arvados API server host (and optional :port) to connect to.
185 The authentication token to send with each API call.
188 If True, ignore SSL certificate validation errors.
191 A timeout value for http requests.
194 Default X-Request-Id header value for outgoing requests that
195 don't already provide one. If None or omitted, generate a random
196 ID. When retrying failed requests, the same ID is used on all
199 Additional keyword arguments will be passed directly to
200 `apiclient_discovery.build` if a new Resource object is created.
201 If the `discoveryServiceUrl` or `http` keyword arguments are
202 missing, this function will set default values for them, based on
203 the current Arvados configuration settings.
209 _logger.info("Using default API version. " +
210 "Call arvados.api('%s') instead." %
212 if 'discoveryServiceUrl' in kwargs:
214 raise ValueError("both discoveryServiceUrl and host provided")
215 # Here we can't use a token from environment, config file,
216 # etc. Those probably have nothing to do with the host
217 # provided by the caller.
219 raise ValueError("discoveryServiceUrl provided, but token missing")
222 elif not host and not token:
223 return api_from_config(
224 version=version, cache=cache, timeout=timeout,
225 request_id=request_id, **kwargs)
227 # Caller provided one but not the other
229 raise ValueError("token argument provided, but host missing.")
231 raise ValueError("host argument provided, but token missing.")
234 # Caller wants us to build the discoveryServiceUrl
235 kwargs['discoveryServiceUrl'] = (
236 'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
238 if 'http' not in kwargs:
239 http_kwargs = {'ca_certs': util.ca_certs_path()}
241 http_kwargs['cache'] = http_cache('discovery')
243 http_kwargs['disable_ssl_certificate_validation'] = True
244 kwargs['http'] = httplib2.Http(**http_kwargs)
246 if kwargs['http'].timeout is None:
247 kwargs['http'].timeout = timeout
249 kwargs['http'] = _patch_http_request(kwargs['http'], token)
251 svc = apiclient_discovery.build('arvados', version, cache_discovery=False, **kwargs)
252 svc.api_token = token
253 svc.insecure = insecure
254 svc.request_id = request_id
255 svc.config = lambda: util.get_config_once(svc)
256 kwargs['http'].max_request_size = svc._rootDesc.get('maxRequestSize', 0)
257 kwargs['http'].cache = None
258 kwargs['http']._request_id = lambda: svc.request_id or util.new_request_id()
261 def api_from_config(version=None, apiconfig=None, **kwargs):
262 """Return an apiclient Resources object enabling access to an Arvados server
266 A string naming the version of the Arvados REST API to use (for
270 If provided, this should be a dict-like object (must support the get()
271 method) with entries for ARVADOS_API_HOST, ARVADOS_API_TOKEN, and
272 optionally ARVADOS_API_HOST_INSECURE. If not provided, use
273 arvados.config (which gets these parameters from the environment by
276 Other keyword arguments such as `cache` will be passed along `api()`
279 # Load from user configuration or environment
280 if apiconfig is None:
281 apiconfig = config.settings()
284 for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
285 if x not in apiconfig:
288 raise ValueError(" and ".join(errors)+" not set.\nPlease set in %s or export environment variable." % config.default_config_file)
289 host = apiconfig.get('ARVADOS_API_HOST')
290 token = apiconfig.get('ARVADOS_API_TOKEN')
291 insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig)
293 return api(version=version, host=host, token=token, insecure=insecure, **kwargs)