From 75adf81bb145098d1aea7b63ba247ce40d5a8140 Mon Sep 17 00:00:00 2001 From: Mohitlikestocode Date: Wed, 22 Oct 2025 23:40:31 +0530 Subject: [PATCH] =?UTF-8?q?Fix:=20retry=20decorator=20re-raises=20last=20e?= =?UTF-8?q?xception=20when=20retries=20exhausted=20(#110)=20=E2=80=94=20ad?= =?UTF-8?q?d=20tests=20and=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ memori/utils/helpers.py | 16 +++++++++++++--- tests/test_retry_helpers.py | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 tests/test_retry_helpers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1226a86d..9e93334f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Memori will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### 🐛 Bug Fixes + +- Fixed retry decorator edge case in `memori/utils/helpers.py`: when all retry attempts fail the decorator now re-raises the original (last) exception instead of raising a generic `MemoriError("Retry attempts exhausted")`. This prevents masking of the real error. (Fix #110 — Hacktoberfest) + ## [2.3.0] - 2025-09-29 ### 🚀 **Major Performance Improvements** diff --git a/memori/utils/helpers.py b/memori/utils/helpers.py index 41dc10b8..d1a6812b 100644 --- a/memori/utils/helpers.py +++ b/memori/utils/helpers.py @@ -284,12 +284,15 @@ def decorator(func: Callable[..., T]) -> Callable[..., T]: @functools.wraps(func) def wrapper(*args, **kwargs) -> T: last_exception = None + last_tb = None for attempt in range(max_attempts): try: return func(*args, **kwargs) except exceptions as e: + # capture exception and its traceback so we can re-raise last_exception = e + last_tb = e.__traceback__ if attempt < max_attempts - 1: sleep_time = delay * (backoff**attempt) import time @@ -297,8 +300,10 @@ def wrapper(*args, **kwargs) -> T: time.sleep(sleep_time) continue - # If all attempts failed, raise the last exception - if last_exception: + # If all attempts failed, re-raise the last exception preserving traceback + if last_exception is not None: + if last_tb is not None: + raise last_exception.with_traceback(last_tb) raise last_exception # This shouldn't happen, but just in case @@ -321,18 +326,23 @@ def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]: @functools.wraps(func) async def wrapper(*args, **kwargs) -> T: last_exception = None + last_tb = None for attempt in range(max_attempts): try: return await func(*args, **kwargs) except exceptions as e: + # capture exception and its traceback so we can re-raise last_exception = e + last_tb = e.__traceback__ if attempt < max_attempts - 1: sleep_time = delay * (backoff**attempt) await asyncio.sleep(sleep_time) continue - if last_exception: + if last_exception is not None: + if last_tb is not None: + raise last_exception.with_traceback(last_tb) raise last_exception raise MemoriError("Async retry attempts exhausted") diff --git a/tests/test_retry_helpers.py b/tests/test_retry_helpers.py new file mode 100644 index 00000000..50dfaa16 --- /dev/null +++ b/tests/test_retry_helpers.py @@ -0,0 +1,38 @@ +import pytest +import asyncio + +from memori.utils.helpers import RetryUtils + + +def test_retry_on_exception_reraises_last_exception(): + calls = {"count": 0} + + @RetryUtils.retry_on_exception(max_attempts=3, delay=0.0, backoff=1.0) + def always_fails(): + calls["count"] += 1 + raise ValueError("boom") + + with pytest.raises(ValueError) as excinfo: + always_fails() + + assert "boom" in str(excinfo.value) + assert calls["count"] == 3 + + +@pytest.mark.asyncio +async def test_async_retry_on_exception_reraises_last_exception(): + calls = {"count": 0} + + @awaitable := RetryUtils.async_retry_on_exception(max_attempts=2, delay=0.0, backoff=1.0) + async def always_fails_async(): + calls["count"] += 1 + raise RuntimeError("async boom") + + # The decorator factory returns a decorator, so we need to apply it + decorated = awaitable(always_fails_async) + + with pytest.raises(RuntimeError) as excinfo: + await decorated() + + assert "async boom" in str(excinfo.value) + assert calls["count"] == 2