Repository: robocorp/rpaframework

Issue:
Failure during work item processing should report correct exception type 
If some unexpected exception during work item processing is raised and the execution fails, Control Room will report the work item as failed with the type `ORCHESTRATOR`, which is technically incorrect. It's should be `APPLICATION`, but the orchestrator has no way of knowing that.

Our library should have a catch-all when the execution fails and it should release the work item with the correct type. It's also an opportunity to propagate the error message from the exception.


Discussion:

Resolving patch:
diff --git a/packages/main/src/RPA/Robocorp/WorkItems.py b/packages/main/src/RPA/Robocorp/WorkItems.py
--- a/packages/main/src/RPA/Robocorp/WorkItems.py
+++ b/packages/main/src/RPA/Robocorp/WorkItems.py
@@ -9,15 +9,15 @@
 from pathlib import Path
 from shutil import copy2
 from threading import Event
-from typing import Callable, Type, Any, Optional, Union, Dict, List, Tuple
+from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
 
 import yaml
-from robot.api.deco import library, keyword
+from robot.api.deco import keyword, library
 from robot.libraries.BuiltIn import BuiltIn
+
 from RPA.core.helpers import import_by_name, required_env
 from RPA.core.logger import deprecation
 from RPA.core.notebook import notebook_print
-
 from RPA.Email.ImapSmtp import ImapSmtp
 from RPA.FileSystem import FileSystem
 from RPA.Robocorp.utils import (
@@ -230,7 +230,8 @@ def release_input(
                 if value is None:
                     del exception[key]
             body["exception"] = exception
-        logging.info(
+        log_func = logging.error if state == State.FAILED else logging.info
+        log_func(
             "Releasing %s input work item %r into %r with exception: %s",
             state.value,
             item_id,
@@ -399,8 +400,9 @@ def reserve_input(self) -> str:
     def release_input(
         self, item_id: str, state: State, exception: Optional[dict] = None
     ):
-        # Nothing happens for now on releasing local dev input work items.
-        logging.info(
+        # Nothing happens for now on releasing local dev input Work Items.
+        log_func = logging.error if state == State.FAILED else logging.info
+        log_func(
             "Releasing item %r with %s state and exception: %s",
             item_id,
             state.value,
@@ -1070,21 +1072,37 @@ def _parse_work_item_from_email(self):
             # pylint: disable=undefined-loop-variable
             self.set_work_item_variable(keys[0], parsed_email)
 
-    def _start_suite(self, data, result):
+    def _start_suite(self, *_):
         """Robot Framework listener method, called when suite starts."""
-        # pylint: disable=unused-argument, broad-except
         if not self.autoload:
             return
 
         try:
             self.get_input_work_item()
+        # pylint: disable=broad-except
         except Exception as exc:
             logging.warning("Failed to load input work item: %s", exc)
         finally:
             self.autoload = False
 
-    def _end_suite(self, data, result):
-        """Robot Framework listener method, called when suite ends."""
+    def _release_on_failure(self, attributes):
+        """Automatically releases current input Work Item when encountering failures
+        with tasks and/or suites.
+        """
+        if attributes["status"] != "FAIL":
+            return
+
+        message = attributes["message"]
+        logging.info("Releasing FAILED input item with APPLICATION error: %s", message)
+        self.release_input_work_item(
+            state=State.FAILED,
+            exception_type=Error.APPLICATION,
+            message=message,
+            _auto_release=True,
+        )
+
+    def _end_suite(self, _, attributes):
+        """Robot Framework listener method, called when the suite ends."""
         # pylint: disable=unused-argument
         for item in self.inputs + self.outputs:
             if item.is_dirty:
@@ -1092,6 +1110,12 @@ def _end_suite(self, data, result):
                     "%s has unsaved changes that will be discarded", self.current
                 )
 
+        self._release_on_failure(attributes)
+
+    def _end_test(self, _, attributes):
+        """Robot Framework listener method, called when each task ends."""
+        self._release_on_failure(attributes)
+
     @keyword
     def set_current_work_item(self, item: WorkItem):
         # pylint: disable=anomalous-backslash-in-string
@@ -1785,10 +1809,10 @@ def log_payloads():
     def release_input_work_item(
         self,
         state: State,
-        _auto_release: bool = False,
         exception_type: Optional[Error] = None,
         code: Optional[str] = None,
         message: Optional[str] = None,
+        _auto_release: bool = False,
     ):
         """Release the lastly retrieved input work item and set its state.
 


Test patch:
diff --git a/packages/main/tests/python/test_robocorp_workitems.py b/packages/main/tests/python/test_robocorp_workitems.py
--- a/packages/main/tests/python/test_robocorp_workitems.py
+++ b/packages/main/tests/python/test_robocorp_workitems.py
@@ -4,19 +4,21 @@
 import os
 import tempfile
 from contextlib import contextmanager
+from pathlib import Path
+from unittest import mock
 
 try:
     from contextlib import nullcontext
 except ImportError:
     from contextlib import suppress as nullcontext
-from pathlib import Path
-from unittest import mock
 
 import pytest
 from requests import HTTPError
+
+from RPA.Robocorp.utils import DEBUG_ON, RequestsHTTPError, set_dot_value
 from RPA.Robocorp.WorkItems import (
-    BaseAdapter,
     ENCODING,
+    BaseAdapter,
     EmptyQueue,
     Error,
     FileAdapter,
@@ -24,11 +26,9 @@
     State,
     WorkItems,
 )
-from RPA.Robocorp.utils import DEBUG_ON, RequestsHTTPError, set_dot_value
 
 from . import RESOURCES_DIR, RESULTS_DIR
 
-
 VARIABLES_FIRST = {"username": "testguy", "address": "guy@company.com"}
 VARIABLES_SECOND = {"username": "another", "address": "dude@company.com"}
 IN_OUT_ID = "workitem-id-out"
@@ -47,6 +47,7 @@
     IN_OUT_ID: {},
 }
 ITEMS_JSON = [{"payload": {"a-key": "a-value"}, "files": {"a-file": "file.txt"}}]
+FAILURE_ATTRIBUTES = {"status": "FAIL", "message": "The task/suite has failed"}
 
 OUTPUT_DIR = RESULTS_DIR / "output_dir"
 OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
@@ -205,6 +206,19 @@ def test_autoload_disable(self, adapter):
         library._start_suite(None, None)
         assert library._current is None
 
+    @pytest.mark.parametrize("end_hook", ["_end_test", "_end_suite"])
+    def test_autorelease(self, library, end_hook):
+        library.get_input_work_item()
+        end_method = getattr(library, end_hook)
+        end_method("My Failure Name", FAILURE_ATTRIBUTES)
+        releases = library.adapter.releases
+        assert len(releases) == 1
+        assert releases[0][2] == {
+            "type": "APPLICATION",
+            "code": None,
+            "message": "The task/suite has failed",
+        }
+
     def test_keyword_get_input_work_item(self, library):
         first = library.get_input_work_item()
         assert first.payload == VARIABLES_FIRST
diff --git a/packages/main/tests/robot/test_robocorp_workitems.robot b/packages/main/tests/robot/test_robocorp_workitems.robot
--- a/packages/main/tests/robot/test_robocorp_workitems.robot
+++ b/packages/main/tests/robot/test_robocorp_workitems.robot
@@ -1,9 +1,10 @@
 *** Settings ***
-Task Setup     Load mock library
 Library        RPA.Email.ImapSmtp
 Library        RPA.FileSystem
 Library        OperatingSystem      WITH NAME   OS
 
+Task Setup     Load mock library
+
 
 *** Variables ***
 ${RESOURCES}    ${CURDIR}/../resources/work-items
@@ -34,6 +35,7 @@ Load mock library
         Set Global Variable     ${first_item}   ${item}
     END
 
+
 Log Payload
     ${payload} =     Get Work Item Payload
     Log    Payload: ${payload}
@@ -55,6 +57,7 @@ Read input and write output
 
     [Teardown]  OS.Remove file     ${temp_out}
 
+
 Explicit state set
     ${payload} =     Get Work Item Payload
     Log     ${payload}
@@ -67,6 +70,7 @@ Explicit state set
     Run Keyword And Expect Error    ${err_state_set}        Create Output Work Item
     Run Keyword And Expect Error    ${err_item_released}    Release Input Work Item     DONE
 
+
 Create output work item with variables and files
     Get Input Work Item  # output gets created over a non-released input
 
@@ -86,16 +90,19 @@ Create output work item with variables and files
     ${obtained_content} =   Read File    ${path}
     Should Be Equal     ${obtained_content}      ${content}
 
+
 Consume queue
     @{results} =     For Each Input Work Item    Log Payload    items_limit=1
     Log   Items keys length: @{results}
     Length should be    ${results}  1
 
+
 Failed release with exception
     Get Input Work Item
     Run Keyword And Expect Error    ${err_fail_without_type}    Release Input Work Item     FAILED      code=LOGIN_PORTAL_DOWN
     Release Input Work Item     FAILED      exception_type=BUSINESS   code=LOGIN_PORTAL_DOWN     message=Unable to login into the portal – not proceeding
 
+
 Consume queue with and without results
     # Since the first item might be released already or not.
     Run Keyword And Ignore Error    Release Input Work Item     DONE
@@ -107,6 +114,7 @@ Consume queue with and without results
     ${results} =     For Each Input Work Item    Log Payload    return_results=${False}
     Should Be Equal     ${results}      ${None}
 
+
 Get payload given e-mail process triggering
     ${parsed_email} =    Get Work Item Variable    parsedEmail
     Set Work Item Variables    &{parsed_email}[Body]