Files
dify/api/controllers/openapi/_errors.py
T

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