mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Codex <codex@openai.com>
728 lines
34 KiB
Python
728 lines
34 KiB
Python
import json
|
|
import pkgutil
|
|
import re
|
|
from collections.abc import Callable, Iterable, Mapping
|
|
from dataclasses import dataclass
|
|
from functools import cache
|
|
from typing import Any, ClassVar, Protocol, TypedDict, override
|
|
|
|
from flask import Blueprint, Flask, current_app, got_request_exception, request
|
|
from flask.typing import ResponseReturnValue
|
|
from flask_restx import Api
|
|
from pydantic import BaseModel
|
|
from pydantic.json_schema import SkipJsonSchema
|
|
from werkzeug.datastructures import Headers
|
|
from werkzeug.exceptions import HTTPException
|
|
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.exception import BaseHTTPException, RelayedHTTPError
|
|
from libs.flask_restx_compat import install_swagger_compatibility
|
|
from libs.token import build_force_logout_cookie_headers
|
|
|
|
|
|
def http_status_message(code):
|
|
return HTTP_STATUS_CODES.get(code, "")
|
|
|
|
|
|
class ErrorResponse(BaseModel):
|
|
"""Documented non-2xx body emitted by ``register_external_error_handlers``.
|
|
|
|
``params`` and ``details`` appear only on the errors that set them. The schema
|
|
stays open because ``HTTPException.data`` may carry extra keys.
|
|
"""
|
|
|
|
code: str
|
|
message: str
|
|
status: int
|
|
params: str | SkipJsonSchema[None] = None
|
|
details: dict[str, Any] | SkipJsonSchema[None] = None
|
|
|
|
|
|
_ERROR_SCHEMA_REF_TEMPLATE = "#/components/schemas/{model}"
|
|
ERROR_CODES_VENDOR_KEY = "dify-error-codes"
|
|
_ERROR_CODES_EXTENSION = f"x-{ERROR_CODES_VENDOR_KEY}"
|
|
# Marks an operation that relays downstream errors: any code may answer it with the error body.
|
|
RELAYS_ERRORS_EXTENSION = "x-dify-relays-errors"
|
|
# ``BaseHTTPException`` ships this placeholder; a documented business error must replace it.
|
|
_UNDECLARED_ERROR_CODE = BaseHTTPException.error_code
|
|
|
|
|
|
class ErrorSpec(TypedDict):
|
|
"""How one documented exception reaches the wire, before a surface picks its ``code``."""
|
|
|
|
status: int
|
|
# The class's own ``error_code``, if it declares one.
|
|
error_code: str | None
|
|
# The ``code`` the surface's handlers emit when no body formatter rewrites it.
|
|
default_code: str
|
|
|
|
|
|
# What the non-HTTP handlers in register_external_error_handlers emit, as (status, code).
|
|
_SHARED_HANDLED_ERRORS: Mapping[type[Exception], tuple[int, str]] = {
|
|
ValueError: (400, "invalid_param"),
|
|
AppInvokeQuotaExceededError: (429, "too_many_requests"),
|
|
PluginRuntimeError: (502, "plugin_runtime_error"),
|
|
}
|
|
|
|
|
|
def _class_attribute(cls: type, name: str) -> object:
|
|
"""``name`` as the class or its nearest base defines it, without instance or ``__getattr__`` lookups."""
|
|
for klass in cls.__mro__:
|
|
if name in klass.__dict__:
|
|
return klass.__dict__[name]
|
|
return None
|
|
|
|
|
|
def _snake_case(name: str) -> str:
|
|
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
|
|
|
|
@cache
|
|
def _framework_error_codes() -> Mapping[int, frozenset[str]]:
|
|
"""The codes Werkzeug's own HTTP exceptions produce, by status.
|
|
|
|
The HTTP handler names the code after the raised class, so subclasses such as
|
|
``BadRequestKeyError`` (a missing form or file field) answer with their own code.
|
|
Werkzeug defines all of them at import, so the walk happens once.
|
|
"""
|
|
by_status: dict[int, set[str]] = {}
|
|
pending: list[type[HTTPException]] = [HTTPException]
|
|
while pending:
|
|
exception = pending.pop()
|
|
pending.extend(exception.__subclasses__())
|
|
if exception.__module__.startswith("werkzeug.") and isinstance(exception.code, int) and exception.code >= 400:
|
|
by_status.setdefault(exception.code, set()).add(_snake_case(exception.__name__))
|
|
return {status: frozenset(codes) for status, codes in by_status.items()}
|
|
|
|
|
|
def _error_spec(
|
|
exception: type[Exception],
|
|
handled: Mapping[type[Exception], tuple[int, str]],
|
|
dispatch_order: Iterable[type[BaseException]],
|
|
) -> ErrorSpec:
|
|
declared = _class_attribute(exception, "error_code")
|
|
error_code = str(declared) if isinstance(declared, str) else None
|
|
# Flask-RESTX dispatches to the first registered handler whose type matches, in
|
|
# registration order (not the most specific one), so resolve the same way.
|
|
for handler_type in dispatch_order:
|
|
if not issubclass(exception, handler_type):
|
|
continue
|
|
if handler_type in handled:
|
|
status, default_code = handled[handler_type]
|
|
return {"status": status, "error_code": error_code, "default_code": default_code}
|
|
if handler_type is HTTPException and issubclass(exception, HTTPException):
|
|
if exception.code is None:
|
|
raise ValueError(f"{exception.__name__} declares no HTTP status")
|
|
if issubclass(exception, BaseHTTPException):
|
|
default_code = exception.error_code
|
|
else:
|
|
default_code = _snake_case(exception.__name__)
|
|
return {"status": exception.code, "error_code": error_code, "default_code": default_code}
|
|
break
|
|
raise ValueError(f"{exception.__name__} has no dedicated error handler; its status and code are not stable")
|
|
|
|
|
|
def keeps_placeholder_error_code(exception: type[Exception]) -> bool:
|
|
"""Whether ``exception`` would answer with ``BaseHTTPException``'s placeholder ``code``.
|
|
|
|
Such an error cannot be documented: declare an ``error_code`` on it. Relayed errors
|
|
take their code at runtime and are exempt.
|
|
"""
|
|
return (
|
|
issubclass(exception, BaseHTTPException)
|
|
and not issubclass(exception, RelayedHTTPError)
|
|
and _class_attribute(exception, "error_code") == _UNDECLARED_ERROR_CODE
|
|
)
|
|
|
|
|
|
def is_business_error(exception: type[Exception]) -> bool:
|
|
"""Whether ``exception`` names an operation-specific outcome worth listing.
|
|
|
|
Werkzeug exceptions and anything dispatched through a dedicated (non-HTTP) handler
|
|
are generic instead: clients handle them centrally, and every operation's open
|
|
``default`` error response covers them.
|
|
"""
|
|
return issubclass(exception, HTTPException) and not exception.__module__.startswith("werkzeug.")
|
|
|
|
|
|
_HTTP_METHOD_DOC_KEYS = ("get", "put", "post", "delete", "patch", "head", "options", "trace")
|
|
|
|
|
|
def _with_documented_errors(doc: Mapping[str, Any], names: Iterable[str]) -> dict[str, Any]:
|
|
vendor = dict(doc.get("vendor", {}))
|
|
# A dict, not a list: Flask-RESTX deep-merges dicts across decorators and class/method
|
|
# docs but replaces lists, so every contributor's errors survive the merge.
|
|
vendor[ERROR_CODES_VENDOR_KEY] = {**vendor.get(ERROR_CODES_VENDOR_KEY, {}), **dict.fromkeys(names, True)}
|
|
return {**doc, "vendor": vendor}
|
|
|
|
|
|
def _documented_names(exceptions: Iterable[type[Exception]]) -> list[str]:
|
|
exceptions = list(exceptions)
|
|
generic = [exception.__name__ for exception in exceptions if not is_business_error(exception)]
|
|
if generic:
|
|
raise ValueError(f"generic errors are implied, do not list them: {', '.join(generic)}")
|
|
undeclared = [exception.__name__ for exception in exceptions if keeps_placeholder_error_code(exception)]
|
|
if undeclared:
|
|
raise ValueError(f"business errors must declare an error_code: {', '.join(undeclared)}")
|
|
return [f"{exception.__module__}:{exception.__qualname__}" for exception in exceptions]
|
|
|
|
|
|
def document_errors[T](target: T, *exceptions: type[Exception]) -> T:
|
|
"""Record the business errors a view or Resource class can raise, for ``error_responses`` docs.
|
|
|
|
A view decorator calls this on the wrapper it returns, so every operation it guards
|
|
documents the errors it raises. On a Resource class (for ``method_decorators``) the
|
|
errors apply to each of its operations. Only business errors are accepted; generic
|
|
ones are implied (see ``is_business_error``). Builds new doc dicts because
|
|
``functools.wraps`` shares the wrapped view's ``__apidoc__``.
|
|
"""
|
|
|
|
names = _documented_names(exceptions)
|
|
existing = _class_attribute(target, "__apidoc__") if isinstance(target, type) else vars(target).get("__apidoc__")
|
|
if existing is False:
|
|
# ``@ns.hide`` hides the view or resource from the document; there is nothing to document.
|
|
return target
|
|
doc: Mapping[str, Any] = existing if isinstance(existing, Mapping) else {}
|
|
if isinstance(target, type):
|
|
# Class-level docs only reach operations through their per-method entries. A method
|
|
# hidden with ``@ns.doc(<method>=False)`` stays hidden.
|
|
doc = {
|
|
**doc,
|
|
**{
|
|
method: entry
|
|
if entry is False
|
|
else _with_documented_errors(entry if isinstance(entry, Mapping) else {}, names)
|
|
for method in _HTTP_METHOD_DOC_KEYS
|
|
for entry in (doc.get(method),)
|
|
},
|
|
}
|
|
else:
|
|
doc = _with_documented_errors(doc, names)
|
|
target.__apidoc__ = doc # type: ignore[attr-defined]
|
|
return target
|
|
|
|
|
|
class ViewDecorator(Protocol):
|
|
"""A decorator that returns the view it is given, keeping the view's type."""
|
|
|
|
def __call__[F: Callable[..., object]](self, view: F, /) -> F: ...
|
|
|
|
|
|
def error_responses(*exceptions: type[Exception]) -> ViewDecorator:
|
|
"""Document the business errors an operation's handler raises.
|
|
|
|
List what the handler and its callees raise; decorators document their own errors
|
|
with ``document_errors``. Generic errors are never listed: every operation documents
|
|
an open ``default`` error for them. Each surface resolves status and ``code`` from its
|
|
own handlers when it builds the document, and documents each code once as a shared
|
|
``{Code}Error`` component, so a business error must declare an ``error_code``.
|
|
|
|
Nothing infers this list from the code: list what the operation's own call chain can
|
|
answer with, not everything a shared translator knows how to map. The controller unit
|
|
tests check every served error response against the document.
|
|
"""
|
|
|
|
_documented_names(exceptions)
|
|
|
|
def decorator[F: Callable[..., object]](view: F, /) -> F:
|
|
return document_errors(view, *exceptions)
|
|
|
|
return decorator
|
|
|
|
|
|
def _pascal_case(value: str) -> str:
|
|
return "".join(word[:1].upper() + word[1:] for word in re.split(r"[^0-9A-Za-z]+", value) if word)
|
|
|
|
|
|
def error_component_name(code: str) -> str:
|
|
"""The component that documents ``code``: ``draft_workflow_not_sync`` becomes ``DraftWorkflowNotSyncError``."""
|
|
name = _pascal_case(code)
|
|
return name if name.endswith("Error") else f"{name}Error"
|
|
|
|
|
|
def _details_model(exception: type[Exception]) -> type[BaseModel] | None:
|
|
"""The Pydantic model an exception declares for its ``details``, if any."""
|
|
model = _class_attribute(exception, "details_model")
|
|
return model if isinstance(model, type) and issubclass(model, BaseModel) else None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _ErrorComponent:
|
|
code: str
|
|
status: int
|
|
exception: type[Exception]
|
|
details_model: type[BaseModel] | None
|
|
where: str
|
|
|
|
def schema(self, name: str, error_body_ref: Mapping[str, object]) -> dict[str, object]:
|
|
properties: dict[str, object] = {
|
|
"code": {"const": self.code, "type": "string"},
|
|
"status": {"const": self.status, "type": "integer"},
|
|
}
|
|
if self.details_model is not None:
|
|
properties["details"] = {"$ref": _ERROR_SCHEMA_REF_TEMPLATE.format(model=self.details_model.__name__)}
|
|
schema: dict[str, object] = {
|
|
"allOf": [
|
|
dict(error_body_ref),
|
|
{"properties": properties, "required": ["code", "status"], "type": "object"},
|
|
],
|
|
"title": name,
|
|
}
|
|
description = _class_attribute(self.exception, "description")
|
|
if isinstance(description, str) and description:
|
|
schema["description"] = description
|
|
return schema
|
|
|
|
|
|
def _explicit_json_schema(response: Mapping[str, Any]) -> object | None:
|
|
"""The JSON body schema a response already declares, if any; other media types are not errors."""
|
|
content = response.get("content")
|
|
media = content.get("application/json") if isinstance(content, dict) else None
|
|
return media.get("schema") if isinstance(media, dict) else None
|
|
|
|
|
|
def _comparable_schema(schema: object) -> object:
|
|
"""``schema`` with order-insensitive parts (``required``, ``enum``) normalized for equality checks."""
|
|
if isinstance(schema, dict):
|
|
return {
|
|
key: sorted(value, key=lambda item: json.dumps(item, sort_keys=True))
|
|
if key in {"required", "enum"} and isinstance(value, list)
|
|
else _comparable_schema(value)
|
|
for key, value in schema.items()
|
|
}
|
|
if isinstance(schema, list):
|
|
return [_comparable_schema(item) for item in schema]
|
|
return schema
|
|
|
|
|
|
def _add_model_schemas(schemas: dict[str, object], model: type[BaseModel]) -> None:
|
|
"""Add ``model`` and its nested definitions; a name already documenting a different schema is an error."""
|
|
schema = model.model_json_schema(ref_template=_ERROR_SCHEMA_REF_TEMPLATE, mode="serialization")
|
|
definitions = schema.pop("$defs", {})
|
|
for name, definition in {**definitions, model.__name__: schema}.items():
|
|
existing = schemas.setdefault(name, definition)
|
|
if _comparable_schema(existing) != _comparable_schema(definition):
|
|
raise ValueError(
|
|
f"schema {name} (from {model.__name__}) collides with a different component of the same name"
|
|
)
|
|
|
|
|
|
def attach_error_response_schema(
|
|
document: dict[str, object],
|
|
model: type[BaseModel],
|
|
resolve_error: Callable[[type[Exception]], tuple[int, str]],
|
|
is_generic: Callable[[type[Exception]], bool] = lambda exception: False,
|
|
) -> dict[str, object]:
|
|
"""Give every 4xx/5xx response the surface's error body and document business errors by code.
|
|
|
|
Every operation gets a ``default`` error response with the open error body: generic
|
|
errors (authentication, validation, rate limits, unexpected failures) can answer any
|
|
operation and clients handle them centrally, so no operation lists them. ``is_generic``
|
|
names the surface's own generic errors (see ``ExternalApi.document_generic_errors``);
|
|
an operation that documents one gets nothing for it beyond the ``default`` error.
|
|
|
|
Each business error an operation documents with ``error_responses`` is one shared
|
|
``{Code}Error`` component: the error body with ``code`` and ``status`` fixed, plus the
|
|
class's ``details_model`` when it declares one. The response of its status lists it in
|
|
an ``anyOf`` that always ends with the open error body, since a generic error can answer
|
|
that status too and ``default`` only covers statuses the operation leaves undeclared; a
|
|
status that also declares its own body schema keeps that schema in front of them. A code
|
|
maps to exactly one status in a document, and two classes may share a code only when
|
|
they agree on it. A documented
|
|
``RelayedHTTPError`` adds no component, since its status and code are only known at
|
|
runtime; it marks the operation with ``x-dify-relays-errors`` instead.
|
|
"""
|
|
|
|
paths = document.get("paths")
|
|
if not isinstance(paths, dict):
|
|
return document
|
|
|
|
ref: dict[str, object] = {"$ref": _ERROR_SCHEMA_REF_TEMPLATE.format(model=model.__name__)}
|
|
error_components: dict[str, _ErrorComponent] = {}
|
|
used = False
|
|
for path, path_item in paths.items():
|
|
if not isinstance(path_item, dict):
|
|
continue
|
|
for method, operation in path_item.items():
|
|
if not isinstance(operation, dict):
|
|
continue
|
|
exception_names: Mapping[str, object] = operation.pop(_ERROR_CODES_EXTENSION, {})
|
|
responses = operation.setdefault("responses", {})
|
|
if not isinstance(responses, dict):
|
|
continue
|
|
where = f"{method.upper()} {path}"
|
|
|
|
components_by_status: dict[str, set[str]] = {}
|
|
for exception_name in exception_names:
|
|
exception = pkgutil.resolve_name(exception_name)
|
|
if is_generic(exception):
|
|
# Covered by the operation's open ``default`` error.
|
|
continue
|
|
if issubclass(exception, RelayedHTTPError):
|
|
# Its status and code come from a downstream service at runtime; the open
|
|
# ``default`` error already documents it. The marker lets checks that compare
|
|
# served bodies with the document admit any code here.
|
|
operation[RELAYS_ERRORS_EXTENSION] = True
|
|
continue
|
|
status, code = resolve_error(exception)
|
|
name = error_component_name(code)
|
|
component = _ErrorComponent(code, status, exception, _details_model(exception), where)
|
|
known = error_components.setdefault(name, component)
|
|
if known.code != code:
|
|
raise ValueError(
|
|
f"error codes {known.code!r} ({known.where}) and {code!r} ({where}) both document as {name}"
|
|
)
|
|
if known.status != status:
|
|
raise ValueError(
|
|
f"error code {code!r} answers {status} on {where} but {known.status} on {known.where}; "
|
|
"a code maps to one status"
|
|
)
|
|
if known.details_model is not component.details_model:
|
|
raise ValueError(
|
|
f"error code {code!r} declares different details models on {known.where} and {where}"
|
|
)
|
|
components_by_status.setdefault(str(status), set()).add(name)
|
|
|
|
for status, names in components_by_status.items():
|
|
response = responses.setdefault(status, {"description": http_status_message(int(status))})
|
|
if not isinstance(response, dict):
|
|
continue
|
|
options: list[object] = [
|
|
{"$ref": _ERROR_SCHEMA_REF_TEMPLATE.format(model=name)} for name in sorted(names)
|
|
]
|
|
explicit = _explicit_json_schema(response)
|
|
if explicit is not None:
|
|
# The declared body and the documented errors can all answer this status.
|
|
options.insert(0, explicit)
|
|
# Generic errors (validation, auth) answer this status as well, and ``default``
|
|
# does not apply to a status the operation declares, so the open body stays.
|
|
options.append(dict(ref))
|
|
content = response.get("content")
|
|
if not isinstance(content, dict):
|
|
content = response["content"] = {}
|
|
media = content.get("application/json")
|
|
# Other media types the status declares (a binary or text body) stay as they are.
|
|
json_media = media if isinstance(media, dict) else {}
|
|
content["application/json"] = {**json_media, "schema": {"anyOf": options}}
|
|
used = True
|
|
|
|
responses.setdefault("default", {"description": "Error"})
|
|
for status, response in responses.items():
|
|
if status in components_by_status or not (status == "default" or str(status).startswith(("4", "5"))):
|
|
continue
|
|
if not isinstance(response, dict):
|
|
continue
|
|
content = response.get("content")
|
|
if not isinstance(content, dict) or not content:
|
|
response["content"] = {"application/json": {"schema": dict(ref)}}
|
|
used = True
|
|
continue
|
|
media = content.get("application/json")
|
|
if not isinstance(media, dict):
|
|
continue
|
|
declared = media.get("schema")
|
|
if declared is None:
|
|
media["schema"] = dict(ref)
|
|
used = True
|
|
elif declared != ref:
|
|
# A declared body never replaces the error body: the surface's handlers can
|
|
# still answer this status with it (validation, auth, unexpected errors).
|
|
media["schema"] = {"anyOf": [declared, dict(ref)]}
|
|
used = True
|
|
|
|
if not used:
|
|
return document
|
|
|
|
components = document.setdefault("components", {})
|
|
if not isinstance(components, dict):
|
|
return document
|
|
schemas = components.setdefault("schemas", {})
|
|
if not isinstance(schemas, dict):
|
|
return document
|
|
_add_model_schemas(schemas, model)
|
|
for name, component in error_components.items():
|
|
if component.details_model is not None:
|
|
_add_model_schemas(schemas, component.details_model)
|
|
if name in schemas:
|
|
raise ValueError(f"error component {name} (code {component.code!r}) collides with an existing component")
|
|
schemas[name] = component.schema(name, ref)
|
|
return document
|
|
|
|
|
|
class ErrorBodyFormatter(Protocol):
|
|
"""Last-touch hook over an error body before it goes on the wire."""
|
|
|
|
def finalize(self, e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]: ...
|
|
|
|
|
|
def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatter | None = None):
|
|
def _finalize(e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]:
|
|
if body_formatter is None:
|
|
return data
|
|
return body_formatter.finalize(e, data, status_code)
|
|
|
|
def handle_http_exception(e: HTTPException):
|
|
got_request_exception.send(current_app, exception=e)
|
|
|
|
# If Werkzeug already prepared a Response, just use it. This bypasses
|
|
# body_formatter entirely — surfaces with a formatter must not raise
|
|
# exceptions carrying a pre-built response.
|
|
if e.response is not None:
|
|
return e.response
|
|
|
|
status_code = getattr(e, "code", 500) or 500
|
|
|
|
# Build a safe, dict-like payload
|
|
default_data = {
|
|
"code": _snake_case(type(e).__name__),
|
|
"message": getattr(e, "description", http_status_message(status_code)),
|
|
"status": status_code,
|
|
}
|
|
if default_data["message"] == "Failed to decode JSON object: Expecting value: line 1 column 1 (char 0)":
|
|
default_data["message"] = "Invalid JSON payload received or JSON payload is empty."
|
|
|
|
# Preserve protocol headers while rendering our own JSON body.
|
|
headers = Headers(e.get_headers())
|
|
headers.remove("Content-Type")
|
|
headers.remove("Content-Length")
|
|
exc_headers = getattr(e, "headers", None)
|
|
if exc_headers:
|
|
headers.update(Headers(exc_headers))
|
|
|
|
# Payload per status
|
|
if status_code == 406 and api.default_mediatype is None:
|
|
data = {"code": "not_acceptable", "message": default_data["message"], "status": status_code}
|
|
return _finalize(e, data, status_code), status_code, headers
|
|
elif status_code == 400:
|
|
msg = default_data["message"]
|
|
if isinstance(msg, Mapping) and msg:
|
|
# Convert param errors like {"field": "reason"} into a friendly shape
|
|
param_key, param_value = next(iter(msg.items()))
|
|
data = {
|
|
"code": "invalid_param",
|
|
"message": str(param_value),
|
|
"params": param_key,
|
|
"status": status_code,
|
|
}
|
|
else:
|
|
data = {**default_data}
|
|
data.setdefault("code", "unknown")
|
|
return _finalize(e, data, status_code), status_code, headers
|
|
else:
|
|
data = {**default_data}
|
|
data.setdefault("code", "unknown")
|
|
# If you need WWW-Authenticate for 401, add it to headers
|
|
if status_code == 401:
|
|
headers.setdefault("WWW-Authenticate", 'Bearer realm="api"')
|
|
# Check if this is a forced logout error - clear cookies
|
|
error_code = getattr(e, "error_code", None)
|
|
if error_code == "unauthorized_and_force_logout":
|
|
# Add Set-Cookie headers to clear auth cookies
|
|
headers.setlist("Set-Cookie", build_force_logout_cookie_headers())
|
|
return _finalize(e, data, status_code), status_code, headers
|
|
|
|
def handle_value_error(e: ValueError):
|
|
got_request_exception.send(current_app, exception=e)
|
|
current_app.logger.exception("value_error in request handler")
|
|
status_code = 400
|
|
data = {"code": "invalid_param", "message": str(e), "status": status_code}
|
|
return _finalize(e, data, status_code), status_code
|
|
|
|
def handle_quota_exceeded(e: AppInvokeQuotaExceededError):
|
|
got_request_exception.send(current_app, exception=e)
|
|
status_code = 429
|
|
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)
|
|
|
|
status_code = 500
|
|
data: dict[str, Any] = getattr(e, "data", {"message": http_status_message(status_code)})
|
|
|
|
# 🔒 Normalize non-mapping data (e.g., if someone set e.data = Response)
|
|
if not isinstance(data, dict):
|
|
data = {"message": str(e)}
|
|
|
|
data.setdefault("code", "unknown")
|
|
data.setdefault("status", status_code)
|
|
|
|
# Note: Exception logging is handled by Flask/Flask-RESTX framework automatically
|
|
# Explicit log_exception call removed to avoid duplicate log entries
|
|
|
|
return _finalize(e, data, status_code), status_code
|
|
|
|
api.errorhandler(HTTPException)(handle_http_exception) # pyrefly: ignore[not-callable] - Flask-RESTX typing
|
|
api.errorhandler(ValueError)(handle_value_error) # pyrefly: ignore[not-callable] - Flask-RESTX typing
|
|
api.errorhandler(AppInvokeQuotaExceededError)(handle_quota_exceeded) # pyrefly: ignore[not-callable] - Flask-RESTX typing
|
|
api.errorhandler(PluginRuntimeError)(handle_plugin_runtime_error) # pyrefly: ignore[not-callable] - Flask-RESTX typing
|
|
api.errorhandler(Exception)(handle_general_exception) # pyrefly: ignore[not-callable] - Flask-RESTX typing
|
|
|
|
|
|
class ExternalApi(Api):
|
|
error_response_model: ClassVar[type[BaseModel]] = ErrorResponse
|
|
_authorizations = {
|
|
"Bearer": {
|
|
"bearerFormat": "API_KEY",
|
|
"description": "Use the Service API key as a Bearer token in the Authorization header.",
|
|
"scheme": "bearer",
|
|
"type": "http",
|
|
}
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
app: Blueprint | Flask,
|
|
*args,
|
|
error_body_formatter: ErrorBodyFormatter | None = None,
|
|
register_default_root: bool = True,
|
|
**kwargs,
|
|
):
|
|
self._error_body_formatter = error_body_formatter
|
|
self._handled_errors: dict[type[Exception], tuple[int, str]] = dict(_SHARED_HANDLED_ERRORS)
|
|
self._generic_errors: list[type[Exception]] = []
|
|
self._implicit_codes: dict[int, frozenset[str]] = {}
|
|
self._register_default_root = register_default_root
|
|
install_swagger_compatibility()
|
|
kwargs.setdefault("authorizations", self._authorizations)
|
|
kwargs.setdefault("security", "Bearer")
|
|
kwargs["add_specs"] = dify_config.SWAGGER_UI_ENABLED
|
|
kwargs["doc"] = dify_config.SWAGGER_UI_PATH if dify_config.SWAGGER_UI_ENABLED else False
|
|
if error_body_formatter is not None:
|
|
kwargs.setdefault("catch_all_404s", True)
|
|
# the override below patches a private flask-restx method; fail at
|
|
# startup (not at the first 404) if an upgrade removes it
|
|
if not callable(getattr(Api, "_should_use_fr_error_handler", None)):
|
|
raise RuntimeError("flask-restx no longer exposes _should_use_fr_error_handler; update ExternalApi")
|
|
|
|
# manual separate call on construction and init_app to ensure configs in kwargs effective
|
|
super().__init__(*args, app=None, **kwargs)
|
|
self.init_app(app, **kwargs)
|
|
register_external_error_handlers(self, body_formatter=error_body_formatter)
|
|
|
|
def finish_document(self, document: dict[str, object]) -> dict[str, object]:
|
|
"""A surface overrides this to rewrite its finished OpenAPI document (see
|
|
``DocumentFinisher`` in ``flask_restx_compat``); overrides must call ``super()``.
|
|
The default documents ``error_response_model`` on error responses."""
|
|
return attach_error_response_schema(
|
|
document, self.error_response_model, self.resolve_error, self.is_generic_error
|
|
)
|
|
|
|
def document_error_handler(self, exception: type[Exception], *, status: int, code: str) -> None:
|
|
"""Record what a surface-specific handler for ``exception`` emits, for ``error_responses``."""
|
|
self._handled_errors[exception] = (status, code)
|
|
self._implicit_codes.clear()
|
|
|
|
def document_generic_errors(self, *exceptions: type[Exception]) -> None:
|
|
"""Declare business-shaped errors this surface's clients handle centrally.
|
|
|
|
A generic error is one any operation may answer with and no caller branches on
|
|
per operation: a missing setup step, an uninitialized account, a revoked
|
|
license, a malformed argument. It is covered by every operation's open
|
|
``default`` error and admitted by ``implicit_error_codes``; an operation that
|
|
documents it gets no per-operation entry for it. Register them once, where the
|
|
surface is built, so the decision is visible in one place.
|
|
"""
|
|
for exception in exceptions:
|
|
if not is_business_error(exception):
|
|
raise ValueError(f"{exception.__name__} is generic already; only business-shaped errors are declared")
|
|
if keeps_placeholder_error_code(exception):
|
|
raise ValueError(f"{exception.__name__} must declare an error_code to be documented as generic")
|
|
self.resolve_error(exception)
|
|
if exception not in self._generic_errors:
|
|
self._generic_errors.append(exception)
|
|
self._implicit_codes.clear()
|
|
|
|
def is_generic_error(self, exception: type[Exception]) -> bool:
|
|
"""Whether this surface documents ``exception`` as generic (see ``document_generic_errors``)."""
|
|
return any(issubclass(exception, generic) for generic in self._generic_errors)
|
|
|
|
def resolve_error(self, exception: type[Exception]) -> tuple[int, str]:
|
|
"""The (status, ``code``) this surface emits for ``exception``.
|
|
|
|
Raises ``ValueError`` when no handler gives it a stable status and code.
|
|
"""
|
|
spec = _error_spec(exception, self._handled_errors, self._error_dispatch_order())
|
|
return spec["status"], self.error_code_for(spec)
|
|
|
|
def _error_dispatch_order(self) -> list[type[BaseException]]:
|
|
"""Handler types in the order Flask-RESTX tries them: the API's own, then new namespace ones."""
|
|
order: list[type[BaseException]] = list(self.error_handlers)
|
|
for namespace in self.namespaces:
|
|
order.extend(exception for exception in namespace.error_handlers if exception not in order)
|
|
return order
|
|
|
|
def error_code_for(self, spec: ErrorSpec) -> str:
|
|
"""The ``code`` this surface emits for a documented exception."""
|
|
return spec["default_code"]
|
|
|
|
def implicit_error_codes(self, status: int) -> set[str]:
|
|
"""Generic codes any operation may emit at ``status`` without listing them.
|
|
|
|
The document covers them with each operation's open ``default`` error; checks that
|
|
compare served error bodies with the document admit them beside the documented
|
|
business codes. The result is cached per status until the surface declares another
|
|
handler or generic error, since checks ask for it on every error response.
|
|
"""
|
|
cached = self._implicit_codes.get(status)
|
|
if cached is not None:
|
|
return set(cached)
|
|
codes = set(_framework_error_codes().get(status, ()))
|
|
codes.update(code for handled_status, code in self._handled_errors.values() if handled_status == status)
|
|
for exception in self._generic_errors:
|
|
generic_status, code = self.resolve_error(exception)
|
|
if generic_status == status:
|
|
codes.add(code)
|
|
if status == 500:
|
|
# Unhandled exceptions reach the catch-all handler.
|
|
codes.add("unknown")
|
|
self._implicit_codes[status] = frozenset(codes)
|
|
return codes
|
|
|
|
@override
|
|
def _register_doc(self, app_or_blueprint: Blueprint | Flask) -> None:
|
|
if self._add_specs and self._doc:
|
|
app_or_blueprint.add_url_rule(self._doc, "doc", self.render_doc)
|
|
# Api.base_path resolves the ``root`` endpoint. A caller that disables
|
|
# Flask-RESTX's 404 root must register its own resource with that endpoint.
|
|
if self._register_default_root:
|
|
|
|
def render_default_root() -> ResponseReturnValue:
|
|
self.render_root()
|
|
return "", 404
|
|
|
|
app_or_blueprint.add_url_rule(self.prefix or "/", "root", render_default_root)
|
|
|
|
@override
|
|
def _should_use_fr_error_handler(self):
|
|
# catch_all_404s makes flask-restx claim NotFound for ANY app path
|
|
# (it wraps the app-level handle_exception), so scope the claim to
|
|
# this blueprint's url prefix; other surfaces keep their own 404s.
|
|
if self._error_body_formatter is not None and not self._request_under_own_prefix():
|
|
return False
|
|
return super()._should_use_fr_error_handler()
|
|
|
|
def _request_under_own_prefix(self) -> bool:
|
|
prefix = self.blueprint.url_prefix if self.blueprint is not None else None
|
|
if not prefix:
|
|
return True
|
|
return request.path == prefix or request.path.startswith(prefix.rstrip("/") + "/")
|