1 # Copyright (C) The Arvados Authors. All rights reserved.
3 # SPDX-License-Identifier: Apache-2.0
5 from __future__ import absolute_import
6 from builtins import str
7 from builtins import range
18 import urllib.parse as urlparse
21 from . import run_test_server
23 from apiclient import errors as apiclient_errors
24 from apiclient import http as apiclient_http
25 from arvados.api import (
28 api_kwargs_from_config,
34 from .arvados_testutil import fake_httplib2_response, queue_with
36 if not mimetypes.inited:
39 class ArvadosApiTest(run_test_server.TestCaseWithServers):
41 ERROR_HEADERS = {'Content-Type': mimetypes.types_map['.json']}
43 def api_error_response(self, code, *errors):
44 return (fake_httplib2_response(code, **self.ERROR_HEADERS),
45 json.dumps({'errors': errors,
46 'error_token': '1234567890+12345678'}).encode())
48 def _config_from_environ(self):
51 for key, value in os.environ.items()
52 if key.startswith('ARVADOS_API_')
55 def _discoveryServiceUrl(
58 path='/discovery/v1/apis/{api}/{apiVersion}/rest',
62 host = os.environ['ARVADOS_API_HOST']
63 return urlparse.urlunsplit((scheme, host, path, None, None))
65 def test_new_api_objects_with_cache(self):
66 clients = [arvados.api('v1', cache=True) for index in [0, 1]]
67 self.assertIsNot(*clients)
69 def test_empty_list(self):
70 answer = arvados.api('v1').humans().list(
71 filters=[['uuid', '=', None]]).execute()
72 self.assertEqual(answer['items_available'], len(answer['items']))
74 def test_nonempty_list(self):
75 answer = arvados.api('v1').collections().list().execute()
76 self.assertNotEqual(0, answer['items_available'])
77 self.assertNotEqual(0, len(answer['items']))
79 def test_timestamp_inequality_filter(self):
80 api = arvados.api('v1')
81 new_item = api.specimens().create(body={}).execute()
82 for operator, should_include in [
83 ['<', False], ['>', False],
84 ['<=', True], ['>=', True], ['=', True]]:
85 response = api.specimens().list(filters=[
86 ['created_at', operator, new_item['created_at']],
87 # Also filter by uuid to ensure (if it matches) it's on page 0
88 ['uuid', '=', new_item['uuid']]]).execute()
89 uuids = [item['uuid'] for item in response['items']]
90 did_include = new_item['uuid'] in uuids
92 did_include, should_include,
93 "'%s %s' filter should%s have matched '%s'" % (
94 operator, new_item['created_at'],
95 ('' if should_include else ' not'),
96 new_item['created_at']))
98 def test_exceptions_include_errors(self):
100 'arvados.humans.get': self.api_error_response(
101 422, "Bad UUID format", "Bad output format"),
103 req_builder = apiclient_http.RequestMockBuilder(mock_responses)
104 api = arvados.api('v1', requestBuilder=req_builder)
105 with self.assertRaises(apiclient_errors.HttpError) as err_ctx:
106 api.humans().get(uuid='xyz-xyz-abcdef').execute()
107 err_s = str(err_ctx.exception)
108 for msg in ["Bad UUID format", "Bad output format"]:
109 self.assertIn(msg, err_s)
111 @mock.patch('time.sleep')
112 def test_exceptions_include_request_id(self, sleep):
113 api = arvados.api('v1')
114 api.request_id='fake-request-id'
115 api._http.orig_http_request = mock.MagicMock()
116 api._http.orig_http_request.side_effect = socket.error('mock error')
119 api.users().current().execute()
120 except Exception as e:
122 self.assertRegex(str(caught), r'fake-request-id')
124 def test_exceptions_without_errors_have_basic_info(self):
126 'arvados.humans.delete': (
127 fake_httplib2_response(500, **self.ERROR_HEADERS),
130 req_builder = apiclient_http.RequestMockBuilder(mock_responses)
131 api = arvados.api('v1', requestBuilder=req_builder)
132 with self.assertRaises(apiclient_errors.HttpError) as err_ctx:
133 api.humans().delete(uuid='xyz-xyz-abcdef').execute()
134 self.assertIn("500", str(err_ctx.exception))
136 def test_request_too_large(self):
137 api = arvados.api('v1')
138 maxsize = api._rootDesc.get('maxRequestSize', 0)
139 with self.assertRaises(apiclient_errors.MediaUploadSizeError):
141 arvados.api('v1').collections().create(body={"manifest_text": text}).execute()
143 def test_default_request_timeout(self):
144 api = arvados.api('v1')
145 self.assertEqual(api._http.timeout, 300,
146 "Default timeout value should be 300")
148 def test_custom_request_timeout(self):
149 api = arvados.api('v1', timeout=1234)
150 self.assertEqual(api._http.timeout, 1234,
151 "Requested timeout value was 1234")
153 def test_ordered_json_model(self):
155 'arvados.humans.get': (
157 json.dumps(collections.OrderedDict(
158 (c, int(c, 16)) for c in string.hexdigits
162 req_builder = apiclient_http.RequestMockBuilder(mock_responses)
163 api = arvados.api('v1',
164 requestBuilder=req_builder, model=OrderedJsonModel())
165 result = api.humans().get(uuid='test').execute()
166 self.assertEqual(string.hexdigits, ''.join(list(result.keys())))
168 def test_api_is_threadsafe(self):
170 'host': os.environ['ARVADOS_API_HOST'],
171 'token': os.environ['ARVADOS_API_TOKEN'],
174 config_kwargs = {'apiconfig': os.environ}
175 for api_constructor, kwargs in [
177 (arvados.api, api_kwargs),
178 (arvados.api_from_config, {}),
179 (arvados.api_from_config, config_kwargs),
181 sub_kwargs = "kwargs" if kwargs else "no kwargs"
182 with self.subTest(f"{api_constructor.__name__} with {sub_kwargs}"):
183 api_client = api_constructor('v1', **kwargs)
184 self.assertTrue(hasattr(api_client, 'localapi'),
185 f"client missing localapi method")
186 self.assertTrue(hasattr(api_client, 'keep'),
187 f"client missing keep attribute")
189 def test_api_host_constructor(self):
192 client = arvados.api(
195 os.environ['ARVADOS_API_HOST'],
196 os.environ['ARVADOS_API_TOKEN'],
199 self.assertEqual(client.api_token, os.environ['ARVADOS_API_TOKEN'],
200 "client constructed with incorrect token")
202 def test_api_url_constructor(self):
203 client = arvados.api(
205 discoveryServiceUrl=self._discoveryServiceUrl(),
206 token=os.environ['ARVADOS_API_TOKEN'],
209 self.assertEqual(client.api_token, os.environ['ARVADOS_API_TOKEN'],
210 "client constructed with incorrect token")
212 def test_api_bad_args(self):
214 'host': os.environ['ARVADOS_API_HOST'],
215 'token': os.environ['ARVADOS_API_TOKEN'],
216 'discoveryServiceUrl': self._discoveryServiceUrl(),
219 # Passing only a single key is missing required info
220 *([key] for key in all_kwargs.keys()),
221 # Passing all keys is a conflict
222 list(all_kwargs.keys()),
224 kwargs = {key: all_kwargs[key] for key in use_keys}
225 kwargs_list = ', '.join(use_keys)
226 with self.subTest(f"calling arvados.api with {kwargs_list} fails"), \
227 self.assertRaises(ValueError):
228 arvados.api('v1', insecure=True, **kwargs)
230 def test_api_bad_url(self):
232 {'discoveryServiceUrl': self._discoveryServiceUrl() + '/BadTestURL'},
233 {'version': 'BadTestVersion', 'host': os.environ['ARVADOS_API_HOST']},
235 bad_key = next(iter(bad_kwargs))
236 with self.subTest(f"api fails with bad {bad_key}"), \
237 self.assertRaises(apiclient_errors.UnknownApiNameOrVersion):
238 arvados.api(**bad_kwargs, token='test_api_bad_url', insecure=True)
240 def test_normalize_api_good_args(self):
241 for version, discoveryServiceUrl, host in [
242 ('Test1', None, os.environ['ARVADOS_API_HOST']),
243 (None, self._discoveryServiceUrl(), None)
245 argname = 'discoveryServiceUrl' if host is None else 'host'
246 with self.subTest(f"normalize_api_kwargs with {argname}"):
247 actual = normalize_api_kwargs(
251 os.environ['ARVADOS_API_TOKEN'],
254 self.assertEqual(actual['discoveryServiceUrl'], self._discoveryServiceUrl())
255 self.assertEqual(actual['token'], os.environ['ARVADOS_API_TOKEN'])
256 self.assertEqual(actual['version'], version or 'v1')
257 self.assertTrue(actual['insecure'])
258 self.assertNotIn('host', actual)
260 def test_normalize_api_bad_args(self):
262 self._discoveryServiceUrl(),
263 os.environ['ARVADOS_API_HOST'],
264 os.environ['ARVADOS_API_TOKEN'],
266 for arg_index, arg_value in enumerate(all_args):
267 args = [None] * len(all_args)
268 args[arg_index] = arg_value
269 with self.subTest(f"normalize_api_kwargs with only arg #{arg_index + 1}"), \
270 self.assertRaises(ValueError):
271 normalize_api_kwargs('v1', *args)
272 with self.subTest("normalize_api_kwargs with discoveryServiceUrl and host"), \
273 self.assertRaises(ValueError):
274 normalize_api_kwargs('v1', *all_args)
276 def test_api_from_config_default(self):
277 client = arvados.api_from_config('v1')
278 self.assertEqual(client.api_token, os.environ['ARVADOS_API_TOKEN'],
279 "client constructed with incorrect token")
281 def test_api_from_config_explicit(self):
282 config = self._config_from_environ()
283 client = arvados.api_from_config('v1', config)
284 self.assertEqual(client.api_token, os.environ['ARVADOS_API_TOKEN'],
285 "client constructed with incorrect token")
287 def test_api_from_bad_config(self):
288 base_config = self._config_from_environ()
289 for del_key in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
290 with self.subTest(f"api_from_config without {del_key} fails"), \
291 self.assertRaises(ValueError):
292 config = dict(base_config)
294 arvados.api_from_config('v1', config)
296 def test_api_kwargs_from_good_config(self):
297 for config in [None, self._config_from_environ()]:
298 conf_type = 'default' if config is None else 'passed'
299 with self.subTest(f"api_kwargs_from_config with {conf_type} config"):
300 version = 'Test1' if config else None
301 actual = api_kwargs_from_config(version, config)
302 self.assertEqual(actual['discoveryServiceUrl'], self._discoveryServiceUrl())
303 self.assertEqual(actual['token'], os.environ['ARVADOS_API_TOKEN'])
304 self.assertEqual(actual['version'], version or 'v1')
305 self.assertTrue(actual['insecure'])
306 self.assertNotIn('host', actual)
308 def test_api_kwargs_from_bad_config(self):
309 base_config = self._config_from_environ()
310 for del_key in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
311 with self.subTest(f"api_kwargs_from_config without {del_key} fails"), \
312 self.assertRaises(ValueError):
313 config = dict(base_config)
315 api_kwargs_from_config('v1', config)
317 def test_api_client_constructor(self):
320 self._discoveryServiceUrl(),
321 os.environ['ARVADOS_API_TOKEN'],
324 self.assertEqual(client.api_token, os.environ['ARVADOS_API_TOKEN'],
325 "client constructed with incorrect token")
327 hasattr(client, 'localapi'),
328 "client has localapi method when it should not be thread-safe",
331 def test_api_client_bad_url(self):
332 all_args = ('v1', self._discoveryServiceUrl(), 'test_api_client_bad_url')
333 for arg_index, arg_value in [
334 (0, 'BadTestVersion'),
335 (1, all_args[1] + '/BadTestURL'),
337 with self.subTest(f"api_client fails with {arg_index}={arg_value!r}"), \
338 self.assertRaises(apiclient_errors.UnknownApiNameOrVersion):
339 args = list(all_args)
340 args[arg_index] = arg_value
341 api_client(*args, insecure=True)
344 class RetryREST(unittest.TestCase):
346 self.api = arvados.api('v1')
347 self.assertTrue(hasattr(self.api._http, 'orig_http_request'),
348 "test doesn't know how to intercept HTTP requests")
349 self.mock_response = {'user': 'person'}
350 self.request_success = (fake_httplib2_response(200),
351 json.dumps(self.mock_response))
352 self.api._http.orig_http_request = mock.MagicMock()
353 # All requests succeed by default. Tests override as needed.
354 self.api._http.orig_http_request.return_value = self.request_success
356 @mock.patch('time.sleep')
357 def test_socket_error_retry_get(self, sleep):
358 self.api._http.orig_http_request.side_effect = (
359 socket.error('mock error'),
360 self.request_success,
362 self.assertEqual(self.api.users().current().execute(),
364 self.assertGreater(self.api._http.orig_http_request.call_count, 1,
365 "client got the right response without retrying")
366 self.assertEqual(sleep.call_args_list,
367 [mock.call(RETRY_DELAY_INITIAL)])
369 @mock.patch('time.sleep')
370 def test_same_automatic_request_id_on_retry(self, sleep):
371 self.api._http.orig_http_request.side_effect = (
372 socket.error('mock error'),
373 self.request_success,
375 self.api.users().current().execute()
376 calls = self.api._http.orig_http_request.call_args_list
377 self.assertEqual(len(calls), 2)
379 calls[0][1]['headers']['X-Request-Id'],
380 calls[1][1]['headers']['X-Request-Id'])
381 self.assertRegex(calls[0][1]['headers']['X-Request-Id'], r'^req-[a-z0-9]{20}$')
383 @mock.patch('time.sleep')
384 def test_provided_request_id_on_retry(self, sleep):
385 self.api.request_id='fake-request-id'
386 self.api._http.orig_http_request.side_effect = (
387 socket.error('mock error'),
388 self.request_success,
390 self.api.users().current().execute()
391 calls = self.api._http.orig_http_request.call_args_list
392 self.assertEqual(len(calls), 2)
394 self.assertEqual(call[1]['headers']['X-Request-Id'], 'fake-request-id')
396 @mock.patch('time.sleep')
397 def test_socket_error_retry_delay(self, sleep):
398 self.api._http.orig_http_request.side_effect = socket.error('mock')
399 self.api._http._retry_count = 3
400 with self.assertRaises(socket.error):
401 self.api.users().current().execute()
402 self.assertEqual(self.api._http.orig_http_request.call_count, 4)
403 self.assertEqual(sleep.call_args_list, [
404 mock.call(RETRY_DELAY_INITIAL),
405 mock.call(RETRY_DELAY_INITIAL * RETRY_DELAY_BACKOFF),
406 mock.call(RETRY_DELAY_INITIAL * RETRY_DELAY_BACKOFF**2),
409 @mock.patch('time.time', side_effect=[i*2**20 for i in range(99)])
410 def test_close_old_connections_non_retryable(self, sleep):
411 self._test_connection_close(expect=1)
413 @mock.patch('time.time', side_effect=itertools.count())
414 def test_no_close_fresh_connections_non_retryable(self, sleep):
415 self._test_connection_close(expect=0)
417 @mock.patch('time.time', side_effect=itertools.count())
418 def test_override_max_idle_time(self, sleep):
419 self.api._http._max_keepalive_idle = 0
420 self._test_connection_close(expect=1)
422 def _test_connection_close(self, expect=0):
423 # Do two POST requests. The second one must close all
424 # connections +expect+ times.
425 self.api.users().create(body={}).execute()
426 mock_conns = {str(i): mock.MagicMock() for i in range(2)}
427 self.api._http.connections = mock_conns.copy()
428 self.api.users().create(body={}).execute()
429 for c in mock_conns.values():
430 self.assertEqual(c.close.call_count, expect)
432 @mock.patch('time.sleep')
433 def test_socket_error_no_retry_post(self, sleep):
434 self.api._http.orig_http_request.side_effect = (
435 socket.error('mock error'),
436 self.request_success,
438 with self.assertRaises(socket.error):
439 self.api.users().create(body={}).execute()
440 self.assertEqual(self.api._http.orig_http_request.call_count, 1,
441 "client should try non-retryable method exactly once")
442 self.assertEqual(sleep.call_args_list, [])
445 if __name__ == '__main__':