feat: unified ErrorBody contract for /openapi/v1 and difyctl (#37285)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Xiyuan Chen 2026-06-11 03:26:27 -07:00 committed by GitHub
parent 2bf66813ae
commit ba59d9a4ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1407 additions and 162 deletions

View File

@ -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,

View File

@ -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

View File

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

View File

@ -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(

View File

@ -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")

View File

@ -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)

View File

@ -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 |

View File

@ -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__

View File

@ -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}

View File

@ -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()

View File

@ -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 })
}
}

View File

@ -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: '<html>not json</html>',
})
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: '<html>not json</html>',
})
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: '<html>not json</html>',
})
const out = formatErrorForCli(err, { isErrTTY: false })
expect(out).toContain('raw_response: <html>not json</html>')
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')
})
})

View File

@ -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)

View File

@ -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<HttpClientError> {
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, '<html>bad gateway</html>')
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()
})
})

View File

@ -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<HttpClientError> {
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<BaseError> {
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 {

View File

@ -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<HttpClientError> {
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')
})
})

View File

@ -168,6 +168,20 @@ export type DevicePollRequest = {
device_code: string
}
export type ErrorBody = {
code: string
details?: Array<ErrorDetail> | null
hint?: string | null
message: string
status: number
}
export type ErrorDetail = {
loc?: Array<unknown>
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
}

View File

@ -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
*/