diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index a48c80ad..00000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,98 +0,0 @@
-version: 2
-
-jobs:
- pep8:
- docker:
- - image: circleci/python:3.8
- steps:
- - checkout
- - run:
- command: |
- sudo pip install tox
- tox -e pep8
- py27:
- docker:
- - image: circleci/python:2.7
- steps:
- - checkout
- - run:
- command: |
- sudo pip install tox
- tox -e py27
- py35:
- docker:
- - image: circleci/python:3.5
- steps:
- - checkout
- - run:
- command: |
- sudo pip install tox
- tox -e py35
- py36:
- docker:
- - image: circleci/python:3.6
- steps:
- - checkout
- - run:
- command: |
- sudo pip install tox
- tox -e py36
- py37:
- docker:
- - image: circleci/python:3.7
- steps:
- - checkout
- - run:
- command: |
- sudo pip install tox
- tox -e py37
- py38:
- docker:
- - image: circleci/python:3.8
- steps:
- - checkout
- - run:
- command: |
- sudo pip install tox
- tox -e py38
- deploy:
- docker:
- - image: circleci/python:3.8
- steps:
- - checkout
- - run: |
- python -m venv venv
- - run: |
- venv/bin/pip install twine wheel
- - run:
- name: init .pypirc
- command: |
- echo -e "[pypi]" >> ~/.pypirc
- echo -e "username = __token__" >> ~/.pypirc
- echo -e "password = $PYPI_TOKEN" >> ~/.pypirc
- - run:
- name: create packages
- command: |
- venv/bin/python setup.py sdist bdist_wheel
- - run:
- name: upload to PyPI
- command: |
- venv/bin/twine upload dist/*
-
-workflows:
- version: 2
-
- test:
- jobs:
- - pep8
- - py27
- - py35
- - py36
- - py37
- - py38
- - deploy:
- filters:
- tags:
- only: /[0-9]+(\.[0-9]+)*/
- branches:
- ignore: /.*/
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..ce78f355
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,22 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 2
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{py,pyx,pxd,pyi}]
+indent_size = 4
+max_line_length = 120
+
+[*.ini]
+indent_size = 4
+
+[*.rst]
+max_line_length = 150
+
+[Makefile]
+indent_style = tab
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..b22df070
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,10 @@
+version: 2
+updates:
+ - package-ecosystem: 'github-actions'
+ directory: '/'
+ schedule:
+ interval: 'monthly'
+ groups:
+ github-actions:
+ patterns:
+ - '*'
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 00000000..03d34198
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,45 @@
+name: Continuous Integration
+permissions: read-all
+
+on:
+ pull_request:
+ branches:
+ - main
+
+concurrency:
+ # yamllint disable-line rule:line-length
+ group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}"
+ cancel-in-progress: true
+
+jobs:
+ test:
+ timeout-minutes: 20
+ runs-on: ubuntu-24.04
+ strategy:
+ matrix:
+ include:
+ - python: "3.10"
+ task: check
+ - python: "3.11"
+ task: check
+ - python: "3.12"
+ task: check
+ - python: "3.13"
+ task: check
+ - python: "3.14"
+ task: check
+ - python: "3.14"
+ task: lint
+ - python: "3.14"
+ task: mypy
+ steps:
+ - name: Checkout 🛎️
+ uses: actions/checkout@v6.0.2
+ with:
+ fetch-depth: 0
+
+ - name: Setup uv đź”§
+ uses: astral-sh/setup-uv@v5
+
+ - name: Build 🔧 & Test 🔍
+ run: uv run --python ${{ matrix.python }} poe ${{ matrix.task }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..55a6903c
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,26 @@
+name: upload release to PyPI
+on:
+ release:
+ types:
+ - published
+
+jobs:
+ pypi-publish:
+ name: upload release to PyPI
+ runs-on: ubuntu-24.04
+ environment: release
+ permissions:
+ id-token: write
+ steps:
+ - uses: actions/checkout@v6.0.2
+ with:
+ fetch-depth: 0
+ fetch-tags: true
+
+ - uses: astral-sh/setup-uv@v5
+
+ - name: Build
+ run: uv build
+
+ - name: Publish package distributions to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.gitignore b/.gitignore
index 933f759e..3c966caa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,10 +3,11 @@ dist
*.pyc
*.egg-info
build
-.tox/
+.venv/
+uv.lock
AUTHORS
ChangeLog
-.eggs/
doc/_build
+tenacity/_version.py
/.pytest_cache
diff --git a/.mergify.yml b/.mergify.yml
index c3c6be04..0135b7c9 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -1,32 +1,21 @@
+queue_rules:
+ - name: default
+ merge_method: squash
+ autoqueue: true
+ queue_conditions:
+ - or:
+ - author = jd
+ - "#approved-reviews-by >= 1"
+ - author = dependabot[bot]
+ - "check-success=test (3.10, check)"
+ - "check-success=test (3.11, check)"
+ - "check-success=test (3.12, check)"
+ - "check-success=test (3.13, check)"
+ - "check-success=test (3.14, check)"
+ - "check-success=test (3.14, lint)"
+ - "check-success=test (3.14, mypy)"
+
pull_request_rules:
- - name: automatic merge
- conditions:
- - "status-success=ci/circleci: pep8"
- - "status-success=ci/circleci: py27"
- - "status-success=ci/circleci: py35"
- - "status-success=ci/circleci: py36"
- - "status-success=ci/circleci: py37"
- - "status-success=ci/circleci: py38"
- - "#approved-reviews-by>=1"
- - label!=work-in-progress
- actions:
- merge:
- strict: "smart"
- method: squash
- - name: automatic merge for jd
- conditions:
- - author=jd
- - "status-success=ci/circleci: pep8"
- - "status-success=ci/circleci: py27"
- - "status-success=ci/circleci: py35"
- - "status-success=ci/circleci: py36"
- - "status-success=ci/circleci: py37"
- - "status-success=ci/circleci: py38"
- - label!=work-in-progress
- actions:
- merge:
- strict: "smart"
- method: squash
- name: dismiss reviews
conditions: []
actions:
diff --git a/.readthedocs.yml b/.readthedocs.yml
index b75e2b97..1ab90f14 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -1,4 +1,8 @@
version: 2
+build:
+ os: ubuntu-24.04
+ tools:
+ python: "3.13"
python:
install:
- method: pip
diff --git a/doc/source/api.rst b/doc/source/api.rst
index 1cf7dc75..7a80c4e7 100644
--- a/doc/source/api.rst
+++ b/doc/source/api.rst
@@ -17,6 +17,9 @@ Retry Main API
.. autoclass:: tenacity.tornadoweb.TornadoRetrying
:members:
+.. autoclass:: tenacity.RetryCallState
+ :members:
+
After Functions
---------------
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 23856bc5..a0de6991 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# Copyright 2016 Étienne Bersac
# Copyright 2016 Julien Danjou
# Copyright 2016 Joshua Harlow
@@ -16,27 +15,15 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import os
-import sys
+from importlib.metadata import version as pkg_version
-master_doc = 'index'
+master_doc = "index"
project = "Tenacity"
-
-# Add tenacity to the path, so sphinx can find the functions for autodoc.
-sys.path.insert(0, os.path.abspath('../..'))
+release = pkg_version("tenacity")
+version = ".".join(release.split(".")[:2])
extensions = [
- 'sphinx.ext.doctest',
- 'sphinx.ext.autodoc',
- 'reno.sphinxext',
+ "sphinx.ext.doctest",
+ "sphinx.ext.autodoc",
+ "reno.sphinxext",
]
-
-# -- Options for sphinx.ext.doctest -----------------------------------------
-
-# doctest_default_flags =
-cwd = os.path.abspath(os.path.dirname(__file__))
-tenacity_path = os.path.join(cwd, os.pardir, os.pardir)
-doctest_path = [tenacity_path]
-# doctest_global_setup =
-# doctest_global_cleanup =
-# doctest_test_doctest_blocks =
diff --git a/doc/source/index.rst b/doc/source/index.rst
index af3c5bba..ba21fd93 100644
--- a/doc/source/index.rst
+++ b/doc/source/index.rst
@@ -1,18 +1,20 @@
Tenacity
========
.. image:: https://img.shields.io/pypi/v/tenacity.svg
- :target: https://pypi.python.org/pypi/tenacity
+ :target: https://pypi.org/project/tenacity
-.. image:: https://circleci.com/gh/jd/tenacity.svg?style=svg
- :target: https://circleci.com/gh/jd/tenacity
+.. image:: https://img.shields.io/pypi/pyversions/tenacity.svg
+ :target: https://pypi.org/project/tenacity
-.. image:: https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg
- :target: https://saythanks.io/to/jd
+.. image:: https://github.com/jd/tenacity/actions/workflows/ci.yaml/badge.svg?branch=main
+ :target: https://github.com/jd/tenacity/actions/workflows/ci.yaml
-.. image:: https://img.shields.io/endpoint.svg?url=https://dashboard.mergify.io/badges/jd/tenacity&style=flat
+.. image:: https://img.shields.io/endpoint.svg?url=https://api.mergify.com/badges/jd/tenacity&style=flat
:target: https://mergify.io
:alt: Mergify Status
+**Please refer to the** `tenacity documentation `_ **for a better experience.**
+
Tenacity is an Apache 2.0 licensed general-purpose retrying library, written in
Python, to simplify the task of adding retry behavior to just about anything.
It originates from `a fork of retrying
@@ -80,7 +82,7 @@ Examples
Basic Retry
~~~~~~~~~~~
-.. testsetup:: *
+.. testsetup::
import logging
#
@@ -98,7 +100,7 @@ an exception is raised.
.. testcode::
@retry
- def never_give_up_never_surrender():
+ def never_gonna_give_you_up():
print("Retry forever ignoring Exceptions, don't wait between retries")
raise Exception
@@ -125,6 +127,16 @@ retrying stuff.
print("Stopping after 10 seconds")
raise Exception
+If you're on a tight deadline, and exceeding your delay time isn't ok,
+then you can give up on retries one attempt before you would exceed the delay.
+
+.. testcode::
+
+ @retry(stop=stop_before_delay(10))
+ def stop_before_10_s():
+ print("Stopping 1 attempt before 10 seconds")
+ raise Exception
+
You can combine several stop conditions by using the `|` operator:
.. testcode::
@@ -208,11 +220,19 @@ exceptions, as in the cases here.
.. testcode::
+ class ClientError(Exception):
+ """Some type of client error."""
+
@retry(retry=retry_if_exception_type(IOError))
def might_io_error():
print("Retry forever with no wait if an IOError occurs, raise any other errors")
raise Exception
+ @retry(retry=retry_if_not_exception_type(ClientError))
+ def might_client_error():
+ print("Retry forever with no wait if any error other than ClientError occurs. Immediately raise ClientError.")
+ raise Exception
+
We can also use the result of the function to alter the behavior of retrying.
.. testcode::
@@ -225,6 +245,21 @@ We can also use the result of the function to alter the behavior of retrying.
def might_return_none():
print("Retry with no wait if return value is None")
+See also these methods:
+
+.. testcode::
+
+ retry_if_exception
+ retry_if_exception_type
+ retry_if_not_exception_type
+ retry_unless_exception_type
+ retry_if_result
+ retry_if_not_result
+ retry_if_exception_message
+ retry_if_not_exception_message
+ retry_any
+ retry_all
+
We can also combine several conditions:
.. testcode::
@@ -254,8 +289,12 @@ exception:
Error Handling
~~~~~~~~~~~~~~
-While callables that "timeout" retrying raise a `RetryError` by default,
-we can reraise the last attempt's exception if needed:
+Normally when your function fails its final time (and will not be retried again based on your settings),
+a `RetryError` is raised. The exception your code encountered will be shown somewhere in the *middle*
+of the stack trace.
+
+If you would rather see the exception your code encountered at the *end* of the stack trace (where it
+is most visible), you can set `reraise=True`.
.. testcode::
@@ -278,6 +317,7 @@ by using the before callback function:
.. testcode::
import logging
+ import sys
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
@@ -292,6 +332,7 @@ In the same spirit, It's possible to execute after a call that failed:
.. testcode::
import logging
+ import sys
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
@@ -308,6 +349,7 @@ retries happen after a wait interval, so the keyword argument is called
.. testcode::
import logging
+ import sys
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
@@ -323,7 +365,7 @@ Statistics
~~~~~~~~~~
You can access the statistics about the retry made over a function by using the
-`retry` attribute attached to the function and its `statistics` attribute:
+`statistics` attribute attached to the function:
.. testcode::
@@ -336,7 +378,7 @@ You can access the statistics about the retry made over a function by using the
except Exception:
pass
- print(raise_my_exception.retry.statistics)
+ print(raise_my_exception.statistics)
.. testoutput::
:hide:
@@ -370,52 +412,39 @@ without raising an exception (or you can re-raise or do anything really)
def eventually_return_false():
return False
-.. note::
-
- Calling the parameter ``retry_state`` is important, because this is how
- *tenacity* internally distinguishes callbacks from their :ref:`deprecated
- counterparts `.
-
RetryCallState
~~~~~~~~~~~~~~
-``retry_state`` argument is an object of `RetryCallState` class:
-
-.. autoclass:: tenacity.RetryCallState
-
- Constant attributes:
-
- .. autoinstanceattribute:: start_time(float)
- :annotation:
+``retry_state`` argument is an object of :class:`~tenacity.RetryCallState` class.
+Its most useful attributes are:
- .. autoinstanceattribute:: retry_object(BaseRetrying)
- :annotation:
+* ``attempt_number`` — number of the current attempt (starts at 1)
+* ``outcome`` — a :class:`concurrent.futures.Future` holding the last result or exception
+* ``seconds_since_start`` — total elapsed seconds from the first attempt to the last outcome (``None`` if no outcome yet)
+* ``idle_for`` — cumulative seconds spent sleeping between attempts
+* ``start_time`` — :func:`time.monotonic` timestamp of the first attempt
- .. autoinstanceattribute:: fn(callable)
- :annotation:
+For example, to log the total elapsed time after all retries:
- .. autoinstanceattribute:: args(tuple)
- :annotation:
-
- .. autoinstanceattribute:: kwargs(dict)
- :annotation:
+.. testcode::
- Variable attributes:
+ import logging
- .. autoinstanceattribute:: attempt_number(int)
- :annotation:
+ logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
- .. autoinstanceattribute:: outcome(tenacity.Future or None)
- :annotation:
+ logger = logging.getLogger(__name__)
- .. autoinstanceattribute:: outcome_timestamp(float or None)
- :annotation:
+ def log_elapsed(retry_state):
+ logger.info('Finished after %.3fs', retry_state.seconds_since_start)
- .. autoinstanceattribute:: idle_for(float)
- :annotation:
+ @retry(stop=stop_after_attempt(3), after=log_elapsed)
+ def raise_my_exception():
+ raise MyException("Fail")
- .. autoinstanceattribute:: next_action(tenacity.RetryAction or None)
- :annotation:
+ try:
+ raise_my_exception()
+ except RetryError:
+ pass
Other Custom Callbacks
~~~~~~~~~~~~~~~~~~~~~~
@@ -424,33 +453,33 @@ It's also possible to define custom callbacks for other keyword arguments.
.. function:: my_stop(retry_state)
- :param RetryState retry_state: info about current retry invocation
+ :param RetryCallState retry_state: info about current retry invocation
:return: whether or not retrying should stop
:rtype: bool
.. function:: my_wait(retry_state)
- :param RetryState retry_state: info about current retry invocation
+ :param RetryCallState retry_state: info about current retry invocation
:return: number of seconds to wait before next retry
:rtype: float
.. function:: my_retry(retry_state)
- :param RetryState retry_state: info about current retry invocation
+ :param RetryCallState retry_state: info about current retry invocation
:return: whether or not retrying should continue
:rtype: bool
.. function:: my_before(retry_state)
- :param RetryState retry_state: info about current retry invocation
+ :param RetryCallState retry_state: info about current retry invocation
.. function:: my_after(retry_state)
- :param RetryState retry_state: info about current retry invocation
+ :param RetryCallState retry_state: info about current retry invocation
.. function:: my_before_sleep(retry_state)
- :param RetryState retry_state: info about current retry invocation
+ :param RetryCallState retry_state: info about current retry invocation
Here's an example with a custom ``before_sleep`` function:
@@ -480,92 +509,52 @@ Here's an example with a custom ``before_sleep`` function:
except RetryError:
pass
-.. _deprecated-callbacks:
-.. note::
-
- It was also possible to define custom callbacks before, but they accepted
- varying parameter sets and none of those provided full state. The old way is
- deprecated, but kept for backward compatibility.
-
- .. function:: my_deprecated_stop(previous_attempt_number, delay_since_first_attempt)
-
- *deprecated*
-
- :param previous_attempt_number: the number of current attempt
- :type previous_attempt_number: int
- :param delay_since_first_attempt: interval in seconds between the
- beginning of first attempt and current time
- :type delay_since_first_attempt: float
- :rtype: bool
-
- .. function:: my_deprecated_wait(previous_attempt_number, delay_since_first_attempt [, last_result])
-
- *deprecated*
-
- :param previous_attempt_number: the number of current attempt
- :type previous_attempt_number: int
- :param delay_since_first_attempt: interval in seconds between the
- beginning of first attempt and current time
- :type delay_since_first_attempt: float
- :param tenacity.Future last_result: current outcome
-
- :return: number of seconds to wait before next retry
- :rtype: float
-
- .. function:: my_deprecated_retry(attempt)
-
- *deprecated*
-
- :param tenacity.Future attempt: current outcome
- :return: whether or not retrying should continue
- :rtype: bool
+Common Patterns
+~~~~~~~~~~~~~~~
- .. function:: my_deprecated_before(func, trial_number)
+**Running setup code between retries** (e.g. reconnecting):
- *deprecated*
+.. testcode::
- :param callable func: function whose outcome is to be retried
- :param int trial_number: the number of current attempt
+ def reconnect(retry_state):
+ print("Reconnecting before next attempt...")
- .. function:: my_deprecated_after(func, trial_number, trial_time_taken)
+ @retry(stop=stop_after_attempt(3), before_sleep=reconnect)
+ def send_data():
+ raise MyException("connection lost")
- *deprecated*
+ try:
+ send_data()
+ except RetryError:
+ pass
- :param callable func: function whose outcome is to be retried
- :param int trial_number: the number of current attempt
- :param float trial_time_taken: interval in seconds between the beginning
- of first attempt and current time
+.. testoutput::
+ :hide:
- .. function:: my_deprecated_before_sleep(func, sleep, last_result)
+ ...
- *deprecated*
+The ``before_sleep`` callback runs after a failed attempt and before sleeping,
+making it ideal for re-establishing connections, refreshing tokens, or any
+other setup that needs to happen before the next attempt.
- :param callable func: function whose outcome is to be retried
- :param float sleep: number of seconds to wait until next retry
- :param tenacity.Future last_result: current outcome
+**Accessing the attempt number inside the function** using the iterator API:
.. testcode::
- import logging
-
- logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
+ from tenacity import Retrying
- logger = logging.getLogger(__name__)
+ for attempt in Retrying(stop=stop_after_attempt(3)):
+ with attempt:
+ print(f"Attempt {attempt.retry_state.attempt_number}")
+ if attempt.retry_state.attempt_number < 3:
+ raise MyException("not yet")
- def my_before_sleep(retry_object, sleep, last_result):
- logger.warning(
- 'Retrying %s: last_result=%s, retrying in %s seconds...',
- retry_object.fn, last_result, sleep)
+.. testoutput::
+ :hide:
- @retry(stop=stop_after_attempt(3), before_sleep=my_before_sleep)
- def raise_my_exception():
- raise MyException("Fail")
+ ...
- try:
- raise_my_exception()
- except RetryError:
- pass
Changing Arguments at Run Time
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -584,7 +573,7 @@ using the `retry_with` function attached to the wrapped function:
except Exception:
pass
- print(raise_my_exception.retry.statistics)
+ print(raise_my_exception.statistics)
.. testoutput::
:hide:
@@ -603,6 +592,64 @@ to use the `retry` decorator - you can instead use `Retrying` directly:
retryer = Retrying(stop=stop_after_attempt(max_attempts), reraise=True)
retryer(never_good_enough, 'I really do try')
+You may also want to change the behaviour of a decorated function temporarily,
+like in tests to avoid unnecessary wait times. You can modify/patch the `retry`
+attribute attached to the function. Bear in mind this is a write-only attribute,
+statistics should be read from the function `statistics` attribute.
+
+.. testcode::
+
+ @retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
+ def raise_my_exception():
+ raise MyException("Fail")
+
+ from unittest import mock
+
+ with mock.patch.object(raise_my_exception.retry, "wait", wait_fixed(0)):
+ try:
+ raise_my_exception()
+ except Exception:
+ pass
+
+ print(raise_my_exception.statistics)
+
+.. testoutput::
+ :hide:
+
+ ...
+
+Disabling Retries
+~~~~~~~~~~~~~~~~~
+
+You can disable retrying entirely by passing ``enabled=False``. When disabled,
+the decorated function is called directly without any retry logic. This is
+useful during development or testing when you want fast feedback on failures:
+
+.. testcode::
+
+ import os
+
+ @retry(
+ enabled=os.getenv("ENABLE_RETRIES", "1") != "0",
+ stop=stop_after_attempt(5),
+ wait=wait_fixed(1),
+ )
+ def call_api():
+ pass # your code here
+
+ call_api()
+
+You can also use ``retry_with`` to disable retries on a per-call basis:
+
+.. testcode::
+
+ @retry(stop=stop_after_attempt(5))
+ def call_api():
+ pass # your code here
+
+ # In tests:
+ call_api.retry_with(enabled=False)()
+
Retrying code block
~~~~~~~~~~~~~~~~~~~
@@ -638,32 +685,84 @@ With async code you can use AsyncRetrying.
except RetryError:
pass
+In both cases, you may want to set the result to the attempt so it's available
+in retry strategies like ``retry_if_result``. This can be done accessing the
+``retry_state`` property:
+
+.. testcode::
+
+ from tenacity import AsyncRetrying, retry_if_result
+
+ async def function():
+ async for attempt in AsyncRetrying(retry=retry_if_result(lambda x: x < 3)):
+ with attempt:
+ result = 1 # Some complex calculation, function call, etc.
+ if not attempt.retry_state.outcome.failed:
+ attempt.retry_state.set_result(result)
+ return result
+
Async and retry
~~~~~~~~~~~~~~~
-Finally, ``retry`` works also on asyncio and Tornado (>= 4.5) coroutines.
+Finally, ``retry`` works also on asyncio, Trio, and Tornado coroutines.
Sleeps are done asynchronously too.
.. code-block:: python
@retry
- async def my_async_function(loop):
- await loop.getaddrinfo('8.8.8.8', 53)
+ async def my_asyncio_function():
+ await asyncio.getaddrinfo('8.8.8.8', 53)
.. code-block:: python
@retry
- @tornado.gen.coroutine
- def my_async_function(http_client, url):
- yield http_client.fetch(url)
+ async def my_async_trio_function():
+ await trio.socket.getaddrinfo('8.8.8.8', 53)
-You can even use alternative event loops such as `curio` or `Trio` by passing the correct sleep function:
+.. code-block:: python
+
+ @retry
+ async def my_async_tornado_function(http_client, url):
+ await http_client.fetch(url)
+
+You can use alternative event loops by passing the correct sleep function:
.. code-block:: python
@retry(sleep=trio.sleep)
- async def my_async_function(loop):
- await asks.get('https://example.org')
+ async def my_async_trio_function_with_sleep():
+ ...
+
+Generators
+~~~~~~~~~~
+
+``retry`` does not support generator or async generator functions. Decorating a
+generator with ``@retry`` will not retry on exceptions raised during iteration
+— the decorator wraps the function call itself, which for generators simply
+returns a generator object without executing any of the body.
+
+Also note that generators passed *as arguments* to a retried function will be
+exhausted after the first attempt and will not be rewound automatically on
+retry. If you need to pass a generator as an argument, consider passing a
+factory function instead:
+
+.. code-block:: python
+
+ # Bad: generator will be exhausted after the first attempt
+ @retry
+ def process(items):
+ for item in items:
+ do_work(item)
+
+ process(my_generator()) # retries will see an empty generator
+
+ # Good: pass a factory so a fresh generator is created on each attempt
+ @retry
+ def process(items_factory):
+ for item in items_factory():
+ do_work(item)
+
+ process(my_generator) # each retry gets a fresh generator
Contribute
----------
@@ -671,24 +770,17 @@ Contribute
#. Check for open issues or open a fresh issue to start a discussion around a
feature idea or a bug.
#. Fork `the repository`_ on GitHub to start making your changes to the
- **master** branch (or branch off of it).
+ **main** branch (or branch off of it).
#. Write a test which shows that the bug was fixed or that the feature works as
expected.
#. Add a `changelog <#Changelogs>`_
#. Make the docs better (or more detailed, or more easier to read, or ...)
-.. _`the repository`: https://github.com/jd/tenacity
-
-Changelogs
-~~~~~~~~~~
-
-`reno`_ is used for managing changelogs. Take a look at their usage docs.
+Running the test suite locally::
-The doc generation will automatically compile the changelogs. You just need to add them.
+ uv run poe check # run tests + build docs
+ uv run poe lint # run ruff linter
+ uv run poe mypy # run type checker
+ uv run poe all # run everything
-.. code-block:: sh
-
- # Opens a template file in an editor
- tox -e reno -- new some-slug-for-my-change --edit
-
-.. _`reno`: https://docs.openstack.org/reno/latest/user/usage.html
+.. _`the repository`: https://github.com/jd/tenacity
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..447c639e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,127 @@
+[build-system]
+requires = ["hatchling", "hatch-vcs"]
+build-backend = "hatchling.build"
+
+[tool.hatch.version]
+source = "vcs"
+
+[tool.hatch.build.hooks.vcs]
+version-file = "tenacity/_version.py"
+
+[project]
+name = "tenacity"
+dynamic = ["version"]
+description = "Retry code until it succeeds"
+readme = "README.rst"
+license = "Apache-2.0"
+requires-python = ">=3.10"
+authors = [
+ { name = "Julien Danjou", email = "julien@danjou.info" },
+]
+classifiers = [
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: Apache Software License",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Topic :: Utilities",
+]
+
+[project.urls]
+Homepage = "https://github.com/jd/tenacity"
+Documentation = "https://tenacity.readthedocs.io"
+Source = "https://github.com/jd/tenacity"
+Issues = "https://github.com/jd/tenacity/issues"
+Changelog = "https://tenacity.readthedocs.io/en/latest/changelog.html"
+
+[project.optional-dependencies]
+doc = [
+ "reno",
+ "sphinx",
+]
+test = [
+ "pytest",
+ "tornado>=6.0",
+ "typeguard",
+]
+
+[dependency-groups]
+dev = [
+ "poethepoet",
+ "pytest",
+ "tornado>=6.0",
+ "typeguard",
+ "ruff",
+ "mypy",
+ "sphinx",
+ "reno",
+ "trio",
+]
+
+[tool.poe.tasks]
+test = "pytest"
+docs-doctest = "sphinx-build -a -E -W -b doctest doc/source doc/build"
+docs-html = "sphinx-build -a -E -W -b html doc/source doc/build"
+docs = ["docs-doctest", "docs-html"]
+check = ["test", "docs"]
+mypy = "mypy"
+reno = "reno"
+
+[tool.poe.tasks.fmt]
+sequence = [
+ { cmd = "ruff check --fix ." },
+ { cmd = "ruff format ." },
+]
+
+[tool.poe.tasks.lint]
+sequence = [
+ { cmd = "ruff check ." },
+ { cmd = "ruff format --check ." },
+]
+
+[tool.poe.tasks.all]
+sequence = [
+ { ref = "lint" },
+ { ref = "mypy" },
+ { ref = "check" },
+]
+
+[tool.pytest.ini_options]
+filterwarnings = [
+ "once::DeprecationWarning",
+]
+
+[tool.ruff]
+line-length = 88
+indent-width = 4
+target-version = "py310"
+exclude = ["tenacity/_version.py"]
+
+[tool.ruff.lint]
+select = ["ASYNC", "B", "C4", "DTZ", "E", "EXE", "F", "FLY", "FURB", "I", "ICN", "ISC", "LOG", "PERF", "PGH", "PIE", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "SLOT", "T10", "TC", "UP", "W"]
+ignore = [
+ "B008", # function calls in default arguments (intentional API design)
+ "B905", # zip() without strict= (not needed in existing code)
+ "E501", # line too long (formatter handles what it can)
+ "PYI036", # false positive on string-quoted __exit__ annotations
+ "RUF003", # ambiguous unicode characters in comments (copyright names)
+ "RUF005", # iterable unpacking vs concatenation (less readable for tuple +)
+ "RUF012", # mutable class default (test constant, never mutated)
+ "SIM108", # ternary instead of if-else (less readable in context)
+]
+
+[tool.mypy]
+strict = true
+files = ["tenacity", "tests"]
+show_error_codes = true
+exclude = ["tenacity/_version\\.py"]
+
+[[tool.mypy.overrides]]
+module = "tornado.*"
+ignore_missing_imports = true
+
diff --git a/releasenotes/notes/Fix-tests-for-typeguard-3.x-6eebfea546b6207e.yaml b/releasenotes/notes/Fix-tests-for-typeguard-3.x-6eebfea546b6207e.yaml
new file mode 100644
index 00000000..9688c3d4
--- /dev/null
+++ b/releasenotes/notes/Fix-tests-for-typeguard-3.x-6eebfea546b6207e.yaml
@@ -0,0 +1,3 @@
+---
+fixes:
+ - "Fixes test failures with typeguard 3.x"
diff --git a/releasenotes/notes/Use--for-formatting-and-validate-using-black-39ec9d57d4691778.yaml b/releasenotes/notes/Use--for-formatting-and-validate-using-black-39ec9d57d4691778.yaml
new file mode 100644
index 00000000..188dc8da
--- /dev/null
+++ b/releasenotes/notes/Use--for-formatting-and-validate-using-black-39ec9d57d4691778.yaml
@@ -0,0 +1,4 @@
+---
+other:
+ - "Use `black` for code formatting and validate using `black --check`. Code compatibility: py26-py39."
+ - "Enforce maximal line length to 120 symbols"
diff --git a/releasenotes/notes/add-async-actions-b249c527d99723bb.yaml b/releasenotes/notes/add-async-actions-b249c527d99723bb.yaml
new file mode 100644
index 00000000..096a24f2
--- /dev/null
+++ b/releasenotes/notes/add-async-actions-b249c527d99723bb.yaml
@@ -0,0 +1,5 @@
+---
+features:
+ - |
+ Added the ability to use async functions for retries. This way, you can now use
+ asyncio coroutines for retry strategy predicates.
diff --git a/releasenotes/notes/add-re-pattern-to-match-types-6a4c1d9e64e2a5e1.yaml b/releasenotes/notes/add-re-pattern-to-match-types-6a4c1d9e64e2a5e1.yaml
new file mode 100644
index 00000000..c71ffd98
--- /dev/null
+++ b/releasenotes/notes/add-re-pattern-to-match-types-6a4c1d9e64e2a5e1.yaml
@@ -0,0 +1,4 @@
+---
+fixes:
+ - |
+ Added `re.Pattern` to allowed match types.
diff --git a/releasenotes/notes/add-retry_except_exception_type-31b31da1924d55f4.yaml b/releasenotes/notes/add-retry_except_exception_type-31b31da1924d55f4.yaml
new file mode 100644
index 00000000..bd89f84d
--- /dev/null
+++ b/releasenotes/notes/add-retry_except_exception_type-31b31da1924d55f4.yaml
@@ -0,0 +1,3 @@
+---
+features:
+ - Add ``retry_if_not_exception_type()`` that allows to retry if a raised exception doesn't match given exceptions.
diff --git a/releasenotes/notes/add-stop-before-delay-a775f88ac872c923.yaml b/releasenotes/notes/add-stop-before-delay-a775f88ac872c923.yaml
new file mode 100644
index 00000000..85df4c3d
--- /dev/null
+++ b/releasenotes/notes/add-stop-before-delay-a775f88ac872c923.yaml
@@ -0,0 +1,7 @@
+---
+features:
+ - |
+ Added a new stop function: stop_before_delay, which will stop execution
+ if the next sleep time would cause overall delay to exceed the specified delay.
+ Useful for use cases where you have some upper bound on retry times that you must
+ not exceed, so returning before that timeout is preferable than returning after that timeout.
\ No newline at end of file
diff --git a/releasenotes/notes/add-test-extra-55e869261b03e56d.yaml b/releasenotes/notes/add-test-extra-55e869261b03e56d.yaml
new file mode 100644
index 00000000..968da1f3
--- /dev/null
+++ b/releasenotes/notes/add-test-extra-55e869261b03e56d.yaml
@@ -0,0 +1,3 @@
+---
+other:
+ - Add a \"test\" extra
diff --git a/releasenotes/notes/add_omitted_modules_to_import_all-2ab282f20a2c22f7.yaml b/releasenotes/notes/add_omitted_modules_to_import_all-2ab282f20a2c22f7.yaml
new file mode 100644
index 00000000..cfcae1f6
--- /dev/null
+++ b/releasenotes/notes/add_omitted_modules_to_import_all-2ab282f20a2c22f7.yaml
@@ -0,0 +1,3 @@
+---
+other:
+ - Add `retry_if_exception_cause_type`and `wait_exponential_jitter` to __all__ of init.py
\ No newline at end of file
diff --git a/releasenotes/notes/add_retry_if_exception_cause_type-d16b918ace4ae0ad.yaml b/releasenotes/notes/add_retry_if_exception_cause_type-d16b918ace4ae0ad.yaml
new file mode 100644
index 00000000..8b5a420f
--- /dev/null
+++ b/releasenotes/notes/add_retry_if_exception_cause_type-d16b918ace4ae0ad.yaml
@@ -0,0 +1,5 @@
+---
+features:
+ - |
+ Add a new `retry_base` class called `retry_if_exception_cause_type` that
+ checks, recursively, if any of the causes of the raised exception is of a certain type.
diff --git a/releasenotes/notes/added_a_link_to_documentation-eefaf8f074b539f8.yaml b/releasenotes/notes/added_a_link_to_documentation-eefaf8f074b539f8.yaml
new file mode 100644
index 00000000..381ddc4a
--- /dev/null
+++ b/releasenotes/notes/added_a_link_to_documentation-eefaf8f074b539f8.yaml
@@ -0,0 +1,5 @@
+---
+other:
+ - |
+ Added a link to the documentation, as code snippets are not being rendered properly
+ Changed branch name to main in index.rst
diff --git a/releasenotes/notes/after_log-50f4d73b24ce9203.yaml b/releasenotes/notes/after_log-50f4d73b24ce9203.yaml
new file mode 100644
index 00000000..b8023e11
--- /dev/null
+++ b/releasenotes/notes/after_log-50f4d73b24ce9203.yaml
@@ -0,0 +1,3 @@
+---
+fixes:
+ - "Fix after_log logger format: function name was used with delay formatting."
diff --git a/releasenotes/notes/annotate_code-197b93130df14042.yaml b/releasenotes/notes/annotate_code-197b93130df14042.yaml
new file mode 100644
index 00000000..faf41635
--- /dev/null
+++ b/releasenotes/notes/annotate_code-197b93130df14042.yaml
@@ -0,0 +1,3 @@
+---
+other:
+ - Add type annotations to cover all public API.
diff --git a/releasenotes/notes/async-sleep-retrying-32de5866f5d041.yaml b/releasenotes/notes/async-sleep-retrying-32de5866f5d041.yaml
new file mode 100644
index 00000000..0ffe819f
--- /dev/null
+++ b/releasenotes/notes/async-sleep-retrying-32de5866f5d041.yaml
@@ -0,0 +1,7 @@
+---
+fixes:
+ - |
+ Passing an async ``sleep`` callable (e.g. ``trio.sleep``) to ``@retry``
+ now correctly uses ``AsyncRetrying``, even when the decorated function is
+ synchronous. Previously, the async sleep would silently not be awaited,
+ resulting in no delay between retries.
diff --git a/releasenotes/notes/clarify-reraise-option-6829667eacf4f599.yaml b/releasenotes/notes/clarify-reraise-option-6829667eacf4f599.yaml
new file mode 100644
index 00000000..31be8350
--- /dev/null
+++ b/releasenotes/notes/clarify-reraise-option-6829667eacf4f599.yaml
@@ -0,0 +1,3 @@
+---
+prelude: >
+ Clarify usage of `reraise` keyword argument
diff --git a/releasenotes/notes/dependabot-for-github-actions-4d2464f3c0928463.yaml b/releasenotes/notes/dependabot-for-github-actions-4d2464f3c0928463.yaml
new file mode 100644
index 00000000..278df6ca
--- /dev/null
+++ b/releasenotes/notes/dependabot-for-github-actions-4d2464f3c0928463.yaml
@@ -0,0 +1,5 @@
+---
+other:
+ - |
+ Add a Dependabot configuration submit PRs monthly (as needed)
+ to keep GitHub action versions updated.
diff --git a/releasenotes/notes/do_not_package_tests-fe5ac61940b0a5ed.yaml b/releasenotes/notes/do_not_package_tests-fe5ac61940b0a5ed.yaml
new file mode 100644
index 00000000..ec8da2ca
--- /dev/null
+++ b/releasenotes/notes/do_not_package_tests-fe5ac61940b0a5ed.yaml
@@ -0,0 +1,3 @@
+---
+other:
+ - Do not package tests with tenacity.
diff --git a/releasenotes/notes/drop-deprecated-python-versions-69a05cb2e0f1034c.yaml b/releasenotes/notes/drop-deprecated-python-versions-69a05cb2e0f1034c.yaml
new file mode 100644
index 00000000..ca370f06
--- /dev/null
+++ b/releasenotes/notes/drop-deprecated-python-versions-69a05cb2e0f1034c.yaml
@@ -0,0 +1,4 @@
+---
+other:
+ - |
+ Drop support for deprecated Python versions (2.7 and 3.5)
diff --git a/releasenotes/notes/drop-python-3.9-ecfa2d7db9773e96.yaml b/releasenotes/notes/drop-python-3.9-ecfa2d7db9773e96.yaml
new file mode 100644
index 00000000..ac2a8cfd
--- /dev/null
+++ b/releasenotes/notes/drop-python-3.9-ecfa2d7db9773e96.yaml
@@ -0,0 +1,5 @@
+---
+upgrade:
+ - |
+ Python 3.9 has reached end-of-life and is no longer supported.
+ The minimum supported version is now Python 3.10.
diff --git a/releasenotes/notes/drop_deprecated-7ea90b212509b082.yaml b/releasenotes/notes/drop_deprecated-7ea90b212509b082.yaml
new file mode 100644
index 00000000..888233ce
--- /dev/null
+++ b/releasenotes/notes/drop_deprecated-7ea90b212509b082.yaml
@@ -0,0 +1,5 @@
+---
+upgrade:
+ - "Removed `BaseRetrying.call`: was long time deprecated and produced `DeprecationWarning`"
+ - "Removed `BaseRetrying.fn`: was noted as deprecated"
+ - "API change: `BaseRetrying.begin()` do not require arguments anymore as it not setting `BaseRetrying.fn`"
diff --git a/releasenotes/notes/export-convenience-symbols-981d9611c8b754f3.yaml b/releasenotes/notes/export-convenience-symbols-981d9611c8b754f3.yaml
new file mode 100644
index 00000000..aa990970
--- /dev/null
+++ b/releasenotes/notes/export-convenience-symbols-981d9611c8b754f3.yaml
@@ -0,0 +1,3 @@
+---
+features:
+ - Explicitly export convenience symbols from tenacity root module
diff --git a/releasenotes/notes/fix-async-loop-with-result-f68e913ccb425aca.yaml b/releasenotes/notes/fix-async-loop-with-result-f68e913ccb425aca.yaml
new file mode 100644
index 00000000..73a58aa8
--- /dev/null
+++ b/releasenotes/notes/fix-async-loop-with-result-f68e913ccb425aca.yaml
@@ -0,0 +1,4 @@
+---
+fixes:
+ - |
+ Fix async loop with retrying code block when result is available.
diff --git a/releasenotes/notes/fix-async-retry-type-overloads-27f3e0c239ed6b.yaml b/releasenotes/notes/fix-async-retry-type-overloads-27f3e0c239ed6b.yaml
new file mode 100644
index 00000000..c5127669
--- /dev/null
+++ b/releasenotes/notes/fix-async-retry-type-overloads-27f3e0c239ed6b.yaml
@@ -0,0 +1,9 @@
+---
+fixes:
+ - |
+ The ``@retry`` decorator's type overloads for the ``sleep=`` parameter
+ (e.g. ``sleep=trio.sleep``) have been improved. Previously, the
+ async-sleep overload used ``R | Awaitable[R]`` as the return type
+ bound, which was ambiguous: for ``async def f() -> T``, pyright could
+ infer ``R = Coroutine[Any, Any, T]`` instead of ``R = T``, producing
+ false-positive type errors in downstream code.
diff --git a/releasenotes/notes/fix-local-context-overwrite-94190ba06a481631.yaml b/releasenotes/notes/fix-local-context-overwrite-94190ba06a481631.yaml
new file mode 100644
index 00000000..ff2ba7ee
--- /dev/null
+++ b/releasenotes/notes/fix-local-context-overwrite-94190ba06a481631.yaml
@@ -0,0 +1,4 @@
+---
+fixes:
+ - |
+ Avoid overwriting local contexts when applying the retry decorator.
diff --git a/releasenotes/notes/fix-retry-wrapper-attributes-f7a3a45b8e90f257.yaml b/releasenotes/notes/fix-retry-wrapper-attributes-f7a3a45b8e90f257.yaml
new file mode 100644
index 00000000..967cd29e
--- /dev/null
+++ b/releasenotes/notes/fix-retry-wrapper-attributes-f7a3a45b8e90f257.yaml
@@ -0,0 +1,6 @@
+---
+fixes:
+ - |
+ Restore the value of the `retry` attribute for wrapped functions. Also,
+ clarify that those attributes are write-only and statistics should be
+ read from the function attribute directly.
diff --git a/releasenotes/notes/fix-setuptools-config-3af71aa3592b6948.yaml b/releasenotes/notes/fix-setuptools-config-3af71aa3592b6948.yaml
new file mode 100644
index 00000000..b8bcc9af
--- /dev/null
+++ b/releasenotes/notes/fix-setuptools-config-3af71aa3592b6948.yaml
@@ -0,0 +1,3 @@
+---
+fixes:
+ - Fix setuptools config to include tenacity.asyncio package in release distributions.
diff --git a/releasenotes/notes/fix-wait-typing-b26eecdb6cc0a1de.yaml b/releasenotes/notes/fix-wait-typing-b26eecdb6cc0a1de.yaml
new file mode 100644
index 00000000..3b560c3b
--- /dev/null
+++ b/releasenotes/notes/fix-wait-typing-b26eecdb6cc0a1de.yaml
@@ -0,0 +1,5 @@
+---
+fixes:
+ - |
+ Argument `wait` was improperly annotated, making mypy checks fail.
+ Now it's annotated as `typing.Union[wait_base, typing.Callable[["RetryCallState"], typing.Union[float, int]]]`
diff --git a/releasenotes/notes/fix_async-52b6594c8e75c4bc.yaml b/releasenotes/notes/fix_async-52b6594c8e75c4bc.yaml
new file mode 100644
index 00000000..1ce0b71b
--- /dev/null
+++ b/releasenotes/notes/fix_async-52b6594c8e75c4bc.yaml
@@ -0,0 +1,3 @@
+---
+fixes:
+ - "Fix issue #288 : __name__ and other attributes for async functions"
diff --git a/releasenotes/notes/logging-protocol-a4cf0f786f21e4ee.yaml b/releasenotes/notes/logging-protocol-a4cf0f786f21e4ee.yaml
new file mode 100644
index 00000000..ad8206df
--- /dev/null
+++ b/releasenotes/notes/logging-protocol-a4cf0f786f21e4ee.yaml
@@ -0,0 +1,5 @@
+---
+other:
+ - |
+ Accept non-standard logger in helpers logging something (eg: structlog, loguru...)
+
diff --git a/releasenotes/notes/make-logger-more-compatible-5da1ddf1bab77047.yaml b/releasenotes/notes/make-logger-more-compatible-5da1ddf1bab77047.yaml
new file mode 100644
index 00000000..4e7c59c0
--- /dev/null
+++ b/releasenotes/notes/make-logger-more-compatible-5da1ddf1bab77047.yaml
@@ -0,0 +1,4 @@
+---
+fixes:
+ - |
+ Use str.format to format the logs internally to make logging compatible with other logger such as loguru.
diff --git a/releasenotes/notes/no-async-iter-6132a42e52348a75.yaml b/releasenotes/notes/no-async-iter-6132a42e52348a75.yaml
new file mode 100644
index 00000000..7a92c1b5
--- /dev/null
+++ b/releasenotes/notes/no-async-iter-6132a42e52348a75.yaml
@@ -0,0 +1,7 @@
+---
+fixes:
+ - |
+ `AsyncRetrying` was erroneously implementing `__iter__()`, making tenacity
+ retrying mechanism working but in a synchronous fashion and not waiting as
+ expected. This interface has been removed, `__aiter__()` should be used
+ instead.
diff --git a/releasenotes/notes/pr320-py3-only-wheel-tag.yaml b/releasenotes/notes/pr320-py3-only-wheel-tag.yaml
new file mode 100644
index 00000000..e85f85f0
--- /dev/null
+++ b/releasenotes/notes/pr320-py3-only-wheel-tag.yaml
@@ -0,0 +1,5 @@
+---
+other: >-
+ Corrected the PyPI-published wheel tag to match the
+ metadata saying that the release is Python 3 only.
+...
diff --git a/releasenotes/notes/py36_plus-c425fb3aa17c6682.yaml b/releasenotes/notes/py36_plus-c425fb3aa17c6682.yaml
new file mode 100644
index 00000000..7e6f5948
--- /dev/null
+++ b/releasenotes/notes/py36_plus-c425fb3aa17c6682.yaml
@@ -0,0 +1,4 @@
+---
+features:
+ - Most part of the code is type annotated.
+ - Python 3.10 support has been added.
diff --git a/releasenotes/notes/remove-py36-876c0416cf279d15.yaml b/releasenotes/notes/remove-py36-876c0416cf279d15.yaml
new file mode 100644
index 00000000..bfb1164d
--- /dev/null
+++ b/releasenotes/notes/remove-py36-876c0416cf279d15.yaml
@@ -0,0 +1,4 @@
+---
+upgrade:
+ - |
+ Support for Python 3.6 has been removed.
diff --git a/releasenotes/notes/retrycallstate-repr-94947f7b00ee15e1.yaml b/releasenotes/notes/retrycallstate-repr-94947f7b00ee15e1.yaml
new file mode 100644
index 00000000..3f57bf23
--- /dev/null
+++ b/releasenotes/notes/retrycallstate-repr-94947f7b00ee15e1.yaml
@@ -0,0 +1,3 @@
+---
+features:
+ - Add a ``__repr__`` method to ``RetryCallState`` objects for easier debugging.
diff --git a/releasenotes/notes/some-slug-for-preserve-defaults-86682846dfa18005.yaml b/releasenotes/notes/some-slug-for-preserve-defaults-86682846dfa18005.yaml
new file mode 100644
index 00000000..617953cf
--- /dev/null
+++ b/releasenotes/notes/some-slug-for-preserve-defaults-86682846dfa18005.yaml
@@ -0,0 +1,4 @@
+---
+fixes:
+ - |
+ Preserve __defaults__ and __kwdefaults__ through retry decorator
diff --git a/releasenotes/notes/sphinx_define_error-642c9cd5c165d39a.yaml b/releasenotes/notes/sphinx_define_error-642c9cd5c165d39a.yaml
new file mode 100644
index 00000000..ccef1c0a
--- /dev/null
+++ b/releasenotes/notes/sphinx_define_error-642c9cd5c165d39a.yaml
@@ -0,0 +1,2 @@
+---
+fixes: Sphinx build error where Sphinx complains about an undefined class.
diff --git a/releasenotes/notes/support-py3.14-14928188cab53b99.yaml b/releasenotes/notes/support-py3.14-14928188cab53b99.yaml
new file mode 100644
index 00000000..3eec60a3
--- /dev/null
+++ b/releasenotes/notes/support-py3.14-14928188cab53b99.yaml
@@ -0,0 +1,3 @@
+---
+features:
+ - Python 3.14 support has been added.
diff --git a/releasenotes/notes/support-timedelta-wait-unit-type-5ba1e9fc0fe45523.yaml b/releasenotes/notes/support-timedelta-wait-unit-type-5ba1e9fc0fe45523.yaml
new file mode 100644
index 00000000..bc7e62dc
--- /dev/null
+++ b/releasenotes/notes/support-timedelta-wait-unit-type-5ba1e9fc0fe45523.yaml
@@ -0,0 +1,3 @@
+---
+features:
+ - Add ``datetime.timedelta`` as accepted wait unit type.
diff --git a/releasenotes/notes/timedelta-for-stop-ef6bf71b88ce9988.yaml b/releasenotes/notes/timedelta-for-stop-ef6bf71b88ce9988.yaml
new file mode 100644
index 00000000..9f792f0a
--- /dev/null
+++ b/releasenotes/notes/timedelta-for-stop-ef6bf71b88ce9988.yaml
@@ -0,0 +1,4 @@
+---
+features:
+ - |
+ - accept ``datetime.timedelta`` instances as argument to ``tenacity.stop.stop_after_delay``
diff --git a/releasenotes/notes/trio-support-retry-22bd544800cd1f36.yaml b/releasenotes/notes/trio-support-retry-22bd544800cd1f36.yaml
new file mode 100644
index 00000000..b8e0c149
--- /dev/null
+++ b/releasenotes/notes/trio-support-retry-22bd544800cd1f36.yaml
@@ -0,0 +1,6 @@
+---
+features:
+ - |
+ If you're using `Trio `__, then
+ ``@retry`` now works automatically. It's no longer necessary to
+ pass ``sleep=trio.sleep``.
diff --git a/releasenotes/notes/wait-random-exponential-min-2a4b7eed9f002436.yaml b/releasenotes/notes/wait-random-exponential-min-2a4b7eed9f002436.yaml
new file mode 100644
index 00000000..34efd1c2
--- /dev/null
+++ b/releasenotes/notes/wait-random-exponential-min-2a4b7eed9f002436.yaml
@@ -0,0 +1,4 @@
+---
+fixes:
+ - |
+ Respects `min` arg for `wait_random_exponential`
diff --git a/releasenotes/notes/wait_exponential_jitter-6ffc81dddcbaa6d3.yaml b/releasenotes/notes/wait_exponential_jitter-6ffc81dddcbaa6d3.yaml
new file mode 100644
index 00000000..870380ca
--- /dev/null
+++ b/releasenotes/notes/wait_exponential_jitter-6ffc81dddcbaa6d3.yaml
@@ -0,0 +1,5 @@
+---
+features:
+ - |
+ Implement a wait.wait_exponential_jitter per Google's storage retry guide.
+ See https://cloud.google.com/storage/docs/retry-strategy
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 5cc3cb18..00000000
--- a/setup.cfg
+++ /dev/null
@@ -1,46 +0,0 @@
-[metadata]
-name = tenacity
-license = Apache 2.0
-url = https://github.com/jd/tenacity
-summary = Retry code until it succeeds
-long_description = Tenacity is a general-purpose retrying library to simplify the task of adding retry behavior to just about anything.
-author = Julien Danjou
-author-email = julien@danjou.info
-home-page = https://github.com/jd/tenacity
-classifier =
- Intended Audience :: Developers
- License :: OSI Approved :: Apache Software License
- Programming Language :: Python
- Programming Language :: Python :: 2
- Programming Language :: Python :: 2.7
- Programming Language :: Python :: 3
- Programming Language :: Python :: 3.5
- Programming Language :: Python :: 3.6
- Programming Language :: Python :: 3.7
- Programming Language :: Python :: 3.8
- Topic :: Utilities
-
-[options]
-install_requires =
- six>=1.9.0
- futures>=3.0;python_version=='2.7'
- monotonic>=0.6;python_version=='2.7'
- typing>=3.7.4.1;python_version=='2.7'
-packages = tenacity
-
-[options.package_data]
-tenacity = py.typed
-
-[options.extras_require]
-doc =
- reno
- sphinx
- tornado>=4.5
-
-[wheel]
-universal = 1
-
-[tool:pytest]
-filterwarnings =
- # Show any DeprecationWarnings once
- once::DeprecationWarning
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 66163685..00000000
--- a/setup.py
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env python
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-# implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-import setuptools
-
-setuptools.setup(
- setup_requires=['setuptools_scm'],
- use_scm_version=True,
-)
diff --git a/tenacity/__init__.py b/tenacity/__init__.py
index 336f4f65..282e6dae 100644
--- a/tenacity/__init__.py
+++ b/tenacity/__init__.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# Copyright 2016-2018 Julien Danjou
# Copyright 2017 Elisey Zanko
# Copyright 2016 Étienne Bersac
@@ -16,118 +15,113 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-
-try:
- from inspect import iscoroutinefunction
-except ImportError:
- iscoroutinefunction = None
-
-try:
- import tornado
-except ImportError:
- tornado = None
-
+import dataclasses
+import functools
import sys
import threading
+import time
import typing as t
import warnings
-from abc import ABCMeta, abstractmethod
+from abc import ABC, abstractmethod
from concurrent import futures
+from . import _utils
-import six
+# Import all built-in after strategies for easier usage.
+from .after import after_log, after_nothing
-from tenacity import _utils
-from tenacity import compat as _compat
+# Import all built-in before strategies for easier usage.
+from .before import before_log, before_nothing
-# Import all built-in retry strategies for easier usage.
-from .retry import retry_all # noqa
-from .retry import retry_always # noqa
-from .retry import retry_any # noqa
-from .retry import retry_if_exception # noqa
-from .retry import retry_if_exception_type # noqa
-from .retry import retry_if_not_result # noqa
-from .retry import retry_if_result # noqa
-from .retry import retry_never # noqa
-from .retry import retry_unless_exception_type # noqa
-from .retry import retry_if_exception_message # noqa
-from .retry import retry_if_not_exception_message # noqa
+# Import all built-in before sleep strategies for easier usage.
+from .before_sleep import before_sleep_log, before_sleep_nothing
# Import all nap strategies for easier usage.
-from .nap import sleep # noqa
-from .nap import sleep_using_event # noqa
+from .nap import sleep, sleep_using_event
+
+# Import all built-in retry strategies for easier usage.
+from .retry import (
+ retry_all,
+ retry_always,
+ retry_any,
+ retry_base,
+ retry_if_exception,
+ retry_if_exception_cause_type,
+ retry_if_exception_message,
+ retry_if_exception_type,
+ retry_if_not_exception_message,
+ retry_if_not_exception_type,
+ retry_if_not_result,
+ retry_if_result,
+ retry_never,
+ retry_unless_exception_type,
+)
# Import all built-in stop strategies for easier usage.
-from .stop import stop_after_attempt # noqa
-from .stop import stop_after_delay # noqa
-from .stop import stop_all # noqa
-from .stop import stop_any # noqa
-from .stop import stop_never # noqa
-from .stop import stop_when_event_set # noqa
+from .stop import (
+ stop_after_attempt,
+ stop_after_delay,
+ stop_all,
+ stop_any,
+ stop_before_delay,
+ stop_never,
+ stop_when_event_set,
+)
# Import all built-in wait strategies for easier usage.
-from .wait import wait_chain # noqa
-from .wait import wait_combine # noqa
-from .wait import wait_exponential # noqa
-from .wait import wait_fixed # noqa
-from .wait import wait_incrementing # noqa
-from .wait import wait_none # noqa
-from .wait import wait_random # noqa
-from .wait import wait_random_exponential # noqa
-from .wait import wait_random_exponential as wait_full_jitter # noqa
-
-# Import all built-in before strategies for easier usage.
-from .before import before_log # noqa
-from .before import before_nothing # noqa
+from .wait import (
+ wait_chain,
+ wait_combine,
+ wait_exception,
+ wait_exponential,
+ wait_exponential_jitter,
+ wait_fixed,
+ wait_incrementing,
+ wait_none,
+ wait_random,
+ wait_random_exponential,
+)
+from .wait import wait_random_exponential as wait_full_jitter
-# Import all built-in after strategies for easier usage.
-from .after import after_log # noqa
-from .after import after_nothing # noqa
+try:
+ import tornado
+except ImportError:
+ tornado = None # type: ignore[assignment]
-# Import all built-in after strategies for easier usage.
-from .before_sleep import before_sleep_log # noqa
-from .before_sleep import before_sleep_nothing # noqa
+if t.TYPE_CHECKING:
+ if sys.version_info >= (3, 11):
+ from typing import Self
+ else:
+ from typing_extensions import Self
+ import types
-WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable)
+ from . import asyncio as tasyncio
+ from .retry import RetryBaseT
+ from .stop import StopBaseT
+ from .wait import WaitBaseT
-@t.overload
-def retry(fn):
- # type: (WrappedFn) -> WrappedFn
- """Type signature for @retry as a raw decorator."""
- pass
+WrappedFnReturnT = t.TypeVar("WrappedFnReturnT")
+WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Any])
+P = t.ParamSpec("P")
+R = t.TypeVar("R")
-@t.overload
-def retry(*dargs, **dkw): # noqa
- # type: (...) -> t.Callable[[WrappedFn], WrappedFn]
- """Type signature for the @retry() decorator constructor."""
- pass
+@dataclasses.dataclass(slots=True)
+class IterState:
+ actions: list[t.Callable[["RetryCallState"], t.Any]] = dataclasses.field(
+ default_factory=list
+ )
+ retry_run_result: bool = False
+ stop_run_result: bool = False
+ is_explicit_retry: bool = False
-
-def retry(*dargs, **dkw): # noqa
- """Wrap a function with a new `Retrying` object.
-
- :param dargs: positional arguments passed to Retrying object
- :param dkw: keyword arguments passed to the Retrying object
- """
- # support both @retry and @retry() as valid syntax
- if len(dargs) == 1 and callable(dargs[0]):
- return retry()(dargs[0])
- else:
- def wrap(f):
- if iscoroutinefunction is not None and iscoroutinefunction(f):
- r = AsyncRetrying(*dargs, **dkw)
- elif tornado and hasattr(tornado.gen, 'is_coroutine_function') \
- and tornado.gen.is_coroutine_function(f):
- r = TornadoRetrying(*dargs, **dkw)
- else:
- r = Retrying(*dargs, **dkw)
-
- return r.wraps(f)
-
- return wrap
+ def reset(self) -> None:
+ self.actions = []
+ self.retry_run_result = False
+ self.stop_run_result = False
+ self.is_explicit_retry = False
class TryAgain(Exception):
@@ -137,7 +131,7 @@ class TryAgain(Exception):
NO_RESULT = object()
-class DoAttempt(object):
+class DoAttempt:
pass
@@ -145,158 +139,183 @@ class DoSleep(float):
pass
-class BaseAction(object):
+class BaseAction:
"""Base class for representing actions to take by retry object.
Concrete implementations must define:
- __init__: to initialize all necessary fields
- - REPR_ATTRS: class variable specifying attributes to include in repr(self)
+ - REPR_FIELDS: class variable specifying attributes to include in repr(self)
- NAME: for identification in retry object methods and callbacks
"""
- REPR_FIELDS = ()
- NAME = None
+ REPR_FIELDS: t.Sequence[str] = ()
+ NAME: str | None = None
- def __repr__(self):
- state_str = ', '.join('%s=%r' % (field, getattr(self, field))
- for field in self.REPR_FIELDS)
- return '%s(%s)' % (type(self).__name__, state_str)
+ def __repr__(self) -> str:
+ state_str = ", ".join(
+ f"{field}={getattr(self, field)!r}" for field in self.REPR_FIELDS
+ )
+ return f"{self.__class__.__name__}({state_str})"
- def __str__(self):
+ def __str__(self) -> str:
return repr(self)
class RetryAction(BaseAction):
- REPR_FIELDS = ('sleep',)
- NAME = 'retry'
+ REPR_FIELDS = ("sleep",)
+ NAME = "retry"
- def __init__(self, sleep):
+ def __init__(self, sleep: t.SupportsFloat) -> None:
self.sleep = float(sleep)
_unset = object()
+def _first_set(first: t.Any | object, second: t.Any) -> t.Any:
+ return second if first is _unset else first
+
+
class RetryError(Exception):
"""Encapsulates the last attempt instance right before giving up."""
- def __init__(self, last_attempt):
+ def __init__(self, last_attempt: "Future") -> None:
self.last_attempt = last_attempt
- super(RetryError, self).__init__(last_attempt)
+ super().__init__(last_attempt)
- def reraise(self):
+ def reraise(self) -> t.NoReturn:
if self.last_attempt.failed:
raise self.last_attempt.result()
raise self
- def __str__(self):
- return "{0}[{1}]".format(self.__class__.__name__, self.last_attempt)
+ def __str__(self) -> str:
+ return f"{self.__class__.__name__}[{self.last_attempt}]"
-class AttemptManager(object):
+class AttemptManager:
"""Manage attempt context."""
- def __init__(self, retry_state):
+ def __init__(self, retry_state: "RetryCallState"):
self.retry_state = retry_state
- def __enter__(self):
+ def __enter__(self) -> None:
pass
- def __exit__(self, exc_type, exc_value, traceback):
- if isinstance(exc_value, BaseException):
+ def __exit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_value: BaseException | None,
+ traceback: "types.TracebackType | None",
+ ) -> bool | None:
+ if exc_type is not None and exc_value is not None:
self.retry_state.set_exception((exc_type, exc_value, traceback))
return True # Swallow exception.
- else:
- # We don't have the result, actually.
- self.retry_state.set_result(None)
-
-
-class BaseRetrying(object):
- __metaclass__ = ABCMeta
-
- def __init__(self,
- sleep=sleep,
- stop=stop_never, wait=wait_none(),
- retry=retry_if_exception_type(),
- before=before_nothing,
- after=after_nothing,
- before_sleep=None,
- reraise=False,
- retry_error_cls=RetryError,
- retry_error_callback=None):
+ # We don't have the result, actually.
+ self.retry_state.set_result(None)
+ return None
+
+ async def __aenter__(self) -> None:
+ pass
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_value: BaseException | None,
+ traceback: "types.TracebackType | None",
+ ) -> bool | None:
+ return self.__exit__(exc_type, exc_value, traceback)
+
+
+class BaseRetrying(ABC):
+ def __init__(
+ self,
+ sleep: t.Callable[[int | float], None] = sleep,
+ stop: "StopBaseT" = stop_never,
+ wait: "WaitBaseT" = wait_none(),
+ retry: "RetryBaseT" = retry_if_exception_type(),
+ before: t.Callable[["RetryCallState"], None] = before_nothing,
+ after: t.Callable[["RetryCallState"], None] = after_nothing,
+ before_sleep: t.Callable[["RetryCallState"], None] | None = None,
+ reraise: bool = False,
+ retry_error_cls: type[RetryError] = RetryError,
+ retry_error_callback: t.Callable[["RetryCallState"], t.Any] | None = None,
+ name: str | None = None,
+ enabled: bool = True,
+ ):
self.sleep = sleep
- self._stop = stop
- self._wait = wait
- self._retry = retry
- self._before = before
- self._after = after
- self._before_sleep = before_sleep
+ self.stop = stop
+ self.wait = wait
+ self.retry = retry
+ self.before = before
+ self.after = after
+ self.before_sleep = before_sleep
self.reraise = reraise
self._local = threading.local()
self.retry_error_cls = retry_error_cls
- self._retry_error_callback = retry_error_callback
-
- # This attribute was moved to RetryCallState and is deprecated on
- # Retrying objects but kept for backward compatibility.
- self.fn = None
-
- @_utils.cached_property
- def stop(self):
- return _compat.stop_func_accept_retry_state(self._stop)
-
- @_utils.cached_property
- def wait(self):
- return _compat.wait_func_accept_retry_state(self._wait)
-
- @_utils.cached_property
- def retry(self):
- return _compat.retry_func_accept_retry_state(self._retry)
-
- @_utils.cached_property
- def before(self):
- return _compat.before_func_accept_retry_state(self._before)
-
- @_utils.cached_property
- def after(self):
- return _compat.after_func_accept_retry_state(self._after)
-
- @_utils.cached_property
- def before_sleep(self):
- return _compat.before_sleep_func_accept_retry_state(self._before_sleep)
-
- @_utils.cached_property
- def retry_error_callback(self):
- return _compat.retry_error_callback_accept_retry_state(
- self._retry_error_callback)
-
- def copy(self, sleep=_unset, stop=_unset, wait=_unset,
- retry=_unset, before=_unset, after=_unset, before_sleep=_unset,
- reraise=_unset):
+ self.retry_error_callback = retry_error_callback
+ self._name = name
+ self.enabled = enabled
+
+ def copy(
+ self,
+ sleep: t.Callable[[int | float], None] | object = _unset,
+ stop: "StopBaseT | object" = _unset,
+ wait: "WaitBaseT | object" = _unset,
+ retry: retry_base | object = _unset,
+ before: t.Callable[["RetryCallState"], None] | object = _unset,
+ after: t.Callable[["RetryCallState"], None] | object = _unset,
+ before_sleep: t.Callable[["RetryCallState"], None] | None | object = _unset,
+ reraise: bool | object = _unset,
+ retry_error_cls: type[RetryError] | object = _unset,
+ retry_error_callback: t.Callable[["RetryCallState"], t.Any]
+ | None
+ | object = _unset,
+ name: str | None | object = _unset,
+ enabled: bool | object = _unset,
+ ) -> "Self":
"""Copy this object with some parameters changed if needed."""
- if before_sleep is _unset:
- before_sleep = self.before_sleep
return self.__class__(
- sleep=self.sleep if sleep is _unset else sleep,
- stop=self.stop if stop is _unset else stop,
- wait=self.wait if wait is _unset else wait,
- retry=self.retry if retry is _unset else retry,
- before=self.before if before is _unset else before,
- after=self.after if after is _unset else after,
- before_sleep=before_sleep,
- reraise=self.reraise if after is _unset else reraise,
+ sleep=_first_set(sleep, self.sleep),
+ stop=_first_set(stop, self.stop),
+ wait=_first_set(wait, self.wait),
+ retry=_first_set(retry, self.retry),
+ before=_first_set(before, self.before),
+ after=_first_set(after, self.after),
+ before_sleep=_first_set(before_sleep, self.before_sleep),
+ reraise=_first_set(reraise, self.reraise),
+ retry_error_cls=_first_set(retry_error_cls, self.retry_error_cls),
+ retry_error_callback=_first_set(
+ retry_error_callback, self.retry_error_callback
+ ),
+ name=_first_set(name, self._name),
+ enabled=_first_set(enabled, self.enabled),
)
- def __repr__(self):
- attrs = dict(
- _utils.visible_attrs(self, attrs={'me': id(self)}),
- __class__=self.__class__.__name__,
+ def __getstate__(self) -> dict[str, t.Any]:
+ # Exclude threading.local which cannot be pickled
+ return {k: v for k, v in self.__dict__.items() if k != "_local"}
+
+ def __setstate__(self, state: dict[str, t.Any]) -> None:
+ self.__dict__.update(state)
+ self._local = threading.local()
+
+ def __str__(self) -> str:
+ return self._name if self._name is not None else ""
+
+ def __repr__(self) -> str:
+ return (
+ f"<{self.__class__.__name__} object at 0x{id(self):x} ("
+ f"stop={self.stop}, "
+ f"wait={self.wait}, "
+ f"sleep={self.sleep}, "
+ f"retry={self.retry}, "
+ f"before={self.before}, "
+ f"after={self.after}, "
+ f"name={self._name!r})>"
)
- return ("<%(__class__)s object at 0x%(me)x (stop=%(stop)s, "
- "wait=%(wait)s, sleep=%(sleep)s, retry=%(retry)s, "
- "before=%(before)s, after=%(after)s)>") % (attrs)
@property
- def statistics(self):
+ def statistics(self) -> dict[str, t.Any]:
"""Return a dictionary of runtime statistics.
This dictionary will be empty when the controller has never been
@@ -304,8 +323,8 @@ def statistics(self):
may not) have useful and/or informational keys and values when
running is underway and/or completed.
- .. warning:: The keys in this dictionary **should** be some what
- stable (not changing), but there existence **may**
+ .. warning:: The keys in this dictionary **should** be somewhat
+ stable (not changing), but their existence **may**
change between major releases as new statistics are
gathered or removed so before accessing keys ensure that
they actually exist and handle when they do not.
@@ -313,81 +332,142 @@ def statistics(self):
.. note:: The values in this dictionary are local to the thread
running call (so if multiple threads share the same retrying
object - either directly or indirectly) they will each have
- there own view of statistics they have collected (in the
+ their own view of statistics they have collected (in the
future we may provide a way to aggregate the various
statistics from each thread).
"""
- try:
- return self._local.statistics
- except AttributeError:
- self._local.statistics = {}
- return self._local.statistics
+ if not hasattr(self._local, "statistics"):
+ self._local.statistics = t.cast("dict[str, t.Any]", {})
+ return self._local.statistics # type: ignore[no-any-return]
- def wraps(self, f):
+ @property
+ def iter_state(self) -> IterState:
+ if not hasattr(self._local, "iter_state"):
+ self._local.iter_state = IterState()
+ return self._local.iter_state # type: ignore[no-any-return]
+
+ def wraps(self, f: t.Callable[P, R]) -> "_RetryDecorated[P, R]":
"""Wrap a function for retrying.
- :param f: A function to wraps for retrying.
+ :param f: A function to wrap for retrying.
"""
- @_utils.wraps(f)
- def wrapped_f(*args, **kw):
- return self(f, *args, **kw)
- def retry_with(*args, **kwargs):
+ @functools.wraps(
+ f, functools.WRAPPER_ASSIGNMENTS + ("__defaults__", "__kwdefaults__")
+ )
+ def wrapped_f(*args: t.Any, **kw: t.Any) -> t.Any:
+ if not self.enabled:
+ return f(*args, **kw)
+ # Always create a copy to prevent overwriting the local contexts when
+ # calling the same wrapped functions multiple times in the same stack
+ copy = self.copy()
+ wrapped_f.statistics = copy.statistics # type: ignore[attr-defined]
+ self._local.statistics = copy.statistics
+ return copy(f, *args, **kw)
+
+ def retry_with(*args: t.Any, **kwargs: t.Any) -> "_RetryDecorated[P, R]":
return self.copy(*args, **kwargs).wraps(f)
- wrapped_f.retry = self
- wrapped_f.retry_with = retry_with
+ # Preserve attributes
+ wrapped_f.retry = self # type: ignore[attr-defined]
+ wrapped_f.retry_with = retry_with # type: ignore[attr-defined]
+ wrapped_f.statistics = {} # type: ignore[attr-defined]
- return wrapped_f
+ return t.cast("_RetryDecorated[P, R]", wrapped_f)
- def begin(self, fn):
+ def begin(self) -> None:
self.statistics.clear()
- self.statistics['start_time'] = _utils.now()
- self.statistics['attempt_number'] = 1
- self.statistics['idle_for'] = 0
- self.fn = fn
+ self.statistics["start_time"] = time.monotonic()
+ self.statistics["attempt_number"] = 1
+ self.statistics["idle_for"] = 0
+ self.statistics["delay_since_first_attempt"] = 0
+
+ def _add_action_func(self, fn: t.Callable[..., t.Any]) -> None:
+ self.iter_state.actions.append(fn)
+
+ def _run_retry(self, retry_state: "RetryCallState") -> None:
+ self.iter_state.retry_run_result = self.retry(retry_state)
+
+ def _run_wait(self, retry_state: "RetryCallState") -> None:
+ if self.wait:
+ sleep = self.wait(retry_state)
+ else:
+ sleep = 0.0
+
+ retry_state.upcoming_sleep = sleep
+
+ def _run_stop(self, retry_state: "RetryCallState") -> None:
+ self.statistics["delay_since_first_attempt"] = retry_state.seconds_since_start
+ self.iter_state.stop_run_result = self.stop(retry_state)
+
+ def iter(self, retry_state: "RetryCallState") -> DoAttempt | DoSleep | t.Any:
+ self._begin_iter(retry_state)
+ result = None
+ for action in self.iter_state.actions:
+ result = action(retry_state)
+ return result
+
+ def _begin_iter(self, retry_state: "RetryCallState") -> None:
+ self.iter_state.reset()
- def iter(self, retry_state): # noqa
fut = retry_state.outcome
if fut is None:
if self.before is not None:
- self.before(retry_state)
- return DoAttempt()
+ self._add_action_func(self.before)
+ self._add_action_func(lambda rs: DoAttempt())
+ return
+
+ self.iter_state.is_explicit_retry = fut.failed and isinstance(
+ fut.exception(), TryAgain
+ )
+ if not self.iter_state.is_explicit_retry:
+ self._add_action_func(self._run_retry)
+ self._add_action_func(self._post_retry_check_actions)
- is_explicit_retry = retry_state.outcome.failed \
- and isinstance(retry_state.outcome.exception(), TryAgain)
- if not (is_explicit_retry or self.retry(retry_state=retry_state)):
- return fut.result()
+ def _post_retry_check_actions(self, retry_state: "RetryCallState") -> None:
+ if not (self.iter_state.is_explicit_retry or self.iter_state.retry_run_result):
+ self._add_action_func(lambda rs: rs.outcome.result())
+ return
if self.after is not None:
- self.after(retry_state=retry_state)
+ self._add_action_func(self.after)
- self.statistics['delay_since_first_attempt'] = \
- retry_state.seconds_since_start
- if self.stop(retry_state=retry_state):
+ self._add_action_func(self._run_wait)
+ self._add_action_func(self._run_stop)
+ self._add_action_func(self._post_stop_check_actions)
+
+ def _post_stop_check_actions(self, retry_state: "RetryCallState") -> None:
+ if self.iter_state.stop_run_result:
if self.retry_error_callback:
- return self.retry_error_callback(retry_state=retry_state)
- retry_exc = self.retry_error_cls(fut)
- if self.reraise:
- raise retry_exc.reraise()
- six.raise_from(retry_exc, fut.exception())
+ self._add_action_func(self.retry_error_callback)
+ return
- if self.wait:
- sleep = self.wait(retry_state=retry_state)
- else:
- sleep = 0.0
- retry_state.next_action = RetryAction(sleep)
- retry_state.idle_for += sleep
- self.statistics['idle_for'] += sleep
- self.statistics['attempt_number'] += 1
+ def exc_check(rs: "RetryCallState") -> None:
+ fut = t.cast("Future", rs.outcome)
+ retry_exc = self.retry_error_cls(fut)
+ if self.reraise:
+ retry_exc.reraise()
+ raise retry_exc from fut.exception()
+
+ self._add_action_func(exc_check)
+ return
+
+ def next_action(rs: "RetryCallState") -> None:
+ sleep = rs.upcoming_sleep
+ rs.next_action = RetryAction(sleep)
+ rs.idle_for += sleep
+ self.statistics["idle_for"] += sleep
+ self.statistics["attempt_number"] += 1
+
+ self._add_action_func(next_action)
if self.before_sleep is not None:
- self.before_sleep(retry_state=retry_state)
+ self._add_action_func(self.before_sleep)
- return DoSleep(sleep)
+ self._add_action_func(lambda rs: DoSleep(rs.upcoming_sleep))
- def __iter__(self):
- self.begin(None)
+ def __iter__(self) -> t.Generator[AttemptManager, None, None]:
+ self.begin()
retry_state = RetryCallState(self, fn=None, args=(), kwargs={})
while True:
@@ -401,54 +481,59 @@ def __iter__(self):
break
@abstractmethod
- def __call__(self, *args, **kwargs):
+ def __call__(
+ self,
+ fn: t.Callable[..., WrappedFnReturnT],
+ *args: t.Any,
+ **kwargs: t.Any,
+ ) -> WrappedFnReturnT:
pass
class Retrying(BaseRetrying):
"""Retrying controller."""
- def __call__(self, fn, *args, **kwargs):
- self.begin(fn)
+ def __call__(
+ self,
+ fn: t.Callable[..., WrappedFnReturnT],
+ *args: t.Any,
+ **kwargs: t.Any,
+ ) -> WrappedFnReturnT:
+ self.begin()
- retry_state = RetryCallState(
- retry_object=self, fn=fn, args=args, kwargs=kwargs)
+ retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs)
while True:
do = self.iter(retry_state=retry_state)
if isinstance(do, DoAttempt):
try:
result = fn(*args, **kwargs)
except BaseException:
- retry_state.set_exception(sys.exc_info())
+ retry_state.set_exception(sys.exc_info()) # type: ignore[arg-type]
else:
retry_state.set_result(result)
elif isinstance(do, DoSleep):
retry_state.prepare_for_next_attempt()
self.sleep(do)
else:
- return do
-
- def call(self, *args, **kwargs):
- """Use ``__call__`` instead because this method is deprecated."""
- warnings.warn("'Retrying.call()' method is deprecated. " +
- "Use 'Retrying.__call__()' instead", DeprecationWarning)
- return self.__call__(*args, **kwargs)
+ return do # type: ignore[no-any-return]
-class Future(futures.Future):
+class Future(futures.Future[t.Any]):
"""Encapsulates a (future or past) attempted call to a target function."""
- def __init__(self, attempt_number):
- super(Future, self).__init__()
+ def __init__(self, attempt_number: int) -> None:
+ super().__init__()
self.attempt_number = attempt_number
@property
- def failed(self):
+ def failed(self) -> bool:
"""Return whether a exception is being held in this future."""
return self.exception() is not None
@classmethod
- def construct(cls, attempt_number, value, has_exception):
+ def construct(
+ cls, attempt_number: int, value: t.Any, has_exception: bool
+ ) -> "Future":
"""Construct a new Future object."""
fut = cls(attempt_number)
if has_exception:
@@ -458,12 +543,18 @@ def construct(cls, attempt_number, value, has_exception):
return fut
-class RetryCallState(object):
+class RetryCallState:
"""State related to a single call wrapped with Retrying."""
- def __init__(self, retry_object, fn, args, kwargs):
+ def __init__(
+ self,
+ retry_object: BaseRetrying,
+ fn: WrappedFn | None,
+ args: t.Any,
+ kwargs: t.Any,
+ ) -> None:
#: Retry call start timestamp
- self.start_time = _utils.now()
+ self.start_time = time.monotonic()
#: Retry manager object
self.retry_object = retry_object
#: Function wrapped by this retry call
@@ -474,43 +565,242 @@ def __init__(self, retry_object, fn, args, kwargs):
self.kwargs = kwargs
#: The number of the current attempt
- self.attempt_number = 1
+ self.attempt_number: int = 1
#: Last outcome (result or exception) produced by the function
- self.outcome = None
+ self.outcome: Future | None = None
#: Timestamp of the last outcome
- self.outcome_timestamp = None
+ self.outcome_timestamp: float | None = None
#: Time spent sleeping in retries
- self.idle_for = 0
+ self.idle_for: float = 0.0
#: Next action as decided by the retry manager
- self.next_action = None
+ self.next_action: RetryAction | None = None
+ #: Next sleep time as decided by the retry manager.
+ self.upcoming_sleep: float = 0.0
+
+ def get_fn_name(self) -> str:
+ """Get the name of the function being retried.
+
+ Returns the fully-qualified name of the wrapped function when used as a
+ decorator, the ``name`` passed to the retrying object when used as a
+ context manager, or ``""`` if neither is available.
+ """
+ if self.fn is not None:
+ return _utils.get_callback_name(self.fn)
+ return str(self.retry_object)
@property
- def seconds_since_start(self):
+ def seconds_since_start(self) -> float | None:
if self.outcome_timestamp is None:
return None
return self.outcome_timestamp - self.start_time
- def prepare_for_next_attempt(self):
+ def prepare_for_next_attempt(self) -> None:
self.outcome = None
self.outcome_timestamp = None
self.attempt_number += 1
self.next_action = None
- def set_result(self, val):
- ts = _utils.now()
+ def set_result(self, val: t.Any) -> None:
+ ts = time.monotonic()
fut = Future(self.attempt_number)
fut.set_result(val)
self.outcome, self.outcome_timestamp = fut, ts
- def set_exception(self, exc_info):
- ts = _utils.now()
+ def set_exception(
+ self,
+ exc_info: tuple[
+ type[BaseException], BaseException, "types.TracebackType | None"
+ ],
+ ) -> None:
+ ts = time.monotonic()
fut = Future(self.attempt_number)
- _utils.capture(fut, exc_info)
+ fut.set_exception(exc_info[1])
self.outcome, self.outcome_timestamp = fut, ts
+ def __repr__(self) -> str:
+ if self.outcome is None:
+ result = "none yet"
+ elif self.outcome.failed:
+ exception = self.outcome.exception()
+ result = f"failed ({exception.__class__.__name__} {exception})"
+ else:
+ result = f"returned {self.outcome.result()}"
+
+ slept = float(round(self.idle_for, 2))
+ clsname = self.__class__.__name__
+ return f"<{clsname} {id(self)}: attempt #{self.attempt_number}; slept for {slept}; last result: {result}>"
-if iscoroutinefunction:
- from tenacity._asyncio import AsyncRetrying
+
+class _RetryDecorated(t.Protocol[P, R]):
+ """Protocol for functions decorated with @retry.
+
+ Provides the original callable signature plus retry control attributes.
+ """
+
+ retry: "BaseRetrying"
+ statistics: dict[str, t.Any]
+
+ def retry_with(self, *args: t.Any, **kwargs: t.Any) -> "_RetryDecorated[P, R]": ...
+
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ...
+
+
+class _AsyncRetryDecorator(t.Protocol):
+ @t.overload
+ def __call__(
+ self, fn: "t.Callable[P, types.CoroutineType[t.Any, t.Any, R]]"
+ ) -> "_RetryDecorated[P, types.CoroutineType[t.Any, t.Any, R]]": ...
+ @t.overload
+ def __call__(
+ self, fn: t.Callable[P, t.Coroutine[t.Any, t.Any, R]]
+ ) -> "_RetryDecorated[P, t.Coroutine[t.Any, t.Any, R]]": ...
+ @t.overload
+ def __call__(
+ self, fn: t.Callable[P, t.Awaitable[R]]
+ ) -> "_RetryDecorated[P, t.Awaitable[R]]": ...
+ @t.overload
+ def __call__(
+ self, fn: t.Callable[P, R]
+ ) -> "_RetryDecorated[P, t.Awaitable[R]]": ...
+
+
+@t.overload
+def retry(func: t.Callable[P, R]) -> _RetryDecorated[P, R]: ...
+
+
+@t.overload
+def retry(
+ *,
+ sleep: t.Callable[[int | float], t.Awaitable[None]],
+ stop: "StopBaseT" = ...,
+ wait: "WaitBaseT" = ...,
+ retry: "RetryBaseT | tasyncio.retry.RetryBaseT" = ...,
+ before: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = ...,
+ after: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = ...,
+ before_sleep: t.Callable[["RetryCallState"], None | t.Awaitable[None]] | None = ...,
+ reraise: bool = ...,
+ retry_error_cls: type["RetryError"] = ...,
+ retry_error_callback: t.Callable[["RetryCallState"], t.Any | t.Awaitable[t.Any]]
+ | None = ...,
+ enabled: bool = ...,
+) -> _AsyncRetryDecorator: ...
+
+
+@t.overload
+def retry(
+ sleep: t.Callable[[int | float], None] = sleep,
+ stop: "StopBaseT" = stop_never,
+ wait: "WaitBaseT" = wait_none(),
+ retry: "RetryBaseT | tasyncio.retry.RetryBaseT" = retry_if_exception_type(),
+ before: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = before_nothing,
+ after: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = after_nothing,
+ before_sleep: t.Callable[["RetryCallState"], None | t.Awaitable[None]]
+ | None = None,
+ reraise: bool = False,
+ retry_error_cls: type["RetryError"] = RetryError,
+ retry_error_callback: t.Callable[["RetryCallState"], t.Any | t.Awaitable[t.Any]]
+ | None = None,
+ enabled: bool = True,
+) -> t.Callable[[t.Callable[P, R]], _RetryDecorated[P, R]]: ...
+
+
+def retry(*dargs: t.Any, **dkw: t.Any) -> t.Any:
+ """Wrap a function with a new `Retrying` object.
+
+ :param dargs: positional arguments passed to Retrying object
+ :param dkw: keyword arguments passed to the Retrying object
+ """
+ # support both @retry and @retry() as valid syntax
+ if len(dargs) == 1 and callable(dargs[0]):
+ return retry()(dargs[0])
+
+ def wrap(f: t.Callable[P, R]) -> _RetryDecorated[P, R]:
+ if isinstance(f, retry_base):
+ warnings.warn(
+ f"Got retry_base instance ({f.__class__.__name__}) as callable argument, "
+ f"this will probably hang indefinitely (did you mean retry={f.__class__.__name__}(...)?)",
+ stacklevel=2,
+ )
+ r: BaseRetrying
+ sleep = dkw.get("sleep")
+ if _utils.is_coroutine_callable(f) or (
+ sleep is not None and _utils.is_coroutine_callable(sleep)
+ ):
+ r = AsyncRetrying(*dargs, **dkw)
+ elif (
+ tornado
+ and hasattr(tornado.gen, "is_coroutine_function")
+ and tornado.gen.is_coroutine_function(f)
+ ):
+ r = TornadoRetrying(*dargs, **dkw)
+ else:
+ r = Retrying(*dargs, **dkw)
+
+ return r.wraps(f)
+
+ return wrap
+
+
+from tenacity.asyncio import AsyncRetrying # noqa: E402
if tornado:
from tenacity.tornadoweb import TornadoRetrying
+
+
+__all__ = [
+ "NO_RESULT",
+ "AsyncRetrying",
+ "AttemptManager",
+ "BaseAction",
+ "BaseRetrying",
+ "DoAttempt",
+ "DoSleep",
+ "Future",
+ "RetryAction",
+ "RetryCallState",
+ "RetryError",
+ "Retrying",
+ "TryAgain",
+ "WrappedFn",
+ "after_log",
+ "after_nothing",
+ "before_log",
+ "before_nothing",
+ "before_sleep_log",
+ "before_sleep_nothing",
+ "retry",
+ "retry_all",
+ "retry_always",
+ "retry_any",
+ "retry_base",
+ "retry_if_exception",
+ "retry_if_exception_cause_type",
+ "retry_if_exception_message",
+ "retry_if_exception_type",
+ "retry_if_not_exception_message",
+ "retry_if_not_exception_type",
+ "retry_if_not_result",
+ "retry_if_result",
+ "retry_never",
+ "retry_unless_exception_type",
+ "sleep",
+ "sleep_using_event",
+ "stop_after_attempt",
+ "stop_after_delay",
+ "stop_all",
+ "stop_any",
+ "stop_before_delay",
+ "stop_never",
+ "stop_when_event_set",
+ "wait_chain",
+ "wait_combine",
+ "wait_exception",
+ "wait_exponential",
+ "wait_exponential_jitter",
+ "wait_fixed",
+ "wait_full_jitter",
+ "wait_incrementing",
+ "wait_none",
+ "wait_random",
+ "wait_random_exponential",
+]
diff --git a/tenacity/_asyncio.py b/tenacity/_asyncio.py
deleted file mode 100644
index 6b24b2e1..00000000
--- a/tenacity/_asyncio.py
+++ /dev/null
@@ -1,73 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2016 Étienne Bersac
-# Copyright 2016 Julien Danjou
-# Copyright 2016 Joshua Harlow
-# Copyright 2013-2014 Ray Holder
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-import sys
-from asyncio import sleep
-
-from tenacity import AttemptManager
-from tenacity import BaseRetrying
-from tenacity import DoAttempt
-from tenacity import DoSleep
-from tenacity import RetryCallState
-
-
-class AsyncRetrying(BaseRetrying):
-
- def __init__(self,
- sleep=sleep,
- **kwargs):
- super(AsyncRetrying, self).__init__(**kwargs)
- self.sleep = sleep
-
- async def __call__(self, fn, *args, **kwargs):
- self.begin(fn)
-
- retry_state = RetryCallState(
- retry_object=self, fn=fn, args=args, kwargs=kwargs)
- while True:
- do = self.iter(retry_state=retry_state)
- if isinstance(do, DoAttempt):
- try:
- result = await fn(*args, **kwargs)
- except BaseException:
- retry_state.set_exception(sys.exc_info())
- else:
- retry_state.set_result(result)
- elif isinstance(do, DoSleep):
- retry_state.prepare_for_next_attempt()
- await self.sleep(do)
- else:
- return do
-
- def __aiter__(self):
- self.begin(None)
- self._retry_state = RetryCallState(self, fn=None, args=(), kwargs={})
- return self
-
- async def __anext__(self):
- while True:
- do = self.iter(retry_state=self._retry_state)
- if do is None:
- raise StopAsyncIteration
- elif isinstance(do, DoAttempt):
- return AttemptManager(retry_state=self._retry_state)
- elif isinstance(do, DoSleep):
- self._retry_state.prepare_for_next_attempt()
- await self.sleep(do)
- else:
- return do
diff --git a/tenacity/_utils.py b/tenacity/_utils.py
index 6703bd9c..6a12678b 100644
--- a/tenacity/_utils.py
+++ b/tenacity/_utils.py
@@ -13,89 +13,54 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-
+import contextlib
+import functools
import inspect
import sys
-import time
-from functools import update_wrapper
-
-import six
-
-# sys.maxint / 2, since Python 3.2 doesn't have a sys.maxint...
-try:
- MAX_WAIT = sys.maxint / 2
-except AttributeError:
- MAX_WAIT = 1073741823
-
+import typing
+from datetime import timedelta
-if six.PY2:
- from functools import WRAPPER_ASSIGNMENTS, WRAPPER_UPDATES
+# sys.maxsize:
+# An integer giving the maximum value a variable of type Py_ssize_t can take.
+MAX_WAIT = sys.maxsize / 2
- def wraps(fn):
- """Do the same as six.wraps but only copy attributes that exist.
- For example, object instances don't have __name__ attribute, so
- six.wraps fails. This is fixed in Python 3
- (https://bugs.python.org/issue3445), but didn't get backported to six.
-
- Also, see https://github.com/benjaminp/six/issues/250.
- """
- def filter_hasattr(obj, attrs):
- return tuple(a for a in attrs if hasattr(obj, a))
- return six.wraps(
- fn,
- assigned=filter_hasattr(fn, WRAPPER_ASSIGNMENTS),
- updated=filter_hasattr(fn, WRAPPER_UPDATES))
-
- def capture(fut, tb):
- # TODO(harlowja): delete this in future, since its
- # has to repeatedly calculate this crap.
- fut.set_exception_info(tb[1], tb[2])
-
- def getargspec(func):
- # This was deprecated in Python 3.
- return inspect.getargspec(func)
-else:
- from functools import wraps # noqa
+class LoggerProtocol(typing.Protocol):
+ """
+ Protocol used by utils expecting a logger (eg: before_log).
- def capture(fut, tb):
- fut.set_exception(tb[1])
+ Compatible with logging, structlog, loguru, etc...
+ """
- def getargspec(func):
- return inspect.getfullargspec(func)
+ def log(
+ self, level: int, msg: str, *args: typing.Any, **kwargs: typing.Any
+ ) -> typing.Any: ...
-def visible_attrs(obj, attrs=None):
- if attrs is None:
- attrs = {}
- for attr_name, attr in inspect.getmembers(obj):
- if attr_name.startswith("_"):
- continue
- attrs[attr_name] = attr
- return attrs
+def find_ordinal(pos_num: int) -> str:
+ # See: https://en.wikipedia.org/wiki/English_numerals#Ordinal_numbers
+ if 11 <= (pos_num % 100) <= 13:
+ return "th"
-def find_ordinal(pos_num):
- # See: https://en.wikipedia.org/wiki/English_numerals#Ordinal_numbers
if pos_num == 0:
return "th"
- elif pos_num == 1:
- return 'st'
- elif pos_num == 2:
- return 'nd'
- elif pos_num == 3:
- return 'rd'
- elif pos_num >= 4 and pos_num <= 20:
- return 'th'
- else:
- return find_ordinal(pos_num % 10)
+ if pos_num == 1:
+ return "st"
+ if pos_num == 2:
+ return "nd"
+ if pos_num == 3:
+ return "rd"
+ if 4 <= pos_num <= 20:
+ return "th"
+ return find_ordinal(pos_num % 10)
-def to_ordinal(pos_num):
- return "%i%s" % (pos_num, find_ordinal(pos_num))
+def to_ordinal(pos_num: int) -> str:
+ return f"{pos_num}{find_ordinal(pos_num)}"
-def get_callback_name(cb):
+def get_callback_name(cb: typing.Callable[..., typing.Any]) -> str:
"""Get a callback fully-qualified name.
If no name can be produced ``repr(cb)`` is called and returned.
@@ -104,51 +69,43 @@ def get_callback_name(cb):
try:
segments.append(cb.__qualname__)
except AttributeError:
- try:
+ with contextlib.suppress(AttributeError):
segments.append(cb.__name__)
- if inspect.ismethod(cb):
- try:
- # This attribute doesn't exist on py3.x or newer, so
- # we optionally ignore it... (on those versions of
- # python `__qualname__` should have been found anyway).
- segments.insert(0, cb.im_class.__name__)
- except AttributeError:
- pass
- except AttributeError:
- pass
if not segments:
return repr(cb)
- else:
- try:
- # When running under sphinx it appears this can be none?
- if cb.__module__:
- segments.insert(0, cb.__module__)
- except AttributeError:
- pass
- return ".".join(segments)
+ with contextlib.suppress(AttributeError):
+ # When running under sphinx it appears this can be none?
+ if cb.__module__:
+ segments.insert(0, cb.__module__)
+ return ".".join(segments)
+
+
+time_unit_type = int | float | timedelta
-try:
- now = time.monotonic # noqa
-except AttributeError:
- from monotonic import monotonic as now # noqa
+def to_seconds(time_unit: time_unit_type) -> float:
+ return float(
+ time_unit.total_seconds() if isinstance(time_unit, timedelta) else time_unit
+ )
-class cached_property(object):
- """A property that is computed once per instance.
+def is_coroutine_callable(call: typing.Callable[..., typing.Any]) -> bool:
+ if inspect.isclass(call):
+ return False
+ if inspect.iscoroutinefunction(call):
+ return True
+ partial_call = isinstance(call, functools.partial) and call.func
+ dunder_call = partial_call or getattr(call, "__call__", None) # noqa: B004
+ return inspect.iscoroutinefunction(dunder_call)
- Upon being computed it replaces itself with an ordinary attribute. Deleting
- the attribute resets the property.
- Source: https://github.com/bottlepy/bottle/blob/1de24157e74a6971d136550afe1b63eec5b0df2b/bottle.py#L234-L246
- """ # noqa: E501
+def wrap_to_async_func(
+ call: typing.Callable[..., typing.Any],
+) -> typing.Callable[..., typing.Awaitable[typing.Any]]:
+ if is_coroutine_callable(call):
+ return call
- def __init__(self, func):
- update_wrapper(self, func)
- self.func = func
+ async def inner(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
+ return call(*args, **kwargs)
- def __get__(self, obj, cls):
- if obj is None:
- return self
- value = obj.__dict__[self.func.__name__] = self.func(obj)
- return value
+ return inner
diff --git a/tenacity/after.py b/tenacity/after.py
index 55522c99..8e380681 100644
--- a/tenacity/after.py
+++ b/tenacity/after.py
@@ -14,22 +14,33 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import typing
+
from tenacity import _utils
+if typing.TYPE_CHECKING:
+ from tenacity import RetryCallState
+
-def after_nothing(retry_state):
+def after_nothing(retry_state: "RetryCallState") -> None:
"""After call strategy that does nothing."""
-def after_log(logger, log_level, sec_format="%0.3f"):
+def after_log(
+ logger: _utils.LoggerProtocol,
+ log_level: int,
+ sec_format: str = "%.3g",
+) -> typing.Callable[["RetryCallState"], None]:
"""After call strategy that logs to some logger the finished attempt."""
- log_tpl = ("Finished call to '%s' after " + str(sec_format) + "(s), "
- "this was the %s time calling it.")
-
- def log_it(retry_state):
- logger.log(log_level, log_tpl,
- _utils.get_callback_name(retry_state.fn),
- retry_state.seconds_since_start,
- _utils.to_ordinal(retry_state.attempt_number))
+
+ def log_it(retry_state: "RetryCallState") -> None:
+ fn_name = retry_state.get_fn_name()
+ secs = retry_state.seconds_since_start
+ logger.log(
+ log_level,
+ f"Finished call to '{fn_name}' "
+ f"after {sec_format % secs if secs is not None else '?'}(s), "
+ f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.",
+ )
return log_it
diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py
new file mode 100644
index 00000000..214a7ae9
--- /dev/null
+++ b/tenacity/asyncio/__init__.py
@@ -0,0 +1,218 @@
+# Copyright 2016 Étienne Bersac
+# Copyright 2016 Julien Danjou
+# Copyright 2016 Joshua Harlow
+# Copyright 2013-2014 Ray Holder
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import functools
+import sys
+import typing as t
+
+import tenacity
+from tenacity import (
+ AttemptManager,
+ BaseRetrying,
+ DoAttempt,
+ DoSleep,
+ RetryCallState,
+ RetryError,
+ _RetryDecorated,
+ _utils,
+ after_nothing,
+ before_nothing,
+)
+
+# Import all built-in retry strategies for easier usage.
+from .retry import (
+ RetryBaseT,
+ retry_all,
+ retry_any,
+ retry_if_exception,
+ retry_if_result,
+)
+
+if t.TYPE_CHECKING:
+ from tenacity.retry import RetryBaseT as SyncRetryBaseT
+ from tenacity.stop import StopBaseT
+ from tenacity.wait import WaitBaseT
+
+WrappedFnReturnT = t.TypeVar("WrappedFnReturnT")
+WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Awaitable[t.Any]])
+P = t.ParamSpec("P")
+R = t.TypeVar("R")
+
+
+def _portable_async_sleep(seconds: float) -> t.Awaitable[None]:
+ # If trio is already imported, then importing it is cheap.
+ # If trio isn't already imported, then it's definitely not running, so we
+ # can skip further checks.
+ if "trio" in sys.modules:
+ # If trio is available, then sniffio is too
+ import sniffio
+ import trio
+
+ if sniffio.current_async_library() == "trio":
+ return trio.sleep(seconds) # noqa: ASYNC105
+ # Otherwise, assume asyncio
+ # Lazy import asyncio as it's expensive (responsible for 25-50% of total import overhead).
+ import asyncio
+
+ return asyncio.sleep(seconds)
+
+
+class AsyncRetrying(BaseRetrying):
+ def __init__(
+ self,
+ sleep: t.Callable[
+ [int | float], None | t.Awaitable[None]
+ ] = _portable_async_sleep,
+ stop: "StopBaseT" = tenacity.stop.stop_never,
+ wait: "WaitBaseT" = tenacity.wait.wait_none(),
+ retry: "SyncRetryBaseT | RetryBaseT" = tenacity.retry_if_exception_type(),
+ before: t.Callable[
+ ["RetryCallState"], None | t.Awaitable[None]
+ ] = before_nothing,
+ after: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = after_nothing,
+ before_sleep: t.Callable[["RetryCallState"], None | t.Awaitable[None]]
+ | None = None,
+ reraise: bool = False,
+ retry_error_cls: type["RetryError"] = RetryError,
+ retry_error_callback: t.Callable[["RetryCallState"], t.Any | t.Awaitable[t.Any]]
+ | None = None,
+ name: str | None = None,
+ enabled: bool = True,
+ ) -> None:
+ super().__init__(
+ sleep=sleep, # type: ignore[arg-type]
+ stop=stop,
+ wait=wait,
+ retry=retry, # type: ignore[arg-type]
+ before=before, # type: ignore[arg-type]
+ after=after, # type: ignore[arg-type]
+ before_sleep=before_sleep, # type: ignore[arg-type]
+ reraise=reraise,
+ retry_error_cls=retry_error_cls,
+ retry_error_callback=retry_error_callback,
+ name=name,
+ enabled=enabled,
+ )
+
+ async def __call__( # type: ignore[override]
+ self, fn: WrappedFn, *args: t.Any, **kwargs: t.Any
+ ) -> WrappedFnReturnT:
+ self.begin()
+
+ retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs)
+ is_async = _utils.is_coroutine_callable(fn)
+ while True:
+ do = await self.iter(retry_state=retry_state)
+ if isinstance(do, DoAttempt):
+ try:
+ if is_async:
+ result = await fn(*args, **kwargs)
+ else:
+ result = fn(*args, **kwargs)
+ except BaseException:
+ retry_state.set_exception(sys.exc_info()) # type: ignore[arg-type]
+ else:
+ retry_state.set_result(result)
+ elif isinstance(do, DoSleep):
+ retry_state.prepare_for_next_attempt()
+ await self.sleep(do) # type: ignore[misc]
+ else:
+ return do # type: ignore[no-any-return]
+
+ def _add_action_func(self, fn: t.Callable[..., t.Any]) -> None:
+ self.iter_state.actions.append(_utils.wrap_to_async_func(fn))
+
+ async def _run_retry(self, retry_state: "RetryCallState") -> None: # type: ignore[override]
+ self.iter_state.retry_run_result = await _utils.wrap_to_async_func(self.retry)(
+ retry_state
+ )
+
+ async def _run_wait(self, retry_state: "RetryCallState") -> None: # type: ignore[override]
+ if self.wait:
+ sleep = await _utils.wrap_to_async_func(self.wait)(retry_state)
+ else:
+ sleep = 0.0
+
+ retry_state.upcoming_sleep = sleep
+
+ async def _run_stop(self, retry_state: "RetryCallState") -> None: # type: ignore[override]
+ self.statistics["delay_since_first_attempt"] = retry_state.seconds_since_start
+ self.iter_state.stop_run_result = await _utils.wrap_to_async_func(self.stop)(
+ retry_state
+ )
+
+ async def iter(self, retry_state: "RetryCallState") -> DoAttempt | DoSleep | t.Any:
+ self._begin_iter(retry_state)
+ result = None
+ for action in self.iter_state.actions:
+ result = await action(retry_state)
+ return result
+
+ def __iter__(self) -> t.Generator[AttemptManager, None, None]:
+ raise TypeError("AsyncRetrying object is not iterable")
+
+ def __aiter__(self) -> "AsyncRetrying":
+ self.begin()
+ self._retry_state = RetryCallState(self, fn=None, args=(), kwargs={})
+ return self
+
+ async def __anext__(self) -> AttemptManager:
+ while True:
+ do = await self.iter(retry_state=self._retry_state)
+ if do is None:
+ raise StopAsyncIteration
+ if isinstance(do, DoAttempt):
+ return AttemptManager(retry_state=self._retry_state)
+ if isinstance(do, DoSleep):
+ self._retry_state.prepare_for_next_attempt()
+ await self.sleep(do) # type: ignore[misc]
+ else:
+ raise StopAsyncIteration
+
+ def wraps(self, fn: t.Callable[P, R]) -> _RetryDecorated[P, R]:
+ wrapped = super().wraps(fn)
+ # Ensure wrapper is recognized as a coroutine function.
+
+ @functools.wraps(
+ fn, functools.WRAPPER_ASSIGNMENTS + ("__defaults__", "__kwdefaults__")
+ )
+ async def async_wrapped(*args: t.Any, **kwargs: t.Any) -> t.Any:
+ if not self.enabled:
+ return await fn(*args, **kwargs) # type: ignore[misc]
+ # Always create a copy to prevent overwriting the local contexts when
+ # calling the same wrapped functions multiple times in the same stack
+ copy = self.copy()
+ async_wrapped.statistics = copy.statistics # type: ignore[attr-defined]
+ self._local.statistics = copy.statistics
+ return await copy(fn, *args, **kwargs) # type: ignore[type-var]
+
+ # Preserve attributes
+ async_wrapped.retry = self # type: ignore[attr-defined]
+ async_wrapped.retry_with = wrapped.retry_with # type: ignore[attr-defined]
+ async_wrapped.statistics = {} # type: ignore[attr-defined]
+
+ return t.cast("_RetryDecorated[P, R]", async_wrapped)
+
+
+__all__ = [
+ "AsyncRetrying",
+ "WrappedFn",
+ "retry_all",
+ "retry_any",
+ "retry_if_exception",
+ "retry_if_result",
+]
diff --git a/tenacity/asyncio/retry.py b/tenacity/asyncio/retry.py
new file mode 100644
index 00000000..e0d44e82
--- /dev/null
+++ b/tenacity/asyncio/retry.py
@@ -0,0 +1,136 @@
+# Copyright 2016–2021 Julien Danjou
+# Copyright 2016 Joshua Harlow
+# Copyright 2013-2014 Ray Holder
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import abc
+import typing
+
+from tenacity import _utils, retry_base
+
+if typing.TYPE_CHECKING:
+ from tenacity import RetryCallState
+
+
+class async_retry_base(retry_base):
+ """Abstract base class for async retry strategies."""
+
+ @abc.abstractmethod
+ async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override]
+ pass
+
+ def __and__( # type: ignore[override]
+ self, other: "retry_base | async_retry_base"
+ ) -> "retry_all":
+ return retry_all(self, other)
+
+ def __rand__( # type: ignore[misc,override]
+ self, other: "retry_base | async_retry_base"
+ ) -> "retry_all":
+ return retry_all(other, self)
+
+ def __or__( # type: ignore[override]
+ self, other: "retry_base | async_retry_base"
+ ) -> "retry_any":
+ return retry_any(self, other)
+
+ def __ror__( # type: ignore[misc,override]
+ self, other: "retry_base | async_retry_base"
+ ) -> "retry_any":
+ return retry_any(other, self)
+
+
+RetryBaseT = (
+ async_retry_base | typing.Callable[["RetryCallState"], typing.Awaitable[bool]]
+)
+
+
+class retry_if_exception(async_retry_base):
+ """Retry strategy that retries if an exception verifies a predicate."""
+
+ def __init__(
+ self, predicate: typing.Callable[[BaseException], typing.Awaitable[bool]]
+ ) -> None:
+ self.predicate = predicate
+
+ async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override]
+ if retry_state.outcome is None:
+ raise RuntimeError("__call__() called before outcome was set")
+
+ if retry_state.outcome.failed:
+ exception = retry_state.outcome.exception()
+ if exception is None:
+ raise RuntimeError("outcome failed but the exception is None")
+ return await self.predicate(exception)
+ return False
+
+
+class retry_if_result(async_retry_base):
+ """Retries if the result verifies a predicate."""
+
+ def __init__(
+ self, predicate: typing.Callable[[typing.Any], typing.Awaitable[bool]]
+ ) -> None:
+ self.predicate = predicate
+
+ async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override]
+ if retry_state.outcome is None:
+ raise RuntimeError("__call__() called before outcome was set")
+
+ if not retry_state.outcome.failed:
+ return await self.predicate(retry_state.outcome.result())
+ return False
+
+
+class retry_any(async_retry_base):
+ """Retries if any of the retries condition is valid."""
+
+ def __init__(self, *retries: retry_base | async_retry_base) -> None:
+ self.retries = retries
+
+ async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override]
+ result = False
+ for r in self.retries:
+ result = result or await _utils.wrap_to_async_func(r)(retry_state)
+ if result:
+ break
+ return result
+
+ def __ror__( # type: ignore[misc,override]
+ self, other: "retry_base | async_retry_base"
+ ) -> "retry_any":
+ if isinstance(other, retry_any):
+ return retry_any(*other.retries, *self.retries)
+ return retry_any(other, *self.retries)
+
+
+class retry_all(async_retry_base):
+ """Retries if all the retries condition are valid."""
+
+ def __init__(self, *retries: retry_base | async_retry_base) -> None:
+ self.retries = retries
+
+ async def __call__(self, retry_state: "RetryCallState") -> bool: # type: ignore[override]
+ result = True
+ for r in self.retries:
+ result = result and await _utils.wrap_to_async_func(r)(retry_state)
+ if not result:
+ break
+ return result
+
+ def __rand__( # type: ignore[misc,override]
+ self, other: "retry_base | async_retry_base"
+ ) -> "retry_all":
+ if isinstance(other, retry_all):
+ return retry_all(*other.retries, *self.retries)
+ return retry_all(other, *self.retries)
diff --git a/tenacity/before.py b/tenacity/before.py
index 54259ddd..89b867ee 100644
--- a/tenacity/before.py
+++ b/tenacity/before.py
@@ -14,19 +14,29 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import typing
+
from tenacity import _utils
+if typing.TYPE_CHECKING:
+ from tenacity import RetryCallState
+
-def before_nothing(retry_state):
+def before_nothing(retry_state: "RetryCallState") -> None:
"""Before call strategy that does nothing."""
-def before_log(logger, log_level):
+def before_log(
+ logger: _utils.LoggerProtocol, log_level: int
+) -> typing.Callable[["RetryCallState"], None]:
"""Before call strategy that logs to some logger the attempt."""
- def log_it(retry_state):
- logger.log(log_level,
- "Starting call to '%s', this is the %s time calling it.",
- _utils.get_callback_name(retry_state.fn),
- _utils.to_ordinal(retry_state.attempt_number))
+
+ def log_it(retry_state: "RetryCallState") -> None:
+ fn_name = retry_state.get_fn_name()
+ logger.log(
+ log_level,
+ f"Starting call to '{fn_name}', "
+ f"this is the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.",
+ )
return log_it
diff --git a/tenacity/before_sleep.py b/tenacity/before_sleep.py
index c8b3d33c..6b691212 100644
--- a/tenacity/before_sleep.py
+++ b/tenacity/before_sleep.py
@@ -14,33 +14,54 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import typing
+
from tenacity import _utils
-from tenacity.compat import get_exc_info_from_future
+
+if typing.TYPE_CHECKING:
+ from tenacity import RetryCallState
+
+
+def before_sleep_nothing(retry_state: "RetryCallState") -> None:
+ """Before sleep strategy that does nothing."""
-def before_sleep_nothing(retry_state):
- """Before call strategy that does nothing."""
+def before_sleep_log(
+ logger: _utils.LoggerProtocol,
+ log_level: int,
+ exc_info: bool = False,
+ sec_format: str = "%.3g",
+) -> typing.Callable[["RetryCallState"], None]:
+ """Before sleep strategy that logs to some logger the attempt."""
+ def log_it(retry_state: "RetryCallState") -> None:
+ local_exc_info: BaseException | bool | None
+
+ if retry_state.outcome is None:
+ raise RuntimeError("log_it() called before outcome was set")
+
+ if retry_state.next_action is None:
+ raise RuntimeError("log_it() called before next_action was set")
-def before_sleep_log(logger, log_level, exc_info=False):
- """Before call strategy that logs to some logger the attempt."""
- def log_it(retry_state):
if retry_state.outcome.failed:
ex = retry_state.outcome.exception()
- verb, value = 'raised', '%s: %s' % (type(ex).__name__, ex)
+ verb, value = "raised", f"{ex.__class__.__name__}: {ex}"
if exc_info:
- local_exc_info = get_exc_info_from_future(retry_state.outcome)
+ local_exc_info = retry_state.outcome.exception()
else:
local_exc_info = False
else:
- verb, value = 'returned', retry_state.outcome.result()
+ verb, value = "returned", retry_state.outcome.result()
local_exc_info = False # exc_info does not apply when no exception
- logger.log(log_level,
- "Retrying %s in %s seconds as it %s %s.",
- _utils.get_callback_name(retry_state.fn),
- getattr(retry_state.next_action, 'sleep'),
- verb, value,
- exc_info=local_exc_info)
+ fn_name = retry_state.get_fn_name()
+
+ logger.log(
+ log_level,
+ f"Retrying {fn_name} "
+ f"in {sec_format % retry_state.next_action.sleep} seconds as it {verb} {value}.",
+ exc_info=local_exc_info,
+ )
+
return log_it
diff --git a/tenacity/compat.py b/tenacity/compat.py
deleted file mode 100644
index 6bd815e0..00000000
--- a/tenacity/compat.py
+++ /dev/null
@@ -1,322 +0,0 @@
-"""Utilities for providing backward compatibility."""
-
-import inspect
-from fractions import Fraction
-from warnings import warn
-
-import six
-
-from tenacity import _utils
-
-
-def warn_about_non_retry_state_deprecation(cbname, func, stacklevel):
- msg = (
- '"%s" function must accept single "retry_state" parameter,'
- ' please update %s' % (cbname, _utils.get_callback_name(func)))
- warn(msg, DeprecationWarning, stacklevel=stacklevel + 1)
-
-
-def warn_about_dunder_non_retry_state_deprecation(fn, stacklevel):
- msg = (
- '"%s" method must be called with'
- ' single "retry_state" parameter' % (_utils.get_callback_name(fn)))
- warn(msg, DeprecationWarning, stacklevel=stacklevel + 1)
-
-
-def func_takes_retry_state(func):
- if not six.callable(func):
- raise Exception(func)
- return False
- if not inspect.isfunction(func) and not inspect.ismethod(func):
- # func is a callable object rather than a function/method
- func = func.__call__
- func_spec = _utils.getargspec(func)
- return 'retry_state' in func_spec.args
-
-
-_unset = object()
-
-
-def _make_unset_exception(func_name, **kwargs):
- missing = []
- for k, v in six.iteritems(kwargs):
- if v is _unset:
- missing.append(k)
- missing_str = ', '.join(repr(s) for s in missing)
- return TypeError(func_name + ' func missing parameters: ' + missing_str)
-
-
-def _set_delay_since_start(retry_state, delay):
- # Ensure outcome_timestamp - start_time is *exactly* equal to the delay to
- # avoid complexity in test code.
- retry_state.start_time = Fraction(retry_state.start_time)
- retry_state.outcome_timestamp = (retry_state.start_time + Fraction(delay))
- assert retry_state.seconds_since_start == delay
-
-
-def make_retry_state(previous_attempt_number, delay_since_first_attempt,
- last_result=None):
- """Construct RetryCallState for given attempt number & delay.
-
- Only used in testing and thus is extra careful about timestamp arithmetics.
- """
- required_parameter_unset = (previous_attempt_number is _unset or
- delay_since_first_attempt is _unset)
- if required_parameter_unset:
- raise _make_unset_exception(
- 'wait/stop',
- previous_attempt_number=previous_attempt_number,
- delay_since_first_attempt=delay_since_first_attempt)
-
- from tenacity import RetryCallState
- retry_state = RetryCallState(None, None, (), {})
- retry_state.attempt_number = previous_attempt_number
- if last_result is not None:
- retry_state.outcome = last_result
- else:
- retry_state.set_result(None)
- _set_delay_since_start(retry_state, delay_since_first_attempt)
- return retry_state
-
-
-def func_takes_last_result(waiter):
- """Check if function has a "last_result" parameter.
-
- Needed to provide backward compatibility for wait functions that didn't
- take "last_result" in the beginning.
- """
- if not six.callable(waiter):
- return False
- if not inspect.isfunction(waiter) and not inspect.ismethod(waiter):
- # waiter is a class, check dunder-call rather than dunder-init.
- waiter = waiter.__call__
- waiter_spec = _utils.getargspec(waiter)
- return 'last_result' in waiter_spec.args
-
-
-def stop_dunder_call_accept_old_params(fn):
- """Decorate cls.__call__ method to accept old "stop" signature."""
- @_utils.wraps(fn)
- def new_fn(self,
- previous_attempt_number=_unset,
- delay_since_first_attempt=_unset,
- retry_state=None):
- if retry_state is None:
- from tenacity import RetryCallState
- retry_state_passed_as_non_kwarg = (
- previous_attempt_number is not _unset and
- isinstance(previous_attempt_number, RetryCallState))
- if retry_state_passed_as_non_kwarg:
- retry_state = previous_attempt_number
- else:
- warn_about_dunder_non_retry_state_deprecation(fn, stacklevel=2)
- retry_state = make_retry_state(
- previous_attempt_number=previous_attempt_number,
- delay_since_first_attempt=delay_since_first_attempt)
- return fn(self, retry_state=retry_state)
- return new_fn
-
-
-def stop_func_accept_retry_state(stop_func):
- """Wrap "stop" function to accept "retry_state" parameter."""
- if not six.callable(stop_func):
- return stop_func
-
- if func_takes_retry_state(stop_func):
- return stop_func
-
- @_utils.wraps(stop_func)
- def wrapped_stop_func(retry_state):
- warn_about_non_retry_state_deprecation(
- 'stop', stop_func, stacklevel=4)
- return stop_func(
- retry_state.attempt_number,
- retry_state.seconds_since_start,
- )
- return wrapped_stop_func
-
-
-def wait_dunder_call_accept_old_params(fn):
- """Decorate cls.__call__ method to accept old "wait" signature."""
- @_utils.wraps(fn)
- def new_fn(self,
- previous_attempt_number=_unset,
- delay_since_first_attempt=_unset,
- last_result=None,
- retry_state=None):
- if retry_state is None:
- from tenacity import RetryCallState
- retry_state_passed_as_non_kwarg = (
- previous_attempt_number is not _unset and
- isinstance(previous_attempt_number, RetryCallState))
- if retry_state_passed_as_non_kwarg:
- retry_state = previous_attempt_number
- else:
- warn_about_dunder_non_retry_state_deprecation(fn, stacklevel=2)
- retry_state = make_retry_state(
- previous_attempt_number=previous_attempt_number,
- delay_since_first_attempt=delay_since_first_attempt,
- last_result=last_result)
- return fn(self, retry_state=retry_state)
- return new_fn
-
-
-def wait_func_accept_retry_state(wait_func):
- """Wrap wait function to accept "retry_state" parameter."""
- if not six.callable(wait_func):
- return wait_func
-
- if func_takes_retry_state(wait_func):
- return wait_func
-
- if func_takes_last_result(wait_func):
- @_utils.wraps(wait_func)
- def wrapped_wait_func(retry_state):
- warn_about_non_retry_state_deprecation(
- 'wait', wait_func, stacklevel=4)
- return wait_func(
- retry_state.attempt_number,
- retry_state.seconds_since_start,
- last_result=retry_state.outcome,
- )
- else:
- @_utils.wraps(wait_func)
- def wrapped_wait_func(retry_state):
- warn_about_non_retry_state_deprecation(
- 'wait', wait_func, stacklevel=4)
- return wait_func(
- retry_state.attempt_number,
- retry_state.seconds_since_start,
- )
- return wrapped_wait_func
-
-
-def retry_dunder_call_accept_old_params(fn):
- """Decorate cls.__call__ method to accept old "retry" signature."""
- @_utils.wraps(fn)
- def new_fn(self, attempt=_unset, retry_state=None):
- if retry_state is None:
- from tenacity import RetryCallState
- if attempt is _unset:
- raise _make_unset_exception('retry', attempt=attempt)
- retry_state_passed_as_non_kwarg = (
- attempt is not _unset and
- isinstance(attempt, RetryCallState))
- if retry_state_passed_as_non_kwarg:
- retry_state = attempt
- else:
- warn_about_dunder_non_retry_state_deprecation(fn, stacklevel=2)
- retry_state = RetryCallState(None, None, (), {})
- retry_state.outcome = attempt
- return fn(self, retry_state=retry_state)
- return new_fn
-
-
-def retry_func_accept_retry_state(retry_func):
- """Wrap "retry" function to accept "retry_state" parameter."""
- if not six.callable(retry_func):
- return retry_func
-
- if func_takes_retry_state(retry_func):
- return retry_func
-
- @_utils.wraps(retry_func)
- def wrapped_retry_func(retry_state):
- warn_about_non_retry_state_deprecation(
- 'retry', retry_func, stacklevel=4)
- return retry_func(retry_state.outcome)
- return wrapped_retry_func
-
-
-def before_func_accept_retry_state(fn):
- """Wrap "before" function to accept "retry_state"."""
- if not six.callable(fn):
- return fn
-
- if func_takes_retry_state(fn):
- return fn
-
- @_utils.wraps(fn)
- def wrapped_before_func(retry_state):
- # func, trial_number, trial_time_taken
- warn_about_non_retry_state_deprecation('before', fn, stacklevel=4)
- return fn(
- retry_state.fn,
- retry_state.attempt_number,
- )
- return wrapped_before_func
-
-
-def after_func_accept_retry_state(fn):
- """Wrap "after" function to accept "retry_state"."""
- if not six.callable(fn):
- return fn
-
- if func_takes_retry_state(fn):
- return fn
-
- @_utils.wraps(fn)
- def wrapped_after_sleep_func(retry_state):
- # func, trial_number, trial_time_taken
- warn_about_non_retry_state_deprecation('after', fn, stacklevel=4)
- return fn(
- retry_state.fn,
- retry_state.attempt_number,
- retry_state.seconds_since_start)
- return wrapped_after_sleep_func
-
-
-def before_sleep_func_accept_retry_state(fn):
- """Wrap "before_sleep" function to accept "retry_state"."""
- if not six.callable(fn):
- return fn
-
- if func_takes_retry_state(fn):
- return fn
-
- @_utils.wraps(fn)
- def wrapped_before_sleep_func(retry_state):
- # retry_object, sleep, last_result
- warn_about_non_retry_state_deprecation(
- 'before_sleep', fn, stacklevel=4)
- return fn(
- retry_state.retry_object,
- sleep=getattr(retry_state.next_action, 'sleep'),
- last_result=retry_state.outcome)
- return wrapped_before_sleep_func
-
-
-def retry_error_callback_accept_retry_state(fn):
- if not six.callable(fn):
- return fn
-
- if func_takes_retry_state(fn):
- return fn
-
- @_utils.wraps(fn)
- def wrapped_retry_error_callback(retry_state):
- warn_about_non_retry_state_deprecation(
- 'retry_error_callback', fn, stacklevel=4)
- return fn(retry_state.outcome)
- return wrapped_retry_error_callback
-
-
-def get_exc_info_from_future(future):
- """
- Get an exc_info value from a Future.
-
- Given a a Future instance, retrieve an exc_info value suitable for passing
- in as the exc_info parameter to logging.Logger.log() and related methods.
-
- On Python 2, this will be a (type, value, traceback) triple.
- On Python 3, this will be an exception instance (with embedded traceback).
-
- If there was no exception, None is returned on both versions of Python.
- """
- if six.PY3:
- return future.exception()
- else:
- ex, tb = future.exception_info()
- if ex is None:
- return None
- return type(ex), ex, tb
diff --git a/tenacity/nap.py b/tenacity/nap.py
index 83ff839c..44fd94ae 100644
--- a/tenacity/nap.py
+++ b/tenacity/nap.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# Copyright 2016 Étienne Bersac
# Copyright 2016 Julien Danjou
# Copyright 2016 Joshua Harlow
@@ -17,9 +16,13 @@
# limitations under the License.
import time
+import typing
+if typing.TYPE_CHECKING:
+ import threading
-def sleep(seconds):
+
+def sleep(seconds: float) -> None:
"""
Sleep strategy that delays execution for a given number of seconds.
@@ -28,13 +31,13 @@ def sleep(seconds):
time.sleep(seconds)
-class sleep_using_event(object):
+class sleep_using_event:
"""Sleep strategy that waits on an event to be set."""
- def __init__(self, event):
+ def __init__(self, event: "threading.Event") -> None:
self.event = event
- def __call__(self, timeout):
+ def __call__(self, timeout: float | None) -> None:
# NOTE(harlowja): this may *not* actually wait for timeout
# seconds if the event is set (ie this may eject out early).
self.event.wait(timeout=timeout)
diff --git a/tenacity/retry.py b/tenacity/retry.py
index 8e4fab32..df0cc4d6 100644
--- a/tenacity/retry.py
+++ b/tenacity/retry.py
@@ -1,4 +1,4 @@
-# Copyright 2016 Julien Danjou
+# Copyright 2016–2021 Julien Danjou
# Copyright 2016 Joshua Harlow
# Copyright 2013-2014 Ray Holder
#
@@ -16,31 +16,55 @@
import abc
import re
+import typing
-import six
+if typing.TYPE_CHECKING:
+ from tenacity import RetryCallState
-from tenacity import compat as _compat
-
-@six.add_metaclass(abc.ABCMeta)
-class retry_base(object):
+class retry_base(abc.ABC):
"""Abstract base class for retry strategies."""
@abc.abstractmethod
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
pass
- def __and__(self, other):
+ def __and__(self, other: "RetryBaseT") -> "retry_all":
+ if isinstance(other, retry_base):
+ return other.__rand__(self)
+ # Plain callable: flatten if self is already a retry_all
+ if isinstance(self, retry_all):
+ return retry_all(*self.retries, other)
return retry_all(self, other)
- def __or__(self, other):
+ def __rand__(self, other: "RetryBaseT") -> "retry_all":
+ # Flatten if other is already a retry_all
+ if isinstance(other, retry_all):
+ return retry_all(*other.retries, self)
+ return retry_all(other, self)
+
+ def __or__(self, other: "RetryBaseT") -> "retry_any":
+ if isinstance(other, retry_base):
+ return other.__ror__(self)
+ # Plain callable: flatten if self is already a retry_any
+ if isinstance(self, retry_any):
+ return retry_any(*self.retries, other)
return retry_any(self, other)
+ def __ror__(self, other: "RetryBaseT") -> "retry_any":
+ # Flatten if other is already a retry_any
+ if isinstance(other, retry_any):
+ return retry_any(*other.retries, self)
+ return retry_any(other, self)
+
+
+RetryBaseT = retry_base | typing.Callable[["RetryCallState"], bool]
+
class _retry_never(retry_base):
"""Retry strategy that never rejects any result."""
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
return False
@@ -50,7 +74,7 @@ def __call__(self, retry_state):
class _retry_always(retry_base):
"""Retry strategy that always rejects any result."""
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
return True
@@ -60,128 +84,210 @@ def __call__(self, retry_state):
class retry_if_exception(retry_base):
"""Retry strategy that retries if an exception verifies a predicate."""
- def __init__(self, predicate):
+ def __init__(self, predicate: typing.Callable[[BaseException], bool]) -> None:
self.predicate = predicate
- @_compat.retry_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
+ if retry_state.outcome is None:
+ raise RuntimeError("__call__() called before outcome was set")
+
if retry_state.outcome.failed:
- return self.predicate(retry_state.outcome.exception())
+ exception = retry_state.outcome.exception()
+ if exception is None:
+ raise RuntimeError("outcome failed but the exception is None")
+ return self.predicate(exception)
+ return False
class retry_if_exception_type(retry_if_exception):
"""Retries if an exception has been raised of one or more types."""
- def __init__(self, exception_types=Exception):
+ def __init__(
+ self,
+ exception_types: type[BaseException]
+ | tuple[type[BaseException], ...] = Exception,
+ ) -> None:
self.exception_types = exception_types
- super(retry_if_exception_type, self).__init__(
- lambda e: isinstance(e, exception_types))
+ super().__init__(self._check)
+
+ def _check(self, e: BaseException) -> bool:
+ return isinstance(e, self.exception_types)
+
+
+class retry_if_not_exception_type(retry_if_exception):
+ """Retries except an exception has been raised of one or more types."""
+
+ def __init__(
+ self,
+ exception_types: type[BaseException]
+ | tuple[type[BaseException], ...] = Exception,
+ ) -> None:
+ self.exception_types = exception_types
+ super().__init__(self._check)
+
+ def _check(self, e: BaseException) -> bool:
+ return not isinstance(e, self.exception_types)
class retry_unless_exception_type(retry_if_exception):
"""Retries until an exception is raised of one or more types."""
- def __init__(self, exception_types=Exception):
+ def __init__(
+ self,
+ exception_types: type[BaseException]
+ | tuple[type[BaseException], ...] = Exception,
+ ) -> None:
self.exception_types = exception_types
- super(retry_unless_exception_type, self).__init__(
- lambda e: not isinstance(e, exception_types))
+ super().__init__(self._check)
+
+ def _check(self, e: BaseException) -> bool:
+ return not isinstance(e, self.exception_types)
+
+ def __call__(self, retry_state: "RetryCallState") -> bool:
+ if retry_state.outcome is None:
+ raise RuntimeError("__call__() called before outcome was set")
- @_compat.retry_dunder_call_accept_old_params
- def __call__(self, retry_state):
# always retry if no exception was raised
if not retry_state.outcome.failed:
return True
- return self.predicate(retry_state.outcome.exception())
+
+ exception = retry_state.outcome.exception()
+ if exception is None:
+ raise RuntimeError("outcome failed but the exception is None")
+ return self.predicate(exception)
+
+
+class retry_if_exception_cause_type(retry_base):
+ """Retries if any of the causes of the raised exception is of one or more types.
+
+ The check on the type of the cause of the exception is done recursively (until finding
+ an exception in the chain that has no `__cause__`)
+ """
+
+ def __init__(
+ self,
+ exception_types: type[BaseException]
+ | tuple[type[BaseException], ...] = Exception,
+ ) -> None:
+ self.exception_cause_types = exception_types
+
+ def __call__(self, retry_state: "RetryCallState") -> bool:
+ if retry_state.outcome is None:
+ raise RuntimeError("__call__ called before outcome was set")
+
+ if retry_state.outcome.failed:
+ exc = retry_state.outcome.exception()
+ while exc is not None:
+ if isinstance(exc.__cause__, self.exception_cause_types):
+ return True
+ exc = exc.__cause__
+
+ return False
class retry_if_result(retry_base):
"""Retries if the result verifies a predicate."""
- def __init__(self, predicate):
+ def __init__(self, predicate: typing.Callable[[typing.Any], bool]) -> None:
self.predicate = predicate
- @_compat.retry_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
+ if retry_state.outcome is None:
+ raise RuntimeError("__call__() called before outcome was set")
+
if not retry_state.outcome.failed:
return self.predicate(retry_state.outcome.result())
+ return False
class retry_if_not_result(retry_base):
"""Retries if the result refutes a predicate."""
- def __init__(self, predicate):
+ def __init__(self, predicate: typing.Callable[[typing.Any], bool]) -> None:
self.predicate = predicate
- @_compat.retry_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
+ if retry_state.outcome is None:
+ raise RuntimeError("__call__() called before outcome was set")
+
if not retry_state.outcome.failed:
return not self.predicate(retry_state.outcome.result())
+ return False
class retry_if_exception_message(retry_if_exception):
"""Retries if an exception message equals or matches."""
- def __init__(self, message=None, match=None):
+ def __init__(
+ self,
+ message: str | None = None,
+ match: None | str | re.Pattern[str] = None,
+ ) -> None:
if message and match:
raise TypeError(
- "{}() takes either 'message' or 'match', not both".format(
- self.__class__.__name__))
-
- # set predicate
- if message:
- def message_fnc(exception):
- return message == str(exception)
- predicate = message_fnc
- elif match:
- prog = re.compile(match)
-
- def match_fnc(exception):
- return prog.match(str(exception))
- predicate = match_fnc
- else:
+ f"{self.__class__.__name__}() takes either 'message' or 'match', not both"
+ )
+
+ if not message and not match:
raise TypeError(
- "{}() missing 1 required argument 'message' or 'match'".
- format(self.__class__.__name__))
+ f"{self.__class__.__name__}() missing 1 required argument 'message' or 'match'"
+ )
+
+ self.message = message
+ self.match = re.compile(match) if match else None
+ super().__init__(self._check)
- super(retry_if_exception_message, self).__init__(predicate)
+ def _check(self, exception: BaseException) -> bool:
+ if self.message:
+ return self.message == str(exception)
+ assert self.match is not None
+ return bool(self.match.match(str(exception)))
class retry_if_not_exception_message(retry_if_exception_message):
"""Retries until an exception message equals or matches."""
- def __init__(self, *args, **kwargs):
- super(retry_if_not_exception_message, self).__init__(*args, **kwargs)
- # invert predicate
- if_predicate = self.predicate
- self.predicate = lambda *args_, **kwargs_: not if_predicate(
- *args_, **kwargs_)
+ def _check(self, exception: BaseException) -> bool:
+ return not super()._check(exception)
+
+ def __call__(self, retry_state: "RetryCallState") -> bool:
+ if retry_state.outcome is None:
+ raise RuntimeError("__call__() called before outcome was set")
- @_compat.retry_dunder_call_accept_old_params
- def __call__(self, retry_state):
if not retry_state.outcome.failed:
return True
- return self.predicate(retry_state.outcome.exception())
+
+ exception = retry_state.outcome.exception()
+ if exception is None:
+ raise RuntimeError("outcome failed but the exception is None")
+ return self.predicate(exception)
class retry_any(retry_base):
"""Retries if any of the retries condition is valid."""
- def __init__(self, *retries):
- self.retries = tuple(_compat.retry_func_accept_retry_state(r)
- for r in retries)
+ def __init__(self, *retries: "RetryBaseT") -> None:
+ self.retries = retries
- @_compat.retry_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
return any(r(retry_state) for r in self.retries)
+ def __ror__(self, other: "RetryBaseT") -> "retry_any":
+ if isinstance(other, retry_any):
+ return retry_any(*other.retries, *self.retries)
+ return retry_any(other, *self.retries)
+
class retry_all(retry_base):
"""Retries if all the retries condition are valid."""
- def __init__(self, *retries):
- self.retries = tuple(_compat.retry_func_accept_retry_state(r)
- for r in retries)
+ def __init__(self, *retries: "RetryBaseT") -> None:
+ self.retries = retries
- @_compat.retry_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
return all(r(retry_state) for r in self.retries)
+
+ def __rand__(self, other: "RetryBaseT") -> "retry_all":
+ if isinstance(other, retry_all):
+ return retry_all(*other.retries, *self.retries)
+ return retry_all(other, *self.retries)
diff --git a/tenacity/stop.py b/tenacity/stop.py
index b5874092..c2251b39 100644
--- a/tenacity/stop.py
+++ b/tenacity/stop.py
@@ -1,4 +1,4 @@
-# Copyright 2016 Julien Danjou
+# Copyright 2016–2021 Julien Danjou
# Copyright 2016 Joshua Harlow
# Copyright 2013-2014 Ray Holder
#
@@ -14,56 +14,57 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import abc
+import typing
-import six
+from tenacity import _utils
-from tenacity import compat as _compat
+if typing.TYPE_CHECKING:
+ import threading
+ from tenacity import RetryCallState
-@six.add_metaclass(abc.ABCMeta)
-class stop_base(object):
+
+class stop_base(abc.ABC):
"""Abstract base class for stop strategies."""
@abc.abstractmethod
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
pass
- def __and__(self, other):
+ def __and__(self, other: "stop_base") -> "stop_all":
return stop_all(self, other)
- def __or__(self, other):
+ def __or__(self, other: "stop_base") -> "stop_any":
return stop_any(self, other)
+StopBaseT = stop_base | typing.Callable[["RetryCallState"], bool]
+
+
class stop_any(stop_base):
"""Stop if any of the stop condition is valid."""
- def __init__(self, *stops):
- self.stops = tuple(_compat.stop_func_accept_retry_state(stop_func)
- for stop_func in stops)
+ def __init__(self, *stops: stop_base) -> None:
+ self.stops = stops
- @_compat.stop_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
return any(x(retry_state) for x in self.stops)
class stop_all(stop_base):
"""Stop if all the stop conditions are valid."""
- def __init__(self, *stops):
- self.stops = tuple(_compat.stop_func_accept_retry_state(stop_func)
- for stop_func in stops)
+ def __init__(self, *stops: stop_base) -> None:
+ self.stops = stops
- @_compat.stop_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
return all(x(retry_state) for x in self.stops)
class _stop_never(stop_base):
"""Never stop."""
- @_compat.stop_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
return False
@@ -73,31 +74,57 @@ def __call__(self, retry_state):
class stop_when_event_set(stop_base):
"""Stop when the given event is set."""
- def __init__(self, event):
+ def __init__(self, event: "threading.Event") -> None:
self.event = event
- @_compat.stop_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
return self.event.is_set()
class stop_after_attempt(stop_base):
"""Stop when the previous attempt >= max_attempt."""
- def __init__(self, max_attempt_number):
+ def __init__(self, max_attempt_number: int) -> None:
self.max_attempt_number = max_attempt_number
- @_compat.stop_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
return retry_state.attempt_number >= self.max_attempt_number
class stop_after_delay(stop_base):
- """Stop when the time from the first attempt >= limit."""
+ """
+ Stop when the time from the first attempt >= limit.
+
+ Note: `max_delay` will be exceeded, so when used with a `wait`, the actual total delay will be greater
+ than `max_delay` by some of the final sleep period before `max_delay` is exceeded.
+
+ If you need stricter timing with waits, consider `stop_before_delay` instead.
+ """
- def __init__(self, max_delay):
- self.max_delay = max_delay
+ def __init__(self, max_delay: _utils.time_unit_type) -> None:
+ self.max_delay = _utils.to_seconds(max_delay)
- @_compat.stop_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> bool:
+ if retry_state.seconds_since_start is None:
+ raise RuntimeError("__call__() called but seconds_since_start is not set")
return retry_state.seconds_since_start >= self.max_delay
+
+
+class stop_before_delay(stop_base):
+ """
+ Stop right before the next attempt would take place after the time from the first attempt >= limit.
+
+ Most useful when you are using with a `wait` function like wait_random_exponential, but need to make
+ sure that the max_delay is not exceeded.
+ """
+
+ def __init__(self, max_delay: _utils.time_unit_type) -> None:
+ self.max_delay = _utils.to_seconds(max_delay)
+
+ def __call__(self, retry_state: "RetryCallState") -> bool:
+ if retry_state.seconds_since_start is None:
+ raise RuntimeError("__call__() called but seconds_since_start is not set")
+ return (
+ retry_state.seconds_since_start + retry_state.upcoming_sleep
+ >= self.max_delay
+ )
diff --git a/tenacity/tests/test_asyncio.py b/tenacity/tests/test_asyncio.py
deleted file mode 100644
index 617a0ef5..00000000
--- a/tenacity/tests/test_asyncio.py
+++ /dev/null
@@ -1,154 +0,0 @@
-# coding: utf-8
-# Copyright 2016 Étienne Bersac
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-import asyncio
-import unittest
-
-import six
-
-from tenacity import AsyncRetrying, RetryError
-from tenacity import _asyncio as tasyncio
-from tenacity import retry, stop_after_attempt
-from tenacity.tests.test_tenacity import NoIOErrorAfterCount, current_time_ms
-from tenacity.wait import wait_fixed
-
-
-def asynctest(callable_):
- @six.wraps(callable_)
- def wrapper(*a, **kw):
- loop = asyncio.get_event_loop()
- return loop.run_until_complete(callable_(*a, **kw))
-
- return wrapper
-
-
-async def _async_function(thing):
- await asyncio.sleep(0.00001)
- return thing.go()
-
-
-@retry
-async def _retryable_coroutine(thing):
- await asyncio.sleep(0.00001)
- return thing.go()
-
-
-@retry(stop=stop_after_attempt(2))
-async def _retryable_coroutine_with_2_attempts(thing):
- await asyncio.sleep(0.00001)
- thing.go()
-
-
-class TestAsync(unittest.TestCase):
- @asynctest
- async def test_retry(self):
- thing = NoIOErrorAfterCount(5)
- await _retryable_coroutine(thing)
- assert thing.counter == thing.count
-
- @asynctest
- async def test_retry_using_async_retying(self):
- thing = NoIOErrorAfterCount(5)
- retrying = AsyncRetrying()
- await retrying(_async_function, thing)
- assert thing.counter == thing.count
-
- @asynctest
- async def test_stop_after_attempt(self):
- thing = NoIOErrorAfterCount(2)
- try:
- await _retryable_coroutine_with_2_attempts(thing)
- except RetryError:
- assert thing.counter == 2
-
- def test_repr(self):
- repr(tasyncio.AsyncRetrying())
-
- @asynctest
- async def test_attempt_number_is_correct_for_interleaved_coroutines(self):
-
- attempts = []
-
- def after(retry_state):
- attempts.append((retry_state.args[0], retry_state.attempt_number))
-
- thing1 = NoIOErrorAfterCount(3)
- thing2 = NoIOErrorAfterCount(3)
-
- await asyncio.gather(
- _retryable_coroutine.retry_with(after=after)(thing1),
- _retryable_coroutine.retry_with(after=after)(thing2))
-
- # There's no waiting on retry, only a wait in the coroutine, so the
- # executions should be interleaved.
- even_thing_attempts = attempts[::2]
- things, attempt_nos1 = zip(*even_thing_attempts)
- assert len(set(things)) == 1
- assert list(attempt_nos1) == [1, 2, 3]
-
- odd_thing_attempts = attempts[1::2]
- things, attempt_nos2 = zip(*odd_thing_attempts)
- assert len(set(things)) == 1
- assert list(attempt_nos2) == [1, 2, 3]
-
-
-class TestContextManager(unittest.TestCase):
- @asynctest
- async def test_do_max_attempts(self):
- attempts = 0
- retrying = tasyncio.AsyncRetrying(stop=stop_after_attempt(3))
- try:
- async for attempt in retrying:
- with attempt:
- attempts += 1
- raise Exception
- except RetryError:
- pass
-
- assert attempts == 3
-
- @asynctest
- async def test_reraise(self):
- class CustomError(Exception):
- pass
-
- try:
- async for attempt in tasyncio.AsyncRetrying(
- stop=stop_after_attempt(1), reraise=True
- ):
- with attempt:
- raise CustomError()
- except CustomError:
- pass
- else:
- raise Exception
-
- @asynctest
- async def test_sleeps(self):
- start = current_time_ms()
- try:
- async for attempt in tasyncio.AsyncRetrying(
- stop=stop_after_attempt(1), wait=wait_fixed(1)
- ):
- with attempt:
- raise Exception()
- except RetryError:
- pass
- t = current_time_ms() - start
- self.assertLess(t, 1.1)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tenacity/tests/test_tenacity.py b/tenacity/tests/test_tenacity.py
deleted file mode 100644
index 223b19a3..00000000
--- a/tenacity/tests/test_tenacity.py
+++ /dev/null
@@ -1,1614 +0,0 @@
-# Copyright 2016 Julien Danjou
-# Copyright 2016 Joshua Harlow
-# Copyright 2013 Ray Holder
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-import logging
-import os
-import re
-import sys
-import time
-import typing
-import unittest
-import warnings
-from contextlib import contextmanager
-from copy import copy
-
-import pytest
-
-import six.moves
-
-import tenacity
-from tenacity import RetryError, Retrying, retry
-from tenacity.compat import make_retry_state
-
-
-class TestBase(unittest.TestCase):
-
- def test_repr(self):
- class ConcreteRetrying(tenacity.BaseRetrying):
- def __call__(self):
- pass
-
- repr(ConcreteRetrying())
-
-
-class TestStopConditions(unittest.TestCase):
-
- def test_never_stop(self):
- r = Retrying()
- self.assertFalse(r.stop(make_retry_state(3, 6546)))
-
- def test_stop_any(self):
- stop = tenacity.stop_any(
- tenacity.stop_after_delay(1),
- tenacity.stop_after_attempt(4))
-
- def s(*args):
- return stop(make_retry_state(*args))
- self.assertFalse(s(1, 0.1))
- self.assertFalse(s(2, 0.2))
- self.assertFalse(s(2, 0.8))
- self.assertTrue(s(4, 0.8))
- self.assertTrue(s(3, 1.8))
- self.assertTrue(s(4, 1.8))
-
- def test_stop_all(self):
- stop = tenacity.stop_all(
- tenacity.stop_after_delay(1),
- tenacity.stop_after_attempt(4))
-
- def s(*args):
- return stop(make_retry_state(*args))
- self.assertFalse(s(1, 0.1))
- self.assertFalse(s(2, 0.2))
- self.assertFalse(s(2, 0.8))
- self.assertFalse(s(4, 0.8))
- self.assertFalse(s(3, 1.8))
- self.assertTrue(s(4, 1.8))
-
- def test_stop_or(self):
- stop = tenacity.stop_after_delay(1) | tenacity.stop_after_attempt(4)
-
- def s(*args):
- return stop(make_retry_state(*args))
- self.assertFalse(s(1, 0.1))
- self.assertFalse(s(2, 0.2))
- self.assertFalse(s(2, 0.8))
- self.assertTrue(s(4, 0.8))
- self.assertTrue(s(3, 1.8))
- self.assertTrue(s(4, 1.8))
-
- def test_stop_and(self):
- stop = tenacity.stop_after_delay(1) & tenacity.stop_after_attempt(4)
-
- def s(*args):
- return stop(make_retry_state(*args))
- self.assertFalse(s(1, 0.1))
- self.assertFalse(s(2, 0.2))
- self.assertFalse(s(2, 0.8))
- self.assertFalse(s(4, 0.8))
- self.assertFalse(s(3, 1.8))
- self.assertTrue(s(4, 1.8))
-
- def test_stop_after_attempt(self):
- r = Retrying(stop=tenacity.stop_after_attempt(3))
- self.assertFalse(r.stop(make_retry_state(2, 6546)))
- self.assertTrue(r.stop(make_retry_state(3, 6546)))
- self.assertTrue(r.stop(make_retry_state(4, 6546)))
-
- def test_stop_after_delay(self):
- r = Retrying(stop=tenacity.stop_after_delay(1))
- self.assertFalse(r.stop(make_retry_state(2, 0.999)))
- self.assertTrue(r.stop(make_retry_state(2, 1)))
- self.assertTrue(r.stop(make_retry_state(2, 1.001)))
-
- def test_legacy_explicit_stop_type(self):
- Retrying(stop="stop_after_attempt")
-
- def test_stop_backward_compat(self):
- r = Retrying(stop=lambda attempt, delay: attempt == delay)
- with reports_deprecation_warning():
- self.assertFalse(r.stop(make_retry_state(1, 3)))
- with reports_deprecation_warning():
- self.assertFalse(r.stop(make_retry_state(100, 99)))
- with reports_deprecation_warning():
- self.assertTrue(r.stop(make_retry_state(101, 101)))
-
- def test_retry_child_class_with_override_backward_compat(self):
-
- class MyStop(tenacity.stop_after_attempt):
- def __init__(self):
- super(MyStop, self).__init__(1)
-
- def __call__(self, attempt_number, seconds_since_start):
- return super(MyStop, self).__call__(
- attempt_number, seconds_since_start)
- retrying = Retrying(wait=tenacity.wait_fixed(0.01),
- stop=MyStop())
-
- def failing():
- raise NotImplementedError()
- with pytest.raises(RetryError):
- retrying(failing)
-
- def test_stop_func_with_retry_state(self):
- def stop_func(retry_state):
- rs = retry_state
- return rs.attempt_number == rs.seconds_since_start
-
- r = Retrying(stop=stop_func)
- self.assertFalse(r.stop(make_retry_state(1, 3)))
- self.assertFalse(r.stop(make_retry_state(100, 99)))
- self.assertTrue(r.stop(make_retry_state(101, 101)))
-
-
-class TestWaitConditions(unittest.TestCase):
-
- def test_no_sleep(self):
- r = Retrying()
- self.assertEqual(0, r.wait(18, 9879))
-
- def test_fixed_sleep(self):
- r = Retrying(wait=tenacity.wait_fixed(1))
- self.assertEqual(1, r.wait(12, 6546))
-
- def test_incrementing_sleep(self):
- r = Retrying(wait=tenacity.wait_incrementing(
- start=500, increment=100))
- self.assertEqual(500, r.wait(1, 6546))
- self.assertEqual(600, r.wait(2, 6546))
- self.assertEqual(700, r.wait(3, 6546))
-
- def test_random_sleep(self):
- r = Retrying(wait=tenacity.wait_random(min=1, max=20))
- times = set()
- for x in six.moves.range(1000):
- times.add(r.wait(1, 6546))
-
- # this is kind of non-deterministic...
- self.assertTrue(len(times) > 1)
- for t in times:
- self.assertTrue(t >= 1)
- self.assertTrue(t < 20)
-
- def test_random_sleep_without_min(self):
- r = Retrying(wait=tenacity.wait_random(max=2))
- times = set()
- times.add(r.wait(1, 6546))
- times.add(r.wait(1, 6546))
- times.add(r.wait(1, 6546))
- times.add(r.wait(1, 6546))
-
- # this is kind of non-deterministic...
- self.assertTrue(len(times) > 1)
- for t in times:
- self.assertTrue(t >= 0)
- self.assertTrue(t <= 2)
-
- def test_exponential(self):
- r = Retrying(wait=tenacity.wait_exponential())
- self.assertEqual(r.wait(1, 0), 1)
- self.assertEqual(r.wait(2, 0), 2)
- self.assertEqual(r.wait(3, 0), 4)
- self.assertEqual(r.wait(4, 0), 8)
- self.assertEqual(r.wait(5, 0), 16)
- self.assertEqual(r.wait(6, 0), 32)
- self.assertEqual(r.wait(7, 0), 64)
- self.assertEqual(r.wait(8, 0), 128)
-
- def test_exponential_with_max_wait(self):
- r = Retrying(wait=tenacity.wait_exponential(max=40))
- self.assertEqual(r.wait(1, 0), 1)
- self.assertEqual(r.wait(2, 0), 2)
- self.assertEqual(r.wait(3, 0), 4)
- self.assertEqual(r.wait(4, 0), 8)
- self.assertEqual(r.wait(5, 0), 16)
- self.assertEqual(r.wait(6, 0), 32)
- self.assertEqual(r.wait(7, 0), 40)
- self.assertEqual(r.wait(8, 0), 40)
- self.assertEqual(r.wait(50, 0), 40)
-
- def test_exponential_with_min_wait(self):
- r = Retrying(wait=tenacity.wait_exponential(min=20))
- self.assertEqual(r.wait(1, 0), 20)
- self.assertEqual(r.wait(2, 0), 20)
- self.assertEqual(r.wait(3, 0), 20)
- self.assertEqual(r.wait(4, 0), 20)
- self.assertEqual(r.wait(5, 0), 20)
- self.assertEqual(r.wait(6, 0), 32)
- self.assertEqual(r.wait(7, 0), 64)
- self.assertEqual(r.wait(8, 0), 128)
- self.assertEqual(r.wait(20, 0), 524288)
-
- def test_exponential_with_max_wait_and_multiplier(self):
- r = Retrying(wait=tenacity.wait_exponential(
- max=50, multiplier=1))
- self.assertEqual(r.wait(1, 0), 1)
- self.assertEqual(r.wait(2, 0), 2)
- self.assertEqual(r.wait(3, 0), 4)
- self.assertEqual(r.wait(4, 0), 8)
- self.assertEqual(r.wait(5, 0), 16)
- self.assertEqual(r.wait(6, 0), 32)
- self.assertEqual(r.wait(7, 0), 50)
- self.assertEqual(r.wait(8, 0), 50)
- self.assertEqual(r.wait(50, 0), 50)
-
- def test_exponential_with_min_wait_and_multiplier(self):
- r = Retrying(wait=tenacity.wait_exponential(
- min=20, multiplier=2))
- self.assertEqual(r.wait(1, 0), 20)
- self.assertEqual(r.wait(2, 0), 20)
- self.assertEqual(r.wait(3, 0), 20)
- self.assertEqual(r.wait(4, 0), 20)
- self.assertEqual(r.wait(5, 0), 32)
- self.assertEqual(r.wait(6, 0), 64)
- self.assertEqual(r.wait(7, 0), 128)
- self.assertEqual(r.wait(8, 0), 256)
- self.assertEqual(r.wait(20, 0), 1048576)
-
- def test_exponential_with_min_wait_and_max_wait(self):
- r = Retrying(wait=tenacity.wait_exponential(min=10, max=100))
- self.assertEqual(r.wait(1, 0), 10)
- self.assertEqual(r.wait(2, 0), 10)
- self.assertEqual(r.wait(3, 0), 10)
- self.assertEqual(r.wait(4, 0), 10)
- self.assertEqual(r.wait(5, 0), 16)
- self.assertEqual(r.wait(6, 0), 32)
- self.assertEqual(r.wait(7, 0), 64)
- self.assertEqual(r.wait(8, 0), 100)
- self.assertEqual(r.wait(9, 0), 100)
- self.assertEqual(r.wait(20, 0), 100)
-
- def test_legacy_explicit_wait_type(self):
- Retrying(wait="exponential_sleep")
-
- def test_wait_backward_compat_with_result(self):
- captures = []
-
- def wait_capture(attempt, delay, last_result=None):
- captures.append(last_result)
- return 1
-
- def dying():
- raise Exception("Broken")
-
- r_attempts = 10
- r = Retrying(wait=wait_capture, sleep=lambda secs: None,
- stop=tenacity.stop_after_attempt(r_attempts),
- reraise=True)
- with reports_deprecation_warning():
- self.assertRaises(Exception, r, dying)
- self.assertEqual(r_attempts - 1, len(captures))
- self.assertTrue(all([r.failed for r in captures]))
-
- def test_wait_func(self):
- def wait_func(retry_state):
- return retry_state.attempt_number * retry_state.seconds_since_start
- r = Retrying(wait=wait_func)
- self.assertEqual(r.wait(make_retry_state(1, 5)), 5)
- self.assertEqual(r.wait(make_retry_state(2, 11)), 22)
- self.assertEqual(r.wait(make_retry_state(10, 100)), 1000)
-
- def test_wait_combine(self):
- r = Retrying(wait=tenacity.wait_combine(tenacity.wait_random(0, 3),
- tenacity.wait_fixed(5)))
- # Test it a few time since it's random
- for i in six.moves.range(1000):
- w = r.wait(1, 5)
- self.assertLess(w, 8)
- self.assertGreaterEqual(w, 5)
-
- def test_wait_double_sum(self):
- r = Retrying(wait=tenacity.wait_random(0, 3) + tenacity.wait_fixed(5))
- # Test it a few time since it's random
- for i in six.moves.range(1000):
- w = r.wait(1, 5)
- self.assertLess(w, 8)
- self.assertGreaterEqual(w, 5)
-
- def test_wait_triple_sum(self):
- r = Retrying(wait=tenacity.wait_fixed(1) + tenacity.wait_random(0, 3) +
- tenacity.wait_fixed(5))
- # Test it a few time since it's random
- for i in six.moves.range(1000):
- w = r.wait(1, 5)
- self.assertLess(w, 9)
- self.assertGreaterEqual(w, 6)
-
- def test_wait_arbitrary_sum(self):
- r = Retrying(wait=sum([tenacity.wait_fixed(1),
- tenacity.wait_random(0, 3),
- tenacity.wait_fixed(5),
- tenacity.wait_none()]))
- # Test it a few time since it's random
- for i in six.moves.range(1000):
- w = r.wait(1, 5)
- self.assertLess(w, 9)
- self.assertGreaterEqual(w, 6)
-
- def _assert_range(self, wait, min_, max_):
- self.assertLess(wait, max_)
- self.assertGreaterEqual(wait, min_)
-
- def _assert_inclusive_range(self, wait, low, high):
- self.assertLessEqual(wait, high)
- self.assertGreaterEqual(wait, low)
-
- def _assert_inclusive_epsilon(self, wait, target, epsilon):
- self.assertLessEqual(wait, target + epsilon)
- self.assertGreaterEqual(wait, target - epsilon)
-
- def test_wait_chain(self):
- r = Retrying(wait=tenacity.wait_chain(
- *[tenacity.wait_fixed(1) for i in six.moves.range(2)] +
- [tenacity.wait_fixed(4) for i in six.moves.range(2)] +
- [tenacity.wait_fixed(8) for i in six.moves.range(1)]))
-
- for i in six.moves.range(10):
- w = r.wait(i + 1, 1)
- if i < 2:
- self._assert_range(w, 1, 2)
- elif i < 4:
- self._assert_range(w, 4, 5)
- else:
- self._assert_range(w, 8, 9)
-
- def test_wait_chain_multiple_invocations(self):
- sleep_intervals = []
- r = Retrying(
- sleep=sleep_intervals.append,
- wait=tenacity.wait_chain(*[
- tenacity.wait_fixed(i + 1) for i in six.moves.range(3)
- ]),
- stop=tenacity.stop_after_attempt(5),
- retry=tenacity.retry_if_result(lambda x: x == 1),
- )
-
- @r.wraps
- def always_return_1():
- return 1
-
- self.assertRaises(tenacity.RetryError, always_return_1)
- self.assertEqual(sleep_intervals, [1.0, 2.0, 3.0, 3.0])
- sleep_intervals[:] = []
-
- # Clear and restart retrying.
- self.assertRaises(tenacity.RetryError, always_return_1)
- self.assertEqual(sleep_intervals, [1.0, 2.0, 3.0, 3.0])
- sleep_intervals[:] = []
-
- def test_wait_random_exponential(self):
- fn = tenacity.wait_random_exponential(0.5, 60.0)
-
- for _ in six.moves.range(1000):
- self._assert_inclusive_range(fn(make_retry_state(1, 0)), 0, 0.5)
- self._assert_inclusive_range(fn(make_retry_state(2, 0)), 0, 1.0)
- self._assert_inclusive_range(fn(make_retry_state(3, 0)), 0, 2.0)
- self._assert_inclusive_range(fn(make_retry_state(4, 0)), 0, 4.0)
- self._assert_inclusive_range(fn(make_retry_state(5, 0)), 0, 8.0)
- self._assert_inclusive_range(fn(make_retry_state(6, 0)), 0, 16.0)
- self._assert_inclusive_range(fn(make_retry_state(7, 0)), 0, 32.0)
- self._assert_inclusive_range(fn(make_retry_state(8, 0)), 0, 60.0)
- self._assert_inclusive_range(fn(make_retry_state(9, 0)), 0, 60.0)
-
- fn = tenacity.wait_random_exponential(10, 5)
- for _ in six.moves.range(1000):
- self._assert_inclusive_range(
- fn(make_retry_state(1, 0)), 0.00, 5.00
- )
-
- # Default arguments exist
- fn = tenacity.wait_random_exponential()
- fn(0, 0)
-
- def test_wait_random_exponential_statistically(self):
- fn = tenacity.wait_random_exponential(0.5, 60.0)
-
- attempt = []
- for i in six.moves.range(10):
- attempt.append(
- [fn(make_retry_state(i, 0)) for _ in six.moves.range(4000)]
- )
-
- def mean(lst):
- return float(sum(lst)) / float(len(lst))
-
- # skipping attempt 0
- self._assert_inclusive_epsilon(mean(attempt[1]), 0.25, 0.02)
- self._assert_inclusive_epsilon(mean(attempt[2]), 0.50, 0.04)
- self._assert_inclusive_epsilon(mean(attempt[3]), 1, 0.08)
- self._assert_inclusive_epsilon(mean(attempt[4]), 2, 0.16)
- self._assert_inclusive_epsilon(mean(attempt[5]), 4, 0.32)
- self._assert_inclusive_epsilon(mean(attempt[6]), 8, 0.64)
- self._assert_inclusive_epsilon(mean(attempt[7]), 16, 1.28)
- self._assert_inclusive_epsilon(mean(attempt[8]), 30, 2.56)
- self._assert_inclusive_epsilon(mean(attempt[9]), 30, 2.56)
-
- def test_wait_backward_compat(self):
- """Ensure Retrying object accepts both old and newstyle wait funcs."""
- def wait1(previous_attempt_number, delay_since_first_attempt):
- wait1.calls.append((
- previous_attempt_number, delay_since_first_attempt))
- return 0
- wait1.calls = []
-
- def wait2(previous_attempt_number, delay_since_first_attempt,
- last_result):
- wait2.calls.append((
- previous_attempt_number, delay_since_first_attempt,
- last_result))
- return 0
- wait2.calls = []
-
- def dying():
- raise Exception("Broken")
-
- retrying1 = Retrying(wait=wait1, stop=tenacity.stop_after_attempt(4))
- with reports_deprecation_warning():
- self.assertRaises(Exception, lambda: retrying1(dying))
- self.assertEqual([t[0] for t in wait1.calls], [1, 2, 3])
- # This assumes that 3 iterations complete within 1 second.
- self.assertTrue(all(t[1] < 1 for t in wait1.calls))
-
- retrying2 = Retrying(wait=wait2, stop=tenacity.stop_after_attempt(4))
- with reports_deprecation_warning():
- self.assertRaises(Exception, lambda: retrying2(dying))
- self.assertEqual([t[0] for t in wait2.calls], [1, 2, 3])
- # This assumes that 3 iterations complete within 1 second.
- self.assertTrue(all(t[1] < 1 for t in wait2.calls))
- self.assertEqual([str(t[2].exception()) for t in wait2.calls],
- ['Broken'] * 3)
-
- def test_wait_class_backward_compatibility(self):
- """Ensure builtin objects accept both old and new parameters."""
- waitobj = tenacity.wait_fixed(5)
- self.assertEqual(waitobj(1, 0.1), 5)
- self.assertEqual(
- waitobj(1, 0.1, tenacity.Future.construct(1, 1, False)), 5)
- retry_state = make_retry_state(123, 456)
- self.assertEqual(retry_state.attempt_number, 123)
- self.assertEqual(retry_state.seconds_since_start, 456)
- self.assertEqual(waitobj(retry_state=retry_state), 5)
-
- def test_wait_retry_state_attributes(self):
-
- class ExtractCallState(Exception):
- pass
-
- # retry_state is mutable, so return it as an exception to extract the
- # exact values it has when wait is called and bypass any other logic.
- def waitfunc(retry_state):
- raise ExtractCallState(retry_state)
-
- retrying = Retrying(
- wait=waitfunc,
- retry=(tenacity.retry_if_exception_type() |
- tenacity.retry_if_result(lambda result: result == 123)))
-
- def returnval():
- return 123
- try:
- retrying(returnval)
- except ExtractCallState as err:
- retry_state = err.args[0]
- self.assertIs(retry_state.fn, returnval)
- self.assertEqual(retry_state.args, ())
- self.assertEqual(retry_state.kwargs, {})
- self.assertEqual(retry_state.outcome.result(), 123)
- self.assertEqual(retry_state.attempt_number, 1)
- self.assertGreater(retry_state.outcome_timestamp,
- retry_state.start_time)
-
- def dying():
- raise Exception("Broken")
- try:
- retrying(dying)
- except ExtractCallState as err:
- retry_state = err.args[0]
- self.assertIs(retry_state.fn, dying)
- self.assertEqual(retry_state.args, ())
- self.assertEqual(retry_state.kwargs, {})
- self.assertEqual(str(retry_state.outcome.exception()), 'Broken')
- self.assertEqual(retry_state.attempt_number, 1)
- self.assertGreater(retry_state.outcome_timestamp,
- retry_state.start_time)
-
-
-class TestRetryConditions(unittest.TestCase):
-
- def test_retry_if_result(self):
- retry = (tenacity.retry_if_result(lambda x: x == 1))
-
- def r(fut):
- retry_state = make_retry_state(1, 1.0, last_result=fut)
- return retry(retry_state)
- self.assertTrue(r(tenacity.Future.construct(1, 1, False)))
- self.assertFalse(r(tenacity.Future.construct(1, 2, False)))
-
- def test_retry_if_not_result(self):
- retry = (tenacity.retry_if_not_result(lambda x: x == 1))
-
- def r(fut):
- retry_state = make_retry_state(1, 1.0, last_result=fut)
- return retry(retry_state)
- self.assertTrue(r(tenacity.Future.construct(1, 2, False)))
- self.assertFalse(r(tenacity.Future.construct(1, 1, False)))
-
- def test_retry_any(self):
- retry = tenacity.retry_any(
- tenacity.retry_if_result(lambda x: x == 1),
- tenacity.retry_if_result(lambda x: x == 2))
-
- def r(fut):
- retry_state = make_retry_state(1, 1.0, last_result=fut)
- return retry(retry_state)
- self.assertTrue(r(tenacity.Future.construct(1, 1, False)))
- self.assertTrue(r(tenacity.Future.construct(1, 2, False)))
- self.assertFalse(r(tenacity.Future.construct(1, 3, False)))
- self.assertFalse(r(tenacity.Future.construct(1, 1, True)))
-
- def test_retry_all(self):
- retry = tenacity.retry_all(
- tenacity.retry_if_result(lambda x: x == 1),
- tenacity.retry_if_result(lambda x: isinstance(x, int)))
-
- def r(fut):
- retry_state = make_retry_state(1, 1.0, last_result=fut)
- return retry(retry_state)
- self.assertTrue(r(tenacity.Future.construct(1, 1, False)))
- self.assertFalse(r(tenacity.Future.construct(1, 2, False)))
- self.assertFalse(r(tenacity.Future.construct(1, 3, False)))
- self.assertFalse(r(tenacity.Future.construct(1, 1, True)))
-
- def test_retry_and(self):
- retry = (tenacity.retry_if_result(lambda x: x == 1) &
- tenacity.retry_if_result(lambda x: isinstance(x, int)))
-
- def r(fut):
- retry_state = make_retry_state(1, 1.0, last_result=fut)
- return retry(retry_state)
- self.assertTrue(r(tenacity.Future.construct(1, 1, False)))
- self.assertFalse(r(tenacity.Future.construct(1, 2, False)))
- self.assertFalse(r(tenacity.Future.construct(1, 3, False)))
- self.assertFalse(r(tenacity.Future.construct(1, 1, True)))
-
- def test_retry_or(self):
- retry = (tenacity.retry_if_result(lambda x: x == "foo") |
- tenacity.retry_if_result(lambda x: isinstance(x, int)))
-
- def r(fut):
- retry_state = make_retry_state(1, 1.0, last_result=fut)
- return retry(retry_state)
- self.assertTrue(r(tenacity.Future.construct(1, "foo", False)))
- self.assertFalse(r(tenacity.Future.construct(1, "foobar", False)))
- self.assertFalse(r(tenacity.Future.construct(1, 2.2, False)))
- self.assertFalse(r(tenacity.Future.construct(1, 42, True)))
-
- def _raise_try_again(self):
- self._attempts += 1
- if self._attempts < 3:
- raise tenacity.TryAgain
-
- def test_retry_try_again(self):
- self._attempts = 0
- Retrying(stop=tenacity.stop_after_attempt(5),
- retry=tenacity.retry_never)(self._raise_try_again)
- self.assertEqual(3, self._attempts)
-
- def test_retry_try_again_forever(self):
- def _r():
- raise tenacity.TryAgain
-
- r = Retrying(stop=tenacity.stop_after_attempt(5),
- retry=tenacity.retry_never)
- self.assertRaises(tenacity.RetryError,
- r,
- _r)
- self.assertEqual(5, r.statistics['attempt_number'])
-
- def test_retry_try_again_forever_reraise(self):
- def _r():
- raise tenacity.TryAgain
-
- r = Retrying(stop=tenacity.stop_after_attempt(5),
- retry=tenacity.retry_never,
- reraise=True)
- self.assertRaises(tenacity.TryAgain,
- r,
- _r)
- self.assertEqual(5, r.statistics['attempt_number'])
-
- def test_retry_if_exception_message_negative_no_inputs(self):
- with self.assertRaises(TypeError):
- tenacity.retry_if_exception_message()
-
- def test_retry_if_exception_message_negative_too_many_inputs(self):
- with self.assertRaises(TypeError):
- tenacity.retry_if_exception_message(
- message="negative", match="negative")
-
-
-class NoneReturnUntilAfterCount(object):
- """Holds counter state for invoking a method several times in a row."""
-
- def __init__(self, count):
- self.counter = 0
- self.count = count
-
- def go(self):
- """Return None until after count threshold has been crossed.
-
- Then return True.
- """
- if self.counter < self.count:
- self.counter += 1
- return None
- return True
-
-
-class NoIOErrorAfterCount(object):
- """Holds counter state for invoking a method several times in a row."""
-
- def __init__(self, count):
- self.counter = 0
- self.count = count
-
- def go(self):
- """Raise an IOError until after count threshold has been crossed.
-
- Then return True.
- """
- if self.counter < self.count:
- self.counter += 1
- raise IOError("Hi there, I'm an IOError")
- return True
-
-
-class NoNameErrorAfterCount(object):
- """Holds counter state for invoking a method several times in a row."""
-
- def __init__(self, count):
- self.counter = 0
- self.count = count
-
- def go(self):
- """Raise a NameError until after count threshold has been crossed.
-
- Then return True.
- """
- if self.counter < self.count:
- self.counter += 1
- raise NameError("Hi there, I'm a NameError")
- return True
-
-
-class NameErrorUntilCount(object):
- """Holds counter state for invoking a method several times in a row."""
-
- derived_message = "Hi there, I'm a NameError"
-
- def __init__(self, count):
- self.counter = 0
- self.count = count
-
- def go(self):
- """Return True until after count threshold has been crossed.
-
- Then raise a NameError.
- """
- if self.counter < self.count:
- self.counter += 1
- return True
- raise NameError(self.derived_message)
-
-
-class IOErrorUntilCount(object):
- """Holds counter state for invoking a method several times in a row."""
-
- def __init__(self, count):
- self.counter = 0
- self.count = count
-
- def go(self):
- """Return True until after count threshold has been crossed.
-
- Then raise an IOError.
- """
- if self.counter < self.count:
- self.counter += 1
- return True
- raise IOError("Hi there, I'm an IOError")
-
-
-class CustomError(Exception):
- """This is a custom exception class.
-
- Note that For Python 2.x, we don't strictly need to extend BaseException,
- however, Python 3.x will complain. While this test suite won't run
- correctly under Python 3.x without extending from the Python exception
- hierarchy, the actual module code is backwards compatible Python 2.x and
- will allow for cases where exception classes don't extend from the
- hierarchy.
- """
-
- def __init__(self, value):
- self.value = value
-
- def __str__(self):
- return self.value
-
-
-class NoCustomErrorAfterCount(object):
- """Holds counter state for invoking a method several times in a row."""
-
- derived_message = "This is a Custom exception class"
-
- def __init__(self, count):
- self.counter = 0
- self.count = count
-
- def go(self):
- """Raise a CustomError until after count threshold has been crossed.
-
- Then return True.
- """
- if self.counter < self.count:
- self.counter += 1
- raise CustomError(self.derived_message)
- return True
-
-
-class CapturingHandler(logging.Handler):
- """Captures log records for inspection."""
-
- def __init__(self, *args, **kwargs):
- super(CapturingHandler, self).__init__(*args, **kwargs)
- self.records = []
-
- def emit(self, record):
- self.records.append(record)
-
-
-def current_time_ms():
- return int(round(time.time() * 1000))
-
-
-@retry(wait=tenacity.wait_fixed(0.05),
- retry=tenacity.retry_if_result(lambda result: result is None))
-def _retryable_test_with_wait(thing):
- return thing.go()
-
-
-@retry(stop=tenacity.stop_after_attempt(3),
- retry=tenacity.retry_if_result(lambda result: result is None))
-def _retryable_test_with_stop(thing):
- return thing.go()
-
-
-@retry(retry=tenacity.retry_if_exception_type(IOError))
-def _retryable_test_with_exception_type_io(thing):
- return thing.go()
-
-
-@retry(
- stop=tenacity.stop_after_attempt(3),
- retry=tenacity.retry_if_exception_type(IOError))
-def _retryable_test_with_exception_type_io_attempt_limit(thing):
- return thing.go()
-
-
-@retry(retry=tenacity.retry_unless_exception_type(NameError))
-def _retryable_test_with_unless_exception_type_name(thing):
- return thing.go()
-
-
-@retry(
- stop=tenacity.stop_after_attempt(3),
- retry=tenacity.retry_unless_exception_type(NameError))
-def _retryable_test_with_unless_exception_type_name_attempt_limit(thing):
- return thing.go()
-
-
-@retry(retry=tenacity.retry_unless_exception_type())
-def _retryable_test_with_unless_exception_type_no_input(thing):
- return thing.go()
-
-
-@retry(
- stop=tenacity.stop_after_attempt(5),
- retry=tenacity.retry_if_exception_message(
- message=NoCustomErrorAfterCount.derived_message))
-def _retryable_test_if_exception_message_message(thing):
- return thing.go()
-
-
-@retry(retry=tenacity.retry_if_not_exception_message(
- message=NoCustomErrorAfterCount.derived_message))
-def _retryable_test_if_not_exception_message_message(thing):
- return thing.go()
-
-
-@retry(retry=tenacity.retry_if_exception_message(
- match=NoCustomErrorAfterCount.derived_message[:3] + ".*"))
-def _retryable_test_if_exception_message_match(thing):
- return thing.go()
-
-
-@retry(retry=tenacity.retry_if_not_exception_message(
- match=NoCustomErrorAfterCount.derived_message[:3] + ".*"))
-def _retryable_test_if_not_exception_message_match(thing):
- return thing.go()
-
-
-@retry(retry=tenacity.retry_if_not_exception_message(
- message=NameErrorUntilCount.derived_message))
-def _retryable_test_not_exception_message_delay(thing):
- return thing.go()
-
-
-@retry
-def _retryable_default(thing):
- return thing.go()
-
-
-@retry()
-def _retryable_default_f(thing):
- return thing.go()
-
-
-@retry(retry=tenacity.retry_if_exception_type(CustomError))
-def _retryable_test_with_exception_type_custom(thing):
- return thing.go()
-
-
-@retry(
- stop=tenacity.stop_after_attempt(3),
- retry=tenacity.retry_if_exception_type(CustomError))
-def _retryable_test_with_exception_type_custom_attempt_limit(thing):
- return thing.go()
-
-
-class TestDecoratorWrapper(unittest.TestCase):
-
- def test_with_wait(self):
- start = current_time_ms()
- result = _retryable_test_with_wait(NoneReturnUntilAfterCount(5))
- t = current_time_ms() - start
- self.assertGreaterEqual(t, 250)
- self.assertTrue(result)
-
- def test_retry_with(self):
- start = current_time_ms()
- result = _retryable_test_with_wait.retry_with(
- wait=tenacity.wait_fixed(0.1))(NoneReturnUntilAfterCount(5))
- t = current_time_ms() - start
- self.assertGreaterEqual(t, 500)
- self.assertTrue(result)
-
- def test_with_stop_on_return_value(self):
- try:
- _retryable_test_with_stop(NoneReturnUntilAfterCount(5))
- self.fail("Expected RetryError after 3 attempts")
- except RetryError as re:
- self.assertFalse(re.last_attempt.failed)
- self.assertEqual(3, re.last_attempt.attempt_number)
- self.assertTrue(re.last_attempt.result() is None)
- print(re)
-
- def test_with_stop_on_exception(self):
- try:
- _retryable_test_with_stop(NoIOErrorAfterCount(5))
- self.fail("Expected IOError")
- except IOError as re:
- self.assertTrue(isinstance(re, IOError))
- print(re)
-
- def test_retry_if_exception_of_type(self):
- self.assertTrue(_retryable_test_with_exception_type_io(
- NoIOErrorAfterCount(5)))
-
- try:
- _retryable_test_with_exception_type_io(NoNameErrorAfterCount(5))
- self.fail("Expected NameError")
- except NameError as n:
- self.assertTrue(isinstance(n, NameError))
- print(n)
-
- self.assertTrue(_retryable_test_with_exception_type_custom(
- NoCustomErrorAfterCount(5)))
-
- try:
- _retryable_test_with_exception_type_custom(
- NoNameErrorAfterCount(5))
- self.fail("Expected NameError")
- except NameError as n:
- self.assertTrue(isinstance(n, NameError))
- print(n)
-
- def test_retry_until_exception_of_type_attempt_number(self):
- try:
- self.assertTrue(_retryable_test_with_unless_exception_type_name(
- NameErrorUntilCount(5)))
- except NameError as e:
- s = _retryable_test_with_unless_exception_type_name.\
- retry.statistics
- self.assertTrue(s['attempt_number'] == 6)
- print(e)
- else:
- self.fail("Expected NameError")
-
- def test_retry_until_exception_of_type_no_type(self):
- try:
- # no input should catch all subclasses of Exception
- self.assertTrue(
- _retryable_test_with_unless_exception_type_no_input(
- NameErrorUntilCount(5))
- )
- except NameError as e:
- s = _retryable_test_with_unless_exception_type_no_input.\
- retry.statistics
- self.assertTrue(s['attempt_number'] == 6)
- print(e)
- else:
- self.fail("Expected NameError")
-
- def test_retry_until_exception_of_type_wrong_exception(self):
- try:
- # two iterations with IOError, one that returns True
- _retryable_test_with_unless_exception_type_name_attempt_limit(
- IOErrorUntilCount(2))
- self.fail("Expected RetryError")
- except RetryError as e:
- self.assertTrue(isinstance(e, RetryError))
- print(e)
-
- def test_retry_if_exception_message(self):
- try:
- self.assertTrue(_retryable_test_if_exception_message_message(
- NoCustomErrorAfterCount(3)))
- except CustomError:
- print(_retryable_test_if_exception_message_message.retry.
- statistics)
- self.fail("CustomError should've been retried from errormessage")
-
- def test_retry_if_not_exception_message(self):
- try:
- self.assertTrue(_retryable_test_if_not_exception_message_message(
- NoCustomErrorAfterCount(2)))
- except CustomError:
- s = _retryable_test_if_not_exception_message_message.retry.\
- statistics
- self.assertTrue(s['attempt_number'] == 1)
-
- def test_retry_if_not_exception_message_delay(self):
- try:
- self.assertTrue(_retryable_test_not_exception_message_delay(
- NameErrorUntilCount(3)))
- except NameError:
- s = _retryable_test_not_exception_message_delay.retry.statistics
- print(s['attempt_number'])
- self.assertTrue(s['attempt_number'] == 4)
-
- def test_retry_if_exception_message_match(self):
- try:
- self.assertTrue(_retryable_test_if_exception_message_match(
- NoCustomErrorAfterCount(3)))
- except CustomError:
- self.fail("CustomError should've been retried from errormessage")
-
- def test_retry_if_not_exception_message_match(self):
- try:
- self.assertTrue(_retryable_test_if_not_exception_message_message(
- NoCustomErrorAfterCount(2)))
- except CustomError:
- s = _retryable_test_if_not_exception_message_message.retry.\
- statistics
- self.assertTrue(s['attempt_number'] == 1)
-
- def test_defaults(self):
- self.assertTrue(_retryable_default(NoNameErrorAfterCount(5)))
- self.assertTrue(_retryable_default_f(NoNameErrorAfterCount(5)))
- self.assertTrue(_retryable_default(NoCustomErrorAfterCount(5)))
- self.assertTrue(_retryable_default_f(NoCustomErrorAfterCount(5)))
-
- def test_retry_function_object(self):
- """Test that six.wraps doesn't cause problems with callable objects.
-
- It raises an error upon trying to wrap it in Py2, because __name__
- attribute is missing. It's fixed in Py3 but was never backported.
- """
- class Hello(object):
- def __call__(self):
- return "Hello"
- retrying = Retrying(wait=tenacity.wait_fixed(0.01),
- stop=tenacity.stop_after_attempt(3))
- h = retrying.wraps(Hello())
- self.assertEqual(h(), "Hello")
-
- def test_retry_child_class_with_override_backward_compat(self):
- def always_true(_):
- return True
-
- class MyRetry(tenacity.retry_if_exception):
- def __init__(self):
- super(MyRetry, self).__init__(always_true)
-
- def __call__(self, attempt):
- return super(MyRetry, self).__call__(attempt)
- retrying = Retrying(wait=tenacity.wait_fixed(0.01),
- stop=tenacity.stop_after_attempt(1),
- retry=MyRetry())
-
- def failing():
- raise NotImplementedError()
- with pytest.raises(RetryError):
- retrying(failing)
-
-
-class TestBeforeAfterAttempts(unittest.TestCase):
- _attempt_number = 0
-
- def test_before_attempts(self):
- TestBeforeAfterAttempts._attempt_number = 0
-
- def _before(retry_state):
- TestBeforeAfterAttempts._attempt_number = \
- retry_state.attempt_number
-
- @retry(wait=tenacity.wait_fixed(1),
- stop=tenacity.stop_after_attempt(1),
- before=_before)
- def _test_before():
- pass
-
- _test_before()
-
- self.assertTrue(TestBeforeAfterAttempts._attempt_number == 1)
-
- def test_after_attempts(self):
- TestBeforeAfterAttempts._attempt_number = 0
-
- def _after(retry_state):
- TestBeforeAfterAttempts._attempt_number = \
- retry_state.attempt_number
-
- @retry(wait=tenacity.wait_fixed(0.1),
- stop=tenacity.stop_after_attempt(3),
- after=_after)
- def _test_after():
- if TestBeforeAfterAttempts._attempt_number < 2:
- raise Exception("testing after_attempts handler")
- else:
- pass
-
- _test_after()
-
- self.assertTrue(TestBeforeAfterAttempts._attempt_number == 2)
-
- def test_before_sleep(self):
- def _before_sleep(retry_state):
- self.assertGreater(retry_state.next_action.sleep, 0)
- _before_sleep.attempt_number = retry_state.attempt_number
-
- @retry(wait=tenacity.wait_fixed(0.01),
- stop=tenacity.stop_after_attempt(3),
- before_sleep=_before_sleep)
- def _test_before_sleep():
- if _before_sleep.attempt_number < 2:
- raise Exception("testing before_sleep_attempts handler")
-
- _test_before_sleep()
- self.assertEqual(_before_sleep.attempt_number, 2)
-
- def test_before_sleep_backward_compat(self):
- def _before_sleep(retry_obj, sleep, last_result):
- self.assertGreater(sleep, 0)
- _before_sleep.attempt_number = \
- retry_obj.statistics['attempt_number']
- _before_sleep.attempt_number = 0
-
- @retry(wait=tenacity.wait_fixed(0.01),
- stop=tenacity.stop_after_attempt(3),
- before_sleep=_before_sleep)
- def _test_before_sleep():
- if _before_sleep.attempt_number < 2:
- raise Exception("testing before_sleep_attempts handler")
-
- with reports_deprecation_warning():
- _test_before_sleep()
- self.assertEqual(_before_sleep.attempt_number, 2)
-
- def _before_sleep(self, retry_state):
- self.slept += 1
-
- def test_before_sleep_backward_compat_method(self):
- self.slept = 0
-
- @retry(wait=tenacity.wait_fixed(0.01),
- stop=tenacity.stop_after_attempt(3),
- before_sleep=self._before_sleep)
- def _test_before_sleep():
- raise Exception("testing before_sleep_attempts handler")
-
- try:
- _test_before_sleep()
- except tenacity.RetryError:
- pass
-
- self.assertEqual(self.slept, 2)
-
- def _before_sleep_log_raises(self, get_call_fn):
- thing = NoIOErrorAfterCount(2)
- logger = logging.getLogger(self.id())
- logger.propagate = False
- logger.setLevel(logging.INFO)
- handler = CapturingHandler()
- logger.addHandler(handler)
- try:
- _before_sleep = tenacity.before_sleep_log(logger, logging.INFO)
- retrying = Retrying(wait=tenacity.wait_fixed(0.01),
- stop=tenacity.stop_after_attempt(3),
- before_sleep=_before_sleep)
- get_call_fn(retrying)(thing.go)
- finally:
- logger.removeHandler(handler)
-
- etalon_re = (r"^Retrying .* in 0\.01 seconds as it raised "
- r"(IO|OS)Error: Hi there, I'm an IOError\.$")
- self.assertEqual(len(handler.records), 2)
- fmt = logging.Formatter().format
- self.assertRegexpMatches(fmt(handler.records[0]), etalon_re)
- self.assertRegexpMatches(fmt(handler.records[1]), etalon_re)
-
- def test_before_sleep_log_raises(self):
- self._before_sleep_log_raises(lambda x: x)
-
- def test_before_sleep_log_raises_deprecated_call(self):
- self._before_sleep_log_raises(lambda x: x.call)
-
- def test_before_sleep_log_raises_with_exc_info(self):
- thing = NoIOErrorAfterCount(2)
- logger = logging.getLogger(self.id())
- logger.propagate = False
- logger.setLevel(logging.INFO)
- handler = CapturingHandler()
- logger.addHandler(handler)
- try:
- _before_sleep = tenacity.before_sleep_log(logger,
- logging.INFO,
- exc_info=True)
- retrying = Retrying(wait=tenacity.wait_fixed(0.01),
- stop=tenacity.stop_after_attempt(3),
- before_sleep=_before_sleep)
- retrying(thing.go)
- finally:
- logger.removeHandler(handler)
-
- etalon_re = re.compile(r"^Retrying .* in 0\.01 seconds as it raised "
- r"(IO|OS)Error: Hi there, I'm an IOError\.{0}"
- r"Traceback \(most recent call last\):{0}"
- r".*$".format(os.linesep),
- flags=re.MULTILINE)
- self.assertEqual(len(handler.records), 2)
- fmt = logging.Formatter().format
- self.assertRegexpMatches(fmt(handler.records[0]), etalon_re)
- self.assertRegexpMatches(fmt(handler.records[1]), etalon_re)
-
- def test_before_sleep_log_returns(self, exc_info=False):
- thing = NoneReturnUntilAfterCount(2)
- logger = logging.getLogger(self.id())
- logger.propagate = False
- logger.setLevel(logging.INFO)
- handler = CapturingHandler()
- logger.addHandler(handler)
- try:
- _before_sleep = tenacity.before_sleep_log(logger,
- logging.INFO,
- exc_info=exc_info)
- _retry = tenacity.retry_if_result(lambda result: result is None)
- retrying = Retrying(wait=tenacity.wait_fixed(0.01),
- stop=tenacity.stop_after_attempt(3),
- retry=_retry, before_sleep=_before_sleep)
- retrying(thing.go)
- finally:
- logger.removeHandler(handler)
-
- etalon_re = r'^Retrying .* in 0\.01 seconds as it returned None\.$'
- self.assertEqual(len(handler.records), 2)
- fmt = logging.Formatter().format
- self.assertRegexpMatches(fmt(handler.records[0]), etalon_re)
- self.assertRegexpMatches(fmt(handler.records[1]), etalon_re)
-
- def test_before_sleep_log_returns_with_exc_info(self):
- self.test_before_sleep_log_returns(exc_info=True)
-
-
-class TestReraiseExceptions(unittest.TestCase):
-
- def test_reraise_by_default(self):
- calls = []
-
- @retry(wait=tenacity.wait_fixed(0.1),
- stop=tenacity.stop_after_attempt(2),
- reraise=True)
- def _reraised_by_default():
- calls.append('x')
- raise KeyError("Bad key")
-
- self.assertRaises(KeyError, _reraised_by_default)
- self.assertEqual(2, len(calls))
-
- def test_reraise_from_retry_error(self):
- calls = []
-
- @retry(wait=tenacity.wait_fixed(0.1),
- stop=tenacity.stop_after_attempt(2))
- def _raise_key_error():
- calls.append('x')
- raise KeyError("Bad key")
-
- def _reraised_key_error():
- try:
- _raise_key_error()
- except tenacity.RetryError as retry_err:
- retry_err.reraise()
-
- self.assertRaises(KeyError, _reraised_key_error)
- self.assertEqual(2, len(calls))
-
- def test_reraise_timeout_from_retry_error(self):
- calls = []
-
- @retry(wait=tenacity.wait_fixed(0.1),
- stop=tenacity.stop_after_attempt(2),
- retry=lambda retry_state: True)
- def _mock_fn():
- calls.append('x')
-
- def _reraised_mock_fn():
- try:
- _mock_fn()
- except tenacity.RetryError as retry_err:
- retry_err.reraise()
-
- self.assertRaises(tenacity.RetryError, _reraised_mock_fn)
- self.assertEqual(2, len(calls))
-
- def test_reraise_no_exception(self):
- calls = []
-
- @retry(wait=tenacity.wait_fixed(0.1),
- stop=tenacity.stop_after_attempt(2),
- retry=lambda retry_state: True,
- reraise=True)
- def _mock_fn():
- calls.append('x')
-
- self.assertRaises(tenacity.RetryError, _mock_fn)
- self.assertEqual(2, len(calls))
-
-
-class TestStatistics(unittest.TestCase):
-
- def test_stats(self):
- @retry()
- def _foobar():
- return 42
-
- self.assertEqual({}, _foobar.retry.statistics)
- _foobar()
- self.assertEqual(1, _foobar.retry.statistics['attempt_number'])
-
- def test_stats_failing(self):
- @retry(stop=tenacity.stop_after_attempt(2))
- def _foobar():
- raise ValueError(42)
-
- self.assertEqual({}, _foobar.retry.statistics)
- try:
- _foobar()
- except Exception:
- pass
- self.assertEqual(2, _foobar.retry.statistics['attempt_number'])
-
-
-class TestRetryErrorCallback(unittest.TestCase):
-
- def setUp(self):
- self._attempt_number = 0
- self._callback_called = False
-
- def _callback(self, fut):
- self._callback_called = True
- return fut
-
- def test_retry_error_callback_backward_compat(self):
- num_attempts = 3
-
- def retry_error_callback(fut):
- retry_error_callback.called_times += 1
- return fut
-
- retry_error_callback.called_times = 0
-
- @retry(stop=tenacity.stop_after_attempt(num_attempts),
- retry_error_callback=retry_error_callback)
- def _foobar():
- self._attempt_number += 1
- raise Exception("This exception should not be raised")
-
- with reports_deprecation_warning():
- result = _foobar()
-
- self.assertEqual(retry_error_callback.called_times, 1)
- self.assertEqual(num_attempts, self._attempt_number)
- self.assertIsInstance(result, tenacity.Future)
-
- def test_retry_error_callback(self):
- num_attempts = 3
-
- def retry_error_callback(retry_state):
- retry_error_callback.called_times += 1
- return retry_state.outcome
-
- retry_error_callback.called_times = 0
-
- @retry(stop=tenacity.stop_after_attempt(num_attempts),
- retry_error_callback=retry_error_callback)
- def _foobar():
- self._attempt_number += 1
- raise Exception("This exception should not be raised")
-
- result = _foobar()
-
- self.assertEqual(retry_error_callback.called_times, 1)
- self.assertEqual(num_attempts, self._attempt_number)
- self.assertIsInstance(result, tenacity.Future)
-
-
-class TestContextManager(unittest.TestCase):
- def test_context_manager_retry_one(self):
- from tenacity import Retrying
-
- raise_ = True
-
- for attempt in Retrying():
- with attempt:
- if raise_:
- raise_ = False
- raise Exception("Retry it!")
-
- def test_context_manager_on_error(self):
- from tenacity import Retrying
-
- class CustomError(Exception):
- pass
-
- retry = Retrying(retry=tenacity.retry_if_exception_type(IOError))
-
- def test():
- for attempt in retry:
- with attempt:
- raise CustomError("Don't retry!")
-
- self.assertRaises(CustomError, test)
-
- def test_context_manager_retry_error(self):
- from tenacity import Retrying
-
- retry = Retrying(stop=tenacity.stop_after_attempt(2))
-
- def test():
- for attempt in retry:
- with attempt:
- raise Exception("Retry it!")
-
- self.assertRaises(RetryError, test)
-
- def test_context_manager_reraise(self):
- from tenacity import Retrying
-
- class CustomError(Exception):
- pass
-
- retry = Retrying(reraise=True, stop=tenacity.stop_after_attempt(2))
-
- def test():
- for attempt in retry:
- with attempt:
- raise CustomError("Don't retry!")
-
- self.assertRaises(CustomError, test)
-
-
-class TestInvokeAsCallable:
- """Test direct invocation of Retrying as a callable."""
-
- @staticmethod
- def invoke(retry, f):
- """
- Invoke Retrying logic.
-
- Wrapper allows testing different call mechanisms in test sub-classes.
- """
- return retry(f)
-
- def test_retry_one(self):
- def f():
- f.calls.append(len(f.calls) + 1)
- if len(f.calls) <= 1:
- raise Exception("Retry it!")
- return 42
- f.calls = []
-
- retry = Retrying()
- assert self.invoke(retry, f) == 42
- assert f.calls == [1, 2]
-
- def test_on_error(self):
- class CustomError(Exception):
- pass
-
- def f():
- f.calls.append(len(f.calls) + 1)
- if len(f.calls) <= 1:
- raise CustomError("Don't retry!")
- return 42
- f.calls = []
-
- retry = Retrying(retry=tenacity.retry_if_exception_type(IOError))
- with pytest.raises(CustomError):
- self.invoke(retry, f)
- assert f.calls == [1]
-
- def test_retry_error(self):
- def f():
- f.calls.append(len(f.calls) + 1)
- raise Exception("Retry it!")
- f.calls = []
-
- retry = Retrying(stop=tenacity.stop_after_attempt(2))
- with pytest.raises(RetryError):
- self.invoke(retry, f)
- assert f.calls == [1, 2]
-
- def test_reraise(self):
- class CustomError(Exception):
- pass
-
- def f():
- f.calls.append(len(f.calls) + 1)
- raise CustomError("Retry it!")
- f.calls = []
-
- retry = Retrying(reraise=True, stop=tenacity.stop_after_attempt(2))
- with pytest.raises(CustomError):
- self.invoke(retry, f)
- assert f.calls == [1, 2]
-
-
-class TestInvokeViaLegacyCallMethod(TestInvokeAsCallable):
- """Retrying.call() method should work the same as Retrying.__call__()."""
-
- @staticmethod
- def invoke(retry, f):
- with reports_deprecation_warning():
- return retry.call(f)
-
-
-class TestRetryException(unittest.TestCase):
-
- def test_retry_error_is_pickleable(self):
- import pickle
- expected = RetryError(last_attempt=123)
- pickled = pickle.dumps(expected)
- actual = pickle.loads(pickled)
- self.assertEqual(expected.last_attempt, actual.last_attempt)
-
-
-class TestRetryTyping(unittest.TestCase):
-
- @pytest.mark.skipif(
- sys.version_info < (3, 0),
- reason="typeguard not supported for python 2"
- )
- def test_retry_type_annotations(self):
- """The decorator should maintain types of decorated functions."""
- # Just in case this is run with unit-test, return early for py2
- if sys.version_info < (3, 0):
- return
-
- # Function-level import because we can't install this for python 2.
- from typeguard import check_type
-
- def num_to_str(number):
- # type: (int) -> str
- return str(number)
-
- # equivalent to a raw @retry decoration
- with_raw = retry(num_to_str)
- with_raw_result = with_raw(1)
-
- # equivalent to a @retry(...) decoration
- with_constructor = retry()(num_to_str)
- with_constructor_result = with_raw(1)
-
- # These raise TypeError exceptions if they fail
- check_type("with_raw", with_raw, typing.Callable[[int], str])
- check_type("with_raw_result", with_raw_result, str)
- check_type(
- "with_constructor", with_constructor, typing.Callable[[int], str]
- )
- check_type("with_constructor_result", with_constructor_result, str)
-
-
-@contextmanager
-def reports_deprecation_warning():
- __tracebackhide__ = True
- oldfilters = copy(warnings.filters)
- warnings.simplefilter('always')
- try:
- with pytest.warns(DeprecationWarning):
- yield
- finally:
- warnings.filters = oldfilters
-
-
-class TestMockingSleep():
- RETRY_ARGS = dict(
- wait=tenacity.wait_fixed(0.1),
- stop=tenacity.stop_after_attempt(5),
- )
-
- def _fail(self):
- raise NotImplementedError()
-
- @retry(**RETRY_ARGS)
- def _decorated_fail(self):
- self._fail()
-
- @pytest.fixture()
- def mock_sleep(self, monkeypatch):
- class MockSleep(object):
- call_count = 0
-
- def __call__(self, seconds):
- self.call_count += 1
-
- sleep = MockSleep()
- monkeypatch.setattr(tenacity.nap.time, "sleep", sleep)
- yield sleep
-
- def test_call(self, mock_sleep):
- retrying = Retrying(**self.RETRY_ARGS)
- with pytest.raises(RetryError):
- retrying.call(self._fail)
- assert mock_sleep.call_count == 4
-
- def test_decorated(self, mock_sleep):
- with pytest.raises(RetryError):
- self._decorated_fail()
- assert mock_sleep.call_count == 4
-
- def test_decorated_retry_with(self, mock_sleep):
- fail_faster = self._decorated_fail.retry_with(
- stop=tenacity.stop_after_attempt(2),
- )
- with pytest.raises(RetryError):
- fail_faster()
- assert mock_sleep.call_count == 1
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tenacity/tornadoweb.py b/tenacity/tornadoweb.py
index 27dd349a..0d46ed7b 100644
--- a/tenacity/tornadoweb.py
+++ b/tenacity/tornadoweb.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# Copyright 2017 Elisey Zanko
#
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,36 +13,46 @@
# limitations under the License.
import sys
-
-from tenacity import BaseRetrying
-from tenacity import DoAttempt
-from tenacity import DoSleep
-from tenacity import RetryCallState
+import typing
from tornado import gen
+from tenacity import BaseRetrying, DoAttempt, DoSleep, RetryCallState
-class TornadoRetrying(BaseRetrying):
+if typing.TYPE_CHECKING:
+ from tornado.concurrent import Future
+
+_RetValT = typing.TypeVar("_RetValT")
- def __init__(self,
- sleep=gen.sleep,
- **kwargs):
- super(TornadoRetrying, self).__init__(**kwargs)
+
+class TornadoRetrying(BaseRetrying):
+ sleep: typing.Callable[..., "Future[None]"]
+
+ def __init__(
+ self,
+ sleep: "typing.Callable[[float], Future[None]]" = gen.sleep,
+ **kwargs: typing.Any,
+ ) -> None:
+ super().__init__(**kwargs)
self.sleep = sleep
@gen.coroutine
- def __call__(self, fn, *args, **kwargs):
- self.begin(fn)
-
- retry_state = RetryCallState(
- retry_object=self, fn=fn, args=args, kwargs=kwargs)
+ def __call__( # type: ignore[override]
+ self,
+ fn: "typing.Callable[..., typing.Generator[typing.Any, typing.Any, _RetValT] | Future[_RetValT]]",
+ *args: typing.Any,
+ **kwargs: typing.Any,
+ ) -> "typing.Generator[typing.Any, typing.Any, _RetValT]":
+ self.begin()
+
+ retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs)
while True:
do = self.iter(retry_state=retry_state)
if isinstance(do, DoAttempt):
try:
result = yield fn(*args, **kwargs)
except BaseException:
- retry_state.set_exception(sys.exc_info())
+ retry_state.set_exception(sys.exc_info()) # type: ignore[arg-type]
else:
retry_state.set_result(result)
elif isinstance(do, DoSleep):
diff --git a/tenacity/wait.py b/tenacity/wait.py
index d3c835f2..b28b7886 100644
--- a/tenacity/wait.py
+++ b/tenacity/wait.py
@@ -1,4 +1,4 @@
-# Copyright 2016 Julien Danjou
+# Copyright 2016–2021 Julien Danjou
# Copyright 2016 Joshua Harlow
# Copyright 2013-2014 Ray Holder
#
@@ -16,72 +16,73 @@
import abc
import random
-
-import six
+import typing
from tenacity import _utils
-from tenacity import compat as _compat
+
+if typing.TYPE_CHECKING:
+ from tenacity import RetryCallState
-@six.add_metaclass(abc.ABCMeta)
-class wait_base(object):
+class wait_base(abc.ABC):
"""Abstract base class for wait strategies."""
@abc.abstractmethod
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> float:
pass
- def __add__(self, other):
+ def __add__(self, other: "wait_base") -> "wait_combine":
return wait_combine(self, other)
- def __radd__(self, other):
+ def __radd__(self, other: "wait_base") -> "wait_combine | wait_base":
# make it possible to use multiple waits with the built-in sum function
- if other == 0:
+ if other == 0: # type: ignore[comparison-overlap]
return self
return self.__add__(other)
+WaitBaseT = wait_base | typing.Callable[["RetryCallState"], float | int]
+
+
class wait_fixed(wait_base):
"""Wait strategy that waits a fixed amount of time between each retry."""
- def __init__(self, wait):
- self.wait_fixed = wait
+ def __init__(self, wait: _utils.time_unit_type) -> None:
+ self.wait_fixed = _utils.to_seconds(wait)
- @_compat.wait_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> float:
return self.wait_fixed
class wait_none(wait_fixed):
"""Wait strategy that doesn't wait at all before retrying."""
- def __init__(self):
- super(wait_none, self).__init__(0)
+ def __init__(self) -> None:
+ super().__init__(0)
class wait_random(wait_base):
"""Wait strategy that waits a random amount of time between min/max."""
- def __init__(self, min=0, max=1): # noqa
- self.wait_random_min = min
- self.wait_random_max = max
+ def __init__(
+ self, min: _utils.time_unit_type = 0, max: _utils.time_unit_type = 1
+ ) -> None:
+ self.wait_random_min = _utils.to_seconds(min)
+ self.wait_random_max = _utils.to_seconds(max)
- @_compat.wait_dunder_call_accept_old_params
- def __call__(self, retry_state):
- return (self.wait_random_min +
- (random.random() *
- (self.wait_random_max - self.wait_random_min)))
+ def __call__(self, retry_state: "RetryCallState") -> float:
+ return self.wait_random_min + (
+ random.random() * (self.wait_random_max - self.wait_random_min)
+ )
class wait_combine(wait_base):
"""Combine several waiting strategies."""
- def __init__(self, *strategies):
- self.wait_funcs = tuple(_compat.wait_func_accept_retry_state(strategy)
- for strategy in strategies)
+ def __init__(self, *strategies: wait_base) -> None:
+ self.wait_funcs = strategies
- @_compat.wait_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> float:
return sum(x(retry_state=retry_state) for x in self.wait_funcs)
@@ -95,24 +96,60 @@ class wait_chain(wait_base):
@retry(wait=wait_chain(*[wait_fixed(1) for i in range(3)] +
[wait_fixed(2) for j in range(5)] +
- [wait_fixed(5) for k in range(4)))
+ [wait_fixed(5) for k in range(4)]))
def wait_chained():
- print("Wait 1s for 3 attempts, 2s for 5 attempts and 5s
- thereafter.")
+ print("Wait 1s for 3 attempts, 2s for 5 attempts and 5s "
+ "thereafter.")
"""
- def __init__(self, *strategies):
- self.strategies = [_compat.wait_func_accept_retry_state(strategy)
- for strategy in strategies]
+ def __init__(self, *strategies: wait_base) -> None:
+ self.strategies = strategies
- @_compat.wait_dunder_call_accept_old_params
- def __call__(self, retry_state):
- wait_func_no = min(max(retry_state.attempt_number, 1),
- len(self.strategies))
+ def __call__(self, retry_state: "RetryCallState") -> float:
+ wait_func_no = min(max(retry_state.attempt_number, 1), len(self.strategies))
wait_func = self.strategies[wait_func_no - 1]
return wait_func(retry_state=retry_state)
+class wait_exception(wait_base):
+ """Wait strategy that waits the amount of time returned by the predicate.
+
+ The predicate is passed the exception object. Based on the exception, the
+ user can decide how much time to wait before retrying.
+
+ For example::
+
+ def http_error(exception: BaseException) -> float:
+ if (
+ isinstance(exception, requests.HTTPError)
+ and exception.response.status_code == requests.codes.too_many_requests
+ ):
+ return float(exception.response.headers.get("Retry-After", "1"))
+ return 60.0
+
+
+ @retry(
+ stop=stop_after_attempt(3),
+ wait=wait_exception(http_error),
+ )
+ def http_get_request(url: str) -> None:
+ response = requests.get(url)
+ response.raise_for_status()
+ """
+
+ def __init__(self, predicate: typing.Callable[[BaseException], float]) -> None:
+ self.predicate = predicate
+
+ def __call__(self, retry_state: "RetryCallState") -> float:
+ if retry_state.outcome is None:
+ raise RuntimeError("__call__() called before outcome was set")
+
+ exception = retry_state.outcome.exception()
+ if exception is None:
+ raise RuntimeError("outcome failed but the exception is None")
+ return self.predicate(exception)
+
+
class wait_incrementing(wait_base):
"""Wait an incremental amount of time after each attempt.
@@ -120,16 +157,18 @@ class wait_incrementing(wait_base):
(and restricting the upper limit to some maximum value).
"""
- def __init__(self, start=0, increment=100, max=_utils.MAX_WAIT): # noqa
- self.start = start
- self.increment = increment
- self.max = max
-
- @_compat.wait_dunder_call_accept_old_params
- def __call__(self, retry_state):
- result = self.start + (
- self.increment * (retry_state.attempt_number - 1)
- )
+ def __init__(
+ self,
+ start: _utils.time_unit_type = 0,
+ increment: _utils.time_unit_type = 100,
+ max: _utils.time_unit_type = _utils.MAX_WAIT,
+ ) -> None:
+ self.start = _utils.to_seconds(start)
+ self.increment = _utils.to_seconds(increment)
+ self.max = _utils.to_seconds(max)
+
+ def __call__(self, retry_state: "RetryCallState") -> float:
+ result = self.start + (self.increment * (retry_state.attempt_number - 1))
return max(0, min(result, self.max))
@@ -146,14 +185,19 @@ class wait_exponential(wait_base):
wait_random_exponential for the latter case.
"""
- def __init__(self, multiplier=1, max=_utils.MAX_WAIT, exp_base=2, min=0): # noqa
+ def __init__(
+ self,
+ multiplier: float = 1,
+ max: _utils.time_unit_type = _utils.MAX_WAIT,
+ exp_base: float = 2,
+ min: _utils.time_unit_type = 0,
+ ) -> None:
self.multiplier = multiplier
- self.min = min
- self.max = max
+ self.min = _utils.to_seconds(min)
+ self.max = _utils.to_seconds(max)
self.exp_base = exp_base
- @_compat.wait_dunder_call_accept_old_params
- def __call__(self, retry_state):
+ def __call__(self, retry_state: "RetryCallState") -> float:
try:
exp = self.exp_base ** (retry_state.attempt_number - 1)
result = self.multiplier * exp
@@ -188,8 +232,40 @@ class wait_random_exponential(wait_exponential):
"""
- @_compat.wait_dunder_call_accept_old_params
- def __call__(self, retry_state):
- high = super(wait_random_exponential, self).__call__(
- retry_state=retry_state)
- return random.uniform(0, high)
+ def __call__(self, retry_state: "RetryCallState") -> float:
+ high = super().__call__(retry_state=retry_state)
+ return random.uniform(self.min, high)
+
+
+class wait_exponential_jitter(wait_base):
+ """Wait strategy that applies exponential backoff and jitter.
+
+ It allows for a customized initial wait, maximum wait and jitter.
+
+ This implements the strategy described here:
+ https://cloud.google.com/storage/docs/retry-strategy
+
+ The wait time is min(initial * 2**n + random.uniform(0, jitter), maximum)
+ where n is the retry count.
+ """
+
+ def __init__(
+ self,
+ initial: float = 1,
+ max: float = _utils.MAX_WAIT,
+ exp_base: float = 2,
+ jitter: float = 1,
+ ) -> None:
+ self.initial = initial
+ self.max = max
+ self.exp_base = exp_base
+ self.jitter = jitter
+
+ def __call__(self, retry_state: "RetryCallState") -> float:
+ jitter = random.uniform(0, self.jitter)
+ try:
+ exp = self.exp_base ** (retry_state.attempt_number - 1)
+ result = self.initial * exp + jitter
+ except OverflowError:
+ result = self.max
+ return max(0, min(result, self.max))
diff --git a/tenacity/tests/__init__.py b/tests/__init__.py
similarity index 100%
rename from tenacity/tests/__init__.py
rename to tests/__init__.py
diff --git a/tests/test_after.py b/tests/test_after.py
new file mode 100644
index 00000000..ccf4a730
--- /dev/null
+++ b/tests/test_after.py
@@ -0,0 +1,84 @@
+import logging
+import random
+import unittest.mock
+
+from tenacity import (
+ _utils,
+ after_log,
+)
+
+from . import test_tenacity
+
+
+class TestAfterLogFormat(unittest.TestCase):
+ def setUp(self) -> None:
+ self.log_level = random.choice(
+ (
+ logging.DEBUG,
+ logging.INFO,
+ logging.WARNING,
+ logging.ERROR,
+ logging.CRITICAL,
+ )
+ )
+ self.previous_attempt_number = random.randint(1, 512)
+
+ def test_01_default(self) -> None:
+ """Test log formatting."""
+ log = unittest.mock.MagicMock(spec="logging.Logger.log")
+ logger = unittest.mock.MagicMock(spec="logging.Logger", log=log)
+
+ sec_format = "%.3g"
+ delay_since_first_attempt = 0.1
+
+ retry_state = test_tenacity.make_retry_state(
+ self.previous_attempt_number, delay_since_first_attempt
+ )
+ fun = after_log(
+ logger=logger, log_level=self.log_level
+ ) # use default sec_format
+ fun(retry_state)
+ log.assert_called_once_with(
+ self.log_level,
+ f"Finished call to '{retry_state.get_fn_name()}' "
+ f"after {sec_format % retry_state.seconds_since_start}(s), "
+ f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.",
+ )
+
+ def test_02_none_seconds_since_start(self) -> None:
+ """Test log formatting when seconds_since_start is None."""
+ log = unittest.mock.MagicMock(spec="logging.Logger.log")
+ logger = unittest.mock.MagicMock(spec="logging.Logger", log=log)
+
+ retry_state = test_tenacity.make_retry_state(self.previous_attempt_number, 0.1)
+ retry_state.outcome_timestamp = None
+ assert retry_state.seconds_since_start is None
+
+ fun = after_log(logger=logger, log_level=self.log_level)
+ fun(retry_state)
+ log.assert_called_once_with(
+ self.log_level,
+ f"Finished call to '{retry_state.get_fn_name()}' "
+ f"after ?(s), "
+ f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.",
+ )
+
+ def test_02_custom_sec_format(self) -> None:
+ """Test log formatting with custom int format.."""
+ log = unittest.mock.MagicMock(spec="logging.Logger.log")
+ logger = unittest.mock.MagicMock(spec="logging.Logger", log=log)
+
+ sec_format = "%.1f"
+ delay_since_first_attempt = 0.1
+
+ retry_state = test_tenacity.make_retry_state(
+ self.previous_attempt_number, delay_since_first_attempt
+ )
+ fun = after_log(logger=logger, log_level=self.log_level, sec_format=sec_format)
+ fun(retry_state)
+ log.assert_called_once_with(
+ self.log_level,
+ f"Finished call to '{retry_state.get_fn_name()}' "
+ f"after {sec_format % retry_state.seconds_since_start}(s), "
+ f"this was the {_utils.to_ordinal(retry_state.attempt_number)} time calling it.",
+ )
diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py
new file mode 100644
index 00000000..7b5c6416
--- /dev/null
+++ b/tests/test_asyncio.py
@@ -0,0 +1,535 @@
+# Copyright 2016 Étienne Bersac
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import asyncio
+import inspect
+import unittest
+from collections.abc import Callable, Coroutine
+from functools import wraps
+from typing import Any, TypeVar
+from unittest import mock
+
+try:
+ import trio
+except ImportError:
+ have_trio = False
+else:
+ have_trio = True
+
+import pytest
+
+import tenacity
+from tenacity import (
+ AsyncRetrying,
+ RetryCallState,
+ RetryError,
+ retry,
+ retry_if_exception,
+ retry_if_result,
+ stop_after_attempt,
+)
+from tenacity import asyncio as tasyncio
+from tenacity.wait import wait_fixed
+
+from .test_tenacity import (
+ NoIOErrorAfterCount,
+ NoneReturnUntilAfterCount,
+ current_time_ms,
+)
+
+_F = TypeVar("_F", bound=Callable[..., Coroutine[Any, Any, Any]])
+
+
+def asynctest(callable_: _F) -> Callable[..., Any]:
+ @wraps(callable_)
+ def wrapper(*a: Any, **kw: Any) -> Any:
+ return asyncio.run(callable_(*a, **kw))
+
+ return wrapper
+
+
+async def _async_function(thing: NoIOErrorAfterCount) -> Any:
+ await asyncio.sleep(0.00001)
+ return thing.go()
+
+
+@retry
+async def _retryable_coroutine(thing: NoIOErrorAfterCount) -> Any:
+ await asyncio.sleep(0.00001)
+ return thing.go()
+
+
+@retry(stop=stop_after_attempt(2))
+async def _retryable_coroutine_with_2_attempts(thing: NoIOErrorAfterCount) -> Any:
+ await asyncio.sleep(0.00001)
+ return thing.go()
+
+
+class TestAsyncio(unittest.TestCase):
+ @asynctest
+ async def test_retry(self) -> None:
+ thing = NoIOErrorAfterCount(5)
+ await _retryable_coroutine(thing)
+ assert thing.counter == thing.count
+
+ @asynctest
+ async def test_iscoroutinefunction(self) -> None:
+ assert asyncio.iscoroutinefunction(_retryable_coroutine)
+ assert inspect.iscoroutinefunction(_retryable_coroutine)
+
+ @asynctest
+ async def test_retry_using_async_retying(self) -> None:
+ thing = NoIOErrorAfterCount(5)
+ retrying = AsyncRetrying()
+ await retrying(_async_function, thing)
+ assert thing.counter == thing.count
+
+ @asynctest
+ async def test_stop_after_attempt(self) -> None:
+ thing = NoIOErrorAfterCount(2)
+ try:
+ await _retryable_coroutine_with_2_attempts(thing)
+ except RetryError:
+ assert thing.counter == 2
+
+ def test_repr(self) -> None:
+ repr(tasyncio.AsyncRetrying())
+
+ def test_retry_attributes(self) -> None:
+ assert hasattr(_retryable_coroutine, "retry")
+ assert hasattr(_retryable_coroutine, "retry_with")
+
+ def test_retry_preserves_argument_defaults(self) -> None:
+ async def function_with_defaults(a: int = 1) -> int:
+ return a
+
+ async def function_with_kwdefaults(*, a: int = 1) -> int:
+ return a
+
+ retrying = AsyncRetrying(
+ wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3)
+ )
+ wrapped_defaults_function = retrying.wraps(function_with_defaults)
+ wrapped_kwdefaults_function = retrying.wraps(function_with_kwdefaults)
+
+ self.assertEqual(
+ function_with_defaults.__defaults__,
+ wrapped_defaults_function.__defaults__, # type: ignore[attr-defined]
+ )
+ self.assertEqual(
+ function_with_kwdefaults.__kwdefaults__,
+ wrapped_kwdefaults_function.__kwdefaults__, # type: ignore[attr-defined]
+ )
+
+ @asynctest
+ async def test_attempt_number_is_correct_for_interleaved_coroutines(self) -> None:
+ attempts: list[Any] = []
+
+ def after(retry_state: RetryCallState) -> None:
+ attempts.append((retry_state.args[0], retry_state.attempt_number))
+
+ thing1 = NoIOErrorAfterCount(3)
+ thing2 = NoIOErrorAfterCount(3)
+
+ await asyncio.gather(
+ _retryable_coroutine.retry_with(after=after)(thing1),
+ _retryable_coroutine.retry_with(after=after)(thing2),
+ )
+
+ # There's no waiting on retry, only a wait in the coroutine, so the
+ # executions should be interleaved.
+ even_thing_attempts = attempts[::2]
+ things, attempt_nos1 = zip(*even_thing_attempts)
+ assert len(set(things)) == 1
+ assert list(attempt_nos1) == [1, 2, 3]
+
+ odd_thing_attempts = attempts[1::2]
+ things, attempt_nos2 = zip(*odd_thing_attempts)
+ assert len(set(things)) == 1
+ assert list(attempt_nos2) == [1, 2, 3]
+
+
+class TestAsyncEnabled(unittest.TestCase):
+ @asynctest
+ async def test_enabled_false_skips_retry(self) -> None:
+ """When enabled=False, async function is called directly without retrying."""
+ call_count = 0
+
+ @retry(enabled=False, stop=stop_after_attempt(3))
+ async def always_fails() -> None:
+ nonlocal call_count
+ call_count += 1
+ raise ValueError("fail")
+
+ with pytest.raises(ValueError, match="fail"):
+ await always_fails()
+ assert call_count == 1
+
+
+@unittest.skipIf(not have_trio, "trio not installed")
+class TestTrio(unittest.TestCase):
+ def test_trio_basic(self) -> None:
+ thing = NoIOErrorAfterCount(5)
+
+ @retry
+ async def trio_function() -> Any:
+ await trio.sleep(0.00001)
+ return thing.go()
+
+ trio.run(trio_function)
+
+ assert thing.counter == thing.count
+
+
+class TestContextManager(unittest.TestCase):
+ @asynctest
+ async def test_do_max_attempts(self) -> None:
+ attempts = 0
+ retrying = tasyncio.AsyncRetrying(stop=stop_after_attempt(3))
+ try:
+ async for attempt in retrying:
+ with attempt:
+ attempts += 1
+ raise Exception
+ except RetryError:
+ pass
+
+ assert attempts == 3
+
+ @asynctest
+ async def test_async_with_attempt_manager(self) -> None:
+ """AttemptManager supports async with for use inside async for."""
+ attempts = 0
+ retrying = tasyncio.AsyncRetrying(stop=stop_after_attempt(3))
+ try:
+ async for attempt in retrying:
+ async with attempt:
+ attempts += 1
+ raise Exception
+ except RetryError:
+ pass
+
+ assert attempts == 3
+
+ @asynctest
+ async def test_reraise(self) -> None:
+ class CustomError(Exception):
+ pass
+
+ try:
+ async for attempt in tasyncio.AsyncRetrying(
+ stop=stop_after_attempt(1), reraise=True
+ ):
+ with attempt:
+ raise CustomError
+ except CustomError:
+ pass
+ else:
+ raise Exception
+
+ @asynctest
+ async def test_sleeps(self) -> None:
+ start = current_time_ms()
+ try:
+ async for attempt in tasyncio.AsyncRetrying(
+ stop=stop_after_attempt(1), wait=wait_fixed(1)
+ ):
+ with attempt:
+ raise Exception
+ except RetryError:
+ pass
+ t = current_time_ms() - start
+ self.assertLess(t, 1.1)
+
+ @asynctest
+ async def test_retry_with_result(self) -> None:
+ async def test() -> int:
+ attempts = 0
+
+ # mypy doesn't have great lambda support
+ def lt_3(x: float) -> bool:
+ return x < 3
+
+ async for attempt in tasyncio.AsyncRetrying(retry=retry_if_result(lt_3)):
+ with attempt:
+ attempts += 1
+ attempt.retry_state.set_result(attempts)
+ return attempts
+
+ result = await test()
+
+ self.assertEqual(3, result)
+
+ @asynctest
+ async def test_retry_with_async_result(self) -> None:
+ async def test() -> int:
+ attempts = 0
+
+ async def lt_3(x: float) -> bool:
+ return x < 3
+
+ async for attempt in tasyncio.AsyncRetrying(
+ retry=tasyncio.retry_if_result(lt_3)
+ ):
+ with attempt:
+ attempts += 1
+
+ assert attempt.retry_state.outcome # help mypy
+ if not attempt.retry_state.outcome.failed:
+ attempt.retry_state.set_result(attempts)
+
+ return attempts
+
+ result = await test()
+
+ self.assertEqual(3, result)
+
+ @asynctest
+ async def test_retry_with_async_exc(self) -> None:
+ async def test() -> int:
+ attempts = 0
+
+ class CustomException(Exception):
+ pass
+
+ async def is_exc(e: BaseException) -> bool:
+ return isinstance(e, CustomException)
+
+ async for attempt in tasyncio.AsyncRetrying(
+ retry=tasyncio.retry_if_exception(is_exc)
+ ):
+ with attempt:
+ attempts += 1
+ if attempts < 3:
+ raise CustomException
+
+ assert attempt.retry_state.outcome # help mypy
+ if not attempt.retry_state.outcome.failed:
+ attempt.retry_state.set_result(attempts)
+
+ return attempts
+
+ result = await test()
+
+ self.assertEqual(3, result)
+
+ @asynctest
+ async def test_retry_with_async_result_or(self) -> None:
+ async def test() -> int:
+ attempts = 0
+
+ async def lt_3(x: float) -> bool:
+ return x < 3
+
+ class CustomException(Exception):
+ pass
+
+ def is_exc(e: BaseException) -> bool:
+ return isinstance(e, CustomException)
+
+ retry_strategy = tasyncio.retry_if_result(lt_3) | retry_if_exception(is_exc)
+ async for attempt in tasyncio.AsyncRetrying(retry=retry_strategy):
+ with attempt:
+ attempts += 1
+ if 2 < attempts < 4:
+ raise CustomException
+
+ assert attempt.retry_state.outcome # help mypy
+ if not attempt.retry_state.outcome.failed:
+ attempt.retry_state.set_result(attempts)
+
+ return attempts
+
+ result = await test()
+
+ self.assertEqual(4, result)
+
+ @asynctest
+ async def test_retry_with_async_result_ror(self) -> None:
+ async def test() -> int:
+ attempts = 0
+
+ def lt_3(x: float) -> bool:
+ return x < 3
+
+ class CustomException(Exception):
+ pass
+
+ async def is_exc(e: BaseException) -> bool:
+ return isinstance(e, CustomException)
+
+ retry_strategy = retry_if_result(lt_3) | tasyncio.retry_if_exception(is_exc)
+ async for attempt in tasyncio.AsyncRetrying(retry=retry_strategy):
+ with attempt:
+ attempts += 1
+ if 2 < attempts < 4:
+ raise CustomException
+
+ assert attempt.retry_state.outcome # help mypy
+ if not attempt.retry_state.outcome.failed:
+ attempt.retry_state.set_result(attempts)
+
+ return attempts
+
+ result = await test()
+
+ self.assertEqual(4, result)
+
+ @asynctest
+ async def test_retry_with_async_result_and(self) -> None:
+ async def test() -> int:
+ attempts = 0
+
+ async def lt_3(x: float) -> bool:
+ return x < 3
+
+ def gt_0(x: float) -> bool:
+ return x > 0
+
+ retry_strategy = tasyncio.retry_if_result(lt_3) & retry_if_result(gt_0)
+ async for attempt in tasyncio.AsyncRetrying(retry=retry_strategy):
+ with attempt:
+ attempts += 1
+ attempt.retry_state.set_result(attempts)
+
+ return attempts
+
+ result = await test()
+
+ self.assertEqual(3, result)
+
+ @asynctest
+ async def test_retry_with_async_result_rand(self) -> None:
+ async def test() -> int:
+ attempts = 0
+
+ async def lt_3(x: float) -> bool:
+ return x < 3
+
+ def gt_0(x: float) -> bool:
+ return x > 0
+
+ retry_strategy = retry_if_result(gt_0) & tasyncio.retry_if_result(lt_3)
+ async for attempt in tasyncio.AsyncRetrying(retry=retry_strategy):
+ with attempt:
+ attempts += 1
+ attempt.retry_state.set_result(attempts)
+
+ return attempts
+
+ result = await test()
+
+ self.assertEqual(3, result)
+
+ @asynctest
+ async def test_async_retying_iterator(self) -> None:
+ thing = NoIOErrorAfterCount(5)
+ with pytest.raises(TypeError):
+ for attempts in AsyncRetrying():
+ with attempts:
+ await _async_function(thing)
+
+
+class TestDecoratorWrapper(unittest.TestCase):
+ @asynctest
+ async def test_retry_function_attributes(self) -> None:
+ """Test that the wrapped function attributes are exposed as intended.
+
+ - statistics contains the value for the latest function run
+ - retry object can be modified to change its behaviour (useful to patch in tests)
+ - retry object statistics are synced with function statistics
+ """
+
+ self.assertTrue(
+ await _retryable_coroutine_with_2_attempts(NoIOErrorAfterCount(1))
+ )
+
+ expected_stats = {
+ "attempt_number": 2,
+ "delay_since_first_attempt": mock.ANY,
+ "idle_for": mock.ANY,
+ "start_time": mock.ANY,
+ }
+ self.assertEqual(
+ _retryable_coroutine_with_2_attempts.statistics,
+ expected_stats,
+ )
+ self.assertEqual(
+ _retryable_coroutine_with_2_attempts.retry.statistics,
+ expected_stats,
+ )
+
+ with mock.patch.object(
+ _retryable_coroutine_with_2_attempts.retry,
+ "stop",
+ tenacity.stop_after_attempt(1),
+ ):
+ try:
+ self.assertTrue(
+ await _retryable_coroutine_with_2_attempts(NoIOErrorAfterCount(2))
+ )
+ except RetryError as exc:
+ expected_stats = {
+ "attempt_number": 1,
+ "delay_since_first_attempt": mock.ANY,
+ "idle_for": mock.ANY,
+ "start_time": mock.ANY,
+ }
+ self.assertEqual(
+ _retryable_coroutine_with_2_attempts.statistics,
+ expected_stats,
+ )
+ self.assertEqual(exc.last_attempt.attempt_number, 1)
+ self.assertEqual(
+ _retryable_coroutine_with_2_attempts.retry.statistics,
+ expected_stats,
+ )
+ else:
+ self.fail("RetryError should have been raised after 1 attempt")
+
+
+# make sure mypy accepts passing an async sleep function
+# https://github.com/jd/tenacity/issues/399
+async def my_async_sleep(x: float) -> None:
+ await asyncio.sleep(x)
+
+
+@retry(sleep=my_async_sleep)
+async def foo() -> None:
+ pass
+
+
+class TestSyncFunctionWithAsyncSleep(unittest.TestCase):
+ @asynctest
+ async def test_sync_function_with_async_sleep(self) -> None:
+ """A sync function with an async sleep callable uses AsyncRetrying."""
+ mock_sleep = mock.AsyncMock()
+
+ thing = NoneReturnUntilAfterCount(2)
+
+ @retry(
+ sleep=mock_sleep,
+ wait=wait_fixed(1),
+ retry=retry_if_result(lambda x: x is None),
+ )
+ def sync_function() -> Any:
+ return thing.go()
+
+ result = await sync_function()
+ assert result is True
+ assert mock_sleep.await_count == 2
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_issue_478.py b/tests/test_issue_478.py
new file mode 100644
index 00000000..a8589436
--- /dev/null
+++ b/tests/test_issue_478.py
@@ -0,0 +1,116 @@
+import asyncio
+import typing
+import unittest
+from functools import wraps
+
+from tenacity import RetryCallState, retry
+
+
+def asynctest(
+ callable_: typing.Callable[..., typing.Any],
+) -> typing.Callable[..., typing.Any]:
+ @wraps(callable_)
+ def wrapper(*a: typing.Any, **kw: typing.Any) -> typing.Any:
+ return asyncio.run(callable_(*a, **kw))
+
+ return wrapper
+
+
+MAX_RETRY_FIX_ATTEMPTS = 2
+
+
+class TestIssue478(unittest.TestCase):
+ def test_issue(self) -> None:
+ results = []
+
+ def do_retry(retry_state: RetryCallState) -> bool:
+ outcome = retry_state.outcome
+ assert outcome
+ ex = outcome.exception()
+ _subject_: str = retry_state.args[0]
+
+ if _subject_ == "Fix": # no retry on fix failure
+ return False
+
+ if retry_state.attempt_number >= MAX_RETRY_FIX_ATTEMPTS:
+ return False
+
+ if ex:
+ do_fix_work()
+ return True
+
+ return False
+
+ @retry(reraise=True, retry=do_retry)
+ def _do_work(subject: str) -> None:
+ if subject == "Error":
+ results.append(f"{subject} is not working")
+ raise Exception(f"{subject} is not working")
+ results.append(f"{subject} is working")
+
+ def do_any_work(subject: str) -> None:
+ _do_work(subject)
+
+ def do_fix_work() -> None:
+ _do_work("Fix")
+
+ try:
+ do_any_work("Error")
+ except Exception as exc:
+ assert str(exc) == "Error is not working"
+ else:
+ raise AssertionError("No exception caught")
+
+ assert results == [
+ "Error is not working",
+ "Fix is working",
+ "Error is not working",
+ ]
+
+ @asynctest
+ async def test_async(self) -> None:
+ results = []
+
+ async def do_retry(retry_state: RetryCallState) -> bool:
+ outcome = retry_state.outcome
+ assert outcome
+ ex = outcome.exception()
+ _subject_: str = retry_state.args[0]
+
+ if _subject_ == "Fix": # no retry on fix failure
+ return False
+
+ if retry_state.attempt_number >= MAX_RETRY_FIX_ATTEMPTS:
+ return False
+
+ if ex:
+ await do_fix_work()
+ return True
+
+ return False
+
+ @retry(reraise=True, retry=do_retry)
+ async def _do_work(subject: str) -> None:
+ if subject == "Error":
+ results.append(f"{subject} is not working")
+ raise Exception(f"{subject} is not working")
+ results.append(f"{subject} is working")
+
+ async def do_any_work(subject: str) -> None:
+ await _do_work(subject)
+
+ async def do_fix_work() -> None:
+ await _do_work("Fix")
+
+ try:
+ await do_any_work("Error")
+ except Exception as exc:
+ assert str(exc) == "Error is not working"
+ else:
+ raise AssertionError("No exception caught")
+
+ assert results == [
+ "Error is not working",
+ "Fix is working",
+ "Error is not working",
+ ]
diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py
new file mode 100644
index 00000000..efefba8c
--- /dev/null
+++ b/tests/test_tenacity.py
@@ -0,0 +1,2088 @@
+# Copyright 2016–2021 Julien Danjou
+# Copyright 2016 Joshua Harlow
+# Copyright 2013 Ray Holder
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import contextlib
+import datetime
+import logging
+import pickle
+import re
+import time
+import typing
+import unittest
+from fractions import Fraction
+from unittest import mock
+
+import pytest
+from typeguard import check_type
+
+import tenacity
+from tenacity import RetryCallState, RetryError, Retrying, retry
+from tenacity.retry import retry_all, retry_any
+
+_unset = object()
+
+
+def _make_unset_exception(func_name: str, **kwargs: typing.Any) -> TypeError:
+ missing = []
+ for k, v in kwargs.items():
+ if v is _unset:
+ missing.append(k)
+ missing_str = ", ".join(repr(s) for s in missing)
+ return TypeError(func_name + " func missing parameters: " + missing_str)
+
+
+def _set_delay_since_start(retry_state: RetryCallState, delay: typing.Any) -> None:
+ # Ensure outcome_timestamp - start_time is *exactly* equal to the delay to
+ # avoid complexity in test code.
+ retry_state.start_time = Fraction(retry_state.start_time) # type: ignore[assignment]
+ retry_state.outcome_timestamp = retry_state.start_time + Fraction(delay)
+ assert retry_state.seconds_since_start == delay
+
+
+def make_retry_state(
+ previous_attempt_number: typing.Any,
+ delay_since_first_attempt: typing.Any,
+ last_result: typing.Any = None,
+ upcoming_sleep: typing.Any = 0,
+) -> RetryCallState:
+ """Construct RetryCallState for given attempt number & delay.
+
+ Only used in testing and thus is extra careful about timestamp arithmetics.
+ """
+ required_parameter_unset = (
+ previous_attempt_number is _unset or delay_since_first_attempt is _unset
+ )
+ if required_parameter_unset:
+ raise _make_unset_exception(
+ "wait/stop",
+ previous_attempt_number=previous_attempt_number,
+ delay_since_first_attempt=delay_since_first_attempt,
+ )
+
+ retry_state = RetryCallState(None, None, (), {}) # type: ignore[arg-type]
+ retry_state.attempt_number = previous_attempt_number
+ if last_result is not None:
+ retry_state.outcome = last_result
+ else:
+ retry_state.set_result(None)
+
+ retry_state.upcoming_sleep = upcoming_sleep
+
+ _set_delay_since_start(retry_state, delay_since_first_attempt)
+ return retry_state
+
+
+class TestBase(unittest.TestCase):
+ def test_retrying_repr(self) -> None:
+ class ConcreteRetrying(tenacity.BaseRetrying):
+ def __call__(
+ self, fn: typing.Any, *args: typing.Any, **kwargs: typing.Any
+ ) -> typing.Any:
+ pass
+
+ repr(ConcreteRetrying())
+
+ def test_callstate_repr(self) -> None:
+ rs = RetryCallState(None, None, (), {}) # type: ignore[arg-type]
+ rs.idle_for = 1.1111111
+ assert repr(rs).endswith("attempt #1; slept for 1.11; last result: none yet>")
+ rs = make_retry_state(2, 5)
+ assert repr(rs).endswith(
+ "attempt #2; slept for 0.0; last result: returned None>"
+ )
+ rs = make_retry_state(
+ 0, 0, last_result=tenacity.Future.construct(1, ValueError("aaa"), True)
+ )
+ assert repr(rs).endswith(
+ "attempt #0; slept for 0.0; last result: failed (ValueError aaa)>"
+ )
+
+
+class TestRetryingName(unittest.TestCase):
+ def test_str_default(self) -> None:
+ """Without a name, str() returns ''."""
+ assert str(Retrying()) == ""
+
+ def test_str_with_name(self) -> None:
+ """With a name, str() returns the given name."""
+ assert str(Retrying(name="my_block")) == "my_block"
+
+ def test_str_preserved_by_copy(self) -> None:
+ """copy() preserves the name."""
+ r = Retrying(name="my_block")
+ assert str(r.copy()) == "my_block"
+
+ def test_str_overridden_by_copy(self) -> None:
+ """copy() allows overriding the name."""
+ r = Retrying(name="original")
+ assert str(r.copy(name="overridden")) == "overridden"
+
+ def test_get_fn_name_decorator(self) -> None:
+ """get_fn_name() returns the function's qualified name when used as decorator."""
+ captured: list[RetryCallState] = []
+
+ @tenacity.retry(
+ stop=tenacity.stop_after_attempt(1),
+ after=lambda rs: captured.append(rs),
+ )
+ def my_func() -> None:
+ raise ValueError
+
+ with contextlib.suppress(Exception):
+ my_func()
+ assert captured
+ assert "my_func" in captured[0].get_fn_name()
+
+ def test_get_fn_name_context_manager_no_name(self) -> None:
+ """get_fn_name() returns '' in context manager mode without a name."""
+ r = Retrying(stop=tenacity.stop_after_attempt(1))
+ rs = RetryCallState(r, None, (), {})
+ assert rs.get_fn_name() == ""
+
+ def test_get_fn_name_context_manager_with_name(self) -> None:
+ """get_fn_name() returns the given name in context manager mode."""
+ r = Retrying(name="ws_listener", stop=tenacity.stop_after_attempt(1))
+ rs = RetryCallState(r, None, (), {})
+ assert rs.get_fn_name() == "ws_listener"
+
+ def test_logging_uses_name(self) -> None:
+ """before_log uses the name parameter in context manager mode."""
+ import unittest.mock
+
+ log = unittest.mock.MagicMock()
+ logger = unittest.mock.MagicMock(log=log)
+
+ with contextlib.suppress(Exception):
+ for attempt in Retrying(
+ name="my_block",
+ before=tenacity.before_log(logger, logging.INFO),
+ stop=tenacity.stop_after_attempt(1),
+ ):
+ with attempt:
+ raise ValueError
+
+ args = log.call_args[0]
+ assert "my_block" in args[1]
+
+
+class TestStopConditions(unittest.TestCase):
+ def test_never_stop(self) -> None:
+ r = Retrying()
+ self.assertFalse(r.stop(make_retry_state(3, 6546)))
+
+ def test_stop_any(self) -> None:
+ stop = tenacity.stop_any(
+ tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4)
+ )
+
+ def s(*args: typing.Any) -> bool:
+ return stop(make_retry_state(*args))
+
+ self.assertFalse(s(1, 0.1))
+ self.assertFalse(s(2, 0.2))
+ self.assertFalse(s(2, 0.8))
+ self.assertTrue(s(4, 0.8))
+ self.assertTrue(s(3, 1.8))
+ self.assertTrue(s(4, 1.8))
+
+ def test_stop_all(self) -> None:
+ stop = tenacity.stop_all(
+ tenacity.stop_after_delay(1), tenacity.stop_after_attempt(4)
+ )
+
+ def s(*args: typing.Any) -> bool:
+ return stop(make_retry_state(*args))
+
+ self.assertFalse(s(1, 0.1))
+ self.assertFalse(s(2, 0.2))
+ self.assertFalse(s(2, 0.8))
+ self.assertFalse(s(4, 0.8))
+ self.assertFalse(s(3, 1.8))
+ self.assertTrue(s(4, 1.8))
+
+ def test_stop_or(self) -> None:
+ stop = tenacity.stop_after_delay(1) | tenacity.stop_after_attempt(4)
+
+ def s(*args: typing.Any) -> bool:
+ return stop(make_retry_state(*args))
+
+ self.assertFalse(s(1, 0.1))
+ self.assertFalse(s(2, 0.2))
+ self.assertFalse(s(2, 0.8))
+ self.assertTrue(s(4, 0.8))
+ self.assertTrue(s(3, 1.8))
+ self.assertTrue(s(4, 1.8))
+
+ def test_stop_and(self) -> None:
+ stop = tenacity.stop_after_delay(1) & tenacity.stop_after_attempt(4)
+
+ def s(*args: typing.Any) -> bool:
+ return stop(make_retry_state(*args))
+
+ self.assertFalse(s(1, 0.1))
+ self.assertFalse(s(2, 0.2))
+ self.assertFalse(s(2, 0.8))
+ self.assertFalse(s(4, 0.8))
+ self.assertFalse(s(3, 1.8))
+ self.assertTrue(s(4, 1.8))
+
+ def test_stop_after_attempt(self) -> None:
+ r = Retrying(stop=tenacity.stop_after_attempt(3))
+ self.assertFalse(r.stop(make_retry_state(2, 6546)))
+ self.assertTrue(r.stop(make_retry_state(3, 6546)))
+ self.assertTrue(r.stop(make_retry_state(4, 6546)))
+
+ def test_stop_after_delay(self) -> None:
+ for delay in (1, datetime.timedelta(seconds=1)):
+ with self.subTest():
+ r = Retrying(stop=tenacity.stop_after_delay(delay))
+ self.assertFalse(r.stop(make_retry_state(2, 0.999)))
+ self.assertTrue(r.stop(make_retry_state(2, 1)))
+ self.assertTrue(r.stop(make_retry_state(2, 1.001)))
+
+ def test_stop_before_delay(self) -> None:
+ for delay in (1, datetime.timedelta(seconds=1)):
+ with self.subTest():
+ r = Retrying(stop=tenacity.stop_before_delay(delay))
+ self.assertFalse(
+ r.stop(make_retry_state(2, 0.999, upcoming_sleep=0.0001))
+ )
+ self.assertTrue(r.stop(make_retry_state(2, 1, upcoming_sleep=0.001)))
+ self.assertTrue(r.stop(make_retry_state(2, 1, upcoming_sleep=1)))
+
+ # It should act the same as stop_after_delay if upcoming sleep is 0
+ self.assertFalse(r.stop(make_retry_state(2, 0.999, upcoming_sleep=0)))
+ self.assertTrue(r.stop(make_retry_state(2, 1, upcoming_sleep=0)))
+ self.assertTrue(r.stop(make_retry_state(2, 1.001, upcoming_sleep=0)))
+
+ def test_legacy_explicit_stop_type(self) -> None:
+ Retrying(stop="stop_after_attempt") # type: ignore[arg-type]
+
+ def test_stop_func_with_retry_state(self) -> None:
+ def stop_func(retry_state: RetryCallState) -> bool:
+ rs = retry_state
+ return rs.attempt_number == rs.seconds_since_start
+
+ r = Retrying(stop=stop_func)
+ self.assertFalse(r.stop(make_retry_state(1, 3)))
+ self.assertFalse(r.stop(make_retry_state(100, 99)))
+ self.assertTrue(r.stop(make_retry_state(101, 101)))
+
+
+class TestWaitConditions(unittest.TestCase):
+ def test_no_sleep(self) -> None:
+ r = Retrying()
+ self.assertEqual(0, r.wait(make_retry_state(18, 9879)))
+
+ def test_fixed_sleep(self) -> None:
+ for wait in (1, datetime.timedelta(seconds=1)):
+ with self.subTest():
+ r = Retrying(wait=tenacity.wait_fixed(wait))
+ self.assertEqual(1, r.wait(make_retry_state(12, 6546)))
+
+ def test_incrementing_sleep(self) -> None:
+ for start, increment in (
+ (500, 100),
+ (datetime.timedelta(seconds=500), datetime.timedelta(seconds=100)),
+ ):
+ with self.subTest():
+ r = Retrying(
+ wait=tenacity.wait_incrementing(start=start, increment=increment)
+ )
+ self.assertEqual(500, r.wait(make_retry_state(1, 6546)))
+ self.assertEqual(600, r.wait(make_retry_state(2, 6546)))
+ self.assertEqual(700, r.wait(make_retry_state(3, 6546)))
+
+ def test_random_sleep(self) -> None:
+ for min_, max_ in (
+ (1, 20),
+ (datetime.timedelta(seconds=1), datetime.timedelta(seconds=20)),
+ ):
+ with self.subTest():
+ r = Retrying(wait=tenacity.wait_random(min=min_, max=max_))
+ times = set()
+ for _ in range(1000):
+ times.add(r.wait(make_retry_state(1, 6546)))
+
+ # this is kind of non-deterministic...
+ self.assertTrue(len(times) > 1)
+ for t in times:
+ self.assertTrue(t >= 1)
+ self.assertTrue(t < 20)
+
+ def test_random_sleep_withoutmin_(self) -> None:
+ r = Retrying(wait=tenacity.wait_random(max=2))
+ times = set()
+ times.add(r.wait(make_retry_state(1, 6546)))
+ times.add(r.wait(make_retry_state(1, 6546)))
+ times.add(r.wait(make_retry_state(1, 6546)))
+ times.add(r.wait(make_retry_state(1, 6546)))
+
+ # this is kind of non-deterministic...
+ self.assertTrue(len(times) > 1)
+ for t in times:
+ self.assertTrue(t >= 0)
+ self.assertTrue(t <= 2)
+
+ def test_exponential(self) -> None:
+ r = Retrying(wait=tenacity.wait_exponential())
+ self.assertEqual(r.wait(make_retry_state(1, 0)), 1)
+ self.assertEqual(r.wait(make_retry_state(2, 0)), 2)
+ self.assertEqual(r.wait(make_retry_state(3, 0)), 4)
+ self.assertEqual(r.wait(make_retry_state(4, 0)), 8)
+ self.assertEqual(r.wait(make_retry_state(5, 0)), 16)
+ self.assertEqual(r.wait(make_retry_state(6, 0)), 32)
+ self.assertEqual(r.wait(make_retry_state(7, 0)), 64)
+ self.assertEqual(r.wait(make_retry_state(8, 0)), 128)
+
+ def test_exponential_with_max_wait(self) -> None:
+ r = Retrying(wait=tenacity.wait_exponential(max=40))
+ self.assertEqual(r.wait(make_retry_state(1, 0)), 1)
+ self.assertEqual(r.wait(make_retry_state(2, 0)), 2)
+ self.assertEqual(r.wait(make_retry_state(3, 0)), 4)
+ self.assertEqual(r.wait(make_retry_state(4, 0)), 8)
+ self.assertEqual(r.wait(make_retry_state(5, 0)), 16)
+ self.assertEqual(r.wait(make_retry_state(6, 0)), 32)
+ self.assertEqual(r.wait(make_retry_state(7, 0)), 40)
+ self.assertEqual(r.wait(make_retry_state(8, 0)), 40)
+ self.assertEqual(r.wait(make_retry_state(50, 0)), 40)
+
+ def test_exponential_with_min_wait(self) -> None:
+ r = Retrying(wait=tenacity.wait_exponential(min=20))
+ self.assertEqual(r.wait(make_retry_state(1, 0)), 20)
+ self.assertEqual(r.wait(make_retry_state(2, 0)), 20)
+ self.assertEqual(r.wait(make_retry_state(3, 0)), 20)
+ self.assertEqual(r.wait(make_retry_state(4, 0)), 20)
+ self.assertEqual(r.wait(make_retry_state(5, 0)), 20)
+ self.assertEqual(r.wait(make_retry_state(6, 0)), 32)
+ self.assertEqual(r.wait(make_retry_state(7, 0)), 64)
+ self.assertEqual(r.wait(make_retry_state(8, 0)), 128)
+ self.assertEqual(r.wait(make_retry_state(20, 0)), 524288)
+
+ def test_exponential_with_max_wait_and_multiplier(self) -> None:
+ r = Retrying(wait=tenacity.wait_exponential(max=50, multiplier=1))
+ self.assertEqual(r.wait(make_retry_state(1, 0)), 1)
+ self.assertEqual(r.wait(make_retry_state(2, 0)), 2)
+ self.assertEqual(r.wait(make_retry_state(3, 0)), 4)
+ self.assertEqual(r.wait(make_retry_state(4, 0)), 8)
+ self.assertEqual(r.wait(make_retry_state(5, 0)), 16)
+ self.assertEqual(r.wait(make_retry_state(6, 0)), 32)
+ self.assertEqual(r.wait(make_retry_state(7, 0)), 50)
+ self.assertEqual(r.wait(make_retry_state(8, 0)), 50)
+ self.assertEqual(r.wait(make_retry_state(50, 0)), 50)
+
+ def test_exponential_with_min_wait_and_multiplier(self) -> None:
+ r = Retrying(wait=tenacity.wait_exponential(min=20, multiplier=2))
+ self.assertEqual(r.wait(make_retry_state(1, 0)), 20)
+ self.assertEqual(r.wait(make_retry_state(2, 0)), 20)
+ self.assertEqual(r.wait(make_retry_state(3, 0)), 20)
+ self.assertEqual(r.wait(make_retry_state(4, 0)), 20)
+ self.assertEqual(r.wait(make_retry_state(5, 0)), 32)
+ self.assertEqual(r.wait(make_retry_state(6, 0)), 64)
+ self.assertEqual(r.wait(make_retry_state(7, 0)), 128)
+ self.assertEqual(r.wait(make_retry_state(8, 0)), 256)
+ self.assertEqual(r.wait(make_retry_state(20, 0)), 1048576)
+
+ def test_exponential_with_min_wait_andmax__wait(self) -> None:
+ for min_, max_ in (
+ (10, 100),
+ (datetime.timedelta(seconds=10), datetime.timedelta(seconds=100)),
+ ):
+ with self.subTest():
+ r = Retrying(wait=tenacity.wait_exponential(min=min_, max=max_))
+ self.assertEqual(r.wait(make_retry_state(1, 0)), 10)
+ self.assertEqual(r.wait(make_retry_state(2, 0)), 10)
+ self.assertEqual(r.wait(make_retry_state(3, 0)), 10)
+ self.assertEqual(r.wait(make_retry_state(4, 0)), 10)
+ self.assertEqual(r.wait(make_retry_state(5, 0)), 16)
+ self.assertEqual(r.wait(make_retry_state(6, 0)), 32)
+ self.assertEqual(r.wait(make_retry_state(7, 0)), 64)
+ self.assertEqual(r.wait(make_retry_state(8, 0)), 100)
+ self.assertEqual(r.wait(make_retry_state(9, 0)), 100)
+ self.assertEqual(r.wait(make_retry_state(20, 0)), 100)
+
+ def test_legacy_explicit_wait_type(self) -> None:
+ Retrying(wait="exponential_sleep") # type: ignore[arg-type]
+
+ def test_wait_func(self) -> None:
+ def wait_func(retry_state: RetryCallState) -> typing.Any:
+ return retry_state.attempt_number * retry_state.seconds_since_start # type: ignore[operator]
+
+ r = Retrying(wait=wait_func)
+ self.assertEqual(r.wait(make_retry_state(1, 5)), 5)
+ self.assertEqual(r.wait(make_retry_state(2, 11)), 22)
+ self.assertEqual(r.wait(make_retry_state(10, 100)), 1000)
+
+ def test_wait_combine(self) -> None:
+ r = Retrying(
+ wait=tenacity.wait_combine(
+ tenacity.wait_random(0, 3), tenacity.wait_fixed(5)
+ )
+ )
+ # Test it a few time since it's random
+ for _i in range(1000):
+ w = r.wait(make_retry_state(1, 5))
+ self.assertLess(w, 8)
+ self.assertGreaterEqual(w, 5)
+
+ def test_wait_exception(self) -> None:
+ def predicate(exc: BaseException) -> float:
+ if isinstance(exc, ValueError):
+ return 3.5
+ return 10.0
+
+ r = Retrying(wait=tenacity.wait_exception(predicate))
+
+ fut1 = tenacity.Future.construct(1, ValueError(), True)
+ self.assertEqual(r.wait(make_retry_state(1, 0, last_result=fut1)), 3.5)
+
+ fut2 = tenacity.Future.construct(1, KeyError(), True)
+ self.assertEqual(r.wait(make_retry_state(1, 0, last_result=fut2)), 10.0)
+
+ fut3 = tenacity.Future.construct(1, None, False)
+ with self.assertRaises(RuntimeError):
+ r.wait(make_retry_state(1, 0, last_result=fut3))
+
+ def test_wait_double_sum(self) -> None:
+ r = Retrying(wait=tenacity.wait_random(0, 3) + tenacity.wait_fixed(5))
+ # Test it a few time since it's random
+ for _i in range(1000):
+ w = r.wait(make_retry_state(1, 5))
+ self.assertLess(w, 8)
+ self.assertGreaterEqual(w, 5)
+
+ def test_wait_triple_sum(self) -> None:
+ r = Retrying(
+ wait=tenacity.wait_fixed(1)
+ + tenacity.wait_random(0, 3)
+ + tenacity.wait_fixed(5)
+ )
+ # Test it a few time since it's random
+ for _i in range(1000):
+ w = r.wait(make_retry_state(1, 5))
+ self.assertLess(w, 9)
+ self.assertGreaterEqual(w, 6)
+
+ def test_wait_arbitrary_sum(self) -> None:
+ r = Retrying(
+ wait=sum( # type: ignore[arg-type]
+ [
+ tenacity.wait_fixed(1), # type: ignore[list-item]
+ tenacity.wait_random(0, 3), # type: ignore[list-item]
+ tenacity.wait_fixed(5), # type: ignore[list-item]
+ tenacity.wait_none(), # type: ignore[list-item]
+ ]
+ )
+ )
+ # Test it a few time since it's random
+ for _ in range(1000):
+ w = r.wait(make_retry_state(1, 5))
+ self.assertLess(w, 9)
+ self.assertGreaterEqual(w, 6)
+
+ def _assert_range(self, wait: float, min_: float, max_: float) -> None:
+ self.assertLess(wait, max_)
+ self.assertGreaterEqual(wait, min_)
+
+ def _assert_inclusive_range(self, wait: float, low: float, high: float) -> None:
+ self.assertLessEqual(wait, high)
+ self.assertGreaterEqual(wait, low)
+
+ def _assert_inclusive_epsilon(
+ self, wait: float, target: float, epsilon: float
+ ) -> None:
+ self.assertLessEqual(wait, target + epsilon)
+ self.assertGreaterEqual(wait, target - epsilon)
+
+ def test_wait_chain(self) -> None:
+ r = Retrying(
+ wait=tenacity.wait_chain(
+ *[tenacity.wait_fixed(1) for i in range(2)]
+ + [tenacity.wait_fixed(4) for i in range(2)]
+ + [tenacity.wait_fixed(8) for i in range(1)]
+ )
+ )
+
+ for i in range(10):
+ w = r.wait(make_retry_state(i + 1, 1))
+ if i < 2:
+ self._assert_range(w, 1, 2)
+ elif i < 4:
+ self._assert_range(w, 4, 5)
+ else:
+ self._assert_range(w, 8, 9)
+
+ def test_wait_chain_multiple_invocations(self) -> None:
+ sleep_intervals: list[float] = []
+ r = Retrying(
+ sleep=sleep_intervals.append,
+ wait=tenacity.wait_chain(*[tenacity.wait_fixed(i + 1) for i in range(3)]),
+ stop=tenacity.stop_after_attempt(5),
+ retry=tenacity.retry_if_result(lambda x: x == 1),
+ )
+
+ @r.wraps
+ def always_return_1() -> int:
+ return 1
+
+ self.assertRaises(tenacity.RetryError, always_return_1)
+ self.assertEqual(sleep_intervals, [1.0, 2.0, 3.0, 3.0])
+ sleep_intervals[:] = []
+
+ # Clear and restart retrying.
+ self.assertRaises(tenacity.RetryError, always_return_1)
+ self.assertEqual(sleep_intervals, [1.0, 2.0, 3.0, 3.0])
+ sleep_intervals[:] = []
+
+ def test_wait_random_exponential(self) -> None:
+ fn = tenacity.wait_random_exponential(0.5, 60.0)
+
+ for _ in range(1000):
+ self._assert_inclusive_range(fn(make_retry_state(1, 0)), 0, 0.5)
+ self._assert_inclusive_range(fn(make_retry_state(2, 0)), 0, 1.0)
+ self._assert_inclusive_range(fn(make_retry_state(3, 0)), 0, 2.0)
+ self._assert_inclusive_range(fn(make_retry_state(4, 0)), 0, 4.0)
+ self._assert_inclusive_range(fn(make_retry_state(5, 0)), 0, 8.0)
+ self._assert_inclusive_range(fn(make_retry_state(6, 0)), 0, 16.0)
+ self._assert_inclusive_range(fn(make_retry_state(7, 0)), 0, 32.0)
+ self._assert_inclusive_range(fn(make_retry_state(8, 0)), 0, 60.0)
+ self._assert_inclusive_range(fn(make_retry_state(9, 0)), 0, 60.0)
+
+ # max wait
+ max_wait = 5
+ fn = tenacity.wait_random_exponential(10, max_wait)
+ for _ in range(1000):
+ self._assert_inclusive_range(fn(make_retry_state(1, 0)), 0.00, max_wait)
+
+ # min wait
+ min_wait = 5
+ fn = tenacity.wait_random_exponential(min=min_wait)
+ for _ in range(1000):
+ self._assert_inclusive_range(fn(make_retry_state(1, 0)), min_wait, 5)
+
+ # Default arguments exist
+ fn = tenacity.wait_random_exponential()
+ fn(make_retry_state(0, 0))
+
+ def test_wait_random_exponential_statistically(self) -> None:
+ fn = tenacity.wait_random_exponential(0.5, 60.0)
+
+ attempt = [[fn(make_retry_state(i, 0)) for _ in range(4000)] for i in range(10)]
+
+ def mean(lst: list[float]) -> float:
+ return float(sum(lst)) / float(len(lst))
+
+ # skipping attempt 0
+ self._assert_inclusive_epsilon(mean(attempt[1]), 0.25, 0.02)
+ self._assert_inclusive_epsilon(mean(attempt[2]), 0.50, 0.04)
+ self._assert_inclusive_epsilon(mean(attempt[3]), 1, 0.08)
+ self._assert_inclusive_epsilon(mean(attempt[4]), 2, 0.16)
+ self._assert_inclusive_epsilon(mean(attempt[5]), 4, 0.32)
+ self._assert_inclusive_epsilon(mean(attempt[6]), 8, 0.64)
+ self._assert_inclusive_epsilon(mean(attempt[7]), 16, 1.28)
+ self._assert_inclusive_epsilon(mean(attempt[8]), 30, 2.56)
+ self._assert_inclusive_epsilon(mean(attempt[9]), 30, 2.56)
+
+ def test_wait_exponential_jitter(self) -> None:
+ fn = tenacity.wait_exponential_jitter(max=60)
+
+ for _ in range(1000):
+ self._assert_inclusive_range(fn(make_retry_state(1, 0)), 1, 2)
+ self._assert_inclusive_range(fn(make_retry_state(2, 0)), 2, 3)
+ self._assert_inclusive_range(fn(make_retry_state(3, 0)), 4, 5)
+ self._assert_inclusive_range(fn(make_retry_state(4, 0)), 8, 9)
+ self._assert_inclusive_range(fn(make_retry_state(5, 0)), 16, 17)
+ self._assert_inclusive_range(fn(make_retry_state(6, 0)), 32, 33)
+ self.assertEqual(fn(make_retry_state(7, 0)), 60)
+ self.assertEqual(fn(make_retry_state(8, 0)), 60)
+ self.assertEqual(fn(make_retry_state(9, 0)), 60)
+
+ fn = tenacity.wait_exponential_jitter(10, 5)
+ for _ in range(1000):
+ self.assertEqual(fn(make_retry_state(1, 0)), 5)
+
+ # Default arguments exist
+ fn = tenacity.wait_exponential_jitter()
+ fn(make_retry_state(0, 0))
+
+ def test_wait_retry_state_attributes(self) -> None:
+ class ExtractCallState(Exception):
+ pass
+
+ # retry_state is mutable, so return it as an exception to extract the
+ # exact values it has when wait is called and bypass any other logic.
+ def waitfunc(retry_state: RetryCallState) -> float:
+ raise ExtractCallState(retry_state)
+
+ retrying = Retrying(
+ wait=waitfunc,
+ retry=(
+ tenacity.retry_if_exception_type()
+ | tenacity.retry_if_result(lambda result: result == 123)
+ ),
+ )
+
+ def returnval() -> int:
+ return 123
+
+ try:
+ retrying(returnval)
+ except ExtractCallState as err:
+ retry_state = err.args[0]
+ self.assertIs(retry_state.fn, returnval)
+ self.assertEqual(retry_state.args, ())
+ self.assertEqual(retry_state.kwargs, {})
+ self.assertEqual(retry_state.outcome.result(), 123)
+ self.assertEqual(retry_state.attempt_number, 1)
+ self.assertGreaterEqual(retry_state.outcome_timestamp, retry_state.start_time)
+
+ def dying() -> None:
+ raise Exception("Broken")
+
+ try:
+ retrying(dying)
+ except ExtractCallState as err:
+ retry_state = err.args[0]
+ self.assertIs(retry_state.fn, dying)
+ self.assertEqual(retry_state.args, ())
+ self.assertEqual(retry_state.kwargs, {})
+ self.assertEqual(str(retry_state.outcome.exception()), "Broken")
+ self.assertEqual(retry_state.attempt_number, 1)
+ self.assertGreaterEqual(retry_state.outcome_timestamp, retry_state.start_time)
+
+
+class TestRetryConditions(unittest.TestCase):
+ def test_retry_if_result(self) -> None:
+ retry = tenacity.retry_if_result(lambda x: x == 1)
+
+ def r(fut: tenacity.Future) -> bool:
+ retry_state = make_retry_state(1, 1.0, last_result=fut)
+ return retry(retry_state)
+
+ self.assertTrue(r(tenacity.Future.construct(1, 1, False)))
+ self.assertFalse(r(tenacity.Future.construct(1, 2, False)))
+
+ def test_retry_if_not_result(self) -> None:
+ retry = tenacity.retry_if_not_result(lambda x: x == 1)
+
+ def r(fut: tenacity.Future) -> bool:
+ retry_state = make_retry_state(1, 1.0, last_result=fut)
+ return retry(retry_state)
+
+ self.assertTrue(r(tenacity.Future.construct(1, 2, False)))
+ self.assertFalse(r(tenacity.Future.construct(1, 1, False)))
+
+ def test_retry_any(self) -> None:
+ retry = tenacity.retry_any(
+ tenacity.retry_if_result(lambda x: x == 1),
+ tenacity.retry_if_result(lambda x: x == 2),
+ )
+
+ def r(fut: tenacity.Future) -> bool:
+ retry_state = make_retry_state(1, 1.0, last_result=fut)
+ return retry(retry_state)
+
+ self.assertTrue(r(tenacity.Future.construct(1, 1, False)))
+ self.assertTrue(r(tenacity.Future.construct(1, 2, False)))
+ self.assertFalse(r(tenacity.Future.construct(1, 3, False)))
+ self.assertFalse(r(tenacity.Future.construct(1, 1, True)))
+
+ def test_retry_all(self) -> None:
+ retry = tenacity.retry_all(
+ tenacity.retry_if_result(lambda x: x == 1),
+ tenacity.retry_if_result(lambda x: isinstance(x, int)),
+ )
+
+ def r(fut: tenacity.Future) -> bool:
+ retry_state = make_retry_state(1, 1.0, last_result=fut)
+ return retry(retry_state)
+
+ self.assertTrue(r(tenacity.Future.construct(1, 1, False)))
+ self.assertFalse(r(tenacity.Future.construct(1, 2, False)))
+ self.assertFalse(r(tenacity.Future.construct(1, 3, False)))
+ self.assertFalse(r(tenacity.Future.construct(1, 1, True)))
+
+ def test_retry_and(self) -> None:
+ retry = tenacity.retry_if_result(lambda x: x == 1) & tenacity.retry_if_result(
+ lambda x: isinstance(x, int)
+ )
+
+ def r(fut: tenacity.Future) -> bool:
+ retry_state = make_retry_state(1, 1.0, last_result=fut)
+ return retry(retry_state)
+
+ self.assertTrue(r(tenacity.Future.construct(1, 1, False)))
+ self.assertFalse(r(tenacity.Future.construct(1, 2, False)))
+ self.assertFalse(r(tenacity.Future.construct(1, 3, False)))
+ self.assertFalse(r(tenacity.Future.construct(1, 1, True)))
+
+ def test_retry_or(self) -> None:
+ retry = tenacity.retry_if_result(
+ lambda x: x == "foo"
+ ) | tenacity.retry_if_result(lambda x: isinstance(x, int))
+
+ def r(fut: tenacity.Future) -> bool:
+ retry_state = make_retry_state(1, 1.0, last_result=fut)
+ return retry(retry_state)
+
+ self.assertTrue(r(tenacity.Future.construct(1, "foo", False)))
+ self.assertFalse(r(tenacity.Future.construct(1, "foobar", False)))
+ self.assertFalse(r(tenacity.Future.construct(1, 2.2, False)))
+ self.assertFalse(r(tenacity.Future.construct(1, 42, True)))
+
+ def test_retry_or_with_plain_function(self) -> None:
+ """Plain callables can be composed with retry_base via |."""
+
+ def my_retry(retry_state: tenacity.RetryCallState) -> bool:
+ return retry_state.outcome is not None and not retry_state.outcome.failed
+
+ # retry_base | plain_callable (exercises __or__ fallback)
+ retry = tenacity.retry_if_exception_type(Exception) | my_retry
+ retry_state = make_retry_state(
+ 1, 1.0, last_result=tenacity.Future.construct(1, "ok", False)
+ )
+ self.assertTrue(retry(retry_state))
+
+ # plain_callable | retry_base (exercises __ror__ via reflection)
+ retry2 = my_retry | tenacity.retry_if_exception_type(Exception)
+ self.assertTrue(retry2(retry_state))
+
+ def test_retry_and_with_plain_function(self) -> None:
+ """Plain callables can be composed with retry_base via &."""
+
+ def my_retry(retry_state: tenacity.RetryCallState) -> bool:
+ return True
+
+ # retry_base & plain_callable (exercises __and__ fallback)
+ retry = tenacity.retry_if_result(lambda x: x == 1) & my_retry
+ retry_state = make_retry_state(
+ 1, 1.0, last_result=tenacity.Future.construct(1, 1, False)
+ )
+ self.assertTrue(retry(retry_state))
+
+ # plain_callable & retry_base (exercises __rand__ via reflection)
+ retry2 = my_retry & tenacity.retry_if_result(lambda x: x == 1)
+ self.assertTrue(retry2(retry_state))
+
+ def test_retry_or_coalesces(self) -> None:
+ """Multiple | operations flatten into a single retry_any."""
+ a = tenacity.retry_if_exception_type(IOError)
+ b = tenacity.retry_if_exception_type(OSError)
+ c = tenacity.retry_if_exception_type(ValueError)
+
+ combined = a | b | c
+ self.assertIsInstance(combined, retry_any)
+ self.assertEqual(len(combined.retries), 3)
+
+ def test_retry_and_coalesces(self) -> None:
+ """Multiple & operations flatten into a single retry_all."""
+ a = tenacity.retry_if_result(lambda x: x == 1)
+ b = tenacity.retry_if_result(lambda x: x > 0)
+ c = tenacity.retry_if_result(lambda x: x < 10)
+
+ combined = a & b & c
+ self.assertIsInstance(combined, retry_all)
+ self.assertEqual(len(combined.retries), 3)
+
+ def _raise_try_again(self) -> None:
+ self._attempts += 1
+ if self._attempts < 3:
+ raise tenacity.TryAgain
+
+ def test_retry_try_again(self) -> None:
+ self._attempts = 0
+ Retrying(stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_never)(
+ self._raise_try_again
+ )
+ self.assertEqual(3, self._attempts)
+
+ def test_retry_try_again_forever(self) -> None:
+ def _r() -> None:
+ raise tenacity.TryAgain
+
+ r = Retrying(stop=tenacity.stop_after_attempt(5), retry=tenacity.retry_never)
+ self.assertRaises(tenacity.RetryError, r, _r)
+ self.assertEqual(5, r.statistics["attempt_number"])
+
+ def test_retry_try_again_forever_reraise(self) -> None:
+ def _r() -> None:
+ raise tenacity.TryAgain
+
+ r = Retrying(
+ stop=tenacity.stop_after_attempt(5),
+ retry=tenacity.retry_never,
+ reraise=True,
+ )
+ self.assertRaises(tenacity.TryAgain, r, _r)
+ self.assertEqual(5, r.statistics["attempt_number"])
+
+ def test_retry_if_exception_message_negative_no_inputs(self) -> None:
+ with self.assertRaises(TypeError):
+ tenacity.retry_if_exception_message()
+
+ def test_retry_if_exception_message_negative_too_many_inputs(self) -> None:
+ with self.assertRaises(TypeError):
+ tenacity.retry_if_exception_message(message="negative", match="negative")
+
+
+class NoneReturnUntilAfterCount:
+ """Holds counter state for invoking a method several times in a row."""
+
+ def __init__(self, count: int) -> None:
+ self.counter = 0
+ self.count = count
+
+ def go(self) -> typing.Any:
+ """Return None until after count threshold has been crossed.
+
+ Then return True.
+ """
+ if self.counter < self.count:
+ self.counter += 1
+ return None
+ return True
+
+
+class NoIOErrorAfterCount:
+ """Holds counter state for invoking a method several times in a row."""
+
+ def __init__(self, count: int) -> None:
+ self.counter = 0
+ self.count = count
+
+ def go(self) -> typing.Any:
+ """Raise an IOError until after count threshold has been crossed.
+
+ Then return True.
+ """
+ if self.counter < self.count:
+ self.counter += 1
+ raise OSError("Hi there, I'm an IOError")
+ return True
+
+
+class NoNameErrorAfterCount:
+ """Holds counter state for invoking a method several times in a row."""
+
+ def __init__(self, count: int) -> None:
+ self.counter = 0
+ self.count = count
+
+ def go(self) -> typing.Any:
+ """Raise a NameError until after count threshold has been crossed.
+
+ Then return True.
+ """
+ if self.counter < self.count:
+ self.counter += 1
+ raise NameError("Hi there, I'm a NameError")
+ return True
+
+
+class NoNameErrorCauseAfterCount:
+ """Holds counter state for invoking a method several times in a row."""
+
+ def __init__(self, count: int) -> None:
+ self.counter = 0
+ self.count = count
+
+ def go2(self) -> typing.Any:
+ raise NameError("Hi there, I'm a NameError")
+
+ def go(self) -> typing.Any:
+ """Raise an IOError with a NameError as cause until after count threshold has been crossed.
+
+ Then return True.
+ """
+ if self.counter < self.count:
+ self.counter += 1
+ try:
+ self.go2()
+ except NameError as e:
+ raise OSError from e
+
+ return True
+
+
+class NoIOErrorCauseAfterCount:
+ """Holds counter state for invoking a method several times in a row."""
+
+ def __init__(self, count: int) -> None:
+ self.counter = 0
+ self.count = count
+
+ def go2(self) -> typing.Any:
+ raise OSError("Hi there, I'm an IOError")
+
+ def go(self) -> typing.Any:
+ """Raise a NameError with an IOError as cause until after count threshold has been crossed.
+
+ Then return True.
+ """
+ if self.counter < self.count:
+ self.counter += 1
+ try:
+ self.go2()
+ except OSError as e:
+ raise NameError from e
+
+ return True
+
+
+class NameErrorUntilCount:
+ """Holds counter state for invoking a method several times in a row."""
+
+ derived_message = "Hi there, I'm a NameError"
+
+ def __init__(self, count: int) -> None:
+ self.counter = 0
+ self.count = count
+
+ def go(self) -> typing.Any:
+ """Return True until after count threshold has been crossed.
+
+ Then raise a NameError.
+ """
+ if self.counter < self.count:
+ self.counter += 1
+ return True
+ raise NameError(self.derived_message)
+
+
+class IOErrorUntilCount:
+ """Holds counter state for invoking a method several times in a row."""
+
+ def __init__(self, count: int) -> None:
+ self.counter = 0
+ self.count = count
+
+ def go(self) -> typing.Any:
+ """Return True until after count threshold has been crossed.
+
+ Then raise an IOError.
+ """
+ if self.counter < self.count:
+ self.counter += 1
+ return True
+ raise OSError("Hi there, I'm an IOError")
+
+
+class CustomError(Exception):
+ """This is a custom exception class.
+
+ Note that For Python 2.x, we don't strictly need to extend BaseException,
+ however, Python 3.x will complain. While this test suite won't run
+ correctly under Python 3.x without extending from the Python exception
+ hierarchy, the actual module code is backwards compatible Python 2.x and
+ will allow for cases where exception classes don't extend from the
+ hierarchy.
+ """
+
+ def __init__(self, value: str) -> None:
+ self.value = value
+
+ def __str__(self) -> str:
+ return self.value
+
+
+class NoCustomErrorAfterCount:
+ """Holds counter state for invoking a method several times in a row."""
+
+ derived_message = "This is a Custom exception class"
+
+ def __init__(self, count: int) -> None:
+ self.counter = 0
+ self.count = count
+
+ def go(self) -> typing.Any:
+ """Raise a CustomError until after count threshold has been crossed.
+
+ Then return True.
+ """
+ if self.counter < self.count:
+ self.counter += 1
+ raise CustomError(self.derived_message)
+ return True
+
+
+class CapturingHandler(logging.Handler):
+ """Captures log records for inspection."""
+
+ def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
+ super().__init__(*args, **kwargs)
+ self.records: list[logging.LogRecord] = []
+
+ def emit(self, record: logging.LogRecord) -> None:
+ self.records.append(record)
+
+
+def current_time_ms() -> int:
+ return round(time.time() * 1000)
+
+
+@retry(
+ wait=tenacity.wait_fixed(0.05),
+ retry=tenacity.retry_if_result(lambda result: result is None),
+)
+def _retryable_test_with_wait(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry(
+ stop=tenacity.stop_after_attempt(3),
+ retry=tenacity.retry_if_result(lambda result: result is None),
+)
+def _retryable_test_with_stop(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry(retry=tenacity.retry_if_exception_cause_type(NameError))
+def _retryable_test_with_exception_cause_type(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry(retry=tenacity.retry_if_exception_type(IOError))
+def _retryable_test_with_exception_type_io(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry(retry=tenacity.retry_if_not_exception_type(IOError))
+def _retryable_test_if_not_exception_type_io(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry(
+ stop=tenacity.stop_after_attempt(3), retry=tenacity.retry_if_exception_type(IOError)
+)
+def _retryable_test_with_exception_type_io_attempt_limit(
+ thing: typing.Any,
+) -> typing.Any:
+ return thing.go()
+
+
+@retry(retry=tenacity.retry_unless_exception_type(NameError))
+def _retryable_test_with_unless_exception_type_name(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry(
+ stop=tenacity.stop_after_attempt(3),
+ retry=tenacity.retry_unless_exception_type(NameError),
+)
+def _retryable_test_with_unless_exception_type_name_attempt_limit(
+ thing: typing.Any,
+) -> typing.Any:
+ return thing.go()
+
+
+@retry(retry=tenacity.retry_unless_exception_type())
+def _retryable_test_with_unless_exception_type_no_input(
+ thing: typing.Any,
+) -> typing.Any:
+ return thing.go()
+
+
+@retry(
+ stop=tenacity.stop_after_attempt(5),
+ retry=tenacity.retry_if_exception_message(
+ message=NoCustomErrorAfterCount.derived_message
+ ),
+)
+def _retryable_test_if_exception_message_message(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry(
+ retry=tenacity.retry_if_not_exception_message(
+ message=NoCustomErrorAfterCount.derived_message
+ )
+)
+def _retryable_test_if_not_exception_message_message(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry(
+ retry=tenacity.retry_if_exception_message(
+ match=NoCustomErrorAfterCount.derived_message[:3] + ".*"
+ )
+)
+def _retryable_test_if_exception_message_match(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry(
+ retry=tenacity.retry_if_not_exception_message(
+ match=NoCustomErrorAfterCount.derived_message[:3] + ".*"
+ )
+)
+def _retryable_test_if_not_exception_message_match(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry(
+ retry=tenacity.retry_if_not_exception_message(
+ message=NameErrorUntilCount.derived_message
+ )
+)
+def _retryable_test_not_exception_message_delay(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry
+def _retryable_default(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry()
+def _retryable_default_f(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry(retry=tenacity.retry_if_exception_type(CustomError))
+def _retryable_test_with_exception_type_custom(thing: typing.Any) -> typing.Any:
+ return thing.go()
+
+
+@retry(
+ stop=tenacity.stop_after_attempt(3),
+ retry=tenacity.retry_if_exception_type(CustomError),
+)
+def _retryable_test_with_exception_type_custom_attempt_limit(
+ thing: typing.Any,
+) -> typing.Any:
+ return thing.go()
+
+
+class TestDecoratorWrapper(unittest.TestCase):
+ def test_with_wait(self) -> None:
+ start = current_time_ms()
+ result = _retryable_test_with_wait(NoneReturnUntilAfterCount(5))
+ t = current_time_ms() - start
+ self.assertGreaterEqual(t, 250)
+ self.assertTrue(result)
+
+ def test_with_stop_on_return_value(self) -> None:
+ try:
+ _retryable_test_with_stop(NoneReturnUntilAfterCount(5))
+ self.fail("Expected RetryError after 3 attempts")
+ except RetryError as re:
+ self.assertFalse(re.last_attempt.failed)
+ self.assertEqual(3, re.last_attempt.attempt_number)
+ self.assertTrue(re.last_attempt.result() is None)
+ print(re)
+
+ def test_with_stop_on_exception(self) -> None:
+ try:
+ _retryable_test_with_stop(NoIOErrorAfterCount(5))
+ self.fail("Expected IOError")
+ except OSError as re:
+ self.assertTrue(isinstance(re, IOError))
+ print(re)
+
+ def test_retry_if_exception_of_type(self) -> None:
+ self.assertTrue(_retryable_test_with_exception_type_io(NoIOErrorAfterCount(5)))
+
+ try:
+ _retryable_test_with_exception_type_io(NoNameErrorAfterCount(5))
+ self.fail("Expected NameError")
+ except NameError as n:
+ self.assertTrue(isinstance(n, NameError))
+ print(n)
+
+ self.assertTrue(
+ _retryable_test_with_exception_type_custom(NoCustomErrorAfterCount(5))
+ )
+
+ try:
+ _retryable_test_with_exception_type_custom(NoNameErrorAfterCount(5))
+ self.fail("Expected NameError")
+ except NameError as n:
+ self.assertTrue(isinstance(n, NameError))
+ print(n)
+
+ def test_retry_except_exception_of_type(self) -> None:
+ self.assertTrue(
+ _retryable_test_if_not_exception_type_io(NoNameErrorAfterCount(5))
+ )
+
+ try:
+ _retryable_test_if_not_exception_type_io(NoIOErrorAfterCount(5))
+ self.fail("Expected IOError")
+ except OSError as err:
+ self.assertTrue(isinstance(err, IOError))
+ print(err)
+
+ def test_retry_until_exception_of_type_attempt_number(self) -> None:
+ try:
+ self.assertTrue(
+ _retryable_test_with_unless_exception_type_name(NameErrorUntilCount(5))
+ )
+ except NameError as e:
+ s = _retryable_test_with_unless_exception_type_name.statistics
+ self.assertTrue(s["attempt_number"] == 6)
+ print(e)
+ else:
+ self.fail("Expected NameError")
+
+ def test_retry_until_exception_of_type_no_type(self) -> None:
+ try:
+ # no input should catch all subclasses of Exception
+ self.assertTrue(
+ _retryable_test_with_unless_exception_type_no_input(
+ NameErrorUntilCount(5)
+ )
+ )
+ except NameError as e:
+ s = _retryable_test_with_unless_exception_type_no_input.statistics
+ self.assertTrue(s["attempt_number"] == 6)
+ print(e)
+ else:
+ self.fail("Expected NameError")
+
+ def test_retry_until_exception_of_type_wrong_exception(self) -> None:
+ try:
+ # two iterations with IOError, one that returns True
+ _retryable_test_with_unless_exception_type_name_attempt_limit(
+ IOErrorUntilCount(2)
+ )
+ self.fail("Expected RetryError")
+ except RetryError as e:
+ self.assertTrue(isinstance(e, RetryError))
+ print(e)
+
+ def test_retry_if_exception_message(self) -> None:
+ try:
+ self.assertTrue(
+ _retryable_test_if_exception_message_message(NoCustomErrorAfterCount(3))
+ )
+ except CustomError:
+ print(_retryable_test_if_exception_message_message.statistics)
+ self.fail("CustomError should've been retried from errormessage")
+
+ def test_retry_if_not_exception_message(self) -> None:
+ try:
+ self.assertTrue(
+ _retryable_test_if_not_exception_message_message(
+ NoCustomErrorAfterCount(2)
+ )
+ )
+ except CustomError:
+ s = _retryable_test_if_not_exception_message_message.statistics
+ self.assertTrue(s["attempt_number"] == 1)
+
+ def test_retry_if_not_exception_message_delay(self) -> None:
+ try:
+ self.assertTrue(
+ _retryable_test_not_exception_message_delay(NameErrorUntilCount(3))
+ )
+ except NameError:
+ s = _retryable_test_not_exception_message_delay.statistics
+ print(s["attempt_number"])
+ self.assertTrue(s["attempt_number"] == 4)
+
+ def test_retry_if_exception_message_match(self) -> None:
+ try:
+ self.assertTrue(
+ _retryable_test_if_exception_message_match(NoCustomErrorAfterCount(3))
+ )
+ except CustomError:
+ self.fail("CustomError should've been retried from errormessage")
+
+ def test_retry_if_not_exception_message_match(self) -> None:
+ try:
+ self.assertTrue(
+ _retryable_test_if_not_exception_message_message(
+ NoCustomErrorAfterCount(2)
+ )
+ )
+ except CustomError:
+ s = _retryable_test_if_not_exception_message_message.statistics
+ self.assertTrue(s["attempt_number"] == 1)
+
+ def test_retry_if_exception_cause_type(self) -> None:
+ self.assertTrue(
+ _retryable_test_with_exception_cause_type(NoNameErrorCauseAfterCount(5))
+ )
+
+ try:
+ _retryable_test_with_exception_cause_type(NoIOErrorCauseAfterCount(5))
+ self.fail("Expected exception without NameError as cause")
+ except NameError:
+ pass
+
+ def test_retry_preserves_argument_defaults(self) -> None:
+ def function_with_defaults(a: int = 1) -> int:
+ return a
+
+ def function_with_kwdefaults(*, a: int = 1) -> int:
+ return a
+
+ retrying = Retrying(
+ wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3)
+ )
+ wrapped_defaults_function = retrying.wraps(function_with_defaults)
+ wrapped_kwdefaults_function = retrying.wraps(function_with_kwdefaults)
+
+ self.assertEqual(
+ function_with_defaults.__defaults__,
+ wrapped_defaults_function.__defaults__, # type: ignore[attr-defined]
+ )
+ self.assertEqual(
+ function_with_kwdefaults.__kwdefaults__,
+ wrapped_kwdefaults_function.__kwdefaults__, # type: ignore[attr-defined]
+ )
+
+ def test_defaults(self) -> None:
+ self.assertTrue(_retryable_default(NoNameErrorAfterCount(5)))
+ self.assertTrue(_retryable_default_f(NoNameErrorAfterCount(5)))
+ self.assertTrue(_retryable_default(NoCustomErrorAfterCount(5)))
+ self.assertTrue(_retryable_default_f(NoCustomErrorAfterCount(5)))
+
+ def test_retry_function_object(self) -> None:
+ """Test that functools.wraps doesn't cause problems with callable objects.
+
+ It raises an error upon trying to wrap it in Py2, because __name__
+ attribute is missing. It's fixed in Py3 but was never backported.
+ """
+
+ class Hello:
+ def __call__(self) -> str:
+ return "Hello"
+
+ retrying = Retrying(
+ wait=tenacity.wait_fixed(0.01), stop=tenacity.stop_after_attempt(3)
+ )
+ h = retrying.wraps(Hello())
+ self.assertEqual(h(), "Hello")
+
+ def test_retry_function_attributes(self) -> None:
+ """Test that the wrapped function attributes are exposed as intended.
+
+ - statistics contains the value for the latest function run
+ - retry object can be modified to change its behaviour (useful to patch in tests)
+ - retry object statistics are synced with function statistics
+ """
+
+ self.assertTrue(_retryable_test_with_stop(NoneReturnUntilAfterCount(2)))
+
+ expected_stats = {
+ "attempt_number": 3,
+ "delay_since_first_attempt": mock.ANY,
+ "idle_for": mock.ANY,
+ "start_time": mock.ANY,
+ }
+ self.assertEqual(_retryable_test_with_stop.statistics, expected_stats)
+ self.assertEqual(_retryable_test_with_stop.retry.statistics, expected_stats)
+
+ with mock.patch.object(
+ _retryable_test_with_stop.retry,
+ "stop",
+ tenacity.stop_after_attempt(1),
+ ):
+ try:
+ self.assertTrue(_retryable_test_with_stop(NoneReturnUntilAfterCount(2)))
+ except RetryError as exc:
+ expected_stats = {
+ "attempt_number": 1,
+ "delay_since_first_attempt": mock.ANY,
+ "idle_for": mock.ANY,
+ "start_time": mock.ANY,
+ }
+ self.assertEqual(_retryable_test_with_stop.statistics, expected_stats)
+ self.assertEqual(exc.last_attempt.attempt_number, 1)
+ self.assertEqual(
+ _retryable_test_with_stop.retry.statistics, expected_stats
+ )
+ else:
+ self.fail("RetryError should have been raised after 1 attempt")
+
+
+class TestStatisticsKeys:
+ def test_delay_since_first_attempt_available_on_first_attempt(self) -> None:
+ """delay_since_first_attempt should be in statistics from the start."""
+
+ @retry(
+ stop=tenacity.stop_after_attempt(3),
+ retry=tenacity.retry_if_result(lambda x: x is None),
+ )
+ def succeeds_first_try() -> bool:
+ assert "delay_since_first_attempt" in succeeds_first_try.statistics
+ assert succeeds_first_try.statistics["delay_since_first_attempt"] == 0
+ return True
+
+ succeeds_first_try()
+ assert succeeds_first_try.statistics["delay_since_first_attempt"] == 0
+
+
+class TestEnabled:
+ def test_enabled_false_skips_retry(self) -> None:
+ """When enabled=False, the function is called directly without retrying."""
+ call_count = 0
+
+ @retry(enabled=False, stop=tenacity.stop_after_attempt(3))
+ def always_fails() -> None:
+ nonlocal call_count
+ call_count += 1
+ raise ValueError("fail")
+
+ with pytest.raises(ValueError, match="fail"):
+ always_fails()
+ assert call_count == 1
+
+ def test_enabled_false_preserves_attributes(self) -> None:
+ """When enabled=False, .retry, .retry_with, .statistics are still available."""
+
+ @retry(enabled=False, stop=tenacity.stop_after_attempt(3))
+ def my_func() -> str:
+ return "ok"
+
+ assert hasattr(my_func, "retry")
+ assert hasattr(my_func, "retry_with")
+ assert hasattr(my_func, "statistics")
+ assert my_func() == "ok"
+
+ def test_enabled_false_via_retry_with(self) -> None:
+ """retry_with(enabled=False) disables retrying."""
+ call_count = 0
+
+ @retry(stop=tenacity.stop_after_attempt(3))
+ def always_fails() -> None:
+ nonlocal call_count
+ call_count += 1
+ raise ValueError("fail")
+
+ disabled = always_fails.retry_with(enabled=False)
+ with pytest.raises(ValueError, match="fail"):
+ disabled()
+ assert call_count == 1
+
+ def test_enabled_true_retries_normally(self) -> None:
+ """When enabled=True (default), retrying works as usual."""
+ call_count = 0
+
+ @retry(enabled=True, stop=tenacity.stop_after_attempt(3), reraise=True)
+ def fails_twice() -> bool:
+ nonlocal call_count
+ call_count += 1
+ if call_count < 3:
+ raise ValueError("fail")
+ return True
+
+ assert fails_twice() is True
+ assert call_count == 3
+
+
+class TestRetryWith:
+ def test_redefine_wait(self) -> None:
+ start = current_time_ms()
+ result = _retryable_test_with_wait.retry_with(wait=tenacity.wait_fixed(0.1))(
+ NoneReturnUntilAfterCount(5)
+ )
+ t = current_time_ms() - start
+ assert t >= 500
+ assert result is True
+
+ def test_redefine_stop(self) -> None:
+ result = _retryable_test_with_stop.retry_with(
+ stop=tenacity.stop_after_attempt(5)
+ )(NoneReturnUntilAfterCount(4))
+ assert result is True
+
+ def test_retry_error_cls_should_be_preserved(self) -> None:
+ @retry(stop=tenacity.stop_after_attempt(10), retry_error_cls=ValueError) # type: ignore[arg-type]
+ def _retryable() -> None:
+ raise Exception("raised for test purposes")
+
+ with pytest.raises(Exception) as exc_ctx:
+ _retryable.retry_with(stop=tenacity.stop_after_attempt(2))()
+
+ assert exc_ctx.type is ValueError, "Should remap to specific exception type"
+
+ def test_retry_error_callback_should_be_preserved(self) -> None:
+ def return_text(retry_state: RetryCallState) -> str:
+ return f"Calling {retry_state.fn.__name__} keeps raising errors after {retry_state.attempt_number} attempts" # type: ignore[union-attr]
+
+ @retry(stop=tenacity.stop_after_attempt(10), retry_error_callback=return_text)
+ def _retryable() -> None:
+ raise Exception("raised for test purposes")
+
+ result = _retryable.retry_with(stop=tenacity.stop_after_attempt(5))()
+ assert result == "Calling _retryable keeps raising errors after 5 attempts"
+
+
+class TestBeforeAfterAttempts(unittest.TestCase):
+ _attempt_number = 0
+
+ def test_before_attempts(self) -> None:
+ TestBeforeAfterAttempts._attempt_number = 0
+
+ def _before(retry_state: RetryCallState) -> None:
+ TestBeforeAfterAttempts._attempt_number = retry_state.attempt_number
+
+ @retry(
+ wait=tenacity.wait_fixed(1),
+ stop=tenacity.stop_after_attempt(1),
+ before=_before,
+ )
+ def _test_before() -> None:
+ pass
+
+ _test_before()
+
+ self.assertTrue(TestBeforeAfterAttempts._attempt_number == 1)
+
+ def test_after_attempts(self) -> None:
+ TestBeforeAfterAttempts._attempt_number = 0
+
+ def _after(retry_state: RetryCallState) -> None:
+ TestBeforeAfterAttempts._attempt_number = retry_state.attempt_number
+
+ @retry(
+ wait=tenacity.wait_fixed(0.1),
+ stop=tenacity.stop_after_attempt(3),
+ after=_after,
+ )
+ def _test_after() -> None:
+ if TestBeforeAfterAttempts._attempt_number < 2:
+ raise Exception("testing after_attempts handler")
+
+ _test_after()
+
+ self.assertTrue(TestBeforeAfterAttempts._attempt_number == 2)
+
+ def test_before_sleep(self) -> None:
+ def _before_sleep(retry_state: RetryCallState) -> None:
+ self.assertGreater(retry_state.next_action.sleep, 0) # type: ignore[union-attr]
+ _before_sleep.attempt_number = retry_state.attempt_number # type: ignore[attr-defined]
+
+ @retry(
+ wait=tenacity.wait_fixed(0.01),
+ stop=tenacity.stop_after_attempt(3),
+ before_sleep=_before_sleep,
+ )
+ def _test_before_sleep() -> None:
+ if _before_sleep.attempt_number < 2: # type: ignore[attr-defined]
+ raise Exception("testing before_sleep_attempts handler")
+
+ _test_before_sleep()
+ self.assertEqual(_before_sleep.attempt_number, 2) # type: ignore[attr-defined]
+
+ def _before_sleep_log_raises(
+ self, get_call_fn: typing.Callable[..., typing.Any]
+ ) -> None:
+ thing = NoIOErrorAfterCount(2)
+ logger = logging.getLogger(self.id())
+ logger.propagate = False
+ logger.setLevel(logging.INFO)
+ handler = CapturingHandler()
+ logger.addHandler(handler)
+ try:
+ _before_sleep = tenacity.before_sleep_log(logger, logging.INFO)
+ retrying = Retrying(
+ wait=tenacity.wait_fixed(0.01),
+ stop=tenacity.stop_after_attempt(3),
+ before_sleep=_before_sleep,
+ )
+ get_call_fn(retrying)(thing.go)
+ finally:
+ logger.removeHandler(handler)
+
+ etalon_re = (
+ r"^Retrying .* in 0\.01 seconds as it raised "
+ r"(IO|OS)Error: Hi there, I'm an IOError\.$"
+ )
+ self.assertEqual(len(handler.records), 2)
+ fmt = logging.Formatter().format
+ self.assertRegex(fmt(handler.records[0]), etalon_re)
+ self.assertRegex(fmt(handler.records[1]), etalon_re)
+
+ def test_before_sleep_log_raises(self) -> None:
+ self._before_sleep_log_raises(lambda x: x)
+
+ def test_before_sleep_log_raises_with_exc_info(self) -> None:
+ thing = NoIOErrorAfterCount(2)
+ logger = logging.getLogger(self.id())
+ logger.propagate = False
+ logger.setLevel(logging.INFO)
+ handler = CapturingHandler()
+ logger.addHandler(handler)
+ try:
+ _before_sleep = tenacity.before_sleep_log(
+ logger, logging.INFO, exc_info=True
+ )
+ retrying = Retrying(
+ wait=tenacity.wait_fixed(0.01),
+ stop=tenacity.stop_after_attempt(3),
+ before_sleep=_before_sleep,
+ )
+ retrying(thing.go)
+ finally:
+ logger.removeHandler(handler)
+
+ etalon_re = re.compile(
+ r"^Retrying .* in 0\.01 seconds as it raised "
+ r"(IO|OS)Error: Hi there, I'm an IOError\.{0}"
+ r"Traceback \(most recent call last\):{0}"
+ r".*$".format("\n"),
+ flags=re.MULTILINE,
+ )
+ self.assertEqual(len(handler.records), 2)
+ fmt = logging.Formatter().format
+ self.assertRegex(fmt(handler.records[0]), etalon_re)
+ self.assertRegex(fmt(handler.records[1]), etalon_re)
+
+ def test_before_sleep_log_returns(self, exc_info: bool = False) -> None:
+ thing = NoneReturnUntilAfterCount(2)
+ logger = logging.getLogger(self.id())
+ logger.propagate = False
+ logger.setLevel(logging.INFO)
+ handler = CapturingHandler()
+ logger.addHandler(handler)
+ try:
+ _before_sleep = tenacity.before_sleep_log(
+ logger, logging.INFO, exc_info=exc_info
+ )
+ _retry = tenacity.retry_if_result(lambda result: result is None)
+ retrying = Retrying(
+ wait=tenacity.wait_fixed(0.01),
+ stop=tenacity.stop_after_attempt(3),
+ retry=_retry,
+ before_sleep=_before_sleep,
+ )
+ retrying(thing.go)
+ finally:
+ logger.removeHandler(handler)
+
+ etalon_re = r"^Retrying .* in 0\.01 seconds as it returned None\.$"
+ self.assertEqual(len(handler.records), 2)
+ fmt = logging.Formatter().format
+ self.assertRegex(fmt(handler.records[0]), etalon_re)
+ self.assertRegex(fmt(handler.records[1]), etalon_re)
+
+ def test_before_sleep_log_returns_with_exc_info(self) -> None:
+ self.test_before_sleep_log_returns(exc_info=True)
+
+
+class TestReraiseExceptions(unittest.TestCase):
+ def test_reraise_by_default(self) -> None:
+ calls = []
+
+ @retry(
+ wait=tenacity.wait_fixed(0.1),
+ stop=tenacity.stop_after_attempt(2),
+ reraise=True,
+ )
+ def _reraised_by_default() -> None:
+ calls.append("x")
+ raise KeyError("Bad key")
+
+ self.assertRaises(KeyError, _reraised_by_default)
+ self.assertEqual(2, len(calls))
+
+ def test_reraise_from_retry_error(self) -> None:
+ calls = []
+
+ @retry(wait=tenacity.wait_fixed(0.1), stop=tenacity.stop_after_attempt(2))
+ def _raise_key_error() -> None:
+ calls.append("x")
+ raise KeyError("Bad key")
+
+ def _reraised_key_error() -> None:
+ try:
+ _raise_key_error()
+ except tenacity.RetryError as retry_err:
+ retry_err.reraise()
+
+ self.assertRaises(KeyError, _reraised_key_error)
+ self.assertEqual(2, len(calls))
+
+ def test_reraise_timeout_from_retry_error(self) -> None:
+ calls = []
+
+ @retry(
+ wait=tenacity.wait_fixed(0.1),
+ stop=tenacity.stop_after_attempt(2),
+ retry=lambda retry_state: True,
+ )
+ def _mock_fn() -> None:
+ calls.append("x")
+
+ def _reraised_mock_fn() -> None:
+ try:
+ _mock_fn()
+ except tenacity.RetryError as retry_err:
+ retry_err.reraise()
+
+ self.assertRaises(tenacity.RetryError, _reraised_mock_fn)
+ self.assertEqual(2, len(calls))
+
+ def test_reraise_no_exception(self) -> None:
+ calls = []
+
+ @retry(
+ wait=tenacity.wait_fixed(0.1),
+ stop=tenacity.stop_after_attempt(2),
+ retry=lambda retry_state: True,
+ reraise=True,
+ )
+ def _mock_fn() -> None:
+ calls.append("x")
+
+ self.assertRaises(tenacity.RetryError, _mock_fn)
+ self.assertEqual(2, len(calls))
+
+
+class TestStatistics(unittest.TestCase):
+ def test_stats(self) -> None:
+ @retry()
+ def _foobar() -> int:
+ return 42
+
+ self.assertEqual({}, _foobar.statistics)
+ _foobar()
+ self.assertEqual(1, _foobar.statistics["attempt_number"])
+
+ def test_stats_failing(self) -> None:
+ @retry(stop=tenacity.stop_after_attempt(2))
+ def _foobar() -> None:
+ raise ValueError(42)
+
+ self.assertEqual({}, _foobar.statistics)
+ with contextlib.suppress(Exception):
+ _foobar()
+ self.assertEqual(2, _foobar.statistics["attempt_number"])
+
+ def test_retry_object_statistics_synced(self) -> None:
+ """Test that func.retry.statistics is synced with func.statistics."""
+
+ @retry(stop=tenacity.stop_after_attempt(3))
+ def _foobar() -> int:
+ return 42
+
+ _foobar()
+ self.assertEqual(
+ _foobar.retry.statistics["attempt_number"],
+ _foobar.statistics["attempt_number"],
+ )
+
+ def test_retry_object_statistics_during_execution(self) -> None:
+ """Test that func.retry.statistics is accessible during execution."""
+ attempts: list[int] = []
+
+ @retry(
+ stop=tenacity.stop_after_attempt(3),
+ retry=tenacity.retry_if_exception_type(ValueError),
+ reraise=True,
+ )
+ def _foobar() -> int:
+ attempts.append(_foobar.retry.statistics["attempt_number"])
+ if len(attempts) < 3:
+ raise ValueError("retry")
+ return 42
+
+ _foobar()
+ self.assertEqual(attempts, [1, 2, 3])
+
+
+class TestRetryErrorCallback(unittest.TestCase):
+ def setUp(self) -> None:
+ self._attempt_number = 0
+ self._callback_called = False
+
+ def _callback(self, fut: tenacity.Future) -> tenacity.Future:
+ self._callback_called = True
+ return fut
+
+ def test_retry_error_callback(self) -> None:
+ num_attempts = 3
+
+ def retry_error_callback(retry_state: RetryCallState) -> typing.Any:
+ retry_error_callback.called_times += 1 # type: ignore[attr-defined]
+ return retry_state.outcome
+
+ retry_error_callback.called_times = 0 # type: ignore[attr-defined]
+
+ @retry(
+ stop=tenacity.stop_after_attempt(num_attempts),
+ retry_error_callback=retry_error_callback,
+ )
+ def _foobar() -> None:
+ self._attempt_number += 1
+ raise Exception("This exception should not be raised")
+
+ result = _foobar()
+
+ self.assertEqual(retry_error_callback.called_times, 1) # type: ignore[attr-defined]
+ self.assertEqual(num_attempts, self._attempt_number)
+ self.assertIsInstance(result, tenacity.Future)
+
+
+class TestContextManager(unittest.TestCase):
+ def test_context_manager_retry_one(self) -> None:
+ from tenacity import Retrying
+
+ raise_ = True
+
+ for attempt in Retrying():
+ with attempt:
+ if raise_:
+ raise_ = False
+ raise Exception("Retry it!")
+
+ def test_context_manager_on_error(self) -> None:
+ from tenacity import Retrying
+
+ class CustomError(Exception):
+ pass
+
+ retry = Retrying(retry=tenacity.retry_if_exception_type(IOError))
+
+ def test() -> None:
+ for attempt in retry:
+ with attempt:
+ raise CustomError("Don't retry!")
+
+ self.assertRaises(CustomError, test)
+
+ def test_context_manager_retry_error(self) -> None:
+ from tenacity import Retrying
+
+ retry = Retrying(stop=tenacity.stop_after_attempt(2))
+
+ def test() -> None:
+ for attempt in retry:
+ with attempt:
+ raise Exception("Retry it!")
+
+ self.assertRaises(RetryError, test)
+
+ def test_context_manager_reraise(self) -> None:
+ from tenacity import Retrying
+
+ class CustomError(Exception):
+ pass
+
+ retry = Retrying(reraise=True, stop=tenacity.stop_after_attempt(2))
+
+ def test() -> None:
+ for attempt in retry:
+ with attempt:
+ raise CustomError("Don't retry!")
+
+ self.assertRaises(CustomError, test)
+
+
+class TestInvokeAsCallable:
+ """Test direct invocation of Retrying as a callable."""
+
+ @staticmethod
+ def invoke(retry: Retrying, f: typing.Callable[..., typing.Any]) -> typing.Any:
+ """
+ Invoke Retrying logic.
+
+ Wrapper allows testing different call mechanisms in test sub-classes.
+ """
+ return retry(f)
+
+ def test_retry_one(self) -> None:
+ def f() -> typing.Any:
+ f.calls.append(len(f.calls) + 1) # type: ignore[attr-defined]
+ if len(f.calls) <= 1: # type: ignore[attr-defined]
+ raise Exception("Retry it!")
+ return 42
+
+ f.calls = [] # type: ignore[attr-defined]
+
+ retry = Retrying()
+ assert self.invoke(retry, f) == 42
+ assert f.calls == [1, 2] # type: ignore[attr-defined]
+
+ def test_on_error(self) -> None:
+ class CustomError(Exception):
+ pass
+
+ def f() -> typing.Any:
+ f.calls.append(len(f.calls) + 1) # type: ignore[attr-defined]
+ if len(f.calls) <= 1: # type: ignore[attr-defined]
+ raise CustomError("Don't retry!")
+ return 42
+
+ f.calls = [] # type: ignore[attr-defined]
+
+ retry = Retrying(retry=tenacity.retry_if_exception_type(IOError))
+ with pytest.raises(CustomError):
+ self.invoke(retry, f)
+ assert f.calls == [1] # type: ignore[attr-defined]
+
+ def test_retry_error(self) -> None:
+ def f() -> typing.Any:
+ f.calls.append(len(f.calls) + 1) # type: ignore[attr-defined]
+ raise Exception("Retry it!")
+
+ f.calls = [] # type: ignore[attr-defined]
+
+ retry = Retrying(stop=tenacity.stop_after_attempt(2))
+ with pytest.raises(RetryError):
+ self.invoke(retry, f)
+ assert f.calls == [1, 2] # type: ignore[attr-defined]
+
+ def test_reraise(self) -> None:
+ class CustomError(Exception):
+ pass
+
+ def f() -> typing.Any:
+ f.calls.append(len(f.calls) + 1) # type: ignore[attr-defined]
+ raise CustomError("Retry it!")
+
+ f.calls = [] # type: ignore[attr-defined]
+
+ retry = Retrying(reraise=True, stop=tenacity.stop_after_attempt(2))
+ with pytest.raises(CustomError):
+ self.invoke(retry, f)
+ assert f.calls == [1, 2] # type: ignore[attr-defined]
+
+
+class TestRetryException(unittest.TestCase):
+ def test_retry_error_is_pickleable(self) -> None:
+ import pickle
+
+ expected = RetryError(last_attempt=123) # type: ignore[arg-type]
+ pickled = pickle.dumps(expected)
+ actual = pickle.loads(pickled)
+ self.assertEqual(expected.last_attempt, actual.last_attempt)
+
+
+class TestRetryTyping(unittest.TestCase):
+ def test_retry_type_annotations(self) -> None:
+ """The decorator should maintain types of decorated functions."""
+
+ def num_to_str(number):
+ # type: (int) -> str
+ return str(number)
+
+ # equivalent to a raw @retry decoration
+ with_raw = retry(num_to_str)
+ with_raw_result = with_raw(1)
+
+ # equivalent to a @retry(...) decoration
+ with_constructor = retry()(num_to_str)
+ with_constructor_result = with_raw(1)
+
+ # These raise TypeError exceptions if they fail
+ check_type(with_raw, typing.Callable[[int], str])
+ check_type(with_raw_result, str)
+ check_type(with_constructor, typing.Callable[[int], str])
+ check_type(with_constructor_result, str)
+
+
+class TestMockingSleep:
+ RETRY_ARGS = {
+ "wait": tenacity.wait_fixed(0.1),
+ "stop": tenacity.stop_after_attempt(5),
+ }
+
+ def _fail(self) -> None:
+ raise NotImplementedError
+
+ @retry(**RETRY_ARGS) # type: ignore[call-overload, untyped-decorator]
+ def _decorated_fail(self) -> None:
+ self._fail()
+
+ @pytest.fixture()
+ def mock_sleep(
+ self, monkeypatch: typing.Any
+ ) -> typing.Generator[typing.Any, None, None]:
+ class MockSleep:
+ call_count = 0
+
+ def __call__(self, seconds: float) -> None:
+ self.call_count += 1
+
+ sleep = MockSleep()
+ monkeypatch.setattr(tenacity.nap.time, "sleep", sleep) # type: ignore[attr-defined]
+ yield sleep
+
+ def test_decorated(self, mock_sleep: typing.Any) -> None:
+ with pytest.raises(RetryError):
+ self._decorated_fail()
+ assert mock_sleep.call_count == 4
+
+ def test_decorated_retry_with(self, mock_sleep: typing.Any) -> None:
+ fail_faster = self._decorated_fail.retry_with(
+ stop=tenacity.stop_after_attempt(2),
+ )
+ with pytest.raises(RetryError):
+ fail_faster()
+ assert mock_sleep.call_count == 1
+
+
+class TestPickle(unittest.TestCase):
+ def test_retrying_picklable(self) -> None:
+ """Retrying objects can be pickled for multiprocessing support."""
+ retrying = Retrying(stop=tenacity.stop_after_attempt(3))
+ pickled = pickle.dumps(retrying)
+ restored = pickle.loads(pickled)
+ assert isinstance(restored, Retrying)
+ assert isinstance(restored.stop, tenacity.stop_after_attempt)
+
+ def test_retrying_picklable_after_run(self) -> None:
+ """Retrying objects can be pickled even after being used."""
+ retrying = Retrying(stop=tenacity.stop_after_attempt(3))
+ # Access statistics to populate _local
+ _ = retrying.statistics
+ pickled = pickle.dumps(retrying)
+ restored = pickle.loads(pickled)
+ assert isinstance(restored, Retrying)
+ # Statistics should be reset on the restored object
+ assert restored.statistics == {}
+
+ def test_retry_strategies_picklable(self) -> None:
+ """All built-in retry strategies can be pickled."""
+ strategies = [
+ tenacity.retry_if_exception_type(ValueError),
+ tenacity.retry_if_not_exception_type(ValueError),
+ tenacity.retry_if_exception_message(message="fail"),
+ tenacity.retry_if_exception_message(match="fail.*"),
+ tenacity.retry_if_not_exception_message(message="fail"),
+ ]
+ for strategy in strategies:
+ restored = pickle.loads(pickle.dumps(strategy))
+ assert type(restored) is type(strategy)
+
+ def test_retrying_pickle_round_trip_works(self) -> None:
+ """A pickled-then-restored Retrying object retries correctly."""
+ retrying = Retrying(
+ stop=tenacity.stop_after_attempt(3),
+ retry=tenacity.retry_if_exception_type(ValueError),
+ reraise=True,
+ )
+ restored = pickle.loads(pickle.dumps(retrying))
+
+ calls = 0
+
+ def succeed_on_third() -> str:
+ nonlocal calls
+ calls += 1
+ if calls < 3:
+ raise ValueError("not yet")
+ return "ok"
+
+ result = restored(succeed_on_third)
+ assert result == "ok"
+ assert calls == 3
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tenacity/tests/test_tornado.py b/tests/test_tornado.py
similarity index 71%
rename from tenacity/tests/test_tornado.py
rename to tests/test_tornado.py
index 23380170..9233cca3 100644
--- a/tenacity/tests/test_tornado.py
+++ b/tests/test_tornado.py
@@ -1,4 +1,3 @@
-# coding: utf-8
# Copyright 2017 Elisey Zanko
#
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,39 +13,42 @@
# limitations under the License.
import unittest
+from collections.abc import Generator
+from typing import Any
-from tenacity import RetryError, retry, stop_after_attempt
-from tenacity import tornadoweb
-from tenacity.tests.test_tenacity import NoIOErrorAfterCount
+from tornado import gen, testing
-from tornado import gen
-from tornado import testing
+from tenacity import RetryError, retry, stop_after_attempt, tornadoweb
+
+from .test_tenacity import NoIOErrorAfterCount
@retry
@gen.coroutine
-def _retryable_coroutine(thing):
+def _retryable_coroutine(thing: NoIOErrorAfterCount) -> Generator[Any, Any, None]:
yield gen.sleep(0.00001)
thing.go()
@retry(stop=stop_after_attempt(2))
@gen.coroutine
-def _retryable_coroutine_with_2_attempts(thing):
+def _retryable_coroutine_with_2_attempts(
+ thing: NoIOErrorAfterCount,
+) -> Generator[Any, Any, None]:
yield gen.sleep(0.00001)
thing.go()
class TestTornado(testing.AsyncTestCase):
@testing.gen_test
- def test_retry(self):
+ def test_retry(self) -> Generator[Any, Any, None]:
assert gen.is_coroutine_function(_retryable_coroutine)
thing = NoIOErrorAfterCount(5)
yield _retryable_coroutine(thing)
assert thing.counter == thing.count
@testing.gen_test
- def test_stop_after_attempt(self):
+ def test_stop_after_attempt(self) -> Generator[Any, Any, None]:
assert gen.is_coroutine_function(_retryable_coroutine)
thing = NoIOErrorAfterCount(2)
try:
@@ -54,10 +56,10 @@ def test_stop_after_attempt(self):
except RetryError:
assert thing.counter == 2
- def test_repr(self):
+ def test_repr(self) -> None:
repr(tornadoweb.TornadoRetrying())
- def test_old_tornado(self):
+ def test_old_tornado(self) -> None:
old_attr = gen.is_coroutine_function
try:
del gen.is_coroutine_function
@@ -65,11 +67,12 @@ def test_old_tornado(self):
# is_coroutine_function was introduced in tornado 4.5;
# verify that we don't *completely* fall over on old versions
@retry
- def retryable(thing):
+ def retryable(thing: NoIOErrorAfterCount) -> None:
pass
+
finally:
gen.is_coroutine_function = old_attr
-if __name__ == '__main__':
+if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 00000000..50a0e3db
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,57 @@
+import functools
+
+from tenacity import _utils
+
+
+def test_is_coroutine_callable() -> None:
+ async def async_func() -> None:
+ pass
+
+ def sync_func() -> None:
+ pass
+
+ class AsyncClass:
+ async def __call__(self) -> None:
+ pass
+
+ class SyncClass:
+ def __call__(self) -> None:
+ pass
+
+ lambda_fn = lambda: None # noqa: E731
+
+ partial_async_func = functools.partial(async_func)
+ partial_sync_func = functools.partial(sync_func)
+ partial_async_class = functools.partial(AsyncClass().__call__)
+ partial_sync_class = functools.partial(SyncClass().__call__)
+ partial_lambda_fn = functools.partial(lambda_fn)
+
+ assert _utils.is_coroutine_callable(async_func) is True
+ assert _utils.is_coroutine_callable(sync_func) is False
+ assert _utils.is_coroutine_callable(AsyncClass) is False
+ assert _utils.is_coroutine_callable(AsyncClass()) is True
+ assert _utils.is_coroutine_callable(SyncClass) is False
+ assert _utils.is_coroutine_callable(SyncClass()) is False
+ assert _utils.is_coroutine_callable(lambda_fn) is False
+
+ assert _utils.is_coroutine_callable(partial_async_func) is True
+ assert _utils.is_coroutine_callable(partial_sync_func) is False
+ assert _utils.is_coroutine_callable(partial_async_class) is True
+ assert _utils.is_coroutine_callable(partial_sync_class) is False
+ assert _utils.is_coroutine_callable(partial_lambda_fn) is False
+
+
+def test_find_ordinal() -> None:
+ assert _utils.find_ordinal(1) == "st"
+ assert _utils.find_ordinal(2) == "nd"
+ assert _utils.find_ordinal(3) == "rd"
+ assert _utils.find_ordinal(4) == "th"
+ assert _utils.find_ordinal(11) == "th"
+ assert _utils.find_ordinal(12) == "th"
+ assert _utils.find_ordinal(13) == "th"
+ assert _utils.find_ordinal(21) == "st"
+ assert _utils.find_ordinal(22) == "nd"
+ assert _utils.find_ordinal(23) == "rd"
+ assert _utils.find_ordinal(111) == "th"
+ assert _utils.find_ordinal(112) == "th"
+ assert _utils.find_ordinal(113) == "th"
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index 88076793..00000000
--- a/tox.ini
+++ /dev/null
@@ -1,37 +0,0 @@
-[tox]
-envlist = py27, py35, py36, py37, py38, pep8, pypy
-
-[testenv]
-usedevelop = True
-sitepackages = False
-deps =
- .[doc]
- pytest
- typeguard;python_version>='3.0'
-commands =
- py{27,py}: pytest --ignore='tenacity/tests/test_asyncio.py' {posargs}
- py3{5,6,7,8}: pytest {posargs}
- py3{5,6,7,8}: sphinx-build -a -E -W -b doctest doc/source doc/build
- py3{5,6,7,8}: sphinx-build -a -E -W -b html doc/source doc/build
-
-[testenv:pep8]
-basepython = python3
-deps = flake8
- flake8-import-order
- flake8-blind-except
- flake8-builtins
- flake8-docstrings
- flake8-rst-docstrings
- flake8-logging-format
-commands = flake8
-
-[testenv:reno]
-basepython = python3
-deps = reno
-commands = reno {posargs}
-
-[flake8]
-exclude = .tox,.eggs
-show-source = true
-ignore = D100,D101,D102,D103,D104,D105,D107,G200,G201,W503,W504
-enable-extensions=G