Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,16 @@ export const ErrorDisplayMarkup = <T extends AppError>({
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 }];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Box p={2}>
Expand All @@ -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),
},
]}
Expand Down
23 changes: 23 additions & 0 deletions fiftyone/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3192,4 +3194,25 @@ def validate_hex_color(value):
)


class Encoder(JSONEncoder):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it make sense to move this out of server/? seems very server specific

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved it out to core.utils as it is now also used by create_operator_response

"""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)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haven't thought it through completely, but why wouldn't we just handle the serialization here? eg if isinstance(o, DatasetView): return o._serialize()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yah, I consider customizing but I think we should leave serialization of complex values to be case-by-case. For example, what if they have view in a nested object or what if another complex class is returned

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this work is release blocking, it's helpful to keep the change as minimal and safe as possible

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")
4 changes: 2 additions & 2 deletions fiftyone/operators/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down
51 changes: 49 additions & 2 deletions fiftyone/operators/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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."
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would users know that dataset and view are not serializable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessarily, but with stack trace and this message, it should give them pointers

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is enough for the release. Always room to improve moving forward.

)
return JSONResponse(
{
"kind": "Server Error",
"error": msg,
"message": msg,
"stack": traceback.format_exc(),
},
status_code=500,
)
25 changes: 1 addition & 24 deletions fiftyone/server/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading