Dan 483d7e858b Improved Errors - Pick up on the new task_trace information in WorkflowException and pass it on through the api.
Also:  All script tasks should raise WorkflowTaskExecExceptions - NOT APIExceptions - this is because our scripts are executed by Spiff (not the other way around)  so the errors need to pass fluidly through spiff, and come back to use THEN we can convert them to APIErrors.  Otherwise we lose all kinds of good information about the error.
2022-03-14 16:00:53 -04:00

145 lines
5.7 KiB
Python

import json
from SpiffWorkflow import WorkflowException
from SpiffWorkflow.exceptions import WorkflowTaskExecException
from flask import g
from jinja2 import TemplateError
from werkzeug.exceptions import InternalServerError
from crc import ma, app
import sentry_sdk
class ApiError(Exception):
def __init__(self, code, message, status_code=400,
file_name="", task_id="", task_name="", tag="",
task_data=None, error_type="", error_line="", line_number=0, offset=0,
task_trace=[]):
if task_data is None:
task_data = {}
self.status_code = status_code
self.code = code # a short consistent string describing the error.
self.message = message # A detailed message that provides more information.
self.task_id = task_id or "" # OPTIONAL: The id of the task in the BPMN Diagram.
self.task_name = task_name or "" # OPTIONAL: The name of the task in the BPMN Diagram.
self.file_name = file_name or "" # OPTIONAL: The file that caused the error.
self.tag = tag or "" # OPTIONAL: The XML Tag that caused the issue.
self.task_data = task_data or "" # OPTIONAL: A snapshot of data connected to the task when error occurred.
self.line_number = line_number
self.offset = offset
self.error_type = error_type
self.error_line = error_line
self.task_stack = task_trace
try:
user = g.user.uid
except Exception as e:
user = 'Unknown'
self.task_user = user
# This is for sentry logging into Slack
sentry_sdk.set_context("User", {'user': user})
Exception.__init__(self, self.message)
def __str__(self):
msg = "ApiError: % s. " % self.message
if self.task_name:
msg += "Error in task '%s' (%s). " % (self.task_name, self.task_id)
if self.line_number:
msg += "Error is on line %i. " % self.line_number
if self.file_name:
msg += "In file %s. " % self.file_name
return msg
@classmethod
def from_task(cls, code, message, task, status_code=400, line_number=0, offset=0, error_type="", error_line="",
task_trace=None):
"""Constructs an API Error with details pulled from the current task."""
instance = cls(code, message, status_code=status_code)
instance.task_id = task.task_spec.name or ""
instance.task_name = task.task_spec.description or ""
instance.file_name = task.workflow.spec.file or ""
instance.line_number = line_number
instance.offset = offset
instance.error_type = error_type
instance.error_line = error_line
if task_trace:
instance.task_trace = task_trace
else:
instance.task_trace = WorkflowTaskExecException.get_task_trace(task)
# Fixme: spiffworkflow is doing something weird where task ends up referenced in the data in some cases.
if "task" in task.data:
task.data.pop("task")
# Assure that there is nothing in the json data that can't be serialized.
instance.task_data = ApiError.remove_unserializeable_from_dict(task.data)
app.logger.error(message, exc_info=True)
return instance
@staticmethod
def remove_unserializeable_from_dict(my_dict):
keys_to_delete = []
for key, value in my_dict.items():
if not ApiError.is_jsonable(value):
keys_to_delete.append(key)
for key in keys_to_delete:
del my_dict[key]
return my_dict
@staticmethod
def is_jsonable(x):
try:
json.dumps(x)
return True
except (TypeError, OverflowError, ValueError):
return False
@classmethod
def from_task_spec(cls, code, message, task_spec, status_code=400):
"""Constructs an API Error with details pulled from the current task."""
instance = cls(code, message, status_code=status_code)
instance.task_id = task_spec.name or ""
instance.task_name = task_spec.description or ""
if task_spec._wf_spec:
instance.file_name = task_spec._wf_spec.file
app.logger.error(message, exc_info=True)
return instance
@classmethod
def from_workflow_exception(cls, code, message, exp: WorkflowException):
"""We catch a lot of workflow exception errors,
so consolidating the code, and doing the best things
we can with the data we have."""
if isinstance(exp, WorkflowTaskExecException):
return ApiError.from_task(code, message, exp.task, line_number=exp.line_number,
offset=exp.offset,
error_type=exp.exception.__class__.__name__,
error_line=exp.error_line,
task_trace=exp.task_trace)
else:
return ApiError.from_task_spec(code, message, exp.sender)
class ApiErrorSchema(ma.Schema):
class Meta:
fields = ("code", "message", "workflow_name", "file_name", "task_name", "task_id",
"task_data", "task_user", "hint", "line_number", "offset", "error_type",
"error_line", "task_trace")
@app.errorhandler(ApiError)
def handle_invalid_usage(error):
response = ApiErrorSchema().dump(error)
return response, error.status_code
@app.errorhandler(InternalServerError)
def handle_internal_server_error(e):
original = getattr(e, "original_exception", None)
api_error = ApiError(code='Internal Server Error (500)', message=str(original))
response = ApiErrorSchema().dump(api_error)
return response, 500