From 88c9948fbfd6fbfab5d9e769dbb90ce14c66e94a Mon Sep 17 00:00:00 2001 From: Daniel K Lyons <dlyons@nrao.edu> Date: Wed, 14 Sep 2022 10:46:50 -0600 Subject: [PATCH] Fixing retry bug revealed by unit test --- .../productfetcher/exceptions.py | 4 +- .../productfetcher/productfetcher/fetchers.py | 2 +- .../productfetcher/productfetcher/retry.py | 6 +- .../productfetcher/tests/test_fetchers.py | 4 +- .../productfetcher/tests/test_retry.py | 61 +++++++++++++++++++ 5 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 apps/cli/executables/pexable/productfetcher/tests/test_retry.py diff --git a/apps/cli/executables/pexable/productfetcher/productfetcher/exceptions.py b/apps/cli/executables/pexable/productfetcher/productfetcher/exceptions.py index c3117a5dc..23f22f68e 100644 --- a/apps/cli/executables/pexable/productfetcher/productfetcher/exceptions.py +++ b/apps/cli/executables/pexable/productfetcher/productfetcher/exceptions.py @@ -27,7 +27,7 @@ import traceback from pathlib import Path -class FetchError(Exception): +class FetchError(BaseException): """ Some sort of problem has occurred, and the program must end. """ @@ -54,7 +54,7 @@ class RetryableFetchError(FetchError): super().terminate_fetch() -class FileValidationFault(FetchError): +class FileValidationFault(RetryableFetchError): """ A file has failed to validate for some reason. This is not a retryable error. """ diff --git a/apps/cli/executables/pexable/productfetcher/productfetcher/fetchers.py b/apps/cli/executables/pexable/productfetcher/productfetcher/fetchers.py index 4f6d7af51..7389a1c9d 100644 --- a/apps/cli/executables/pexable/productfetcher/productfetcher/fetchers.py +++ b/apps/cli/executables/pexable/productfetcher/productfetcher/fetchers.py @@ -247,7 +247,7 @@ class RetryableFileFetcher(FileFetcherDecorator): self.retries = retries def do_fetch(self) -> Path: - return retry(self.underlying.do_fetch, [2**attempt for attempt in range(1, 4)]) + return retry(self.underlying.do_fetch, [2**attempt for attempt in range(1, self.retries)]) def __str__(self): return f"{self.underlying} (with up to {self.retries} retries)" diff --git a/apps/cli/executables/pexable/productfetcher/productfetcher/retry.py b/apps/cli/executables/pexable/productfetcher/productfetcher/retry.py index 97fc0c2ce..23b4113ea 100644 --- a/apps/cli/executables/pexable/productfetcher/productfetcher/retry.py +++ b/apps/cli/executables/pexable/productfetcher/productfetcher/retry.py @@ -18,6 +18,8 @@ import time from typing import Callable, List, TypeVar +from .exceptions import RetryableFetchError + T = TypeVar("T") @@ -38,7 +40,7 @@ def retry(f: Callable[[], T], backoff_times: List[int] = None, retry_number=1) - exception = None try: return f() - except Exception as ex: + except RetryableFetchError as ex: if backoff_times: # if we have, say, [1, 4, 9] in our list of backoff times, # we will delay for 1 second and then have [4, 9] remaining backoff times @@ -54,4 +56,4 @@ def retry(f: Callable[[], T], backoff_times: List[int] = None, retry_number=1) - ex.retries = retry_number # now raise the exception - raise exception + raise ex diff --git a/apps/cli/executables/pexable/productfetcher/tests/test_fetchers.py b/apps/cli/executables/pexable/productfetcher/tests/test_fetchers.py index 9d38ae00a..bcbb6ebe9 100644 --- a/apps/cli/executables/pexable/productfetcher/tests/test_fetchers.py +++ b/apps/cli/executables/pexable/productfetcher/tests/test_fetchers.py @@ -187,8 +187,8 @@ def test_retrying_succeeds(capsys): def test_retrying_fails(capsys): # three failures is too many - fail_twice = MagicMock(wraps=FailNTimesFileFetcher(3)) + fail_thrice = MagicMock(wraps=FailNTimesFileFetcher(3)) with patch("time.sleep"): with pytest.raises(RetryableFetchError): - RetryableFileFetcher(fail_twice).do_fetch() + RetryableFileFetcher(fail_thrice).do_fetch() capsys.readouterr() diff --git a/apps/cli/executables/pexable/productfetcher/tests/test_retry.py b/apps/cli/executables/pexable/productfetcher/tests/test_retry.py new file mode 100644 index 000000000..86a7b82c3 --- /dev/null +++ b/apps/cli/executables/pexable/productfetcher/tests/test_retry.py @@ -0,0 +1,61 @@ +# +# Copyright (C) 2021 Associated Universities, Inc. Washington DC, USA. +# +# This file is part of NRAO Workspaces. +# +# Workspaces is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Workspaces is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Workspaces. If not, see <https://www.gnu.org/licenses/>. +from unittest.mock import MagicMock, call, patch + +import pytest +from productfetcher.exceptions import RetryableFetchError +from productfetcher.retry import retry + + +class FailNTimes: + def __init__(self, n): + self.total = n + self.current = 1 + + def __call__(self, *args, **kwargs): + if self.current <= self.total: + self.current += 1 + raise RetryableFetchError(f"Failure #{self.current-1}") + + +def test_fail_n_times(): + x = FailNTimes(2) + with pytest.raises(RetryableFetchError): + x() + with pytest.raises(RetryableFetchError): + x() + x() + + +def test_retrying_succeeds(capsys): + # two failures is OK + with patch("time.sleep") as sleep: + fail_twice = MagicMock(wraps=FailNTimes(2)) + retry(fail_twice, [3, 7]) + assert sleep.call_count == 2 + assert sleep.call_args_list == [call(3), call(7)] + capsys.readouterr() + + +def test_retrying_fails(capsys): + # three failures is too many + with patch("time.sleep"): + fail_thrice = MagicMock(wraps=FailNTimes(3)) + with pytest.raises(RetryableFetchError): + retry(fail_thrice, [3, 7]) + capsys.readouterr() -- GitLab