diff --git a/app/packages/components/src/components/ErrorBoundary/ErrorBoundary.tsx b/app/packages/components/src/components/ErrorBoundary/ErrorBoundary.tsx index 5afa06d4027..e384d1516db 100644 --- a/app/packages/components/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/app/packages/components/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -73,13 +73,16 @@ export const ErrorDisplayMarkup = ({ content: JSON.stringify(error.payload, null, 2), }); } else if (error instanceof OperatorError) { + if (error.message) { + messages.push({ message: "Message", content: error.message }); + } if (error.operator) { messages.push({ message: "Operator", content: error.operator }); } if (error instanceof PanelEventError) { messages.push({ message: "Event", content: error.event }); } - messages.push({ message: error.message, content: error.stack }); + messages.push({ message: "Trace", content: error.stack }); } if (error.stack && !(error instanceof OperatorError)) { messages = [...messages, { message: "Trace", content: error.stack }]; diff --git a/app/packages/operators/src/components/OperatorPromptOutput.tsx b/app/packages/operators/src/components/OperatorPromptOutput.tsx index 178bb2ab011..d013230f07e 100644 --- a/app/packages/operators/src/components/OperatorPromptOutput.tsx +++ b/app/packages/operators/src/components/OperatorPromptOutput.tsx @@ -7,8 +7,13 @@ export default function OperatorPromptOutput({ operatorPrompt, outputFields }) { const executorError = operatorPrompt?.executorError; const resolveError = operatorPrompt?.resolveError; const error = resolveError || executorError; + if (!outputFields && !executorError && !resolveError) return null; + const { result } = operatorPrompt.executor; + const defaultMsg = "Error occurred during operator execution"; + const message = error?.bodyResponse?.error; + const reason = message ? defaultMsg + ". " + message : defaultMsg; return ( @@ -24,7 +29,7 @@ export default function OperatorPromptOutput({ operatorPrompt, outputFields }) { schema={{ view: { detailed: true } }} data={[ { - reason: "Error occurred during operator execution", + reason, details: stringifyError(error), }, ]} diff --git a/fiftyone/core/utils.py b/fiftyone/core/utils.py index 60a5aeda65c..348fe303649 100644 --- a/fiftyone/core/utils.py +++ b/fiftyone/core/utils.py @@ -15,6 +15,8 @@ from copy import deepcopy from datetime import date, datetime from functools import partial +from starlette.responses import Response +from json import JSONEncoder import glob import hashlib import importlib @@ -3192,4 +3194,25 @@ def validate_hex_color(value): ) +class Encoder(JSONEncoder): + """Custom JSON encoder that handles numpy types.""" + + def default(self, o): + if isinstance(o, np.floating): + return float(o) + + if isinstance(o, np.integer): + return int(o) + + return JSONEncoder.default(self, o) + + +async def create_response(response: dict): + """Creates a JSON response from the given dictionary.""" + return Response( + await run_sync_task(lambda: json_util.dumps(response, cls=Encoder)), + headers={"Content-Type": "application/json"}, + ) + + fos = lazy_import("fiftyone.core.storage") diff --git a/fiftyone/operators/server.py b/fiftyone/operators/server.py index 1334cd5ce28..caaaba8dc35 100644 --- a/fiftyone/operators/server.py +++ b/fiftyone/operators/server.py @@ -22,7 +22,7 @@ ) from .message import GeneratedMessage from .permissions import PermissionedOperatorRegistry -from .utils import is_method_overridden +from .utils import is_method_overridden, create_operator_response from .operator import Operator @@ -107,7 +107,7 @@ async def post(self, request: Request, data: dict) -> dict: raise HTTPException(status_code=404, detail=error_detail) result = await execute_or_delegate_operator(operator_uri, data) - return result.to_json() + return await create_operator_response(result) def create_response_generator(generator): diff --git a/fiftyone/operators/utils.py b/fiftyone/operators/utils.py index cfbada1aea7..0941b907bef 100644 --- a/fiftyone/operators/utils.py +++ b/fiftyone/operators/utils.py @@ -6,10 +6,17 @@ | """ -from datetime import datetime, timedelta import logging +import traceback + +from datetime import datetime, timedelta +from typing import Union -from .operator import Operator +from fiftyone.core.utils import create_response +from starlette.exceptions import HTTPException +from starlette.responses import JSONResponse, Response + +from .executor import ExecutionResult class ProgressHandler(logging.Handler): @@ -107,3 +114,43 @@ def is_new(release_date, days=30): raise ValueError("release_date must be a string or datetime object") return (datetime.now() - release_date).days <= days + + +async def create_operator_response( + result: ExecutionResult, +) -> Union[Response, JSONResponse]: + """ + Creates a :class:`starlette.responses.JSONResponse` from the given + :class:`fiftyone.operators.executor.ExecutionResult` or returns a + server error :class:`starlette.responses.JSONResponse` if serialization fails. + + Args: + result: the operator execution result + + Returns: + :class:`starlette.responses.Response` or + :class:`starlette.responses.JSONResponse` + """ + try: + return await create_response(result.to_json()) + except Exception as e: + # Immediately re-raise starlette HTTP exceptions + if isinstance(e, HTTPException): + raise e + + # Cast non-starlette HTTP exceptions as JSON with 500 status code + logging.exception(e) + + msg = ( + f"Failed to serialize operator result. {e}." + + " Make sure that the return value of the operation is JSON-serializable." + ) + return JSONResponse( + { + "kind": "Server Error", + "error": msg, + "message": msg, + "stack": traceback.format_exc(), + }, + status_code=500, + ) diff --git a/fiftyone/server/decorators.py b/fiftyone/server/decorators.py index f85a1a97813..bb25fe71006 100644 --- a/fiftyone/server/decorators.py +++ b/fiftyone/server/decorators.py @@ -6,40 +6,17 @@ | """ -from json import JSONEncoder import traceback import typing as t import logging from bson import json_util -import numpy as np from starlette.endpoints import HTTPEndpoint from starlette.exceptions import HTTPException from starlette.responses import JSONResponse, Response from starlette.requests import Request -from fiftyone.core.utils import run_sync_task - - -class Encoder(JSONEncoder): - """Custom JSON encoder that handles numpy types.""" - - def default(self, o): - if isinstance(o, np.floating): - return float(o) - - if isinstance(o, np.integer): - return int(o) - - return JSONEncoder.default(self, o) - - -async def create_response(response: dict): - """Creates a JSON response from the given dictionary.""" - return Response( - await run_sync_task(lambda: json_util.dumps(response, cls=Encoder)), - headers={"Content-Type": "application/json"}, - ) +from fiftyone.core.utils import create_response def route(func):