Merge branch '17989-pysdk-timeout' into main. Refs #17989
[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 sys
18 import time
19 import types
20
21 import apiclient
22 from apiclient import discovery as apiclient_discovery
23 from apiclient import errors as apiclient_errors
24 from . import config
25 from . import errors
26 from . import util
27 from . import cache
28
29 _logger = logging.getLogger('arvados.api')
30
31 MAX_IDLE_CONNECTION_DURATION = 30
32 RETRY_DELAY_INITIAL = 2
33 RETRY_DELAY_BACKOFF = 2
34 RETRY_COUNT = 2
35
36 if sys.version_info >= (3,):
37     httplib2.SSLHandshakeError = None
38
39 class OrderedJsonModel(apiclient.model.JsonModel):
40     """Model class for JSON that preserves the contents' order.
41
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::
44
45         from arvados.api import OrderedJsonModel
46         client = arvados.api('v1', ..., model=OrderedJsonModel())
47     """
48
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:
55             body = body['data']
56         return body
57
58
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))
64
65     if config.get("ARVADOS_EXTERNAL_CLIENT", "") == "true":
66         headers['X-External-Client'] = '1'
67
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()
71
72     retryable = method in [
73         'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
74     retry_count = self._retry_count if retryable else 0
75
76     if (not retryable and
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():
82             conn.close()
83         self.connections.clear()
84
85     delay = self._retry_delay_initial
86     for _ in range(retry_count):
87         self._last_request_time = time.time()
88         try:
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",
92                           delay, exc_info=True)
93         except socket.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():
102                 conn.close()
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))
106
107         time.sleep(delay)
108         delay = delay * self._retry_delay_backoff
109
110     self._last_request_time = time.time()
111     return self.orig_http_request(uri, method, headers=headers, **kwargs)
112
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
124     return http
125
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):
130     global _cast_orig
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)
135     else:
136         return _cast_orig(value, schema_type)
137 apiclient_discovery._cast = _cast_objects_too
138
139 # Convert apiclient's HttpErrors into our own API error subclass for better
140 # error reporting.
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)
147
148 def http_cache(data_type):
149     homedir = os.environ.get('HOME')
150     if not homedir or len(homedir) == 0:
151         return None
152     path = homedir + '/.cache/arvados/' + data_type
153     try:
154         util.mkdir_dash_p(path)
155     except OSError:
156         return None
157     return cache.SafeHTTPCache(path, max_age=60*60*24*2)
158
159 def api(version=None, cache=True, host=None, token=None, insecure=False,
160         request_id=None, timeout=10, **kwargs):
161     """Return an apiclient Resources object for an Arvados instance.
162
163     :version:
164       A string naming the version of the Arvados API to use (for
165       example, 'v1').
166
167     :cache:
168       Use a cache (~/.cache/arvados/discovery) for the discovery
169       document.
170
171     :host:
172       The Arvados API server host (and optional :port) to connect to.
173
174     :token:
175       The authentication token to send with each API call.
176
177     :insecure:
178       If True, ignore SSL certificate validation errors.
179
180     :timeout:
181       A timeout value for http requests.
182
183     :request_id:
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
187       attempts.
188
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.
194
195     """
196
197     if not version:
198         version = 'v1'
199         _logger.info("Using default API version. " +
200                      "Call arvados.api('%s') instead." %
201                      version)
202     if 'discoveryServiceUrl' in kwargs:
203         if host:
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.
208         if not token:
209             raise ValueError("discoveryServiceUrl provided, but token missing")
210     elif host and token:
211         pass
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)
216     else:
217         # Caller provided one but not the other
218         if not host:
219             raise ValueError("token argument provided, but host missing.")
220         else:
221             raise ValueError("host argument provided, but token missing.")
222
223     if host:
224         # Caller wants us to build the discoveryServiceUrl
225         kwargs['discoveryServiceUrl'] = (
226             'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
227
228     if 'http' not in kwargs:
229         http_kwargs = {'ca_certs': util.ca_certs_path()}
230         if cache:
231             http_kwargs['cache'] = http_cache('discovery')
232         if insecure:
233             http_kwargs['disable_ssl_certificate_validation'] = True
234         kwargs['http'] = httplib2.Http(**http_kwargs)
235
236     if kwargs['http'].timeout is None:
237         kwargs['http'].timeout = timeout
238
239     kwargs['http'] = _patch_http_request(kwargs['http'], token)
240
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()
249     return svc
250
251 def api_from_config(version=None, apiconfig=None, **kwargs):
252     """Return an apiclient Resources object enabling access to an Arvados server
253     instance.
254
255     :version:
256       A string naming the version of the Arvados REST API to use (for
257       example, 'v1').
258
259     :apiconfig:
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
264       default.)
265
266     Other keyword arguments such as `cache` will be passed along `api()`
267
268     """
269     # Load from user configuration or environment
270     if apiconfig is None:
271         apiconfig = config.settings()
272
273     errors = []
274     for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
275         if x not in apiconfig:
276             errors.append(x)
277     if errors:
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)
282
283     return api(version=version, host=host, token=token, insecure=insecure, **kwargs)