From 8d0c04bede5a9e1bfe1559d8f1c714e218e263a7 Mon Sep 17 00:00:00 2001 From: MemOS AutoDev Date: Thu, 4 Jun 2026 19:59:18 +0800 Subject: [PATCH] fix: re-raise exceptions in timed_with_status when no fallback configured Fixes #1523 The timed_with_status decorator was catching all exceptions but silently swallowing them when no fallback was provided, causing decorated functions to return None instead of propagating errors. This masked real errors - for example, when OpenAILLM.generate() received a BadRequestError from the LLM, it returned None, causing downstream code to crash with AttributeError instead of showing the real error. Changes: - Added explicit 'raise' after fallback branch in exception handler - Preserves existing fallback behavior when configured - Makes no-fallback path fail-fast as intended Test coverage: - Added tests/test_utils_timing_exception_reraise.py with 4 test cases - Verified exception re-raising, type preservation, and fallback compatibility - Existing test coverage for fallback behavior remains unchanged --- src/memos/utils.py | 1 + tests/test_utils_timing_exception_reraise.py | 59 ++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/test_utils_timing_exception_reraise.py diff --git a/src/memos/utils.py b/src/memos/utils.py index f7111f8ad..fd639d07f 100644 --- a/src/memos/utils.py +++ b/src/memos/utils.py @@ -179,6 +179,7 @@ def wrapper(*args, **kwargs): if fallback is not None and callable(fallback): result = fallback(e, *args, **kwargs) return result + raise finally: elapsed_ms = (time.perf_counter() - start) * 1000.0 diff --git a/tests/test_utils_timing_exception_reraise.py b/tests/test_utils_timing_exception_reraise.py new file mode 100644 index 000000000..3b6abc07c --- /dev/null +++ b/tests/test_utils_timing_exception_reraise.py @@ -0,0 +1,59 @@ +"""Test that timed_with_status re-raises exceptions when no fallback is configured. + +Regression test for issue #1523: timed_with_status decorator silently swallowed +exceptions and returned None when no fallback was provided, masking real errors. +""" + +import pytest + +from memos.utils import timed_with_status + + +class TestTimedWithStatusExceptionReraise: + """Verify that exceptions are re-raised when no fallback is configured.""" + + def test_exception_reraised_when_no_fallback(self): + """When no fallback is configured, exceptions should propagate to caller.""" + + @timed_with_status(log_prefix="test_func") + def failing_func(): + raise ValueError("upstream error") + + with pytest.raises(ValueError, match="upstream error"): + failing_func() + + def test_exception_reraised_preserves_type(self): + """The re-raised exception should preserve its original type.""" + + class CustomError(Exception): + pass + + @timed_with_status() + def custom_error_func(): + raise CustomError("specific error") + + with pytest.raises(CustomError, match="specific error"): + custom_error_func() + + def test_fallback_still_works(self): + """When fallback is provided, it should still be called instead of re-raising.""" + + @timed_with_status(fallback=lambda exc, *args, **kwargs: "fallback_result") + def failing_with_fallback(): + raise RuntimeError("error") + + result = failing_with_fallback() + assert result == "fallback_result" + + def test_no_implicit_none_return(self): + """Decorated function should never return None on exception without fallback.""" + + @timed_with_status() + def fail_and_return(): + raise KeyError("missing key") + + # Should raise, not return None + with pytest.raises(KeyError): + result = fail_and_return() + # This line should never execute + assert result is not None, "Function returned None instead of raising"