diff --git a/mpesakit/retry/__init__.py b/mpesakit/retry/__init__.py new file mode 100644 index 0000000..b2668cd --- /dev/null +++ b/mpesakit/retry/__init__.py @@ -0,0 +1,6 @@ +from retry import retryable_request, post_request + +__all__ = { + "retryable_request", + "post_request" +} diff --git a/mpesakit/retry/retry.py b/mpesakit/retry/retry.py new file mode 100644 index 0000000..6f1a30d --- /dev/null +++ b/mpesakit/retry/retry.py @@ -0,0 +1,55 @@ +# mpesakit/retry.py + +""" +HOW IT WORKS: +____________ + +Decorator Factory: retryable_request() is reusable and configurable. + +Logging: Logs each retry attempt. + +Post request helper: post_request() wraps requests.post for automatic retries. +""" +from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type +import requests +import logging + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +def retryable_request( + stop_attempts: int = 3, + wait_min: int = 1, + wait_max: int = 10, + exception_type=Exception +): + """ + Decorator factory that returns a retry decorator using tenacity. + + Usage: + @retryable_request() + def your_func(...): + ... + """ + return retry( + stop=stop_after_attempt(stop_attempts), + wait=wait_exponential(multiplier=1, min=wait_min, max=wait_max), + retry=retry_if_exception_type(exception_type), + before=lambda retry_state: logger.info( + f"Retrying {retry_state.fn.__name__} after attempt {retry_state.attempt_number}" + ), + after=lambda retry_state: logger.warning( + f"Finished attempt {retry_state.attempt_number} for {retry_state.fn.__name__}" + ), + ) + + +# Example wrapper for requests.post +@retryable_request(exception_type=requests.exceptions.RequestException) +def post_request(url: str, payload: dict, headers: dict = None): + """Send POST request with retry logic.""" + headers = headers or {} + response = requests.post(url, json=payload, headers=headers, timeout=10) + response.raise_for_status() + return response.json() diff --git a/pyproject.toml b/pyproject.toml index e09e0b9..16e5e04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "typing_extensions >= 4.12.2,<5.0.0", "cryptography >=41.0.7", "httpx >=0.27.0,<1.0.0", + "tenacity>=8.2.2" ] [project.urls] diff --git a/tests/integration/retry/__init__.py b/tests/integration/retry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/retry/retry.py b/tests/integration/retry/retry.py new file mode 100644 index 0000000..50a2c95 --- /dev/null +++ b/tests/integration/retry/retry.py @@ -0,0 +1,13 @@ +# tests/test_retry.py +import pytest +from mpesakit.retry.retry import retryable_request +attempt_counter = {"count": 0} + + +@retryable_request(stop_attempts=5, wait_min=0, wait_max=0, exception_type=ValueError) +def sometimes_fails(): +"""Fails deterministically to test retry logic.""" + attempt_counter["count"] += 1 + if attempt_counter["count"] < 5: + raise ValueError("Planned failure") + return "Success"