Repository: scrapinghub/spidermon

Issue:
feature: Fallback for actions
This was an idea proposed by Chandral from Zyte to address Sentry downtimes, as important Spidermon alerts might get lost while this happens.

Multiple alert actions can be configured in Spidermon as a workaround to prevent messages from getting lost if one of the services is down, but receiving duplicated alerts would generate a lot of noise. Ideally, we'd like to use a backup alert system as a fallback only if the main alert system didn't work.

/cc @rennerocha @VMRuiz 

Discussion:

Patch:
diff --git a/docs/source/actions/custom-action.rst b/docs/source/actions/custom-action.rst
index b1401f2..81b81b9 100644
--- a/docs/source/actions/custom-action.rst
+++ b/docs/source/actions/custom-action.rst
@@ -13,3 +13,41 @@ the `run_action` method.
         def run_action(self):
             # Include here the logic of your action
             # (...)
+
+
+Fallback Actions
+================
+
+When creating your own custom actions, you can also add a fallback action to run if
+an action throws an unhandled exception. To do this, add a fallback attribute to
+your custom action.
+
+.. code-block:: python
+
+    from spidermon.core.actions import Action
+
+    class MyFallbackAction(Action):
+        def run_action(self):
+            # Include here the logic of your action
+            # Runs if MyCustomAction().run_action() throws an unhandled exception
+            # (...)
+
+    class MyCustomAction(Action):
+        fallback = MyFallbackAction
+        def run_action(self):
+            # Include here the logic of your action
+            # (...)
+
+
+You can also add fallbacks to spidermon built-in actions by subclassing them. For
+example, send an email if a slack message could not be sent.
+
+.. code-block:: python
+
+    from spidermon.core.actions import Action
+    from spidermon.contrib.actions import Slack
+    from spidermon.contrib.actions.email.smtp import SendSmtpEmail
+
+    class MyCustomSlackAction(Slack):
+        fallback = SendSmtpEmail
+
diff --git a/spidermon/core/actions.py b/spidermon/core/actions.py
index a9ae690..c7a0441 100644
--- a/spidermon/core/actions.py
+++ b/spidermon/core/actions.py
@@ -11,9 +11,13 @@ class Action(metaclass=ActionOptionsMetaclass):
     Base class for actions.
     """
 
+    fallback = None
+
     def __init__(self):
         self.result = None
         self.data = None
+        if self.fallback is not None:
+            self.fallback = self.fallback()
 
     @classmethod
     def from_crawler(cls, crawler):
@@ -37,6 +41,8 @@ class Action(metaclass=ActionOptionsMetaclass):
             result.add_action_skip(self, e.args[0])
         except:
             result.add_action_error(self, traceback.format_exc())
+            if self.fallback is not None:
+                self.fallback.run(self.result, self.data)
         else:
             result.add_action_success(self)
         data.meta.update(self.get_meta())


Test patch:
diff --git a/tests/test_actions.py b/tests/test_actions.py
new file mode 100644
index 0000000..b2577e5
--- /dev/null
+++ b/tests/test_actions.py
@@ -0,0 +1,94 @@
+from spidermon.core.actions import Action
+from spidermon.exceptions import NotConfigured, SkipAction
+from unittest.mock import MagicMock
+
+
+def test_action_success():
+    class TestAction(Action):
+        def run_action(self):
+            pass
+
+    result_mock = MagicMock()
+    action = TestAction()
+
+    action.run(result_mock, MagicMock())
+
+    result_mock.add_action_success.assert_called()
+
+
+def test_action_fail():
+    class TestAction(Action):
+        def run_action(self):
+            raise Exception
+
+    result_mock = MagicMock()
+    action = TestAction()
+
+    action.run(result_mock, MagicMock())
+
+    result_mock.add_action_error.assert_called()
+    result_mock.add_action_success.assert_not_called()
+
+
+def test_action_skip():
+    class TestAction(Action):
+        def run_action(self):
+            raise SkipAction("Test")
+
+    result_mock = MagicMock()
+    action = TestAction()
+
+    action.run(result_mock, MagicMock())
+
+    result_mock.add_action_skip.assert_called()
+
+
+def test_fallback_action():
+    fallback_mock = MagicMock()
+
+    class TestAction(Action):
+        fallback = fallback_mock
+
+        def run_action(self):
+            raise Exception
+
+    action = TestAction()
+    action.run(MagicMock(), MagicMock())
+
+    fallback_mock.assert_called()
+    fallback_mock().run.assert_called()
+
+
+def test_fallback_skip_action():
+    # fallback not called for SkipAction exception
+    fallback_mock = MagicMock()
+
+    class TestAction(Action):
+        fallback = fallback_mock
+
+        def run_action(self):
+            raise SkipAction("Test")
+
+    action = TestAction()
+
+    action.run(MagicMock(), MagicMock())
+    fallback_mock().run.assert_not_called()
+
+
+def test_fallback_not_configured():
+    # raises not configured error for unconfigured fallback actions
+    fallback_mock = MagicMock()
+    fallback_mock.side_effect = NotConfigured
+
+    class TestAction(Action):
+        fallback = fallback_mock
+
+        def run_action(self):
+            pass
+
+    try:
+        TestAction()
+    except NotConfigured:
+        assert True
+    else:
+        assert False