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