mirror of
https://github.com/langgenius/dify.git
synced 2026-06-12 11:32:08 +08:00
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:
parent
2bf66813ae
commit
ba59d9a4ac
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
241
api/controllers/openapi/_errors.py
Normal file
241
api/controllers/openapi/_errors.py
Normal 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."
|
||||
@ -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(
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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__
|
||||
|
||||
349
api/tests/unit_tests/controllers/openapi/test_error_contract.py
Normal file
349
api/tests/unit_tests/controllers/openapi/test_error_contract.py
Normal 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}
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
168
cli/src/errors/format.test.ts
Normal file
168
cli/src/errors/format.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
73
cli/src/http/error-mapper.test.ts
Normal file
73
cli/src/http/error-mapper.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user