mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 07:20:34 +08:00
480 lines
18 KiB
Python
480 lines
18 KiB
Python
"""Canonical error contract for the /openapi/v1 surface.
|
|
|
|
``ErrorBody`` is the wire shape an /openapi/v1 endpoint emits for a non-2xx
|
|
response, with two exceptions: RFC 8628 device-flow responses (that shape is
|
|
mandated by the OAuth spec), and a failed DSL import or import confirm, which
|
|
answers 400 with the ``Import`` body (``status: failed`` and ``error``), as the
|
|
console does::
|
|
|
|
code str semantic error code (OpenApiErrorCode member)
|
|
message str human-readable summary
|
|
status int HTTP status, duplicated in the body
|
|
hint str | None actionable next step for the caller
|
|
hints list[Hint] | None ops the caller can run next, with ready input
|
|
details list[ErrorDetail] per-field validation breakdown {type, loc, msg}
|
|
|
|
``OpenApiErrorFormatter`` is injected into ``ExternalApi`` so every
|
|
error-handler path funnels through one builder, and it also rewrites
|
|
``e.data`` because flask-restx ``Api.handle_error`` lets a pre-existing
|
|
``e.data`` override the registered handler's return value.
|
|
|
|
The transport-generic enum members, ``_CODE_BY_STATUS`` and the
|
|
``OpenApiError``/``OpenApiErrorFormatter`` bases are openapi-only today;
|
|
promote them to ``libs`` if a second surface adopts ``ErrorBody``.
|
|
"""
|
|
|
|
import contextlib
|
|
import logging
|
|
from enum import StrEnum
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel
|
|
from werkzeug.exceptions import HTTPException
|
|
|
|
from controllers.openapi._models import Hint
|
|
from libs.external_api import http_status_message
|
|
|
|
|
|
class OpenApiErrorCode(StrEnum):
|
|
# transport-generic (resolved from HTTP status for plain werkzeug raises)
|
|
BAD_REQUEST = "bad_request"
|
|
UNAUTHORIZED = "unauthorized"
|
|
FORBIDDEN = "forbidden"
|
|
NOT_FOUND = "not_found"
|
|
METHOD_NOT_ALLOWED = "method_not_allowed"
|
|
NOT_ACCEPTABLE = "not_acceptable"
|
|
CONFLICT = "conflict"
|
|
REQUEST_TOO_LARGE = "request_entity_too_large"
|
|
UNSUPPORTED_MEDIA_TYPE = "unsupported_media_type"
|
|
INVALID_PARAM = "invalid_param"
|
|
TOO_MANY_REQUESTS = "too_many_requests"
|
|
INTERNAL_ERROR = "internal_server_error"
|
|
BAD_GATEWAY = "bad_gateway"
|
|
UNKNOWN = "unknown"
|
|
# domain codes (must match the error_code attribute of the exception
|
|
# classes raised on the openapi surface)
|
|
APP_UNAVAILABLE = "app_unavailable"
|
|
AGENT_NOT_PUBLISHED = "agent_not_published"
|
|
CONVERSATION_COMPLETED = "conversation_completed"
|
|
PROVIDER_NOT_INITIALIZE = "provider_not_initialize"
|
|
PROVIDER_QUOTA_EXCEEDED = "provider_quota_exceeded"
|
|
MODEL_NOT_SUPPORTED = "model_currently_not_support"
|
|
COMPLETION_REQUEST_ERROR = "completion_request_error"
|
|
RATE_LIMIT_ERROR = "rate_limit_error"
|
|
FILE_TOO_LARGE = "file_too_large"
|
|
UNSUPPORTED_FILE_TYPE = "unsupported_file_type"
|
|
NO_FILE_UPLOADED = "no_file_uploaded"
|
|
TOO_MANY_FILES = "too_many_files"
|
|
FILENAME_NOT_EXISTS = "filename_not_exists"
|
|
FILE_EXTENSION_BLOCKED = "file_extension_blocked"
|
|
MEMBER_LIMIT_EXCEEDED = "member_limit_exceeded"
|
|
MEMBER_LICENSE_EXCEEDED = "member_license_exceeded"
|
|
HUMAN_INPUT_FORM_NOT_FOUND = "form_not_found"
|
|
RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch"
|
|
CATALOG_STALE = "catalog_stale"
|
|
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE = "trigger_workflow_service_mode_unavailable"
|
|
RUN_NOT_FOUND = "run_not_found"
|
|
VERSION_NOT_FOUND = "version_not_found"
|
|
VERSION_NOT_RESTORABLE = "version_not_restorable"
|
|
DRAFT_NOT_FOUND = "draft_not_found"
|
|
ENV_VARIABLE_NOT_FOUND = "env_variable_not_found"
|
|
SECRET_MASK_UNKNOWN_ID = "secret_mask_unknown_id"
|
|
WEBAPP_ACCESS_REQUIRES_EE = "webapp_access_requires_ee"
|
|
WEBAPP_ACCESS_UNAVAILABLE = "webapp_access_unavailable"
|
|
NODE_TYPE_NOT_FOUND = "node_type_not_found"
|
|
NODE_NOT_FOUND = "node_not_found"
|
|
WEBAPP_ACCESS_MODE_CONSOLE_ONLY = "webapp_access_mode_console_only"
|
|
WEBAPP_PUBLIC_ACCESS_DISABLED = "webapp_public_access_disabled"
|
|
SECRET_MASK_NOT_SECRET = "secret_mask_not_secret"
|
|
MARKETPLACE_DISABLED = "marketplace_disabled"
|
|
MARKETPLACE_UNAVAILABLE = "marketplace_unavailable"
|
|
PLUGIN_INSTALL_FORBIDDEN = "plugin_install_forbidden"
|
|
PLUGIN_NOT_INSTALLED = "plugin_not_installed"
|
|
PROVIDER_NOT_FOUND = "provider_not_found"
|
|
CREDENTIAL_INVALID = "credential_invalid"
|
|
CREDENTIAL_OAUTH_ONLY = "credential_oauth_only"
|
|
CREDENTIAL_NOT_FOUND = "credential_not_found"
|
|
PLUGIN_SERVICE_UNAVAILABLE = "plugin_service_unavailable"
|
|
DSL_INVALID = "dsl_invalid"
|
|
DRAFT_CHANGED = "draft_changed"
|
|
APP_MODE_MISMATCH = "app_mode_mismatch"
|
|
SERVICE_API_DISABLED = "service_api_disabled"
|
|
|
|
|
|
class ErrorDetail(BaseModel):
|
|
type: str
|
|
loc: list[str | int] = []
|
|
msg: str
|
|
|
|
|
|
class ErrorBody(BaseModel):
|
|
"""Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the
|
|
generated client schema stays an open enum — old CLIs keep parsing when a
|
|
future server adds a code. Formatter tests pin emitted values to the enum."""
|
|
|
|
code: str
|
|
message: str
|
|
status: int
|
|
hint: str | None = None
|
|
hints: list[Hint] | None = None
|
|
details: list[ErrorDetail] | None = None
|
|
|
|
|
|
_CODE_BY_STATUS: dict[int, OpenApiErrorCode] = {
|
|
400: OpenApiErrorCode.BAD_REQUEST,
|
|
401: OpenApiErrorCode.UNAUTHORIZED,
|
|
403: OpenApiErrorCode.FORBIDDEN,
|
|
404: OpenApiErrorCode.NOT_FOUND,
|
|
405: OpenApiErrorCode.METHOD_NOT_ALLOWED,
|
|
406: OpenApiErrorCode.NOT_ACCEPTABLE,
|
|
409: OpenApiErrorCode.CONFLICT,
|
|
413: OpenApiErrorCode.REQUEST_TOO_LARGE,
|
|
415: OpenApiErrorCode.UNSUPPORTED_MEDIA_TYPE,
|
|
422: OpenApiErrorCode.INVALID_PARAM,
|
|
429: OpenApiErrorCode.TOO_MANY_REQUESTS,
|
|
500: OpenApiErrorCode.INTERNAL_ERROR,
|
|
502: OpenApiErrorCode.BAD_GATEWAY,
|
|
}
|
|
|
|
_GENERIC_500_MESSAGE = "Internal Server Error"
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class OpenApiError(HTTPException):
|
|
"""Dedicated throwable for the /openapi/v1 surface.
|
|
|
|
A subclass declares ``code`` (HTTP status), ``error_code`` and
|
|
``description`` exactly once; call sites just ``raise SomeError()`` —
|
|
no per-site dict building, no duplicated message constants. The
|
|
formatter emits all three (plus optional ``hint``/``details``) verbatim.
|
|
"""
|
|
|
|
code = 400
|
|
error_code: OpenApiErrorCode = OpenApiErrorCode.UNKNOWN
|
|
hint: str | None = None
|
|
hints: list[Hint] | None = None
|
|
|
|
def __init__(
|
|
self,
|
|
message: str | None = None,
|
|
*,
|
|
hint: str | None = None,
|
|
hints: list[Hint] | None = None,
|
|
details: list[ErrorDetail] | None = None,
|
|
) -> None:
|
|
super().__init__(description=message)
|
|
if hint is not None:
|
|
self.hint = hint
|
|
if hints is not None:
|
|
self.hints = hints
|
|
self.details = details
|
|
|
|
|
|
class OpenApiErrorFormatter:
|
|
"""Builds the canonical ErrorBody from whatever the shared handlers computed.
|
|
|
|
Resolution order for ``code``: explicit ``error_code`` class attribute
|
|
(BaseHTTPException subclasses and OpenApiError subclasses) → HTTP status
|
|
map → ``unknown``. Class-name-derived codes from the shared handler are
|
|
deliberately ignored — they are not a stable contract.
|
|
"""
|
|
|
|
def finalize(self, e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]:
|
|
exc_data = getattr(e, "data", None)
|
|
merged: dict[str, Any] = {**data, **exc_data} if isinstance(exc_data, dict) else dict(data)
|
|
|
|
# finalize runs inside the framework error handler: raising here would
|
|
# replace the response with an unformatted 500, so fall back instead
|
|
try:
|
|
body = ErrorBody(
|
|
code=self._resolve_code(e, status_code),
|
|
message=self._resolve_message(e, merged, status_code),
|
|
status=status_code,
|
|
hint=self._resolve_hint(e),
|
|
hints=e.hints if isinstance(e, OpenApiError) else None,
|
|
details=self._extract_details(e, merged),
|
|
)
|
|
wire = body.model_dump(mode="json", exclude_none=True)
|
|
except Exception:
|
|
logger.exception("error-body build failed; emitting fallback body")
|
|
wire = {
|
|
"code": str(_CODE_BY_STATUS.get(status_code, OpenApiErrorCode.UNKNOWN)),
|
|
"message": http_status_message(status_code) or "request failed",
|
|
"status": status_code,
|
|
}
|
|
|
|
# flask-restx Api.handle_error does `data = getattr(e, "data", default_data)`
|
|
# AFTER our handler returns, so a pre-existing e.data (flask_restx.abort,
|
|
# BaseHTTPException) would override the canonical body. Rewrite it.
|
|
with contextlib.suppress(AttributeError):
|
|
e.data = wire # type: ignore[attr-defined]
|
|
return wire
|
|
|
|
def _resolve_code(self, e: Exception, status_code: int) -> str:
|
|
explicit = getattr(type(e), "error_code", None)
|
|
return self.code_for(status_code, str(explicit) if isinstance(explicit, str) else None)
|
|
|
|
@staticmethod
|
|
def code_for(status_code: int, error_code: str | None) -> str:
|
|
"""The wire ``code`` for an exception class's ``error_code`` (if any) at this status."""
|
|
if error_code is not None and error_code != "unknown":
|
|
return error_code
|
|
return str(_CODE_BY_STATUS.get(status_code, OpenApiErrorCode.UNKNOWN))
|
|
|
|
def _resolve_message(self, e: Exception, merged: dict[str, Any], status_code: int) -> str:
|
|
if status_code >= 500 and not isinstance(e, OpenApiError):
|
|
return _GENERIC_500_MESSAGE
|
|
message = merged.get("message")
|
|
if isinstance(message, str) and message:
|
|
return message
|
|
return http_status_message(status_code) or "request failed"
|
|
|
|
def _resolve_hint(self, e: Exception) -> str | None:
|
|
hint = getattr(e, "hint", None)
|
|
return hint if isinstance(hint, str) and hint else None
|
|
|
|
def _extract_details(self, e: Exception, merged: dict[str, Any]) -> list[ErrorDetail] | None:
|
|
explicit = getattr(e, "details", None)
|
|
if isinstance(explicit, list) and explicit and all(isinstance(d, ErrorDetail) for d in explicit):
|
|
return explicit
|
|
# an already-canonical body (e.g. e.data rewritten by a prior finalize)
|
|
# carries "details"; re-validate so finalize stays idempotent
|
|
canonical = merged.get("details")
|
|
if isinstance(canonical, list) and canonical and all(isinstance(d, dict) for d in canonical):
|
|
return [ErrorDetail.model_validate(d) for d in canonical]
|
|
errors = merged.get("errors")
|
|
if isinstance(errors, list) and errors:
|
|
details = [
|
|
ErrorDetail(
|
|
type=str(item.get("type", "invalid")),
|
|
loc=[part for part in item.get("loc", []) if self._is_loc_part(part)],
|
|
msg=str(item.get("msg", "")),
|
|
)
|
|
for item in errors
|
|
if isinstance(item, dict)
|
|
]
|
|
return details or None
|
|
params = merged.get("params")
|
|
if isinstance(params, str) and params:
|
|
return [ErrorDetail(type="invalid", loc=[params], msg=str(merged.get("message", "")))]
|
|
return None
|
|
|
|
@staticmethod
|
|
def _is_loc_part(part: Any) -> bool:
|
|
# bool is an int subclass but is not a valid path segment
|
|
return isinstance(part, (str, int)) and not isinstance(part, bool)
|
|
|
|
|
|
class FilenameNotExists(OpenApiError): # noqa: N818
|
|
code = 400
|
|
error_code = OpenApiErrorCode.FILENAME_NOT_EXISTS
|
|
description = "The specified filename does not exist."
|
|
|
|
|
|
class MemberLimitExceeded(OpenApiError): # noqa: N818
|
|
code = 403
|
|
error_code = OpenApiErrorCode.MEMBER_LIMIT_EXCEEDED
|
|
description = "Subscription member limit reached."
|
|
hint = "Upgrade your plan to invite more members or remove an existing member first."
|
|
|
|
|
|
class MemberLicenseExceeded(OpenApiError): # noqa: N818
|
|
code = 403
|
|
error_code = OpenApiErrorCode.MEMBER_LICENSE_EXCEEDED
|
|
description = "Workspace member license capacity reached."
|
|
hint = "Contact your workspace administrator to expand the license seat count."
|
|
|
|
|
|
class HumanInputFormNotFound(OpenApiError): # noqa: N818
|
|
code = 404
|
|
error_code = OpenApiErrorCode.HUMAN_INPUT_FORM_NOT_FOUND
|
|
description = "No human-input form matches this token. It may be wrong, expired, or already submitted."
|
|
|
|
|
|
class RecipientSurfaceMismatch(OpenApiError): # noqa: N818
|
|
code = 403
|
|
error_code = OpenApiErrorCode.RECIPIENT_SURFACE_MISMATCH
|
|
description = "This form's recipient can't be submitted via the OpenAPI surface."
|
|
hint = "Action it through its channel (web app or console)."
|
|
|
|
|
|
class CatalogStale(OpenApiError): # noqa: N818
|
|
code = 412
|
|
error_code = OpenApiErrorCode.CATALOG_STALE
|
|
description = "The request was built from a catalog that is not this server's current catalog."
|
|
hint = "GET /openapi/v1/_catalog, rebuild the request from it, and send its sha256 in X-Dify-Catalog."
|
|
|
|
|
|
class InvalidFilePart(OpenApiError): # noqa: N818
|
|
code = 422
|
|
error_code = OpenApiErrorCode.INVALID_PARAM
|
|
description = "A request part could not be used"
|
|
|
|
|
|
class RunNotFound(OpenApiError): # noqa: N818
|
|
code = 404
|
|
error_code = OpenApiErrorCode.RUN_NOT_FOUND
|
|
description = "No run with this id exists for this app."
|
|
|
|
|
|
class VersionNotFound(OpenApiError): # noqa: N818
|
|
code = 404
|
|
error_code = OpenApiErrorCode.VERSION_NOT_FOUND
|
|
description = "No version with this id exists for this app."
|
|
|
|
|
|
class WebAppAccessRequiresEE(OpenApiError): # noqa: N818
|
|
code = 403
|
|
error_code = OpenApiErrorCode.WEBAPP_ACCESS_REQUIRES_EE
|
|
description = "Web-app access control needs Dify Enterprise with web-app sign-in turned on."
|
|
|
|
|
|
class WebAppAccessUnavailable(OpenApiError): # noqa: N818
|
|
code = 503
|
|
error_code = OpenApiErrorCode.WEBAPP_ACCESS_UNAVAILABLE
|
|
description = "Web-app access could not be read or changed."
|
|
hint = "Enterprise is unreachable or did not answer."
|
|
|
|
|
|
class NodeTypeNotFound(OpenApiError): # noqa: N818
|
|
code = 404
|
|
error_code = OpenApiErrorCode.NODE_TYPE_NOT_FOUND
|
|
description = "No node type with this name exists on this server."
|
|
hint = "List the node types with get.node_type."
|
|
|
|
|
|
class NodeNotFound(OpenApiError): # noqa: N818
|
|
code = 404
|
|
error_code = OpenApiErrorCode.NODE_NOT_FOUND
|
|
description = "The draft has no node with this id."
|
|
|
|
|
|
class WebAppAccessModeConsoleOnly(OpenApiError): # noqa: N818
|
|
code = 400
|
|
error_code = OpenApiErrorCode.WEBAPP_ACCESS_MODE_CONSOLE_ONLY
|
|
description = "Member lists for access_mode private must be chosen in the Dify console."
|
|
hint = "Use public, private_all or sso_verified here."
|
|
|
|
|
|
class WebAppPublicAccessDisabled(OpenApiError): # noqa: N818
|
|
code = 403
|
|
error_code = OpenApiErrorCode.WEBAPP_PUBLIC_ACCESS_DISABLED
|
|
description = "This server does not allow web apps with access_mode public."
|
|
hint = "Use private_all or sso_verified."
|
|
|
|
|
|
class VersionNotRestorable(OpenApiError): # noqa: N818
|
|
code = 400
|
|
error_code = OpenApiErrorCode.VERSION_NOT_RESTORABLE
|
|
description = "Only a published version can be restored."
|
|
|
|
|
|
class DraftNotFound(OpenApiError): # noqa: N818
|
|
code = 404
|
|
error_code = OpenApiErrorCode.DRAFT_NOT_FOUND
|
|
description = "This app has no draft."
|
|
|
|
|
|
class EnvVariableNotFound(OpenApiError): # noqa: N818
|
|
code = 404
|
|
error_code = OpenApiErrorCode.ENV_VARIABLE_NOT_FOUND
|
|
description = "The draft has no environment variable with this id."
|
|
|
|
|
|
class SecretMaskUnknownId(OpenApiError): # noqa: N818
|
|
code = 400
|
|
error_code = OpenApiErrorCode.SECRET_MASK_UNKNOWN_ID
|
|
description = "The value is the masked secret placeholder, but env_id is not an existing secret."
|
|
|
|
|
|
class SecretMaskNotSecret(OpenApiError): # noqa: N818
|
|
code = 400
|
|
error_code = OpenApiErrorCode.SECRET_MASK_NOT_SECRET
|
|
description = "The value is the masked secret placeholder, which only a secret value_type can keep."
|
|
|
|
|
|
class MarketplaceDisabled(OpenApiError): # noqa: N818
|
|
code = 503
|
|
error_code = OpenApiErrorCode.MARKETPLACE_DISABLED
|
|
description = "Marketplace is disabled on this server."
|
|
hint = "Ask an admin to install the plugin."
|
|
|
|
|
|
class MarketplaceUnavailable(OpenApiError): # noqa: N818
|
|
code = 503
|
|
error_code = OpenApiErrorCode.MARKETPLACE_UNAVAILABLE
|
|
description = "The marketplace did not answer."
|
|
|
|
|
|
class PluginInstallForbidden(OpenApiError): # noqa: N818
|
|
code = 403
|
|
error_code = OpenApiErrorCode.PLUGIN_INSTALL_FORBIDDEN
|
|
description = "Plugin install is not allowed here."
|
|
|
|
|
|
class PluginNotInstalled(OpenApiError): # noqa: N818
|
|
code = 404
|
|
error_code = OpenApiErrorCode.PLUGIN_NOT_INSTALLED
|
|
description = "This plugin is not installed in the workspace."
|
|
hint = "List installed plugins with get.plugin."
|
|
|
|
|
|
class ProviderNotFound(OpenApiError): # noqa: N818
|
|
code = 404
|
|
error_code = OpenApiErrorCode.PROVIDER_NOT_FOUND
|
|
description = "No provider with this id in the workspace."
|
|
hint = "Provider ids look like langgenius/openai/openai; get.plugin shows them under provides."
|
|
|
|
|
|
class CredentialInvalid(OpenApiError): # noqa: N818
|
|
code = 422
|
|
error_code = OpenApiErrorCode.CREDENTIAL_INVALID
|
|
description = "The provider rejected these credentials; nothing was saved."
|
|
|
|
|
|
class CredentialOAuthOnly(OpenApiError): # noqa: N818
|
|
code = 400
|
|
error_code = OpenApiErrorCode.CREDENTIAL_OAUTH_ONLY
|
|
description = "This provider uses OAuth."
|
|
hint = "Ask the user to authorize it in the console."
|
|
|
|
|
|
class CredentialNotFound(OpenApiError): # noqa: N818
|
|
code = 404
|
|
error_code = OpenApiErrorCode.CREDENTIAL_NOT_FOUND
|
|
description = "No credential with this id for this provider that you can see."
|
|
hint = "List them with the describe op of the provider."
|
|
|
|
|
|
class PluginServiceUnavailable(OpenApiError): # noqa: N818
|
|
code = 503
|
|
error_code = OpenApiErrorCode.PLUGIN_SERVICE_UNAVAILABLE
|
|
description = "Plugin service is unavailable."
|
|
|
|
|
|
class DslInvalid(OpenApiError): # noqa: N818
|
|
code = 422
|
|
error_code = OpenApiErrorCode.DSL_INVALID
|
|
description = "The DSL has problems the import refuses; each is in details with its location"
|
|
|
|
|
|
class DraftChanged(OpenApiError): # noqa: N818
|
|
code = 409
|
|
error_code = OpenApiErrorCode.DRAFT_CHANGED
|
|
description = "The draft changed after it was exported. Export it again and reapply your edits."
|
|
|
|
|
|
class AppModeMismatch(OpenApiError): # noqa: N818
|
|
code = 422
|
|
error_code = OpenApiErrorCode.APP_MODE_MISMATCH
|
|
description = "This op does not serve apps of this mode."
|
|
hint = "describe.console_app shows the app's mode."
|
|
|
|
|
|
class ServiceApiDisabled(OpenApiError): # noqa: N818
|
|
code = 403
|
|
error_code = OpenApiErrorCode.SERVICE_API_DISABLED
|
|
description = "This app's Service API is turned off."
|
|
hint = "A workspace admin can turn it on; hints holds the op."
|