Repository: celery/billiard

Issue:
ExceptionWithTraceback object has no attribute __cause__
Platform: Windows
Python: 3.8.6
Celery: 5.3.0b1
billiard: 4.0.1

## Description

I have a celery task that raises `RuntimeError("requires pyicat-plus")`. In `celery.app.trace` the exception gets [logged](https://github.com/celery/celery/blob/feaad3f9fdf98d0453a07a68e307e48c6c3c2550/celery/app/trace.py#L262) before sending a `task_failure` signal. This causes an error in the logging module.

## Reason

Because of https://github.com/celery/billiard/pull/368 the exception in `Traceinfo.handle_failure` is no longer an instance of a class derived from `Exception` but it is an instance of `ExceptionWithTraceback` which does not have a `__cause__` attribute.

## Possible solution

`ExceptionWithTraceback` should derive from `Exception` (for local usage like logging) while at the same time keeping its pickling behavior to preserve the remote traceback.

## Traceback

```
  C:\gitlab-runner\builds\L4y3rgSY\0\workflow\ewoks\ewoksjob\build_venv\lib\site-packages\celery\app\trace.py:657: RuntimeWarning: Exception raised outside body: AttributeError("'ExceptionWithTraceback' object has no attribute '__cause__'"):
  Traceback (most recent call last):
    File "C:\gitlab-runner\builds\L4y3rgSY\0\workflow\ewoks\ewoksjob\build_venv\lib\site-packages\celery\app\trace.py", line 448, in trace_task
      R = retval = fun(*args, **kwargs)
    File "C:\gitlab-runner\builds\L4y3rgSY\0\workflow\ewoks\ewoksjob\build_venv\lib\site-packages\ewoksjob\apps\ewoks.py", line 20, in wrapper
      return method(self, *args, **kwargs)
    File "C:\gitlab-runner\builds\L4y3rgSY\0\workflow\ewoks\ewoksjob\build_venv\lib\site-packages\ewoksjob\apps\ewoks.py", line 30, in wrapper
      return method(self, *args, **kwargs)
    File "C:\gitlab-runner\builds\L4y3rgSY\0\workflow\ewoks\ewoksjob\build_venv\lib\site-packages\ewoksjob\apps\ewoks.py", line 59, in execute_and_upload_workflow
      return tasks.execute_and_upload_graph(*args, **kwargs)
    File "C:\gitlab-runner\builds\L4y3rgSY\0\workflow\ewoks\ewoksjob\build_venv\lib\site-packages\ewoksjob\tasks\execute_and_upload.py", line 28, in execute_and_upload_graph
      raise RuntimeError("requires pyicat-plus")
  RuntimeError: requires pyicat-plus
  
  During handling of the above exception, another exception occurred:
  
  Traceback (most recent call last):
    File "C:\gitlab-runner\builds\L4y3rgSY\0\workflow\ewoks\ewoksjob\build_venv\lib\site-packages\celery\app\trace.py", line 465, in trace_task
      I, R, state, retval = on_error(task_request, exc)
    File "C:\gitlab-runner\builds\L4y3rgSY\0\workflow\ewoks\ewoksjob\build_venv\lib\site-packages\celery\app\trace.py", line 376, in on_error
      R = I.handle_error_state(
    File "C:\gitlab-runner\builds\L4y3rgSY\0\workflow\ewoks\ewoksjob\build_venv\lib\site-packages\celery\app\trace.py", line 175, in handle_error_state
      return {
    File "C:\gitlab-runner\builds\L4y3rgSY\0\workflow\ewoks\ewoksjob\build_venv\lib\site-packages\celery\app\trace.py", line 234, in handle_failure
      self._log_error(task, req, einfo)
    File "C:\gitlab-runner\builds\L4y3rgSY\0\workflow\ewoks\ewoksjob\build_venv\lib\site-packages\celery\app\trace.py", line 262, in _log_error
      logger.log(policy.severity, policy.format.strip(), context,
    File "C:\python\3.8\lib\logging\__init__.py", line 1500, in log
      self._log(level, msg, args, **kwargs)
    File "C:\python\3.8\lib\logging\__init__.py", line 1577, in _log
      self.handle(record)
    File "C:\python\3.8\lib\logging\__init__.py", line 1587, in handle
      self.callHandlers(record)
    File "C:\python\3.8\lib\logging\__init__.py", line 1649, in callHandlers
      hdlr.handle(record)
    File "C:\python\3.8\lib\logging\__init__.py", line 950, in handle
      self.emit(record)
    File "C:\gitlab-runner\builds\L4y3rgSY\0\workflow\ewoks\ewoksjob\build_venv\lib\site-packages\_pytest\logging.py", line 342, in emit
      super().emit(record)
    File "C:\python\3.8\lib\logging\__init__.py", line 1089, in emit
      self.handleError(record)
    File "C:\python\3.8\lib\logging\__init__.py", line 1081, in emit
      msg = self.format(record)
    File "C:\python\3.8\lib\logging\__init__.py", line 925, in format
      return fmt.format(record)
    File "C:\gitlab-runner\builds\L4y3rgSY\0\workflow\ewoks\ewoksjob\build_venv\lib\site-packages\_pytest\logging.py", line 113, in format
      return super().format(record)
    File "C:\python\3.8\lib\logging\__init__.py", line 672, in format
      record.exc_text = self.formatException(record.exc_info)
    File "C:\python\3.8\lib\logging\__init__.py", line 622, in formatException
      traceback.print_exception(ei[0], ei[1], tb, None, sio)
    File "C:\python\3.8\lib\traceback.py", line 103, in print_exception
      for line in TracebackException(
    File "C:\python\3.8\lib\traceback.py", line 479, in __init__
      if (exc_value and exc_value.__cause__ is not None
  AttributeError: 'ExceptionWithTraceback' object has no attribute '__cause__'
```

Comments:

Patch:
diff --git a/billiard/einfo.py b/billiard/einfo.py
index de385c0..640ddd8 100644
--- a/billiard/einfo.py
+++ b/billiard/einfo.py
@@ -91,16 +91,24 @@ class Traceback:
 class RemoteTraceback(Exception):
     def __init__(self, tb):
         self.tb = tb
+
     def __str__(self):
         return self.tb
 
-class ExceptionWithTraceback:
+
+class ExceptionWithTraceback(Exception):
     def __init__(self, exc, tb):
         self.exc = exc
         self.tb = '\n"""\n%s"""' % tb
+        super().__init__()
+
+    def __str__(self):
+        return self.tb
+
     def __reduce__(self):
         return rebuild_exc, (self.exc, self.tb)
 
+
 def rebuild_exc(exc, tb):
     exc.__cause__ = RemoteTraceback(tb)
     return exc


Test patch:
diff --git a/t/unit/test_einfo.py b/t/unit/test_einfo.py
new file mode 100644
index 0000000..5d2482d
--- /dev/null
+++ b/t/unit/test_einfo.py
@@ -0,0 +1,28 @@
+import pickle
+import logging
+from billiard.einfo import ExceptionInfo
+
+logger = logging.getLogger(__name__)
+
+
+def test_exception_info_log_before_pickle(caplog):
+    try:
+        raise RuntimeError("some message")
+    except Exception:
+        exception = ExceptionInfo().exception
+
+    logger.exception("failed", exc_info=exception)
+    assert ' raise RuntimeError("some message")' in caplog.text
+    assert "RuntimeError: some message" in caplog.text
+
+
+def test_exception_info_log_after_pickle(caplog):
+    try:
+        raise RuntimeError("some message")
+    except Exception:
+        exception = ExceptionInfo().exception
+        exception = pickle.loads(pickle.dumps(exception))
+
+    logger.exception("failed", exc_info=exception)
+    assert ' raise RuntimeError("some message")' in caplog.text
+    assert "RuntimeError: some message" in caplog.text