e0d1c50f03a10fe6f3c0e0e5f45df4cb0f57aec9
[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 ssl
18 import sys
19 import time
20 import types
21
22 import apiclient
23 from apiclient import discovery as apiclient_discovery
24 from apiclient import errors as apiclient_errors
25 from . import config
26 from . import errors
27 from . import util
28 from . import cache
29
30 _logger = logging.getLogger('arvados.api')
31
32 MAX_IDLE_CONNECTION_DURATION = 30
33 RETRY_DELAY_INITIAL = 2
34 RETRY_DELAY_BACKOFF = 2
35 RETRY_COUNT = 2
36
37 if sys.version_info >= (3,):
38     httplib2.SSLHandshakeError = None
39
40 class OrderedJsonModel(apiclient.model.JsonModel):
41     """Model class for JSON that preserves the contents' order.
42
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::
45
46         from arvados.api import OrderedJsonModel
47         client = arvados.api('v1', ..., model=OrderedJsonModel())
48     """
49
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:
56             body = body['data']
57         return body
58
59
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()
63     try:
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))
68
69         if config.get("ARVADOS_EXTERNAL_CLIENT", "") == "true":
70             headers['X-External-Client'] = '1'
71
72         headers['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
73
74         retryable = method in [
75             'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
76         retry_count = self._retry_count if retryable else 0
77
78         if (not retryable and
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():
84                 conn.close()
85             self.connections.clear()
86
87         delay = self._retry_delay_initial
88         for _ in range(retry_count):
89             self._last_request_time = time.time()
90             try:
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
97             except socket.error:
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():
106                     conn.close()
107
108             time.sleep(delay)
109             delay = delay * self._retry_delay_backoff
110
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
116         # constructor.
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)
121         raise
122
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
134     return http
135
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):
140     global _cast_orig
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)
145     else:
146         return _cast_orig(value, schema_type)
147 apiclient_discovery._cast = _cast_objects_too
148
149 # Convert apiclient's HttpErrors into our own API error subclass for better
150 # error reporting.
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)
157
158 def http_cache(data_type):
159     homedir = os.environ.get('HOME')
160     if not homedir or len(homedir) == 0:
161         return None
162     path = homedir + '/.cache/arvados/' + data_type
163     try:
164         util.mkdir_dash_p(path)
165     except OSError:
166         return None
167     return cache.SafeHTTPCache(path, max_age=60*60*24*2)
168
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.
172
173     :version:
174       A string naming the version of the Arvados API to use (for
175       example, 'v1').
176
177     :cache:
178       Use a cache (~/.cache/arvados/discovery) for the discovery
179       document.
180
181     :host:
182       The Arvados API server host (and optional :port) to connect to.
183
184     :token:
185       The authentication token to send with each API call.
186
187     :insecure:
188       If True, ignore SSL certificate validation errors.
189
190     :timeout:
191       A timeout value for http requests.
192
193     :request_id:
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
197       attempts.
198
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.
204
205     """
206
207     if not version:
208         version = 'v1'
209         _logger.info("Using default API version. " +
210                      "Call arvados.api('%s') instead." %
211                      version)
212     if 'discoveryServiceUrl' in kwargs:
213         if host:
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.
218         if not token:
219             raise ValueError("discoveryServiceUrl provided, but token missing")
220     elif host and token:
221         pass
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)
226     else:
227         # Caller provided one but not the other
228         if not host:
229             raise ValueError("token argument provided, but host missing.")
230         else:
231             raise ValueError("host argument provided, but token missing.")
232
233     if host:
234         # Caller wants us to build the discoveryServiceUrl
235         kwargs['discoveryServiceUrl'] = (
236             'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
237
238     if 'http' not in kwargs:
239         http_kwargs = {'ca_certs': util.ca_certs_path()}
240         if cache:
241             http_kwargs['cache'] = http_cache('discovery')
242         if insecure:
243             http_kwargs['disable_ssl_certificate_validation'] = True
244         kwargs['http'] = httplib2.Http(**http_kwargs)
245
246     if kwargs['http'].timeout is None:
247         kwargs['http'].timeout = timeout
248
249     kwargs['http'] = _patch_http_request(kwargs['http'], token)
250
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     svc.vocabulary = lambda: util.get_vocabulary_once(svc)
257     kwargs['http'].max_request_size = svc._rootDesc.get('maxRequestSize', 0)
258     kwargs['http'].cache = None
259     kwargs['http']._request_id = lambda: svc.request_id or util.new_request_id()
260     return svc
261
262 def api_from_config(version=None, apiconfig=None, **kwargs):
263     """Return an apiclient Resources object enabling access to an Arvados server
264     instance.
265
266     :version:
267       A string naming the version of the Arvados REST API to use (for
268       example, 'v1').
269
270     :apiconfig:
271       If provided, this should be a dict-like object (must support the get()
272       method) with entries for ARVADOS_API_HOST, ARVADOS_API_TOKEN, and
273       optionally ARVADOS_API_HOST_INSECURE.  If not provided, use
274       arvados.config (which gets these parameters from the environment by
275       default.)
276
277     Other keyword arguments such as `cache` will be passed along `api()`
278
279     """
280     # Load from user configuration or environment
281     if apiconfig is None:
282         apiconfig = config.settings()
283
284     errors = []
285     for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
286         if x not in apiconfig:
287             errors.append(x)
288     if errors:
289         raise ValueError(" and ".join(errors)+" not set.\nPlease set in %s or export environment variable." % config.default_config_file)
290     host = apiconfig.get('ARVADOS_API_HOST')
291     token = apiconfig.get('ARVADOS_API_TOKEN')
292     insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig)
293
294     return api(version=version, host=host, token=token, insecure=insecure, **kwargs)