Repository: Textualize/rich

Issue:
[BUG] Rich enters infinite loop in certain cases when printing exceptions
- [x] I've checked [docs](https://rich.readthedocs.io/en/latest/introduction.html) and [closed issues](https://github.com/Textualize/rich/issues?q=is%3Aissue+is%3Aclosed) for possible solutions.
- [x] I can't find my issue in the [FAQ](https://github.com/Textualize/rich/blob/master/FAQ.md).

**Describe the bug**

`console.print_exception` will enter an infinite loop when `exc.__cause__ is exc`. This can happen if we `raise exc from exc`. Here is the minimal example. Python enters infinite loop eating all resources until killed.

```python
from rich.console import Console

console = Console()


def foo() -> None:
    try:
        raise RuntimeError("Hello")
    except Exception as e:
        raise e from e

def bar():
    try:
        foo()
    except Exception as e:
        assert e is e.__cause__
        console.print_exception(show_locals=True)

bar()
```

I suspect the culprit is here:

https://github.com/Textualize/rich/blob/0c6c75644f80530de219dae3e94f0aeb999f9b4c/rich/traceback.py#L564-L572

I image the patch to look smth like this, but I am not familiar with the codebase. I am not sure if the same can happen with the `__context__` attribute a few lines below the snippet above.

```diff
            cause = getattr(exc_value, "__cause__", None)
-            if cause:
+            if cause and cause is not exc_value:
                exc_type = cause.__class__
                exc_value = cause
                # __traceback__ can be None, e.g. for exceptions raised by the
                # 'multiprocessing' module
                traceback = cause.__traceback__
                is_cause = True
                continue
```

Now I know that `raise exc from exc` does not make sense and should just be `raise exc`. But nevertheless, Rich should handle this case as well and not loop forever until it eats the whole RAM. Some python libraries unfortunately use `raise exc from exc` and fixing all of them is not a viable option.

I am using the latest release of Rich with Python 3.13.3

Hints from issue comments:
@github-actions: Thank you for your issue. Give us a little time to review it.

PS. You might want to check the [FAQ](https://github.com/textualize/rich/blob/master/FAQ.md) if you haven't done so already.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)@willmcgugan: Out of interest, why would you use `raise e from e` ?@chovanecadam: I wouldn't, but a library I use does. I created a MR to fix that in said library, but I still think it is worthwhile fixing here too. `raise e from e` is syntactically correct after all.@willmcgugan: I wasn't proposing not fixing it.@github-actions: I hope we solved your problem.

If you like using Rich, you might also enjoy [Textual](https://textual.textualize.io)

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb147695..490857b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Removed `typing_extensions` from runtime dependencies https://github.com/Textualize/rich/pull/3763
 - Live objects (including Progress) may now be nested https://github.com/Textualize/rich/pull/3768
 
+### Fixed
+
+- Fixed extraction of recursive exceptions https://github.com/Textualize/rich/pull/3772
+
 ## [14.0.0] - 2025-03-30
 
 ### Added
diff --git a/rich/traceback.py b/rich/traceback.py
index b2cc6304..25d399a7 100644
--- a/rich/traceback.py
+++ b/rich/traceback.py
@@ -14,6 +14,7 @@ from typing import (
     List,
     Optional,
     Sequence,
+    Set,
     Tuple,
     Type,
     Union,
@@ -418,6 +419,7 @@ class Traceback:
         locals_max_string: int = LOCALS_MAX_STRING,
         locals_hide_dunder: bool = True,
         locals_hide_sunder: bool = False,
+        _visited_exceptions: Optional[Set[BaseException]] = None,
     ) -> Trace:
         """Extract traceback information.
 
@@ -443,6 +445,10 @@ class Traceback:
 
         notes: List[str] = getattr(exc_value, "__notes__", None) or []
 
+        grouped_exceptions: Set[BaseException] = (
+            set() if _visited_exceptions is None else _visited_exceptions
+        )
+
         def safe_str(_object: Any) -> str:
             """Don't allow exceptions from __str__ to propagate."""
             try:
@@ -462,6 +468,9 @@ class Traceback:
                 if isinstance(exc_value, (BaseExceptionGroup, ExceptionGroup)):
                     stack.is_group = True
                     for exception in exc_value.exceptions:
+                        if exception in grouped_exceptions:
+                            continue
+                        grouped_exceptions.add(exception)
                         stack.exceptions.append(
                             Traceback.extract(
                                 type(exception),
@@ -471,6 +480,7 @@ class Traceback:
                                 locals_max_length=locals_max_length,
                                 locals_hide_dunder=locals_hide_dunder,
                                 locals_hide_sunder=locals_hide_sunder,
+                                _visited_exceptions=grouped_exceptions,
                             )
                         )
 
@@ -561,23 +571,26 @@ class Traceback:
                 if frame_summary.f_locals.get("_rich_traceback_guard", False):
                     del stack.frames[:]
 
-            cause = getattr(exc_value, "__cause__", None)
-            if cause:
-                exc_type = cause.__class__
-                exc_value = cause
-                # __traceback__ can be None, e.g. for exceptions raised by the
-                # 'multiprocessing' module
-                traceback = cause.__traceback__
-                is_cause = True
-                continue
+            if not grouped_exceptions:
+                cause = getattr(exc_value, "__cause__", None)
+                if cause is not None and cause is not exc_value:
+                    exc_type = cause.__class__
+                    exc_value = cause
+                    # __traceback__ can be None, e.g. for exceptions raised by the
+                    # 'multiprocessing' module
+                    traceback = cause.__traceback__
+                    is_cause = True
+                    continue
 
-            cause = exc_value.__context__
-            if cause and not getattr(exc_value, "__suppress_context__", False):
-                exc_type = cause.__class__
-                exc_value = cause
-                traceback = cause.__traceback__
-                is_cause = False
-                continue
+                cause = exc_value.__context__
+                if cause is not None and not getattr(
+                    exc_value, "__suppress_context__", False
+                ):
+                    exc_type = cause.__class__
+                    exc_value = cause
+                    traceback = cause.__traceback__
+                    is_cause = False
+                    continue
             # No cover, code is reached but coverage doesn't recognize it.
             break  # pragma: no cover
 


Test patch:
diff --git a/tests/test_traceback.py b/tests/test_traceback.py
index bc9bc91e..bcae6920 100644
--- a/tests/test_traceback.py
+++ b/tests/test_traceback.py
@@ -373,3 +373,27 @@ def test_notes() -> None:
         traceback = Traceback()
 
         assert traceback.trace.stacks[0].notes == ["Hello", "World"]
+
+
+def test_recursive_exception() -> None:
+    """Regression test for https://github.com/Textualize/rich/issues/3708
+
+    Test this doesn't create an infinite loop.
+
+    """
+    console = Console()
+
+    def foo() -> None:
+        try:
+            raise RuntimeError("Hello")
+        except Exception as e:
+            raise e from e
+
+    def bar() -> None:
+        try:
+            foo()
+        except Exception as e:
+            assert e is e.__cause__
+            console.print_exception(show_locals=True)
+
+    bar()