diff --git a/api/core/plugin/impl/base.py b/api/core/plugin/impl/base.py index 6977f643859..755c7362428 100644 --- a/api/core/plugin/impl/base.py +++ b/api/core/plugin/impl/base.py @@ -1,7 +1,7 @@ import inspect import json import logging -from collections.abc import Callable, Generator +from collections.abc import Callable, Generator, Mapping from typing import Any, cast from urllib.parse import unquote @@ -23,6 +23,7 @@ from core.plugin.impl.exc import ( PluginLLMPollingUnsupportedError, PluginNotFoundError, PluginPermissionDeniedError, + PluginRuntimeError, PluginUniqueIdentifierError, ) from core.trigger.errors import ( @@ -375,6 +376,18 @@ class BasePluginClient: # type `PluginLLMPollingUnsupportedError`. case PluginLLMPollingUnsupportedError.__name__: raise PluginLLMPollingUnsupportedError(description=error_object.get("message")) + case PluginRuntimeError.__name__: + args = error_object.get("args") + lambda_request_id = args.get("request_id") if isinstance(args, Mapping) else None + if not isinstance(lambda_request_id, str): + lambda_request_id = None + runtime_message = error_object.get("message") + if not isinstance(runtime_message, str): + runtime_message = "Plugin runtime request failed" + raise PluginRuntimeError( + description=runtime_message, + lambda_request_id=lambda_request_id, + ) case _: raise PluginInvokeError(description=message) case PluginDaemonInternalServerError.__name__: diff --git a/api/core/plugin/impl/exc.py b/api/core/plugin/impl/exc.py index abb9f0b1713..20ab58e281b 100644 --- a/api/core/plugin/impl/exc.py +++ b/api/core/plugin/impl/exc.py @@ -49,6 +49,18 @@ class PluginDaemonBadRequestError(PluginDaemonClientSideError): description: str = "Bad Request" +class PluginRuntimeError(PluginDaemonInternalError): + """A plugin runtime failed before it could return a valid plugin response.""" + + lambda_request_id: str | None + + def __init__(self, description: str, lambda_request_id: str | None = None) -> None: + self.lambda_request_id = lambda_request_id + if lambda_request_id: + description = description.replace(f"RequestId: {lambda_request_id} Error: ", "", 1) + super().__init__(description) + + class PluginInvokeError(PluginDaemonClientSideError, ValueError): description: str = "Invoke Error" diff --git a/api/libs/external_api.py b/api/libs/external_api.py index 06419b16f88..271023750ca 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -9,6 +9,8 @@ from werkzeug.http import HTTP_STATUS_CODES from configs import dify_config from core.errors.error import AppInvokeQuotaExceededError +from core.plugin.impl.exc import PluginRuntimeError +from extensions.ext_logging import get_request_id from libs.flask_restx_compat import install_swagger_compatibility from libs.token import build_force_logout_cookie_headers @@ -100,6 +102,20 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte data = {"code": "too_many_requests", "message": str(e), "status": status_code} return _finalize(e, data, status_code), status_code + def handle_plugin_runtime_error(e: PluginRuntimeError): + got_request_exception.send(current_app, exception=e) + status_code = 502 + details = {"request_id": get_request_id()} + if e.lambda_request_id: + details["lambda_request_id"] = e.lambda_request_id + data = { + "code": "plugin_runtime_error", + "message": e.description, + "details": details, + "status": status_code, + } + return _finalize(e, data, status_code), status_code + def handle_general_exception(e: Exception): got_request_exception.send(current_app, exception=e) @@ -121,6 +137,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte api.errorhandler(HTTPException)(handle_http_exception) api.errorhandler(ValueError)(handle_value_error) api.errorhandler(AppInvokeQuotaExceededError)(handle_quota_exceeded) + api.errorhandler(PluginRuntimeError)(handle_plugin_runtime_error) api.errorhandler(Exception)(handle_general_exception) diff --git a/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py b/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py index 613982f9c03..25b9ec4ef28 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py +++ b/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py @@ -7,7 +7,7 @@ from pytest_mock import MockerFixture from core.plugin.endpoint.exc import EndpointSetupFailedError from core.plugin.entities.plugin_daemon import PluginDaemonInnerError from core.plugin.impl.base import PLUGIN_DAEMON_MAX_PATH_LENGTH, BasePluginClient -from core.plugin.impl.exc import PluginLLMPollingUnsupportedError +from core.plugin.impl.exc import PluginLLMPollingUnsupportedError, PluginRuntimeError from core.trigger.errors import ( EventIgnoreError, TriggerInvokeError, @@ -175,3 +175,25 @@ class TestBasePluginClientImpl: with pytest.raises(PluginLLMPollingUnsupportedError): client._handle_plugin_daemon_error("PluginInvokeError", message) + + def test_handle_plugin_daemon_error_maps_runtime_error_to_typed_exception(self): + client = BasePluginClient() + lambda_request_id = "45664803-3d3c-4d4f-93fe-e3b19e43092b" + message = json.dumps( + { + "error_type": PluginRuntimeError.__name__, + "message": ( + "Plugin runtime request failed: Runtime.ExitError: " + f"RequestId: {lambda_request_id} Error: Runtime exited with error: exit status 1" + ), + "args": {"request_id": lambda_request_id, "status_code": 200}, + } + ) + + with pytest.raises(PluginRuntimeError) as exc_info: + client._handle_plugin_daemon_error("PluginInvokeError", message) + + assert exc_info.value.description == ( + "Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1" + ) + assert exc_info.value.lambda_request_id == lambda_request_id diff --git a/api/tests/unit_tests/libs/test_external_api.py b/api/tests/unit_tests/libs/test_external_api.py index 7ebdf5f60eb..77ebe6fe351 100644 --- a/api/tests/unit_tests/libs/test_external_api.py +++ b/api/tests/unit_tests/libs/test_external_api.py @@ -4,6 +4,7 @@ from werkzeug.exceptions import BadRequest, Unauthorized from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_CSRF_TOKEN, COOKIE_NAME_REFRESH_TOKEN from core.errors.error import AppInvokeQuotaExceededError +from core.plugin.impl.exc import PluginRuntimeError from libs.exception import BaseHTTPException from libs.external_api import ExternalApi from libs.rate_limit import _BearerRateLimited @@ -39,6 +40,14 @@ def _create_api_app(): def get(self): raise RuntimeError("oops") + @api.route("/plugin-runtime-error") + class PluginRuntime(Resource): + def get(self): + raise PluginRuntimeError( + "Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1", + lambda_request_id="lambda-request-id", + ) + # Note: We avoid altering default_mediatype to keep normal error paths # Special 400 message rewrite @@ -107,6 +116,24 @@ def test_external_api_json_message_and_bad_request_rewrite(): assert res.get_json()["message"] == "Invalid JSON payload received or JSON payload is empty." +def test_external_api_plugin_runtime_error(mocker): + mocker.patch("libs.external_api.get_request_id", return_value="api-request-id") + app = _create_api_app() + + res = app.test_client().get("/api/plugin-runtime-error") + + assert res.status_code == 502 + assert res.get_json() == { + "code": "plugin_runtime_error", + "message": "Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1", + "details": { + "request_id": "api-request-id", + "lambda_request_id": "lambda-request-id", + }, + "status": 502, + } + + def test_external_api_param_mapping_and_quota(): app = _create_api_app() client = app.test_client()