17004: Fix lingering resource error
[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 def _close_connections(self):
137     for conn in self._http.connections.values():
138         conn.close()
139
140 # Monkey patch discovery._cast() so objects and arrays get serialized
141 # with json.dumps() instead of str().
142 _cast_orig = apiclient_discovery._cast
143 def _cast_objects_too(value, schema_type):
144     global _cast_orig
145     if (type(value) != type('') and
146         type(value) != type(b'') and
147         (schema_type == 'object' or schema_type == 'array')):
148         return json.dumps(value)
149     else:
150         return _cast_orig(value, schema_type)
151 apiclient_discovery._cast = _cast_objects_too
152
153 # Convert apiclient's HttpErrors into our own API error subclass for better
154 # error reporting.
155 # Reassigning apiclient_errors.HttpError is not sufficient because most of the
156 # apiclient submodules import the class into their own namespace.
157 def _new_http_error(cls, *args, **kwargs):
158     return super(apiclient_errors.HttpError, cls).__new__(
159         errors.ApiError, *args, **kwargs)
160 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
161
162 def http_cache(data_type):
163     homedir = os.environ.get('HOME')
164     if not homedir or len(homedir) == 0:
165         return None
166     path = homedir + '/.cache/arvados/' + data_type
167     try:
168         util.mkdir_dash_p(path)
169     except OSError:
170         return None
171     return cache.SafeHTTPCache(path, max_age=60*60*24*2)
172
173 def api(version=None, cache=True, host=None, token=None, insecure=False,
174         request_id=None, timeout=5*60, **kwargs):
175     """Return an apiclient Resources object for an Arvados instance.
176
177     :version:
178       A string naming the version of the Arvados API to use (for
179       example, 'v1').
180
181     :cache:
182       Use a cache (~/.cache/arvados/discovery) for the discovery
183       document.
184
185     :host:
186       The Arvados API server host (and optional :port) to connect to.
187
188     :token:
189       The authentication token to send with each API call.
190
191     :insecure:
192       If True, ignore SSL certificate validation errors.
193
194     :timeout:
195       A timeout value for http requests.
196
197     :request_id:
198       Default X-Request-Id header value for outgoing requests that
199       don't already provide one. If None or omitted, generate a random
200       ID. When retrying failed requests, the same ID is used on all
201       attempts.
202
203     Additional keyword arguments will be passed directly to
204     `apiclient_discovery.build` if a new Resource object is created.
205     If the `discoveryServiceUrl` or `http` keyword arguments are
206     missing, this function will set default values for them, based on
207     the current Arvados configuration settings.
208
209     """
210
211     if not version:
212         version = 'v1'
213         _logger.info("Using default API version. " +
214                      "Call arvados.api('%s') instead." %
215                      version)
216     if 'discoveryServiceUrl' in kwargs:
217         if host:
218             raise ValueError("both discoveryServiceUrl and host provided")
219         # Here we can't use a token from environment, config file,
220         # etc. Those probably have nothing to do with the host
221         # provided by the caller.
222         if not token:
223             raise ValueError("discoveryServiceUrl provided, but token missing")
224     elif host and token:
225         pass
226     elif not host and not token:
227         return api_from_config(
228             version=version, cache=cache, timeout=timeout,
229             request_id=request_id, **kwargs)
230     else:
231         # Caller provided one but not the other
232         if not host:
233             raise ValueError("token argument provided, but host missing.")
234         else:
235             raise ValueError("host argument provided, but token missing.")
236
237     if host:
238         # Caller wants us to build the discoveryServiceUrl
239         kwargs['discoveryServiceUrl'] = (
240             'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
241
242     if 'http' not in kwargs:
243         http_kwargs = {'ca_certs': util.ca_certs_path()}
244         if cache:
245             http_kwargs['cache'] = http_cache('discovery')
246         if insecure:
247             http_kwargs['disable_ssl_certificate_validation'] = True
248         kwargs['http'] = httplib2.Http(**http_kwargs)
249
250     if kwargs['http'].timeout is None:
251         kwargs['http'].timeout = timeout
252
253     kwargs['http'] = _patch_http_request(kwargs['http'], token)
254
255     svc = apiclient_discovery.build('arvados', version, cache_discovery=False, **kwargs)
256     svc.api_token = token
257     svc.insecure = insecure
258     svc.request_id = request_id
259     svc.config = lambda: util.get_config_once(svc)
260     svc.vocabulary = lambda: util.get_vocabulary_once(svc)
261     svc.close_connections = types.MethodType(_close_connections, svc)
262     kwargs['http'].max_request_size = svc._rootDesc.get('maxRequestSize', 0)
263     kwargs['http'].cache = None
264     kwargs['http']._request_id = lambda: svc.request_id or util.new_request_id()
265     return svc
266
267 def api_from_config(version=None, apiconfig=None, **kwargs):
268     """Return an apiclient Resources object enabling access to an Arvados server
269     instance.
270
271     :version:
272       A string naming the version of the Arvados REST API to use (for
273       example, 'v1').
274
275     :apiconfig:
276       If provided, this should be a dict-like object (must support the get()
277       method) with entries for ARVADOS_API_HOST, ARVADOS_API_TOKEN, and
278       optionally ARVADOS_API_HOST_INSECURE.  If not provided, use
279       arvados.config (which gets these parameters from the environment by
280       default.)
281
282     Other keyword arguments such as `cache` will be passed along `api()`
283
284     """
285     # Load from user configuration or environment
286     if apiconfig is None:
287         apiconfig = config.settings()
288
289     errors = []
290     for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
291         if x not in apiconfig:
292             errors.append(x)
293     if errors:
294         raise ValueError(" and ".join(errors)+" not set.\nPlease set in %s or export environment variable." % config.default_config_file)
295     host = apiconfig.get('ARVADOS_API_HOST')
296     token = apiconfig.get('ARVADOS_API_TOKEN')
297     insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig)
298
299     return api(version=version, host=host, token=token, insecure=insecure, **kwargs)