Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
16 changes: 13 additions & 3 deletions memori/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,21 +284,26 @@ 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

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
Expand All @@ -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")
Expand Down
38 changes: 38 additions & 0 deletions tests/test_retry_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import pytest
import asyncio

Check notice

Code scanning / CodeQL

Unused import Note test

Import of 'asyncio' is not used.

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
Loading