diff --git a/src/dsdk/utils.py b/src/dsdk/utils.py
index a9d68af..9debc0d 100644
--- a/src/dsdk/utils.py
+++ b/src/dsdk/utils.py
@@ -6,6 +6,10 @@ from __future__ import annotations
 import pickle
 from collections import OrderedDict
 from datetime import datetime
+from functools import wraps
+from logging import NullHandler, getLogger
+from time import sleep as default_sleep
+from typing import Callable, Sequence
 from warnings import warn
 
 from configargparse import ArgParser
@@ -29,6 +33,10 @@ except ImportError:
     MongoClient = None
 
 
+logger = getLogger(__name__)
+logger.addHandler(NullHandler())
+
+
 def get_base_config() -> ArgParser:
     """Get the base configuration parser."""
     config_parser = ArgParser(
@@ -124,3 +132,48 @@ class WriteOnceDict(OrderedDict):
         if key in self:
             raise KeyError("{} has already been set".format(key))
         super(WriteOnceDict, self).__setitem__(key, value)
+
+
+def retry(
+    exceptions: Sequence[Exception],
+    retries: int = 5,
+    delay: float = 1.0,
+    backoff: float = 1.5,
+    sleep: Callable = default_sleep,
+):
+    """
+    Retry calling the decorated function using an exponential backoff.
+
+    Args:
+        exceptions: The exception to check. may be a tuple of
+            exceptions to check.
+        retries: Number of times to retry before giving up.
+        delay: Initial delay between retries in seconds.
+        backoff: Backoff multiplier (e.g. value of 2 will double the delay
+            each retry).
+    """
+    delay = float(delay)
+    backoff = float(backoff)
+
+    def wrapper(func):
+        @wraps(func)
+        def wrapped(*args, **kwargs):
+            try:
+                return func(*args, **kwargs)
+            except exceptions as exception:
+                logger.exception(exception)
+                wait = delay
+                for _ in range(retries):
+                    message = f"Retrying in {wait:.2f} seconds..."
+                    logger.warning(message)
+                    sleep(wait)
+                    wait *= backoff
+                    try:
+                        return func(*args, **kwargs)
+                    except exceptions as exception:
+                        logger.exception(exception)
+                raise
+
+        return wrapped
+
+    return wrapper

Add retry decorator
Pull the retry decorator up from cdi sepsis.

Fix:
- for loop inside exception handler after initial attempt.
- spurious delay after the final retry attempt.
- accumulated floating point errors in the delay.

Add:
+ default argument for `sleep=time.sleep` function to replace duration in test.
+ default argument for `warn=logger.warn` function to replace
stderr/stdout during test.

Logging appearing to come from `dsdk.util.retry` may not be useful. Passing the client module's `warn=logger.warn` during decoration allows the retry attempt to be logged in the client module's stderr or stdout stream.
diff --git a/tests/test_dsdk.py b/tests/test_dsdk.py
index a8399e2..c3b79a6 100644
--- a/tests/test_dsdk.py
+++ b/tests/test_dsdk.py
@@ -6,6 +6,7 @@ from unittest.mock import Mock
 import configargparse
 
 from dsdk import BaseBatchJob, Block
+from dsdk.utils import retry
 
 
 def test_batch(monkeypatch):
@@ -23,3 +24,67 @@ def test_batch(monkeypatch):
     batch.run()
     assert len(batch.evidence) == 1
     assert batch.evidence["test"] == 42
+
+
+def test_retry_other_exception():
+    """Test retry other exception."""
+
+    exceptions_in = [
+        RuntimeError("what?"),
+        NotImplementedError("how?"),
+        RuntimeError("no!"),
+    ]
+    actual = []
+    expected = [1.0, 1.5, 2.25]
+
+    def sleep(wait: float):
+        actual.append(wait)
+
+    @retry(
+        (NotImplementedError, RuntimeError),
+        retries=4,
+        delay=1.0,
+        backoff=1.5,
+        sleep=sleep,
+    )
+    def explode():
+        raise exceptions_in.pop()
+
+    try:
+        explode()
+        raise AssertionError("IndexError expected")
+    except IndexError:
+        assert actual == expected
+
+
+def test_retry_exhausted():
+    """Test retry."""
+
+    exceptions_in = [
+        RuntimeError("what?"),
+        NotImplementedError("how?"),
+        RuntimeError("no!"),
+        NotImplementedError("when?"),
+    ]
+    actual = []
+    expected = [1.0, 1.5]
+
+    def sleep(wait: float):
+        actual.append(wait)
+
+    @retry(
+        (NotImplementedError, RuntimeError),
+        retries=2,
+        delay=1.0,
+        backoff=1.5,
+        sleep=sleep,
+    )
+    def explode():
+        raise exceptions_in.pop()
+
+    try:
+        explode()
+        raise AssertionError("NotImplementedError expected")
+    except NotImplementedError as exception:
+        assert actual == expected
+        assert str(exception) == "when?"

pip install -e .[lint,test,doc]
parse_log_pytest
requirements.txt
pytest pytest-cov tox coverage
apt-get update apt-get install -y gcc
3.7
docs/requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
astroid==2.15.8
attrs==24.2.0
Babel==2.14.0
black==23.3.0
cachetools==5.5.2
certifi @ file:///croot/certifi_1671487769961/work/certifi
cfgv==3.3.1
chardet==5.2.0
charset-normalizer==3.4.1
click==8.1.8
colorama==0.4.6
ConfigArgParse==1.7
coverage==7.2.7
dill==0.3.7
distlib==0.3.9
docutils==0.17.1
-e git+https://github.com/pennsignals/dsdk.git@5cf83cfc0950ad49b25bd86e6525ea42b1cb0165#egg=dsdk
exceptiongroup==1.2.2
filelock==3.12.2
flake8==5.0.4
flake8-bugbear==23.3.12
flake8-commas==2.1.0
flake8-comprehensions==3.13.0
flake8-docstrings==1.7.0
flake8-logging-format==2024.24.12
flake8-mutable==1.2.0
flake8-sorted-keys==0.2.0
identify==2.5.24
idna==3.10
imagesize==1.4.1
importlib-metadata==4.2.0
iniconfig==2.0.0
isort==5.11.5
Jinja2==3.1.6
lazy-object-proxy==1.9.0
MarkupSafe==2.1.5
mccabe==0.7.0
mypy==1.4.1
mypy-extensions==1.0.0
nodeenv==1.9.1
numpy==1.21.6
packaging==24.0
pandas==1.3.5
pathspec==0.11.2
pep8-naming==0.14.0
platformdirs==2.6.2
pluggy==1.2.0
pre-commit==2.21.0
py==1.11.0
pycodestyle==2.9.1
pydocstyle==6.3.0
pyflakes==2.5.0
Pygments==2.17.2
pylint==2.17.7
pyproject-api==1.5.3
pytest==7.4.4
pytest-cov==4.1.0
python-dateutil==2.9.0.post0
pytz==2025.2
PyYAML==6.0.1
requests==2.31.0
six==1.17.0
snowballstemmer==2.2.0
Sphinx==4.3.2
sphinx-py3doc-enhanced-theme==2.4.0
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
tomli==2.0.1
tomlkit==0.12.5
tox==3.28.0
typed-ast==1.5.5
typing_extensions==4.7.1
urllib3==2.0.7
virtualenv==20.16.2
wrapt==1.16.0
zipp==3.15.0

name: dsdk
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=22.3.1=py37h06a4308_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.38.4=py37h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.13
      - astroid==2.15.8
      - attrs==24.2.0
      - babel==2.14.0
      - black==23.3.0
      - cachetools==5.5.2
      - cfgv==3.3.1
      - chardet==5.2.0
      - charset-normalizer==3.4.1
      - click==8.1.8
      - colorama==0.4.6
      - configargparse==1.7
      - coverage==7.2.7
      - dill==0.3.7
      - distlib==0.3.9
      - docutils==0.17.1
      - dsdk==0.1.dev57
      - exceptiongroup==1.2.2
      - filelock==3.12.2
      - flake8==5.0.4
      - flake8-bugbear==23.3.12
      - flake8-commas==2.1.0
      - flake8-comprehensions==3.13.0
      - flake8-docstrings==1.7.0
      - flake8-logging-format==2024.24.12
      - flake8-mutable==1.2.0
      - flake8-sorted-keys==0.2.0
      - identify==2.5.24
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==4.2.0
      - iniconfig==2.0.0
      - isort==5.11.5
      - jinja2==3.1.6
      - lazy-object-proxy==1.9.0
      - markupsafe==2.1.5
      - mccabe==0.7.0
      - mypy==1.4.1
      - mypy-extensions==1.0.0
      - nodeenv==1.9.1
      - numpy==1.21.6
      - packaging==24.0
      - pandas==1.3.5
      - pathspec==0.11.2
      - pep8-naming==0.14.0
      - platformdirs==2.6.2
      - pluggy==1.2.0
      - pre-commit==2.21.0
      - py==1.11.0
      - pycodestyle==2.9.1
      - pydocstyle==6.3.0
      - pyflakes==2.5.0
      - pygments==2.17.2
      - pylint==2.17.7
      - pyproject-api==1.5.3
      - pytest==7.4.4
      - pytest-cov==4.1.0
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - pyyaml==6.0.1
      - requests==2.31.0
      - six==1.17.0
      - snowballstemmer==2.2.0
      - sphinx==4.3.2
      - sphinx-py3doc-enhanced-theme==2.4.0
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - tomli==2.0.1
      - tomlkit==0.12.5
      - tox==3.28.0
      - typed-ast==1.5.5
      - typing-extensions==4.7.1
      - urllib3==2.0.7
      - virtualenv==20.16.2
      - wrapt==1.16.0
      - zipp==3.15.0
prefix: /opt/conda/envs/dsdk

tests/test_dsdk.py::test_retry_other_exception tests/test_dsdk.py::test_retry_exhausted
tests/test_dsdk.py::test_batch
src/dsdk/utils.py
src/dsdk/utils.py