diff --git a/exoscale/api/v2.py b/exoscale/api/v2.py index 4d9eb2b..1b933e1 100644 --- a/exoscale/api/v2.py +++ b/exoscale/api/v2.py @@ -31,7 +31,12 @@ ... version=version, ... ) >>> c.wait(operation["id"]) - {'id': 'e2047130-b86e-11ef-83b3-0d8312b2c2d7', 'state': 'success', 'reference': {'id': '8561ee34-09f0-42da-a765-abde807f944b', 'link': '/v2/sks-cluster/8561ee34-09f0-42da-a765-abde807f944b', 'command': 'get-sks-cluster'}} + {'id': 'e2047130-b86e-11ef-83b3-0d8312b2c2d7', + 'state': 'success', + 'reference': { + 'id': '8561ee34-09f0-42da-a765-abde807f944b', + 'link': '/v2/sks-cluster/8561ee34-09f0-42da-a765-abde807f944b', + 'command': 'get-sks-cluster'}} """ import copy @@ -41,16 +46,16 @@ from pathlib import Path from exoscale_auth import ExoscaleV2Auth + +import requests + from .exceptions import ( + ExoscaleAPIAuthException, ExoscaleAPIClientException, ExoscaleAPIServerException, - ExoscaleAPIAuthException, ) -import requests - - with open(Path(__file__).parent.parent / "openapi.json", "r") as f: API_SPEC = json.load(f) @@ -185,6 +190,16 @@ def _poll_interval(run_time): return interval +def _time(): + return time.time() + + +def _sleep(start_time): + run_time = _time() - start_time + interval = _poll_interval(run_time) + return time.sleep(interval) + + class BaseClient: def __init__(self, key, secret, url=None, **kwargs): if url is None: @@ -274,12 +289,14 @@ def wait(self, operation_id: str, max_wait_time: int = None): Args: operation_id (str) - max_wait_time (int): When set, stop waiting after this time in seconds. Defaults to ``None``, which waits until operation completion. + max_wait_time (int): When set, stop waiting after this time in + seconds. Defaults to ``None``, which waits until operation + completion. Returns: {ret} """ - start_time = time.time() + start_time = _time() subsequent_errors = 0 while True: try: @@ -291,6 +308,7 @@ def wait(self, operation_id: str, max_wait_time: int = None): raise ExoscaleAPIServerException( "Server error while polling operation" ) from e + _sleep(start_time) continue state = result["state"] if state == "success": @@ -300,13 +318,12 @@ def wait(self, operation_id: str, max_wait_time: int = None): f"Operation error: {state}, {result['reason']}" ) elif state == "pending": - run_time = time.time() - start_time + run_time = _time() - start_time if max_wait_time is not None and run_time > max_wait_time: raise ExoscaleAPIClientException( "Operation max wait time reached" ) - interval = _poll_interval(run_time) - time.sleep(interval) + _sleep(start_time) else: raise ExoscaleAPIServerException( f"Invalid operation state: {state}" diff --git a/tests/test_client.py b/tests/test_client.py index b1434c9..04a8517 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,10 +1,15 @@ -import pytest -from exoscale.api.v2 import Client, _poll_interval +import json + +from unittest.mock import patch + from exoscale.api.exceptions import ( + ExoscaleAPIAuthException, ExoscaleAPIClientException, ExoscaleAPIServerException, - ExoscaleAPIAuthException, ) +from exoscale.api.v2 import Client, _poll_interval + +import pytest def test_client_creation(): @@ -96,36 +101,110 @@ def test_operation_poll_failure(requests_mock): raise AssertionError("exception not raised") -def test_operation_abort_on_500(requests_mock): +def test_operation_invalid_state(requests_mock): requests_mock.get( "https://api-ch-gva-2.exoscale.com/v2/operation/e2047130-b86e-11ef-83b3-0d8312b2c2d7", # noqa - status_code=500, - text='{"message": "server error"}', + status_code=200, + text='{"id": "4c5547c0-b870-11ef-83b3-0d8312b2c2d7", "state": "weird", "reference": {"id": "97d7426f-8b25-4591-91d5-4a19e9a1d61a", "link": "/v2/sks-cluster/97d7426f-8b25-4591-91d5-4a19e9a1d61a", "command": "get-sks-cluster"}}', # noqa ) client = Client(key="EXOtest", secret="sdsd") try: client.wait(operation_id="e2047130-b86e-11ef-83b3-0d8312b2c2d7") except ExoscaleAPIServerException as e: - assert "Server error while polling operation" in str(e) + assert "Invalid operation state: weird" in str(e) else: raise AssertionError("exception not raised") -def test_operation_invalid_state(requests_mock): +def _mock_poll_response(poll_counts, status_code=200, result="success"): + return [ + { + "status_code": status_code, + "text": '{"id": "4c5547c0-b870-11ef-83b3-0d8312b2c2d7", "state": "pending", "reference": {"id": "97d7426f-8b25-4591-91d5-4a19e9a1d61a", "link": "/v2/sks-cluster/97d7426f-8b25-4591-91d5-4a19e9a1d61a", "command": "get-sks-cluster"}}', # noqa + } + ] * (poll_counts - 1) + [ + { + "status_code": status_code, + "text": json.dumps( + { + "id": "4c5547c0-b870-11ef-83b3-0d8312b2c2d7", + "state": result, + "reason": "some reason", + "reference": { + "id": "97d7426f-8b25-4591-91d5-4a19e9a1d61a", + "link": "/v2/sks-cluster/97d7426f-8b25-4591-91d5-4a19e9a1d61a", # noqa + "command": "get-sks-cluster", + }, + } + ), + } + ] + + +def test_wait_time_success(requests_mock): requests_mock.get( "https://api-ch-gva-2.exoscale.com/v2/operation/e2047130-b86e-11ef-83b3-0d8312b2c2d7", # noqa - status_code=200, - text='{"id": "4c5547c0-b870-11ef-83b3-0d8312b2c2d7", "state": "weird", "reference": {"id": "97d7426f-8b25-4591-91d5-4a19e9a1d61a", "link": "/v2/sks-cluster/97d7426f-8b25-4591-91d5-4a19e9a1d61a", "command": "get-sks-cluster"}}', # noqa + _mock_poll_response(3), ) - - client = Client(key="EXOtest", secret="sdsd") - try: + with patch( + "exoscale.api.v2._time", + side_effect=[ + 0, # start of poll + 1, # duration of first loop: 1s + 5, # duration of second loop: 4s + ], + ) as time, patch("exoscale.api.v2._sleep") as sleep: + client = Client(key="EXOtest", secret="sdsd") client.wait(operation_id="e2047130-b86e-11ef-83b3-0d8312b2c2d7") - except ExoscaleAPIServerException as e: - assert "Invalid operation state: weird" in str(e) - else: - raise AssertionError("exception not raised") + assert len(time.call_args_list) == 3 + assert len(sleep.call_args_list) == 2 + + +def test_wait_time_poll_errors(requests_mock): + requests_mock.get( + "https://api-ch-gva-2.exoscale.com/v2/operation/e2047130-b86e-11ef-83b3-0d8312b2c2d7", # noqa + _mock_poll_response(6, status_code=500), + ) + with patch( + "exoscale.api.v2._time", + side_effect=[ + 0, # start of poll + ], + ) as time, patch("exoscale.api.v2._sleep") as sleep: + client = Client(key="EXOtest", secret="sdsd") + try: + client.wait(operation_id="e2047130-b86e-11ef-83b3-0d8312b2c2d7") + except ExoscaleAPIServerException: + pass + else: + raise AssertionError("Exception not raised") + assert len(time.call_args_list) == 1 + assert len(sleep.call_args_list) == 4 + + +def test_wait_time_failure(requests_mock): + requests_mock.get( + "https://api-ch-gva-2.exoscale.com/v2/operation/e2047130-b86e-11ef-83b3-0d8312b2c2d7", # noqa + _mock_poll_response(3, result="failure"), + ) + with patch( + "exoscale.api.v2._time", + side_effect=[ + 0, # start of poll + 1, # duration of first loop: 1s + 5, # duration of second loop: 4s + ], + ) as time, patch("exoscale.api.v2._sleep") as sleep: + client = Client(key="EXOtest", secret="sdsd") + try: + client.wait(operation_id="e2047130-b86e-11ef-83b3-0d8312b2c2d7") + except ExoscaleAPIServerException as e: + assert "Operation error" in str(e) + else: + raise AssertionError("Exception not raised") + assert len(time.call_args_list) == 3 + assert len(sleep.call_args_list) == 2 if __name__ == "__main__":