Files
dify/api/libs/external_api.py
T
64a99c5556 chore: bump restx. now deco has type (#37391)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
2026-11-02 03:24:33 +00:00

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("/") + "/")