Add timeout for the python api + A.C.R.
[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 time
18 import types
19
20 import apiclient
21 from apiclient import discovery as apiclient_discovery
22 from apiclient import errors as apiclient_errors
23 from . import config
24 from . import errors
25 from . import util
26 from . import cache
27
28 _logger = logging.getLogger('arvados.api')
29
30 MAX_IDLE_CONNECTION_DURATION = 30
31 RETRY_DELAY_INITIAL = 2
32 RETRY_DELAY_BACKOFF = 2
33 RETRY_COUNT = 2
34
35 class OrderedJsonModel(apiclient.model.JsonModel):
36     """Model class for JSON that preserves the contents' order.
37
38     API clients that care about preserving the order of fields in API
39     server responses can use this model to do so, like this::
40
41         from arvados.api import OrderedJsonModel
42         client = arvados.api('v1', ..., model=OrderedJsonModel())
43     """
44
45     def deserialize(self, content):
46         # This is a very slightly modified version of the parent class'
47         # implementation.  Copyright (c) 2010 Google.
48         content = content.decode('utf-8')
49         body = json.loads(content, object_pairs_hook=collections.OrderedDict)
50         if self._data_wrapper and isinstance(body, dict) and 'data' in body:
51             body = body['data']
52         return body
53
54
55 def _intercept_http_request(self, uri, method="GET", headers={}, **kwargs):
56     if (self.max_request_size and
57         kwargs.get('body') and
58         self.max_request_size < len(kwargs['body'])):
59         raise apiclient_errors.MediaUploadSizeError("Request size %i bytes exceeds published limit of %i bytes" % (len(kwargs['body']), self.max_request_size))
60
61     if config.get("ARVADOS_EXTERNAL_CLIENT", "") == "true":
62         headers['X-External-Client'] = '1'
63
64     headers['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
65     if not headers.get('X-Request-Id'):
66         headers['X-Request-Id'] = self._request_id()
67
68     retryable = method in [
69         'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
70     retry_count = self._retry_count if retryable else 0
71
72     if (not retryable and
73         time.time() - self._last_request_time > self._max_keepalive_idle):
74         # High probability of failure due to connection atrophy. Make
75         # sure this request [re]opens a new connection by closing and
76         # forgetting all cached connections first.
77         for conn in self.connections.values():
78             conn.close()
79         self.connections.clear()
80
81     delay = self._retry_delay_initial
82     for _ in range(retry_count):
83         self._last_request_time = time.time()
84         try:
85             return self.orig_http_request(uri, method, headers=headers, **kwargs)
86         except http.client.HTTPException:
87             _logger.debug("Retrying API request in %d s after HTTP error",
88                           delay, exc_info=True)
89         except socket.error:
90             # This is the one case where httplib2 doesn't close the
91             # underlying connection first.  Close all open
92             # connections, expecting this object only has the one
93             # connection to the API server.  This is safe because
94             # httplib2 reopens connections when needed.
95             _logger.debug("Retrying API request in %d s after socket error",
96                           delay, exc_info=True)
97             for conn in self.connections.values():
98                 conn.close()
99         time.sleep(delay)
100         delay = delay * self._retry_delay_backoff
101
102     self._last_request_time = time.time()
103     return self.orig_http_request(uri, method, headers=headers, **kwargs)
104
105 def _patch_http_request(http, api_token):
106     http.arvados_api_token = api_token
107     http.max_request_size = 0
108     http.orig_http_request = http.request
109     http.request = types.MethodType(_intercept_http_request, http)
110     http._last_request_time = 0
111     http._max_keepalive_idle = MAX_IDLE_CONNECTION_DURATION
112     http._retry_delay_initial = RETRY_DELAY_INITIAL
113     http._retry_delay_backoff = RETRY_DELAY_BACKOFF
114     http._retry_count = RETRY_COUNT
115     http._request_id = util.new_request_id
116     return http
117
118 # Monkey patch discovery._cast() so objects and arrays get serialized
119 # with json.dumps() instead of str().
120 _cast_orig = apiclient_discovery._cast
121 def _cast_objects_too(value, schema_type):
122     global _cast_orig
123     if (type(value) != type('') and
124         type(value) != type(b'') and
125         (schema_type == 'object' or schema_type == 'array')):
126         return json.dumps(value)
127     else:
128         return _cast_orig(value, schema_type)
129 apiclient_discovery._cast = _cast_objects_too
130
131 # Convert apiclient's HttpErrors into our own API error subclass for better
132 # error reporting.
133 # Reassigning apiclient_errors.HttpError is not sufficient because most of the
134 # apiclient submodules import the class into their own namespace.
135 def _new_http_error(cls, *args, **kwargs):
136     return super(apiclient_errors.HttpError, cls).__new__(
137         errors.ApiError, *args, **kwargs)
138 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
139
140 def http_cache(data_type):
141     homedir = os.environ.get('HOME')
142     if not homedir or len(homedir) == 0:
143         return None
144     path = homedir + '/.cache/arvados/' + data_type
145     try:
146         util.mkdir_dash_p(path)
147     except OSError:
148         return None
149     return cache.SafeHTTPCache(path, max_age=60*60*24*2)
150
151 def api(version=None, cache=True, host=None, token=None, insecure=False,
152         request_id=None, timeout=5*60, **kwargs):
153     """Return an apiclient Resources object for an Arvados instance.
154
155     :version:
156       A string naming the version of the Arvados API to use (for
157       example, 'v1').
158
159     :cache:
160       Use a cache (~/.cache/arvados/discovery) for the discovery
161       document.
162
163     :host:
164       The Arvados API server host (and optional :port) to connect to.
165
166     :token:
167       The authentication token to send with each API call.
168
169     :insecure:
170       If True, ignore SSL certificate validation errors.
171
172     :timeout:
173       A timeout value for http requests.
174
175     :request_id:
176       Default X-Request-Id header value for outgoing requests that
177       don't already provide one. If None or omitted, generate a random
178       ID. When retrying failed requests, the same ID is used on all
179       attempts.
180
181     Additional keyword arguments will be passed directly to
182     `apiclient_discovery.build` if a new Resource object is created.
183     If the `discoveryServiceUrl` or `http` keyword arguments are
184     missing, this function will set default values for them, based on
185     the current Arvados configuration settings.
186
187     """
188
189     if not version:
190         version = 'v1'
191         _logger.info("Using default API version. " +
192                      "Call arvados.api('%s') instead." %
193                      version)
194     if 'discoveryServiceUrl' in kwargs:
195         if host:
196             raise ValueError("both discoveryServiceUrl and host provided")
197         # Here we can't use a token from environment, config file,
198         # etc. Those probably have nothing to do with the host
199         # provided by the caller.
200         if not token:
201             raise ValueError("discoveryServiceUrl provided, but token missing")
202     elif host and token:
203         pass
204     elif not host and not token:
205         return api_from_config(
206             version=version, cache=cache, request_id=request_id, **kwargs)
207     else:
208         # Caller provided one but not the other
209         if not host:
210             raise ValueError("token argument provided, but host missing.")
211         else:
212             raise ValueError("host argument provided, but token missing.")
213
214     if host:
215         # Caller wants us to build the discoveryServiceUrl
216         kwargs['discoveryServiceUrl'] = (
217             'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
218
219     if 'http' not in kwargs:
220         http_kwargs = {'ca_certs': util.ca_certs_path()}
221         if cache:
222             http_kwargs['cache'] = http_cache('discovery')
223         if insecure:
224             http_kwargs['disable_ssl_certificate_validation'] = True
225         kwargs['http'] = httplib2.Http(**http_kwargs)
226
227     if kwargs['http'].timeout is None:
228         kwargs['http'].timeout = timeout
229
230     kwargs['http'] = _patch_http_request(kwargs['http'], token)
231
232     svc = apiclient_discovery.build('arvados', version, cache_discovery=False, **kwargs)
233     svc.api_token = token
234     svc.insecure = insecure
235     svc.request_id = request_id
236     kwargs['http'].max_request_size = svc._rootDesc.get('maxRequestSize', 0)
237     kwargs['http'].cache = None
238     kwargs['http']._request_id = lambda: svc.request_id or util.new_request_id()
239     return svc
240
241 def api_from_config(version=None, apiconfig=None, **kwargs):
242     """Return an apiclient Resources object enabling access to an Arvados server
243     instance.
244
245     :version:
246       A string naming the version of the Arvados REST API to use (for
247       example, 'v1').
248
249     :apiconfig:
250       If provided, this should be a dict-like object (must support the get()
251       method) with entries for ARVADOS_API_HOST, ARVADOS_API_TOKEN, and
252       optionally ARVADOS_API_HOST_INSECURE.  If not provided, use
253       arvados.config (which gets these parameters from the environment by
254       default.)
255
256     Other keyword arguments such as `cache` will be passed along `api()`
257
258     """
259     # Load from user configuration or environment
260     if apiconfig is None:
261         apiconfig = config.settings()
262
263     for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
264         if x not in apiconfig:
265             raise ValueError("%s is not set. Aborting." % x)
266     host = apiconfig.get('ARVADOS_API_HOST')
267     token = apiconfig.get('ARVADOS_API_TOKEN')
268     insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig)
269
270     return api(version=version, host=host, token=token, insecure=insecure, **kwargs)