17344: Remove client code setting X-External-Client header.
[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         headers['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
70
71         retryable = method in [
72             'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
73         retry_count = self._retry_count if retryable else 0
74
75         if (not retryable and
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():
81                 conn.close()
82             self.connections.clear()
83
84         delay = self._retry_delay_initial
85         for _ in range(retry_count):
86             self._last_request_time = time.time()
87             try:
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
94             except socket.error:
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():
103                     conn.close()
104
105             time.sleep(delay)
106             delay = delay * self._retry_delay_backoff
107
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
113         # constructor.
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)
118         raise
119
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
131     return http
132
133 def _close_connections(self):
134     for conn in self._http.connections.values():
135         conn.close()
136
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):
141     global _cast_orig
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)
146     else:
147         return _cast_orig(value, schema_type)
148 apiclient_discovery._cast = _cast_objects_too
149
150 # Convert apiclient's HttpErrors into our own API error subclass for better
151 # error reporting.
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)
158
159 def http_cache(data_type):
160     homedir = os.environ.get('HOME')
161     if not homedir or len(homedir) == 0:
162         return None
163     path = homedir + '/.cache/arvados/' + data_type
164     try:
165         util.mkdir_dash_p(path)
166     except OSError:
167         return None
168     return cache.SafeHTTPCache(path, max_age=60*60*24*2)
169
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.
173
174     :version:
175       A string naming the version of the Arvados API to use (for
176       example, 'v1').
177
178     :cache:
179       Use a cache (~/.cache/arvados/discovery) for the discovery
180       document.
181
182     :host:
183       The Arvados API server host (and optional :port) to connect to.
184
185     :token:
186       The authentication token to send with each API call.
187
188     :insecure:
189       If True, ignore SSL certificate validation errors.
190
191     :timeout:
192       A timeout value for http requests.
193
194     :request_id:
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
198       attempts.
199
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.
205
206     """
207
208     if not version:
209         version = 'v1'
210         _logger.info("Using default API version. " +
211                      "Call arvados.api('%s') instead." %
212                      version)
213     if 'discoveryServiceUrl' in kwargs:
214         if host:
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.
219         if not token:
220             raise ValueError("discoveryServiceUrl provided, but token missing")
221     elif host and token:
222         pass
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)
227     else:
228         # Caller provided one but not the other
229         if not host:
230             raise ValueError("token argument provided, but host missing.")
231         else:
232             raise ValueError("host argument provided, but token missing.")
233
234     if host:
235         # Caller wants us to build the discoveryServiceUrl
236         kwargs['discoveryServiceUrl'] = (
237             'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
238
239     if 'http' not in kwargs:
240         http_kwargs = {'ca_certs': util.ca_certs_path()}
241         if cache:
242             http_kwargs['cache'] = http_cache('discovery')
243         if insecure:
244             http_kwargs['disable_ssl_certificate_validation'] = True
245         kwargs['http'] = httplib2.Http(**http_kwargs)
246
247     if kwargs['http'].timeout is None:
248         kwargs['http'].timeout = timeout
249
250     kwargs['http'] = _patch_http_request(kwargs['http'], token)
251
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()
262     return svc
263
264 def api_from_config(version=None, apiconfig=None, **kwargs):
265     """Return an apiclient Resources object enabling access to an Arvados server
266     instance.
267
268     :version:
269       A string naming the version of the Arvados REST API to use (for
270       example, 'v1').
271
272     :apiconfig:
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
277       default.)
278
279     Other keyword arguments such as `cache` will be passed along `api()`
280
281     """
282     # Load from user configuration or environment
283     if apiconfig is None:
284         apiconfig = config.settings()
285
286     errors = []
287     for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
288         if x not in apiconfig:
289             errors.append(x)
290     if errors:
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)
295
296     return api(version=version, host=host, token=token, insecure=insecure, **kwargs)