diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index fc7ee94c91d..a117c180640 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -1,6 +1,7 @@ from flask import Blueprint from flask_restx import Namespace +from controllers.openapi._errors import ErrorBody, OpenApiErrorCode, OpenApiErrorFormatter from libs.device_flow_security import attach_anti_framing from libs.external_api import ExternalApi @@ -12,13 +13,14 @@ api = ExternalApi( version="1.0", title="OpenAPI", description="User-scoped programmatic API (bearer auth)", + error_body_formatter=OpenApiErrorFormatter(), ) openapi_ns = Namespace("openapi", description="User-scoped operations", path="/") # Register response/query models BEFORE importing controller modules so that # @openapi_ns.response / @openapi_ns.expect decorators can resolve model names. -from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models from controllers.openapi._models import ( AccountPayload, AccountResponse, @@ -89,6 +91,7 @@ register_schema_models( ) register_response_schema_models( openapi_ns, + ErrorBody, TagItem, UsageInfo, MessageMetadata, @@ -124,6 +127,9 @@ register_response_schema_models( ServerVersionResponse, HealthResponse, ) +# Standalone definition for contract codegen; ErrorBody.code stays an open +# string on the wire so old clients keep parsing future codes. +register_enum_models(openapi_ns, OpenApiErrorCode) from . import ( _meta, diff --git a/api/controllers/openapi/_contract.py b/api/controllers/openapi/_contract.py index 0979b01a357..a7dcf9093da 100644 --- a/api/controllers/openapi/_contract.py +++ b/api/controllers/openapi/_contract.py @@ -21,6 +21,7 @@ from pydantic import BaseModel, ValidationError from controllers.common.schema import query_params_from_model, query_params_from_request from controllers.openapi import openapi_ns +from controllers.openapi._errors import ErrorBody def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | None = None) -> Callable: @@ -51,6 +52,8 @@ def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | Non openapi_ns.doc(params=query_params_from_model(query))(wrapper) if body is not None: openapi_ns.expect(openapi_ns.models[body.__name__])(wrapper) + if query is not None or body is not None: + openapi_ns.response(422, "Validation error", openapi_ns.models[ErrorBody.__name__])(wrapper) return wrapper return decorator @@ -76,6 +79,7 @@ def returns(code: int, model: type[BaseModel], description: str | None = None) - return result openapi_ns.response(code, description or model.__name__, openapi_ns.models[model.__name__])(wrapper) + openapi_ns.response("default", "Error", openapi_ns.models[ErrorBody.__name__])(wrapper) return wrapper return decorator diff --git a/api/controllers/openapi/_errors.py b/api/controllers/openapi/_errors.py new file mode 100644 index 00000000000..38c068bd354 --- /dev/null +++ b/api/controllers/openapi/_errors.py @@ -0,0 +1,241 @@ +"""Canonical error contract for the /openapi/v1 surface. + +``ErrorBody`` is the only wire shape an /openapi/v1 endpoint may emit for a +non-2xx response (RFC 8628 device-flow responses excepted — that shape is +mandated by the OAuth spec):: + + 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 + 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 logging +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel +from werkzeug.exceptions import HTTPException + +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" + 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" + + +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 + 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 + + def __init__( + self, + message: str | None = None, + *, + hint: str | None = None, + details: list[ErrorDetail] | None = None, + ) -> None: + super().__init__(description=message) + if hint is not None: + self.hint = hint + 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(merged, status_code), + status=status_code, + hint=self._resolve_hint(e), + 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. + try: + e.data = wire # type: ignore[attr-defined] + except AttributeError: + pass + return wire + + def _resolve_code(self, e: Exception, status_code: int) -> str: + explicit = getattr(type(e), "error_code", None) + if isinstance(explicit, (OpenApiErrorCode, str)) and str(explicit) != "unknown": + return str(explicit) + return str(_CODE_BY_STATUS.get(status_code, OpenApiErrorCode.UNKNOWN)) + + def _resolve_message(self, merged: dict[str, Any], status_code: int) -> str: + if status_code >= 500: + 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." diff --git a/api/controllers/openapi/files.py b/api/controllers/openapi/files.py index e77e4bc3027..7326a4a922e 100644 --- a/api/controllers/openapi/files.py +++ b/api/controllers/openapi/files.py @@ -10,7 +10,6 @@ from werkzeug.exceptions import BadRequest import services from controllers.common.errors import ( BlockedFileExtensionError, - FilenameNotExistsError, FileTooLargeError, NoFileUploadedError, TooManyFilesError, @@ -18,6 +17,7 @@ from controllers.common.errors import ( ) from controllers.openapi import openapi_ns from controllers.openapi._contract import returns +from controllers.openapi._errors import FilenameNotExists from controllers.openapi.auth.composition import auth_router from controllers.openapi.auth.data import AuthData from extensions.ext_database import db @@ -52,7 +52,7 @@ class AppFileUploadApi(Resource): if not file.mimetype: raise UnsupportedFileTypeError() if not file.filename: - raise FilenameNotExistsError() + raise FilenameNotExists() try: upload_file = FileService(db.engine).upload_file( diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py index 902337703aa..0ff225271df 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -14,13 +14,13 @@ from __future__ import annotations from itertools import starmap from urllib import parse -from flask import jsonify, make_response from flask_restx import Resource -from werkzeug.exceptions import BadRequest, Forbidden, NotFound +from werkzeug.exceptions import BadRequest, NotFound from configs import dify_config from controllers.openapi import openapi_ns from controllers.openapi._contract import accepts, returns +from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded from controllers.openapi._models import ( MemberActionResponse, MemberInvitePayload, @@ -77,34 +77,16 @@ def _load_account(account_id: object) -> Account: return account -def _quota_error(*, code: str, message: str, hint: str) -> Forbidden: - err = Forbidden(message) - err.response = make_response( - jsonify({"code": code, "message": message, "hint": hint}), - 403, - ) - return err - - def _check_member_invite_quota(tenant_id: str) -> None: features = FeatureService.get_features(tenant_id) if features.billing.enabled: members = features.members if 0 < members.limit <= members.size: - raise _quota_error( - code="members.limit_exceeded", - message="Subscription member limit reached.", - hint="Upgrade your plan to invite more members or remove an existing member first.", - ) + raise MemberLimitExceeded() - if features.workspace_members.enabled: - if not features.workspace_members.is_available(1): - raise _quota_error( - code="workspace_members.license_exceeded", - message="Workspace member license capacity reached.", - hint="Contact your workspace administrator to expand the license seat count.", - ) + if features.workspace_members.enabled and not features.workspace_members.is_available(1): + raise MemberLicenseExceeded() @openapi_ns.route("/workspaces") diff --git a/api/libs/external_api.py b/api/libs/external_api.py index dd2d7347846..43f7c409f5b 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -1,8 +1,8 @@ import re from collections.abc import Mapping -from typing import Any +from typing import Any, Protocol, override -from flask import Blueprint, Flask, current_app, got_request_exception +from flask import Blueprint, Flask, current_app, got_request_exception, request from flask_restx import Api from werkzeug.exceptions import HTTPException from werkzeug.http import HTTP_STATUS_CODES @@ -17,11 +17,24 @@ def http_status_message(code): return HTTP_STATUS_CODES.get(code, "") -def register_external_error_handlers(api: Api): +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. + # 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 @@ -45,7 +58,7 @@ def register_external_error_handlers(api: Api): # 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 data, status_code, headers + return _finalize(e, data, status_code), status_code, headers elif status_code == 400: msg = default_data["message"] if isinstance(msg, Mapping) and msg: @@ -60,7 +73,7 @@ def register_external_error_handlers(api: Api): else: data = {**default_data} data.setdefault("code", "unknown") - return data, status_code, headers + return _finalize(e, data, status_code), status_code, headers else: data = {**default_data} data.setdefault("code", "unknown") @@ -72,20 +85,20 @@ def register_external_error_handlers(api: Api): if error_code == "unauthorized_and_force_logout": # Add Set-Cookie headers to clear auth cookies headers["Set-Cookie"] = build_force_logout_cookie_headers() - return data, status_code, 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 data, 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 data, status_code + return _finalize(e, data, status_code), status_code def handle_general_exception(e: Exception): got_request_exception.send(current_app, exception=e) @@ -103,7 +116,7 @@ def register_external_error_handlers(api: Api): # Note: Exception logging is handled by Flask/Flask-RESTX framework automatically # Explicit log_exception call removed to avoid duplicate log entries - return data, status_code + return _finalize(e, data, status_code), status_code api.errorhandler(HTTPException)(handle_http_exception) api.errorhandler(ValueError)(handle_value_error) @@ -121,14 +134,46 @@ class ExternalApi(Api): } } - def __init__(self, app: Blueprint | Flask, *args, **kwargs): + def __init__(self, app: Blueprint | Flask, *args, error_body_formatter: ErrorBodyFormatter | None = None, **kwargs): + self._error_body_formatter = error_body_formatter patch_swagger_for_inline_nested_dicts() 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 overrides below patch private flask-restx methods; fail at + # startup (not at the first 404) if an upgrade removes them + for private_hook in ("_should_use_fr_error_handler", "_help_on_404"): + if not callable(getattr(Api, private_hook, None)): + raise RuntimeError(f"flask-restx no longer exposes {private_hook}; update ExternalApi overrides") # manual separate call on construction and init_app to ensure configs in kwargs effective super().__init__(app=None, *args, **kwargs) self.init_app(app, **kwargs) - register_external_error_handlers(self) + register_external_error_handlers(self, body_formatter=error_body_formatter) + + @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("/") + "/") + + @override + def _help_on_404(self, message: str | None = None) -> str | None: + # flask-restx appends route suggestions post-handler; with a canonical + # formatter installed, that would corrupt the contract and enumerate + # routes to unauthenticated callers. + if self._error_body_formatter is not None: + return message + return super()._help_on_404(message) diff --git a/api/openapi/markdown/openapi-swagger.md b/api/openapi/markdown/openapi-swagger.md index 7214bcaa94d..451be85a1c3 100644 --- a/api/openapi/markdown/openapi-swagger.md +++ b/api/openapi/markdown/openapi-swagger.md @@ -24,6 +24,7 @@ User-scoped operations | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Health check | [HealthResponse](#healthresponse) | +| default | Error | [ErrorBody](#errorbody) | ### /_version @@ -33,6 +34,7 @@ User-scoped operations | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Server version | [ServerVersionResponse](#serverversionresponse) | +| default | Error | [ErrorBody](#errorbody) | ### /account @@ -42,6 +44,7 @@ User-scoped operations | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Account info | [AccountResponse](#accountresponse) | +| default | Error | [ErrorBody](#errorbody) | ### /account/sessions @@ -58,6 +61,8 @@ User-scoped operations | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Session list | [SessionListResponse](#sessionlistresponse) | +| 422 | Validation error | [ErrorBody](#errorbody) | +| default | Error | [ErrorBody](#errorbody) | ### /account/sessions/self @@ -67,6 +72,7 @@ User-scoped operations | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Session revoked | [RevokeResponse](#revokeresponse) | +| default | Error | [ErrorBody](#errorbody) | ### /account/sessions/{session_id} @@ -82,6 +88,7 @@ User-scoped operations | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Session revoked | [RevokeResponse](#revokeresponse) | +| default | Error | [ErrorBody](#errorbody) | ### /apps @@ -102,6 +109,8 @@ User-scoped operations | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | App list | [AppListResponse](#applistresponse) | +| 422 | Validation error | [ErrorBody](#errorbody) | +| default | Error | [ErrorBody](#errorbody) | ### /apps/{app_id}/check-dependencies @@ -117,6 +126,7 @@ User-scoped operations | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Dependencies checked | [CheckDependenciesResult](#checkdependenciesresult) | +| default | Error | [ErrorBody](#errorbody) | ### /apps/{app_id}/describe @@ -133,6 +143,8 @@ User-scoped operations | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | App description | [AppDescribeResponse](#appdescriberesponse) | +| 422 | Validation error | [ErrorBody](#errorbody) | +| default | Error | [ErrorBody](#errorbody) | ### /apps/{app_id}/export @@ -150,6 +162,8 @@ User-scoped operations | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Export successful | [AppDslExportResponse](#appdslexportresponse) | +| 422 | Validation error | [ErrorBody](#errorbody) | +| default | Error | [ErrorBody](#errorbody) | ### /apps/{app_id}/files/upload @@ -173,6 +187,7 @@ Upload a file to use as an input variable when running the app | 401 | Unauthorized — invalid or expired bearer token | | | 413 | File too large | | | 415 | Unsupported file type or blocked extension | | +| default | Error | [ErrorBody](#errorbody) | ### /apps/{app_id}/form/human_input/{form_token} @@ -204,6 +219,8 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Form submitted | [FormSubmitResponse](#formsubmitresponse) | +| 422 | Validation error | [ErrorBody](#errorbody) | +| default | Error | [ErrorBody](#errorbody) | ### /apps/{app_id}/run @@ -217,9 +234,10 @@ Upload a file to use as an input variable when running the app ##### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Run result (SSE stream) | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Run result (SSE stream) | | +| 422 | Validation error | [ErrorBody](#errorbody) | ### /apps/{app_id}/tasks/{task_id}/events @@ -252,6 +270,7 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Task stopped | [TaskStopResponse](#taskstopresponse) | +| default | Error | [ErrorBody](#errorbody) | ### /oauth/device/approve @@ -345,6 +364,8 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Permitted external apps list | [PermittedExternalAppsListResponse](#permittedexternalappslistresponse) | +| 422 | Validation error | [ErrorBody](#errorbody) | +| default | Error | [ErrorBody](#errorbody) | ### /workspaces @@ -354,6 +375,7 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Workspace list | [WorkspaceListResponse](#workspacelistresponse) | +| default | Error | [ErrorBody](#errorbody) | ### /workspaces/{workspace_id} @@ -369,6 +391,7 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) | +| default | Error | [ErrorBody](#errorbody) | ### /workspaces/{workspace_id}/apps/imports @@ -387,6 +410,8 @@ Upload a file to use as an input variable when running the app | 200 | Import completed | [Import](#import) | | 202 | Import pending confirmation | [Import](#import) | | 400 | Import failed | [Import](#import) | +| 422 | Validation error | [ErrorBody](#errorbody) | +| default | Error | [ErrorBody](#errorbody) | ### /workspaces/{workspace_id}/apps/imports/{import_id}/confirm @@ -404,6 +429,7 @@ Upload a file to use as an input variable when running the app | ---- | ----------- | ------ | | 200 | Import confirmed | [Import](#import) | | 400 | Import failed | [Import](#import) | +| default | Error | [ErrorBody](#errorbody) | ### /workspaces/{workspace_id}/members @@ -421,6 +447,8 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Member list | [MemberListResponse](#memberlistresponse) | +| 422 | Validation error | [ErrorBody](#errorbody) | +| default | Error | [ErrorBody](#errorbody) | #### POST ##### Parameters @@ -435,6 +463,8 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | | 201 | Member invited | [MemberInviteResponse](#memberinviteresponse) | +| 422 | Validation error | [ErrorBody](#errorbody) | +| default | Error | [ErrorBody](#errorbody) | ### /workspaces/{workspace_id}/members/{member_id} @@ -451,6 +481,7 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Member removed | [MemberActionResponse](#memberactionresponse) | +| default | Error | [ErrorBody](#errorbody) | ### /workspaces/{workspace_id}/members/{member_id}/role @@ -468,6 +499,8 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Role updated | [MemberActionResponse](#memberactionresponse) | +| 422 | Validation error | [ErrorBody](#errorbody) | +| default | Error | [ErrorBody](#errorbody) | ### /workspaces/{workspace_id}/switch @@ -483,6 +516,7 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) | +| default | Error | [ErrorBody](#errorbody) | --- ### Models @@ -693,6 +727,28 @@ mode is a closed enum. | client_id | string | | Yes | | device_code | string | | Yes | +#### ErrorBody + +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. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| details | [ [ErrorDetail](#errordetail) ] | | No | +| hint | string | | No | +| message | string | | Yes | +| status | integer | | Yes | + +#### ErrorDetail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| loc | [ ] | | No | +| msg | string | | Yes | +| type | string | | Yes | + #### FileResponse | Name | Type | Description | Required | @@ -844,6 +900,12 @@ Strict (extra='forbid'). | retriever_resources | [ object ] | | No | | usage | [UsageInfo](#usageinfo) | | No | +#### OpenApiErrorCode + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| OpenApiErrorCode | string | | | + #### Package | Name | Type | Description | Required | diff --git a/api/tests/unit_tests/controllers/openapi/test_contract.py b/api/tests/unit_tests/controllers/openapi/test_contract.py index 990437e37f4..b8773f56df8 100644 --- a/api/tests/unit_tests/controllers/openapi/test_contract.py +++ b/api/tests/unit_tests/controllers/openapi/test_contract.py @@ -208,3 +208,41 @@ def test_accepts_body_emits_expect_through_guard_stack(): apidoc = getattr(view, "__apidoc__", {}) assert apidoc.get("expect") # body schema advertised via @openapi_ns.expect + + +def _response_model_name(entry) -> str: + """Extract the model name from a flask-restx __apidoc__ response entry. + + flask-restx stores responses as ``(description, model, kwargs)`` tuples + where ``model.name`` is the registered schema name. + """ + if isinstance(entry, tuple) and len(entry) >= 2: + model = entry[1] + return getattr(model, "name", "") or "" + return "" + + +def test_accepts_documents_422_error_response(app): + from controllers.openapi._errors import ErrorBody + + @accepts(query=ContractQuery) + def view(*, query): + return query + + doc = getattr(view, "__apidoc__", {}) + responses = doc.get("responses", {}) + assert "422" in responses + assert _response_model_name(responses["422"]) == ErrorBody.__name__ + + +def test_returns_documents_default_error_response(app): + from controllers.openapi._errors import ErrorBody + + @returns(200, ContractResp) + def view(): + return ContractResp(value=1) + + doc = getattr(view, "__apidoc__", {}) + responses = doc.get("responses", {}) + assert "default" in responses + assert _response_model_name(responses["default"]) == ErrorBody.__name__ diff --git a/api/tests/unit_tests/controllers/openapi/test_error_contract.py b/api/tests/unit_tests/controllers/openapi/test_error_contract.py new file mode 100644 index 00000000000..12293de321d --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_error_contract.py @@ -0,0 +1,349 @@ +"""Wire-contract tests for the canonical /openapi/v1 error body.""" + +from unittest.mock import MagicMock, patch + +import pytest +from werkzeug.exceptions import ( + BadGateway, + BadRequest, + Conflict, + Forbidden, + InternalServerError, + NotFound, + Unauthorized, + UnprocessableEntity, +) + +from controllers.common.errors import ( + BlockedFileExtensionError, + FileTooLargeError, + NoFileUploadedError, + TooManyFilesError, + UnsupportedFileTypeError, +) +from controllers.openapi._errors import ( + ErrorBody, + ErrorDetail, + FilenameNotExists, + MemberLicenseExceeded, + MemberLimitExceeded, + OpenApiError, + OpenApiErrorCode, + OpenApiErrorFormatter, +) +from controllers.service_api.app.error import ( + AppUnavailableError, + CompletionRequestError, + ConversationCompletedError, + ProviderModelCurrentlyNotSupportError, + ProviderNotInitializeError, + ProviderQuotaExceededError, +) +from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError + + +@pytest.fixture +def fmt() -> OpenApiErrorFormatter: + return OpenApiErrorFormatter() + + +class TestErrorBodyModel: + def test_minimal_body_serializes_without_optional_fields(self): + body = ErrorBody(code=OpenApiErrorCode.NOT_FOUND, message="app not found", status=404) + + wire = body.model_dump(mode="json", exclude_none=True) + + assert wire == {"code": "not_found", "message": "app not found", "status": 404} + + def test_full_body_round_trips(self): + body = ErrorBody( + code=OpenApiErrorCode.INVALID_PARAM, + message="Request validation failed", + status=422, + hint="check the request payload", + details=[ErrorDetail(type="int_parsing", loc=["page"], msg="must be >= 1")], + ) + + wire = body.model_dump(mode="json", exclude_none=True) + + assert wire["details"] == [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}] + assert ErrorBody.model_validate(wire) == body + + def test_code_field_is_open_string_for_forward_compat(self): + # Old CLIs must not hard-fail when a future server adds a code, so the + # schema type is str; enum membership is enforced by the formatter tests. + body = ErrorBody.model_validate({"code": "some_future_code", "message": "x", "status": 400}) + + assert body.code == "some_future_code" + + +class TestOpenApiErrorFormatter: + def test_plain_werkzeug_exception_maps_code_from_status(self, fmt): + e = NotFound("app not found") + data = {"code": "not_found", "message": "app not found", "status": 404} + + wire = fmt.finalize(e, data, 404) + + assert wire == {"code": "not_found", "message": "app not found", "status": 404} + + def test_422_maps_to_invalid_param(self, fmt): + e = UnprocessableEntity("workspace_id is required for name-based lookup") + data = {"code": "unprocessable_entity", "message": e.description, "status": 422} + + wire = fmt.finalize(e, data, 422) + + assert wire["code"] == "invalid_param" + + def test_flask_restx_abort_data_path_yields_canonical_body(self, fmt): + # Simulates _contract.py's abort(422, message=..., errors=...): flask_restx + # attaches kwargs to e.data, which handle_error would otherwise put on the + # wire verbatim (no code/status). + e = UnprocessableEntity() + e.data = { + "message": "Request validation failed", + "errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1", "extra": "drop me"}], + } + data = {"code": "unprocessable_entity", "message": e.description, "status": 422} + + wire = fmt.finalize(e, data, 422) + + assert wire["code"] == "invalid_param" + assert wire["message"] == "Request validation failed" + assert wire["status"] == 422 + assert wire["details"] == [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}] + # the override channel now carries the canonical body + assert e.data == wire + + def test_finalize_is_idempotent(self, fmt): + e = UnprocessableEntity() + e.data = { + "message": "Request validation failed", + "errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}], + } + data = {"code": "unprocessable_entity", "message": e.description, "status": 422} + + first = fmt.finalize(e, data, 422) + second = fmt.finalize(e, data, 422) + + assert second == first + + def test_malformed_canonical_details_falls_back_instead_of_raising(self, fmt): + # finalize runs inside the framework error handler; a ValidationError + # escaping it would replace the response with an unformatted 500 + e = UnprocessableEntity() + e.data = {"message": "broken", "details": [{"bad": "shape"}]} + data = {"code": "unprocessable_entity", "message": "broken", "status": 422} + + wire = fmt.finalize(e, data, 422) + + assert wire == {"code": "invalid_param", "message": "Unprocessable Entity", "status": 422} + + def test_base_http_exception_error_code_wins_over_status_map(self, fmt): + e = ProviderQuotaExceededError() + data = dict(e.data) + + wire = fmt.finalize(e, data, 400) + + assert wire["code"] == "provider_quota_exceeded" + assert wire["status"] == 400 + + def test_hint_attribute_is_emitted(self, fmt): + e = Conflict("seat limit") + e.hint = "remove a member first" + data = {"code": "conflict", "message": "seat limit", "status": 409} + + wire = fmt.finalize(e, data, 409) + + assert wire["hint"] == "remove a member first" + + def test_params_shape_becomes_details(self, fmt): + e = ValueError("is required") + data = {"code": "invalid_param", "message": "is required", "params": "email", "status": 400} + + wire = fmt.finalize(e, data, 400) + + assert "params" not in wire + assert wire["details"] == [{"type": "invalid", "loc": ["email"], "msg": "is required"}] + + def test_catch_all_exception_never_leaks_str_e(self, fmt): + e = RuntimeError("postgres password=hunter2 connection refused") + data = {"message": str(e), "code": "unknown", "status": 500} + + wire = fmt.finalize(e, data, 500) + + assert wire["code"] == "internal_server_error" + assert "hunter2" not in wire["message"] + + def test_unmapped_status_falls_back_to_unknown(self, fmt): + from werkzeug.exceptions import Gone + + e = Gone() + data = {"code": "gone", "message": e.description, "status": 410} + + wire = fmt.finalize(e, data, 410) + + assert wire["code"] == "unknown" + + def test_openapi_error_subclass_is_throw_and_done(self, fmt): + # The dedicated throwable: subclass declares status + code + message once, + # call sites just `raise`; the formatter emits everything verbatim. + class TeapotError(OpenApiError): + code = 418 + error_code = OpenApiErrorCode.INVALID_PARAM + description = "kettle says no" + + e = TeapotError(details=[ErrorDetail(type="invalid", loc=["kettle"], msg="too hot")]) + data = {"code": "im_a_teapot", "message": e.description, "status": 418} + + wire = fmt.finalize(e, data, 418) + + assert wire["code"] == OpenApiErrorCode.INVALID_PARAM + assert wire["message"] == TeapotError.description + assert wire["details"] == [{"type": "invalid", "loc": ["kettle"], "msg": "too hot"}] + + def test_openapi_error_message_override(self, fmt): + e = OpenApiError("custom reason") + data = {"code": "bad_request", "message": e.description, "status": 400} + + wire = fmt.finalize(e, data, 400) + + assert wire["message"] == "custom reason" + assert wire["code"] == "bad_request" + + def test_every_emitted_code_is_an_enum_member(self, fmt): + # Guard against the formatter inventing codes outside the contract. + cases = [ + (NotFound("x"), {"code": "not_found", "message": "x", "status": 404}, 404), + (ProviderQuotaExceededError(), dict(ProviderQuotaExceededError().data), 400), + (ValueError("x"), {"code": "invalid_param", "message": "x", "status": 400}, 400), + ] + for e, data, status in cases: + wire = fmt.finalize(e, data, status) + assert wire["code"] in {c.value for c in OpenApiErrorCode} + + +class TestQuotaExceptions: + @pytest.mark.parametrize("exc_class", [MemberLimitExceeded, MemberLicenseExceeded]) + def test_quota_exception_carries_declared_code_and_message(self, fmt, exc_class): + # Single source: assertions read the class attributes, no re-typed strings. + e = exc_class() + data = {"code": "forbidden", "message": e.description, "status": 403} + + wire = fmt.finalize(e, data, 403) + + assert wire["code"] == exc_class.error_code + assert wire["message"] == exc_class.description + assert wire["hint"] == exc_class.hint + assert wire["status"] == 403 + + +class TestWireContract: + """End-to-end: request in, canonical JSON out, through the real openapi blueprint.""" + + def test_accepts_422_carries_code_status_details(self, openapi_app, bypass_pipeline): + client = openapi_app.test_client() + + resp = client.get("/openapi/v1/apps?page=0") + + assert resp.status_code == 422 + wire = resp.get_json() + ErrorBody.model_validate(wire) + assert wire["code"] == "invalid_param" + assert wire["status"] == 422 + assert wire["details"] + + def test_unknown_route_404_is_canonical_without_route_suggestions(self, openapi_app): + client = openapi_app.test_client() + + resp = client.get("/openapi/v1/definitely-not-a-route") + + assert resp.status_code == 404 + wire = resp.get_json() + ErrorBody.model_validate(wire) + assert wire["code"] == "not_found" + assert "did you mean" not in wire["message"].lower() + + def test_404_outside_blueprint_prefix_is_not_claimed(self, openapi_app): + # catch_all_404s wraps the app-level exception handler; the prefix + # guard must keep non-/openapi/v1 paths on the app's own 404 handling + client = openapi_app.test_client() + + resp = client.get("/console/definitely-not-a-route") + + assert resp.status_code == 404 + # not intercepted → Flask's default HTML 404, not the canonical JSON body + assert "application/json" not in (resp.content_type or "") + + @patch("controllers.openapi.oauth_device.DeviceFlowRedis") + def test_oauth_device_token_keeps_rfc8628_shape(self, mock_redis_cls, openapi_app): + store = MagicMock() + mock_redis_cls.return_value = store + store.record_poll.return_value = None # not SlowDownDecision.SLOW_DOWN + store.load_by_device_code.return_value = None # unknown code → expired_token + + client = openapi_app.test_client() + + resp = client.post( + "/openapi/v1/oauth/device/token", + json={"client_id": "difyctl", "device_code": "nope"}, + ) + + assert resp.status_code == 400 + wire = resp.get_json() + assert wire == {"error": "expired_token"} + + +ERROR_MATRIX = [ + (BadRequest("x"), 400, "bad_request"), + (Unauthorized("x"), 401, "unauthorized"), + (Forbidden("x"), 403, "forbidden"), + (NotFound("x"), 404, "not_found"), + (Conflict("x"), 409, "conflict"), + (UnprocessableEntity("x"), 422, "invalid_param"), + (InternalServerError(), 500, "internal_server_error"), + (BadGateway("x"), 502, "bad_gateway"), + (AppUnavailableError(), 400, "app_unavailable"), + (ConversationCompletedError(), 400, "conversation_completed"), + (ProviderNotInitializeError(), 400, "provider_not_initialize"), + (ProviderQuotaExceededError(), 400, "provider_quota_exceeded"), + (ProviderModelCurrentlyNotSupportError(), 400, "model_currently_not_support"), + (CompletionRequestError(), 400, "completion_request_error"), + (InvokeRateLimitHttpError(), 429, "rate_limit_error"), + (FileTooLargeError(), 413, "file_too_large"), + (UnsupportedFileTypeError(), 415, "unsupported_file_type"), + (NoFileUploadedError(), 400, "no_file_uploaded"), + (TooManyFilesError(), 400, "too_many_files"), + (FilenameNotExists(), 400, "filename_not_exists"), + (BlockedFileExtensionError(), 400, "file_extension_blocked"), + (MemberLimitExceeded(), 403, "member_limit_exceeded"), + (MemberLicenseExceeded(), 403, "member_license_exceeded"), +] + + +class TestErrorMatrix: + @pytest.mark.parametrize( + ("exc", "status", "expected_code"), + ERROR_MATRIX, + ids=lambda v: type(v).__name__ if isinstance(v, Exception) else str(v), + ) + def test_every_known_error_path_yields_canonical_code(self, fmt, exc, status, expected_code): + data = dict(getattr(exc, "data", None) or {"message": str(exc), "status": status}) + + wire = fmt.finalize(exc, data, status) + + assert wire["code"] == expected_code + assert wire["status"] == status + assert wire["code"] in {c.value for c in OpenApiErrorCode} + ErrorBody.model_validate(wire) + + +class TestErrorCodeEnumRegistration: + def test_enum_registered_with_all_values(self): + from controllers.openapi import openapi_ns + from controllers.openapi._errors import OpenApiErrorCode + + model = openapi_ns.models.get("OpenApiErrorCode") + assert model is not None + schema = model.__schema__ + assert schema["type"] == "string" + assert set(schema["enum"]) == {member.value for member in OpenApiErrorCode} diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py index 6bb13ad3227..4c09491ab59 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py @@ -29,9 +29,10 @@ import pytest from flask import Flask from flask.views import MethodView from pydantic import ValidationError -from werkzeug.exceptions import BadRequest, Forbidden, NotFound, UnprocessableEntity +from werkzeug.exceptions import BadRequest, NotFound, UnprocessableEntity from controllers.openapi import bp as openapi_bp +from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePayload from controllers.openapi.workspaces import ( WorkspaceMemberApi, @@ -507,11 +508,7 @@ def _invite_request(app, ws_id: str, acct_id: uuid.UUID): def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch): - """SaaS billing plan member cap → 403 with `members.limit_exceeded`. - - Verifies the envelope shape the CLI error-mapper relies on (code + - message + hint on the wire body). - """ + """SaaS billing plan member cap → MemberLimitExceeded (403).""" ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMembersApi() @@ -538,18 +535,14 @@ def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch): with _invite_request(app, ws_id, acct_id): _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(Forbidden) as exc_info: + with pytest.raises(MemberLimitExceeded): api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) - body = exc_info.value.response.json - assert body["code"] == "members.limit_exceeded" - assert "Subscription member limit" in body["message"] - assert body["hint"] invite_mock.assert_not_called() def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, monkeypatch): - """EE License workspace_members cap → 403 with `workspace_members.license_exceeded`. + """EE License workspace_members cap → MemberLicenseExceeded (403). Note: billing.enabled is False (EE without SaaS billing); only the license cap fires. @@ -584,13 +577,9 @@ def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, mo with _invite_request(app, ws_id, acct_id): _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(Forbidden) as exc_info: + with pytest.raises(MemberLicenseExceeded): api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) - body = exc_info.value.response.json - assert body["code"] == "workspace_members.license_exceeded" - assert "license" in body["message"].lower() - assert body["hint"] invite_mock.assert_not_called() diff --git a/cli/src/errors/base.ts b/cli/src/errors/base.ts index 0ad438a7437..f1f9ca98535 100644 --- a/cli/src/errors/base.ts +++ b/cli/src/errors/base.ts @@ -1,3 +1,4 @@ +import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' import type { ErrorCodeValue, ExitCodeValue } from './codes' import type { ErrorEnvelope, PrintableError } from './format' import { ErrorCode, exitFor } from './codes' @@ -83,6 +84,7 @@ type HttpClientErrorOptions = BaseErrorOptions & { readonly method?: string readonly url?: string readonly rawResponse?: string + readonly serverError?: ErrorBody } export class HttpClientError extends BaseError { @@ -90,6 +92,7 @@ export class HttpClientError extends BaseError { readonly method?: string readonly url?: string readonly rawResponse?: string + readonly serverError?: ErrorBody constructor(opts: HttpClientErrorOptions) { super(opts) @@ -97,6 +100,7 @@ export class HttpClientError extends BaseError { this.method = opts.method this.url = opts.url this.rawResponse = opts.rawResponse + this.serverError = opts.serverError } override toEnvelope(): ErrorEnvelope { @@ -109,6 +113,8 @@ export class HttpClientError extends BaseError { envelope.error.url = this.url if (this.rawResponse !== undefined) envelope.error.raw_response = this.rawResponse + if (this.serverError !== undefined) + envelope.error.server = this.serverError return envelope } @@ -119,6 +125,7 @@ export class HttpClientError extends BaseError { method: this.method, url: this.url, rawResponse: this.rawResponse, + serverError: this.serverError, } } @@ -145,4 +152,8 @@ export class HttpClientError extends BaseError { } return new HttpClientError({ ...this.snapshot(), rawResponse }) } + + withServerError(serverError: ErrorBody): HttpClientError { + return new HttpClientError({ ...this.snapshot(), serverError }) + } } diff --git a/cli/src/errors/format.test.ts b/cli/src/errors/format.test.ts new file mode 100644 index 00000000000..dd75b53591c --- /dev/null +++ b/cli/src/errors/format.test.ts @@ -0,0 +1,168 @@ +import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' +import { afterEach, describe, expect, it } from 'vitest' + +import { setVerbose } from '@/framework/context' +import { HttpClientError } from './base' +import { ErrorCode } from './codes' +import { formatErrorForCli } from './format' + +type ValidationErrorOverrides = { + readonly cliHint?: string + readonly serverHint?: string + readonly details?: ErrorBody['details'] +} + +function validationError(overrides: ValidationErrorOverrides = {}): HttpClientError { + const details + = overrides.details + ?? [ + { type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' }, + { type: 'missing', loc: ['inputs', 'query'], msg: 'field required' }, + ] + return new HttpClientError({ + code: ErrorCode.Server4xxOther, + message: 'Request validation failed', + httpStatus: 422, + hint: overrides.cliHint, + serverError: { + code: 'invalid_param', + message: 'Request validation failed', + status: 422, + hint: overrides.serverHint, + details, + }, + }) +} + +afterEach(() => { + setVerbose(false) +}) + +describe('formatErrorForCli — human', () => { + it('prints server code, message, and details without verbose', () => { + const out = formatErrorForCli(validationError({ serverHint: 'check the page parameter' }), { isErrTTY: false }) + + expect(out).toContain('invalid_param: Request validation failed') + expect(out).toContain('- page: must be >= 1 (int_parsing)') + expect(out).toContain('- inputs.query: field required (missing)') + expect(out).toContain('check the page parameter') + expect(out).not.toContain('raw_response') + }) + + it('falls back to cli code when no server code', () => { + const err = new HttpClientError({ code: ErrorCode.Server5xx, message: 'server error (HTTP 502)', httpStatus: 502 }) + + const out = formatErrorForCli(err, { isErrTTY: false }) + + expect(out).toContain('server_5xx: server error (HTTP 502)') + }) + + it('cli hint wins over server hint; server hint fills when cli sent none', () => { + const withBothHints = validationError({ cliHint: 'cli local hint', serverHint: 'check the page parameter', details: [] }) + expect(formatErrorForCli(withBothHints, { isErrTTY: false })).toContain('cli local hint') + expect(formatErrorForCli(withBothHints, { isErrTTY: false })).not.toContain('check the page parameter') + + // no cli hint → server hint shown + const noCliHint = validationError({ serverHint: 'check the page parameter', details: [] }) + expect(formatErrorForCli(noCliHint, { isErrTTY: false })).toContain('check the page parameter') + + // no server hint → cli hint shown + const noServerHint = new HttpClientError({ + code: ErrorCode.AuthExpired, + message: 'session expired', + hint: 'run difyctl auth login', + }) + expect(formatErrorForCli(noServerHint, { isErrTTY: false })).toContain('run difyctl auth login') + }) + + it('omits the loc prefix when a detail has no loc', () => { + const out = formatErrorForCli( + validationError({ details: [{ type: 'invalid', loc: [], msg: 'body required' }] }), + { isErrTTY: false }, + ) + + expect(out).toContain('- body required (invalid)') + expect(out).not.toContain('- : body required') + }) + + it('hints at -v when a raw response exists but is hidden', () => { + const err = new HttpClientError({ + code: ErrorCode.Server4xxOther, + message: 'request failed (HTTP 400)', + httpStatus: 400, + rawResponse: 'not json', + }) + + const out = formatErrorForCli(err, { isErrTTY: false }) + + expect(out).toContain('run again with -v to see the raw server response') + expect(out).not.toContain('raw_response') + }) + + it('no -v hint when the server body parsed', () => { + const err = new HttpClientError({ + code: ErrorCode.Server4xxOther, + message: 'Request validation failed', + httpStatus: 422, + rawResponse: '{"code":"invalid_param","message":"Request validation failed","status":422}', + serverError: { code: 'invalid_param', message: 'Request validation failed', status: 422 }, + }) + + const out = formatErrorForCli(err, { isErrTTY: false }) + + expect(out).not.toContain('run again with -v') + }) + + it('existing hints win over the -v hint', () => { + const err = new HttpClientError({ + code: ErrorCode.Server4xxOther, + message: 'request failed (HTTP 400)', + httpStatus: 400, + hint: 'cli hint', + rawResponse: 'not json', + }) + + const out = formatErrorForCli(err, { isErrTTY: false }) + + expect(out).toContain('cli hint') + expect(out).not.toContain('run again with -v') + }) + + it('shows raw_response instead of the -v hint when verbose', () => { + setVerbose(true) + const err = new HttpClientError({ + code: ErrorCode.Server4xxOther, + message: 'request failed (HTTP 400)', + httpStatus: 400, + rawResponse: 'not json', + }) + + const out = formatErrorForCli(err, { isErrTTY: false }) + + expect(out).toContain('raw_response: not json') + expect(out).not.toContain('run again with -v') + }) + + it('renders request and http_status lines', () => { + const err = new HttpClientError({ + code: ErrorCode.Server5xx, + message: 'upstream boom', + httpStatus: 502, + method: 'GET', + url: 'https://api.dify.ai/v1/me', + }) + const out = formatErrorForCli(err, { isErrTTY: false }) + expect(out).toContain('request: GET https://api.dify.ai/v1/me') + expect(out).toContain('http_status: 502') + }) +}) + +describe('formatErrorForCli — json', () => { + it('envelope nests the whole server error', () => { + const out = JSON.parse(formatErrorForCli(validationError(), { format: 'json' })) + + expect(out.error.server.code).toBe('invalid_param') + expect(out.error.server.details).toHaveLength(2) + expect(out.error.code).toBe('server_4xx_other') + }) +}) diff --git a/cli/src/errors/format.ts b/cli/src/errors/format.ts index b8c3fe6cab7..f32bba3482c 100644 --- a/cli/src/errors/format.ts +++ b/cli/src/errors/format.ts @@ -1,7 +1,10 @@ +import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' import { isVerbose } from '@/framework/context' import { redactBearer } from '@/http/sanitize' import { colorEnabled, colorScheme } from '@/sys/io/color' +const RAW_RESPONSE_HINT = 'run again with -v to see the raw server response' + export type FormatErrorOptions = { readonly format?: string readonly isErrTTY?: boolean @@ -16,6 +19,7 @@ export type ErrorEnvelope = { method?: string url?: string raw_response?: string + server?: ErrorBody } } @@ -42,12 +46,30 @@ function renderEnvelope(env: ErrorEnvelope): string { return JSON.stringify(env) } +// CLI-authored hint wins: it knows local remediation (e.g. which command to +// run); the server hint fills in when the CLI has nothing for this error. +function resolveHint(e: ErrorEnvelope['error']): string | undefined { + if (e.hint !== undefined) + return e.hint + if (e.server?.hint != null) + return e.server.hint + const rawHiddenAndUnparsed = e.server === undefined && Boolean(e.raw_response) && !isVerbose() + return rawHiddenAndUnparsed ? RAW_RESPONSE_HINT : undefined +} + function renderHuman(env: ErrorEnvelope, isErrTTY: boolean): string { const cs = colorScheme(colorEnabled(isErrTTY)) const e = env.error - const lines: string[] = [`${e.code}: ${e.message}`] - if (e.hint !== undefined) - lines.push(`${cs.magenta('hint:')} ${cs.cyan(e.hint)}`) + const server = e.server + const headerCode = server?.code ?? e.code + const lines: string[] = [`${headerCode}: ${e.message}`] + for (const d of server?.details ?? []) { + const loc = (d.loc ?? []).join('.') + lines.push(` - ${loc ? `${loc}: ` : ''}${d.msg} (${d.type})`) + } + const hint = resolveHint(e) + if (hint !== undefined) + lines.push(`${cs.magenta('hint:')} ${cs.cyan(hint)}`) if (e.method !== undefined && e.url !== undefined) lines.push(`request: ${e.method} ${e.url}`) if (e.http_status !== undefined) diff --git a/cli/src/http/error-mapper.test.ts b/cli/src/http/error-mapper.test.ts new file mode 100644 index 00000000000..ab0397cce73 --- /dev/null +++ b/cli/src/http/error-mapper.test.ts @@ -0,0 +1,73 @@ +import type { HttpClientError } from '@/errors/base' +import { describe, expect, it } from 'vitest' +import { ErrorCode } from '@/errors/codes' +import { classifyResponse } from './error-mapper' + +function res(status: number, body: unknown): Response { + return new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +const req = new Request('https://dify.test/openapi/v1/apps') + +function classified(status: number, body: unknown): Promise { + return classifyResponse(req, res(status, body)) +} + +describe('classifyResponse — canonical ErrorBody', () => { + it('attaches the parsed body whole as serverError', async () => { + const body = { + code: 'invalid_param', + message: 'Request validation failed', + status: 422, + hint: 'check the page parameter', + details: [{ type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' }], + } + + const err = await classified(422, body) + + expect(err.serverError).toEqual(body) + expect(err.message).toBe('Request validation failed') + expect(err.code).toBe(ErrorCode.Server4xxOther) + }) + + it('401 classifies by status as AuthExpired with CLI login hint', async () => { + const err = await classified(401, { + code: 'unauthorized', + message: 'session expired or revoked', + status: 401, + }) + + expect(err.code).toBe(ErrorCode.AuthExpired) + expect(err.hint).toBe('run \'difyctl auth login\' to sign in again') + }) + + it('unknown future server code is data, not behavior — status bucket decides', async () => { + const err = await classified(409, { + code: 'some_future_code', + message: 'nope', + status: 409, + }) + + expect(err.code).toBe(ErrorCode.Server4xxOther) + expect(err.serverError?.code).toBe('some_future_code') + }) +}) + +describe('classifyResponse — non-conforming bodies (no fallback by design)', () => { + it('non-JSON body yields no serverError, classification by status', async () => { + const err = await classified(502, 'bad gateway') + + expect(err.code).toBe(ErrorCode.Server5xx) + expect(err.serverError).toBeUndefined() + }) + + it('RFC 8628 string error field yields no serverError and a generic message', async () => { + const err = await classified(400, { error: 'slow_down' }) + + expect(err.message).toBe('request failed (HTTP 400)') + expect(err.serverError).toBeUndefined() + }) +}) diff --git a/cli/src/http/error-mapper.ts b/cli/src/http/error-mapper.ts index cb0d03c2068..f2b3b1bf605 100644 --- a/cli/src/http/error-mapper.ts +++ b/cli/src/http/error-mapper.ts @@ -1,70 +1,85 @@ +import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen' +import type { ErrorCodeValue } from '@/errors/codes' +import { zErrorBody } from '@dify/contracts/api/openapi/zod.gen' import { BaseError, HttpClientError, newError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { redactBearer } from './sanitize' -type WireFields = { - code?: string - message?: string - hint?: string +const AUTH_EXPIRED_MESSAGE = 'session expired or revoked' +const AUTH_LOGIN_HINT = 'run \'difyctl auth login\' to sign in again' + +// How one HTTP status bucket classifies: CLI code, message fallback when the +// body is not a canonical ErrorBody, optional CLI hint, raw-body retention. +type StatusClass = { + readonly code: ErrorCodeValue + readonly fallbackMessage: (status: number) => string + readonly hint?: string + readonly includeRaw: boolean } -type WireEnvelope = WireFields & { - error?: WireFields +const AUTH_EXPIRED_CLASS: StatusClass = { + code: ErrorCode.AuthExpired, + fallbackMessage: () => AUTH_EXPIRED_MESSAGE, + hint: AUTH_LOGIN_HINT, + includeRaw: false, } -async function readBody(response: Response): Promise<{ raw: string, parsed?: WireEnvelope }> { +const SERVER_5XX_CLASS: StatusClass = { + code: ErrorCode.Server5xx, + fallbackMessage: status => `server error (HTTP ${status})`, + includeRaw: true, +} + +const SERVER_4XX_CLASS: StatusClass = { + code: ErrorCode.Server4xxOther, + fallbackMessage: status => `request failed (HTTP ${status})`, + includeRaw: true, +} + +function statusClass(status: number): StatusClass { + if (status === 401) + return AUTH_EXPIRED_CLASS + if (status >= 500) + return SERVER_5XX_CLASS + return SERVER_4XX_CLASS +} + +function parseServerError(raw: string): ErrorBody | undefined { + if (raw === '') + return undefined + let parsed: unknown + try { + parsed = JSON.parse(raw) + } + catch { + return undefined + } + const result = zErrorBody.safeParse(parsed) + return result.success ? result.data : undefined +} + +export async function classifyResponse(request: Request, response: Response): Promise { let raw = '' try { - raw = await response.text() + raw = await response.clone().text() } catch { - return { raw: '' } + // ignore read errors; raw stays '' } - if (raw === '') - return { raw } - try { - return { raw, parsed: JSON.parse(raw) as WireEnvelope } - } - catch { - return { raw } - } -} -export async function classifyResponse(request: Request, response: Response): Promise { - const { parsed, raw } = await readBody(response.clone()) - const wire: WireFields = parsed?.error ?? parsed ?? {} + const serverError = parseServerError(raw) const status = response.status - const url = redactBearer(response.url || request.url) - const method = request.method - - if (status === 401) { - return HttpClientError.from(newError( - ErrorCode.AuthExpired, - wire.message ?? 'session expired or revoked', - )) - .withHint(wire.hint ?? 'run \'difyctl auth login\' to sign in again') - .withHttpStatus(status) - .withRequest(method, url) - } - - if (status >= 500) { - return HttpClientError.from(newError( - ErrorCode.Server5xx, - wire.message ?? `server error (HTTP ${status})`, - )) - .withHttpStatus(status) - .withRequest(method, url) - .withRawResponse(raw) - } - - const err = HttpClientError.from(newError( - ErrorCode.Server4xxOther, - wire.message ?? `request failed (HTTP ${status})`, - )) - .withHttpStatus(status) - .withRequest(method, url) - .withRawResponse(raw) - return wire.hint !== undefined ? err.withHint(wire.hint) : err + const c = statusClass(status) + return new HttpClientError({ + code: c.code, + message: serverError?.message ?? c.fallbackMessage(status), + hint: c.hint, + httpStatus: status, + method: request.method, + url: redactBearer(response.url || request.url), + rawResponse: c.includeRaw && raw !== '' ? raw : undefined, + serverError, + }) } export function classifyTransportError(err: unknown): BaseError { diff --git a/cli/src/http/orpc.test.ts b/cli/src/http/orpc.test.ts index 05e9a8405f0..c99232b975f 100644 --- a/cli/src/http/orpc.test.ts +++ b/cli/src/http/orpc.test.ts @@ -1,4 +1,5 @@ import type { StubServer } from '@test/fixtures/stub-server' +import type { HttpClientError } from '@/errors/base' import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { afterEach, describe, expect, it } from 'vitest' import { isHttpClientError } from '@/errors/base' @@ -33,66 +34,49 @@ describe('createOpenApiClient error mapping', () => { await stub?.stop() }) - it('recovers Dify message + hint from a top-level 4xx envelope', async () => { - stub = await startStubServer(cap => jsonResponder(403, { message: 'no access', hint: 'ask an admin' }, cap)) + async function classifiedError(status: number, body: unknown): Promise { + stub = await startStubServer(cap => jsonResponder(status, body, cap)) const orpc = orpcClient(stub.url) - const caught = await catchErr(() => orpc.account.get()) + if (!isHttpClientError(caught)) + throw new Error(`expected HttpClientError, got: ${String(caught)}`) + return caught + } - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) { - expect(caught.code).toBe(ErrorCode.Server4xxOther) - expect(caught.httpStatus).toBe(403) - expect(caught.message).toBe('no access') - expect(caught.hint).toBe('ask an admin') - // Parity with the transport path: the migrated endpoint's error keeps the request - // method/url and the raw body, so formatted errors still print the `request:` line - // and the raw-response dump (not just message/hint). - expect(caught.method).toBe('GET') - expect(caught.url).toContain('/account') - expect(caught.rawResponse).toContain('no access') - } + it('recovers Dify message from a canonical ErrorBody 4xx response', async () => { + const caught = await classifiedError(403, { code: 'access_denied', message: 'no access', status: 403 }) + + expect(caught.code).toBe(ErrorCode.Server4xxOther) + expect(caught.httpStatus).toBe(403) + expect(caught.message).toBe('no access') + // Parity with the transport path: the migrated endpoint's error keeps the request + // method/url and the raw body, so formatted errors still print the `request:` line + // and the raw-response dump (not just message/hint). + expect(caught.method).toBe('GET') + expect(caught.url).toContain('/account') + expect(caught.rawResponse).toContain('no access') }) - it('recovers from a nested { error: { message, hint } } envelope and keeps the auth code on 401', async () => { - stub = await startStubServer(cap => jsonResponder(401, { error: { message: 'expired', hint: 'relogin' } }, cap)) - const orpc = orpcClient(stub.url) + it('reads server message from canonical ErrorBody on 401 and keeps the auth code', async () => { + const caught = await classifiedError(401, { code: 'unauthorized', message: 'expired', status: 401 }) - const caught = await catchErr(() => orpc.account.get()) - - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) { - expect(caught.code).toBe(ErrorCode.AuthExpired) - expect(caught.httpStatus).toBe(401) - expect(caught.message).toBe('expired') - expect(caught.hint).toBe('relogin') - } + expect(caught.code).toBe(ErrorCode.AuthExpired) + expect(caught.httpStatus).toBe(401) + expect(caught.message).toBe('expired') }) - it('falls back to the default auth-login hint when the body carries none', async () => { - stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap)) - const orpc = orpcClient(stub.url) + it('uses CLI default auth-login hint for non-conforming 401 body', async () => { + const caught = await classifiedError(401, { error: 'expired' }) - const caught = await catchErr(() => orpc.account.get()) - - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) { - expect(caught.code).toBe(ErrorCode.AuthExpired) - expect(caught.hint).toContain('difyctl auth login') - } + expect(caught.code).toBe(ErrorCode.AuthExpired) + expect(caught.hint).toContain('difyctl auth login') }) - it('maps 5xx to Server5xx', async () => { - stub = await startStubServer(cap => jsonResponder(503, { message: 'down for maintenance' }, cap)) - const orpc = orpcClient(stub.url) + it('maps 5xx to Server5xx with message from canonical ErrorBody', async () => { + const caught = await classifiedError(503, { code: 'service_unavailable', message: 'down for maintenance', status: 503 }) - const caught = await catchErr(() => orpc.account.get()) - - expect(isHttpClientError(caught)).toBe(true) - if (isHttpClientError(caught)) { - expect(caught.code).toBe(ErrorCode.Server5xx) - expect(caught.httpStatus).toBe(503) - expect(caught.message).toBe('down for maintenance') - } + expect(caught.code).toBe(ErrorCode.Server5xx) + expect(caught.httpStatus).toBe(503) + expect(caught.message).toBe('down for maintenance') }) }) diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index b957d0ad9ff..619eef42d2a 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -168,6 +168,20 @@ export type DevicePollRequest = { device_code: string } +export type ErrorBody = { + code: string + details?: Array | null + hint?: string | null + message: string + status: number +} + +export type ErrorDetail = { + loc?: Array + msg: string + type: string +} + export type FileResponse = { conversation_id?: string | null created_at?: number | null @@ -278,6 +292,37 @@ export type MessageMetadata = { usage?: UsageInfo } +export type OpenApiErrorCode + = | 'app_unavailable' + | 'bad_gateway' + | 'bad_request' + | 'completion_request_error' + | 'conflict' + | 'conversation_completed' + | 'file_extension_blocked' + | 'file_too_large' + | 'filename_not_exists' + | 'forbidden' + | 'internal_server_error' + | 'invalid_param' + | 'member_license_exceeded' + | 'member_limit_exceeded' + | 'method_not_allowed' + | 'model_currently_not_support' + | 'no_file_uploaded' + | 'not_acceptable' + | 'not_found' + | 'provider_not_initialize' + | 'provider_quota_exceeded' + | 'rate_limit_error' + | 'request_entity_too_large' + | 'too_many_files' + | 'too_many_requests' + | 'unauthorized' + | 'unknown' + | 'unsupported_file_type' + | 'unsupported_media_type' + export type Package = { plugin_unique_identifier: string version?: string | null @@ -401,6 +446,12 @@ export type GetHealthData = { url: '/_health' } +export type GetHealthErrors = { + default: ErrorBody +} + +export type GetHealthError = GetHealthErrors[keyof GetHealthErrors] + export type GetHealthResponses = { 200: HealthResponse } @@ -414,6 +465,12 @@ export type GetVersionData = { url: '/_version' } +export type GetVersionErrors = { + default: ErrorBody +} + +export type GetVersionError = GetVersionErrors[keyof GetVersionErrors] + export type GetVersionResponses = { 200: ServerVersionResponse } @@ -427,6 +484,12 @@ export type GetAccountData = { url: '/account' } +export type GetAccountErrors = { + default: ErrorBody +} + +export type GetAccountError = GetAccountErrors[keyof GetAccountErrors] + export type GetAccountResponses = { 200: AccountResponse } @@ -443,6 +506,13 @@ export type GetAccountSessionsData = { url: '/account/sessions' } +export type GetAccountSessionsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetAccountSessionsError = GetAccountSessionsErrors[keyof GetAccountSessionsErrors] + export type GetAccountSessionsResponses = { 200: SessionListResponse } @@ -457,6 +527,13 @@ export type DeleteAccountSessionsSelfData = { url: '/account/sessions/self' } +export type DeleteAccountSessionsSelfErrors = { + default: ErrorBody +} + +export type DeleteAccountSessionsSelfError + = DeleteAccountSessionsSelfErrors[keyof DeleteAccountSessionsSelfErrors] + export type DeleteAccountSessionsSelfResponses = { 200: RevokeResponse } @@ -473,6 +550,13 @@ export type DeleteAccountSessionsBySessionIdData = { url: '/account/sessions/{session_id}' } +export type DeleteAccountSessionsBySessionIdErrors = { + default: ErrorBody +} + +export type DeleteAccountSessionsBySessionIdError + = DeleteAccountSessionsBySessionIdErrors[keyof DeleteAccountSessionsBySessionIdErrors] + export type DeleteAccountSessionsBySessionIdResponses = { 200: RevokeResponse } @@ -494,6 +578,13 @@ export type GetAppsData = { url: '/apps' } +export type GetAppsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetAppsError = GetAppsErrors[keyof GetAppsErrors] + export type GetAppsResponses = { 200: AppListResponse } @@ -509,6 +600,13 @@ export type GetAppsByAppIdCheckDependenciesData = { url: '/apps/{app_id}/check-dependencies' } +export type GetAppsByAppIdCheckDependenciesErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdCheckDependenciesError + = GetAppsByAppIdCheckDependenciesErrors[keyof GetAppsByAppIdCheckDependenciesErrors] + export type GetAppsByAppIdCheckDependenciesResponses = { 200: CheckDependenciesResult } @@ -527,6 +625,14 @@ export type GetAppsByAppIdDescribeData = { url: '/apps/{app_id}/describe' } +export type GetAppsByAppIdDescribeErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetAppsByAppIdDescribeError + = GetAppsByAppIdDescribeErrors[keyof GetAppsByAppIdDescribeErrors] + export type GetAppsByAppIdDescribeResponses = { 200: AppDescribeResponse } @@ -546,6 +652,13 @@ export type GetAppsByAppIdExportData = { url: '/apps/{app_id}/export' } +export type GetAppsByAppIdExportErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetAppsByAppIdExportError = GetAppsByAppIdExportErrors[keyof GetAppsByAppIdExportErrors] + export type GetAppsByAppIdExportResponses = { 200: AppDslExportResponse } @@ -575,6 +688,7 @@ export type PostAppsByAppIdFilesUploadErrors = { 415: { [key: string]: unknown } + default: ErrorBody } export type PostAppsByAppIdFilesUploadError @@ -616,6 +730,14 @@ export type PostAppsByAppIdFormHumanInputByFormTokenData = { url: '/apps/{app_id}/form/human_input/{form_token}' } +export type PostAppsByAppIdFormHumanInputByFormTokenErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostAppsByAppIdFormHumanInputByFormTokenError + = PostAppsByAppIdFormHumanInputByFormTokenErrors[keyof PostAppsByAppIdFormHumanInputByFormTokenErrors] + export type PostAppsByAppIdFormHumanInputByFormTokenResponses = { 200: FormSubmitResponse } @@ -632,6 +754,12 @@ export type PostAppsByAppIdRunData = { url: '/apps/{app_id}/run' } +export type PostAppsByAppIdRunErrors = { + 422: ErrorBody +} + +export type PostAppsByAppIdRunError = PostAppsByAppIdRunErrors[keyof PostAppsByAppIdRunErrors] + export type PostAppsByAppIdRunResponses = { 200: { [key: string]: unknown @@ -670,6 +798,13 @@ export type PostAppsByAppIdTasksByTaskIdStopData = { url: '/apps/{app_id}/tasks/{task_id}/stop' } +export type PostAppsByAppIdTasksByTaskIdStopErrors = { + default: ErrorBody +} + +export type PostAppsByAppIdTasksByTaskIdStopError + = PostAppsByAppIdTasksByTaskIdStopErrors[keyof PostAppsByAppIdTasksByTaskIdStopErrors] + export type PostAppsByAppIdTasksByTaskIdStopResponses = { 200: TaskStopResponse } @@ -763,6 +898,14 @@ export type GetPermittedExternalAppsData = { url: '/permitted-external-apps' } +export type GetPermittedExternalAppsErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetPermittedExternalAppsError + = GetPermittedExternalAppsErrors[keyof GetPermittedExternalAppsErrors] + export type GetPermittedExternalAppsResponses = { 200: PermittedExternalAppsListResponse } @@ -777,6 +920,12 @@ export type GetWorkspacesData = { url: '/workspaces' } +export type GetWorkspacesErrors = { + default: ErrorBody +} + +export type GetWorkspacesError = GetWorkspacesErrors[keyof GetWorkspacesErrors] + export type GetWorkspacesResponses = { 200: WorkspaceListResponse } @@ -792,6 +941,13 @@ export type GetWorkspacesByWorkspaceIdData = { url: '/workspaces/{workspace_id}' } +export type GetWorkspacesByWorkspaceIdErrors = { + default: ErrorBody +} + +export type GetWorkspacesByWorkspaceIdError + = GetWorkspacesByWorkspaceIdErrors[keyof GetWorkspacesByWorkspaceIdErrors] + export type GetWorkspacesByWorkspaceIdResponses = { 200: WorkspaceDetailResponse } @@ -810,6 +966,8 @@ export type PostWorkspacesByWorkspaceIdAppsImportsData = { export type PostWorkspacesByWorkspaceIdAppsImportsErrors = { 400: Import + 422: ErrorBody + default: ErrorBody } export type PostWorkspacesByWorkspaceIdAppsImportsError @@ -835,6 +993,7 @@ export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmData = { export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors = { 400: Import + default: ErrorBody } export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmError @@ -859,6 +1018,14 @@ export type GetWorkspacesByWorkspaceIdMembersData = { url: '/workspaces/{workspace_id}/members' } +export type GetWorkspacesByWorkspaceIdMembersErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetWorkspacesByWorkspaceIdMembersError + = GetWorkspacesByWorkspaceIdMembersErrors[keyof GetWorkspacesByWorkspaceIdMembersErrors] + export type GetWorkspacesByWorkspaceIdMembersResponses = { 200: MemberListResponse } @@ -875,6 +1042,14 @@ export type PostWorkspacesByWorkspaceIdMembersData = { url: '/workspaces/{workspace_id}/members' } +export type PostWorkspacesByWorkspaceIdMembersErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PostWorkspacesByWorkspaceIdMembersError + = PostWorkspacesByWorkspaceIdMembersErrors[keyof PostWorkspacesByWorkspaceIdMembersErrors] + export type PostWorkspacesByWorkspaceIdMembersResponses = { 201: MemberInviteResponse } @@ -892,6 +1067,13 @@ export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdData = { url: '/workspaces/{workspace_id}/members/{member_id}' } +export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors = { + default: ErrorBody +} + +export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdError + = DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors[keyof DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors] + export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses = { 200: MemberActionResponse } @@ -909,6 +1091,14 @@ export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleData = { url: '/workspaces/{workspace_id}/members/{member_id}/role' } +export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleError + = PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors[keyof PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors] + export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses = { 200: MemberActionResponse } @@ -925,6 +1115,13 @@ export type PostWorkspacesByWorkspaceIdSwitchData = { url: '/workspaces/{workspace_id}/switch' } +export type PostWorkspacesByWorkspaceIdSwitchErrors = { + default: ErrorBody +} + +export type PostWorkspacesByWorkspaceIdSwitchError + = PostWorkspacesByWorkspaceIdSwitchErrors[keyof PostWorkspacesByWorkspaceIdSwitchErrors] + export type PostWorkspacesByWorkspaceIdSwitchResponses = { 200: WorkspaceDetailResponse } diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 2d632814d06..3c815ad2b6c 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -156,6 +156,30 @@ export const zDevicePollRequest = z.object({ device_code: z.string(), }) +/** + * ErrorDetail + */ +export const zErrorDetail = z.object({ + loc: z.array(z.unknown()).optional().default([]), + msg: z.string(), + type: z.string(), +}) + +/** + * ErrorBody + * + * 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. + */ +export const zErrorBody = z.object({ + code: z.string(), + details: z.array(zErrorDetail).nullish(), + hint: z.string().nullish(), + message: z.string(), + status: z.int(), +}) + /** * FileResponse */ @@ -308,6 +332,41 @@ export const zMemberRoleUpdatePayload = z.object({ role: z.enum(['admin', 'normal']), }) +/** + * OpenApiErrorCode + */ +export const zOpenApiErrorCode = z.enum([ + 'app_unavailable', + 'bad_gateway', + 'bad_request', + 'completion_request_error', + 'conflict', + 'conversation_completed', + 'file_extension_blocked', + 'file_too_large', + 'filename_not_exists', + 'forbidden', + 'internal_server_error', + 'invalid_param', + 'member_license_exceeded', + 'member_limit_exceeded', + 'method_not_allowed', + 'model_currently_not_support', + 'no_file_uploaded', + 'not_acceptable', + 'not_found', + 'provider_not_initialize', + 'provider_quota_exceeded', + 'rate_limit_error', + 'request_entity_too_large', + 'too_many_files', + 'too_many_requests', + 'unauthorized', + 'unknown', + 'unsupported_file_type', + 'unsupported_media_type', +]) + /** * Package */