mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 05:41:09 +08:00
825 lines
30 KiB
Python
825 lines
30 KiB
Python
from collections.abc import Callable
|
|
from functools import wraps
|
|
from typing import Literal
|
|
|
|
import pytest
|
|
from flask import Blueprint, Flask
|
|
from flask_restx import Api, Namespace, Resource
|
|
from flask_restx.swagger import Swagger
|
|
from pydantic import BaseModel
|
|
from werkzeug.datastructures import WWWAuthenticate
|
|
from werkzeug.exceptions import BadRequest, NotFound, ServiceUnavailable, Unauthorized
|
|
|
|
from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_CSRF_TOKEN, COOKIE_NAME_REFRESH_TOKEN
|
|
from core.errors.error import AppInvokeQuotaExceededError
|
|
from core.plugin.impl.exc import PluginRuntimeError
|
|
from libs.exception import BaseHTTPException
|
|
from libs.external_api import (
|
|
ERROR_CODES_VENDOR_KEY,
|
|
ErrorResponse,
|
|
ExternalApi,
|
|
attach_error_response_schema,
|
|
document_errors,
|
|
error_responses,
|
|
keeps_placeholder_error_code,
|
|
)
|
|
from libs.rate_limit import _BearerRateLimited
|
|
|
|
|
|
def _create_api_app():
|
|
app = Flask(__name__)
|
|
bp = Blueprint("t", __name__)
|
|
api = ExternalApi(bp)
|
|
|
|
@api.route("/bad-request")
|
|
class Bad(Resource):
|
|
def get(self):
|
|
raise BadRequest("invalid input")
|
|
|
|
@api.route("/unauth")
|
|
class Unauth(Resource):
|
|
def get(self):
|
|
raise Unauthorized("auth required")
|
|
|
|
@api.route("/value-error")
|
|
class ValErr(Resource):
|
|
def get(self):
|
|
raise ValueError("boom")
|
|
|
|
@api.route("/quota")
|
|
class Quota(Resource):
|
|
def get(self):
|
|
raise AppInvokeQuotaExceededError("quota exceeded")
|
|
|
|
@api.route("/general")
|
|
class Gen(Resource):
|
|
def get(self):
|
|
raise RuntimeError("oops")
|
|
|
|
@api.route("/plugin-runtime-error")
|
|
class PluginRuntime(Resource):
|
|
def get(self):
|
|
raise PluginRuntimeError(
|
|
"Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1",
|
|
lambda_request_id="lambda-request-id",
|
|
)
|
|
|
|
# Note: We avoid altering default_mediatype to keep normal error paths
|
|
|
|
# Special 400 message rewrite
|
|
@api.route("/json-empty")
|
|
class JsonEmpty(Resource):
|
|
def get(self):
|
|
e = BadRequest()
|
|
# Force the specific message the handler rewrites
|
|
e.description = "Failed to decode JSON object: Expecting value: line 1 column 1 (char 0)"
|
|
raise e
|
|
|
|
# 400 mapping payload path
|
|
@api.route("/param-errors")
|
|
class ParamErrors(Resource):
|
|
def get(self):
|
|
e = BadRequest()
|
|
# Coerce a mapping description to trigger param error shaping
|
|
e.description = {"field": "is required"}
|
|
raise e
|
|
|
|
# The per-token rate limit carries Retry-After in an explicit headers attribute.
|
|
@api.route("/rate-limited")
|
|
class RateLimited(Resource):
|
|
def get(self):
|
|
raise _BearerRateLimited(23)
|
|
|
|
app.register_blueprint(bp, url_prefix="/api")
|
|
return app
|
|
|
|
|
|
def test_external_api_error_handlers_basic_paths():
|
|
app = _create_api_app()
|
|
client = app.test_client()
|
|
|
|
# 400
|
|
res = client.get("/api/bad-request")
|
|
assert res.status_code == 400
|
|
data = res.get_json()
|
|
assert data["code"] == "bad_request"
|
|
assert data["status"] == 400
|
|
|
|
# 401
|
|
res = client.get("/api/unauth")
|
|
assert res.status_code == 401
|
|
assert "WWW-Authenticate" in res.headers
|
|
|
|
# 400 ValueError
|
|
res = client.get("/api/value-error")
|
|
assert res.status_code == 400
|
|
assert res.get_json()["code"] == "invalid_param"
|
|
|
|
# 500 general
|
|
res = client.get("/api/general")
|
|
assert res.status_code == 500
|
|
assert res.get_json()["status"] == 500
|
|
|
|
|
|
def test_external_api_json_message_and_bad_request_rewrite():
|
|
app = _create_api_app()
|
|
client = app.test_client()
|
|
|
|
# JSON empty special rewrite
|
|
res = client.get("/api/json-empty")
|
|
assert res.status_code == 400
|
|
assert res.get_json()["message"] == "Invalid JSON payload received or JSON payload is empty."
|
|
|
|
|
|
def test_external_api_plugin_runtime_error(mocker):
|
|
mocker.patch("libs.external_api.get_request_id", return_value="api-request-id")
|
|
app = _create_api_app()
|
|
|
|
res = app.test_client().get("/api/plugin-runtime-error")
|
|
|
|
assert res.status_code == 502
|
|
assert res.get_json() == {
|
|
"code": "plugin_runtime_error",
|
|
"message": "Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1",
|
|
"details": {
|
|
"request_id": "api-request-id",
|
|
"lambda_request_id": "lambda-request-id",
|
|
},
|
|
"status": 502,
|
|
}
|
|
|
|
|
|
def test_external_api_param_mapping_and_quota():
|
|
app = _create_api_app()
|
|
client = app.test_client()
|
|
|
|
# Param errors mapping payload path
|
|
res = client.get("/api/param-errors")
|
|
assert res.status_code == 400
|
|
data = res.get_json()
|
|
assert data["code"] == "invalid_param"
|
|
assert data["params"] == "field"
|
|
|
|
# Quota path — depending on Flask-RESTX internals it may be handled
|
|
res = client.get("/api/quota")
|
|
assert res.status_code in (400, 429)
|
|
|
|
|
|
def test_external_api_carries_exception_headers_to_429_response():
|
|
# Keep supporting explicit headers used by enforce_bearer_rate_limit.
|
|
app = _create_api_app()
|
|
res = app.test_client().get("/api/rate-limited")
|
|
assert res.status_code == 429
|
|
assert res.headers["Retry-After"] == "23"
|
|
assert (res.get_json() or {})["status"] == 429
|
|
|
|
|
|
def test_external_api_explicit_headers_override_generated_headers():
|
|
class ExplicitRetryAfter(ServiceUnavailable):
|
|
headers = {"retry-after": "23"}
|
|
|
|
app = Flask(__name__)
|
|
api = ExternalApi(app)
|
|
|
|
@api.route("/pending")
|
|
class Pending(Resource):
|
|
def get(self):
|
|
raise ExplicitRetryAfter(retry_after=5)
|
|
|
|
response = app.test_client().get("/pending")
|
|
assert response.status_code == 503
|
|
assert response.headers.getlist("Retry-After") == ["23"]
|
|
assert response.mimetype == "application/json"
|
|
assert response.get_json()["status"] == 503
|
|
|
|
|
|
def test_external_api_preserves_multiple_authentication_challenges():
|
|
challenges = [WWWAuthenticate("Basic", {"realm": "api"}), WWWAuthenticate("Bearer", {"realm": "api"})]
|
|
app = Flask(__name__)
|
|
api = ExternalApi(app)
|
|
|
|
@api.route("/unauth")
|
|
class Unauth(Resource):
|
|
def get(self):
|
|
raise Unauthorized(www_authenticate=challenges)
|
|
|
|
response = app.test_client().get("/unauth")
|
|
assert response.status_code == 401
|
|
assert response.headers.getlist("WWW-Authenticate") == [str(challenge) for challenge in challenges]
|
|
assert response.mimetype == "application/json"
|
|
|
|
|
|
def test_unauthorized_and_force_logout_clears_cookies():
|
|
"""Test that UnauthorizedAndForceLogout error clears auth cookies"""
|
|
|
|
class UnauthorizedAndForceLogout(BaseHTTPException):
|
|
error_code = "unauthorized_and_force_logout"
|
|
description = "Unauthorized and force logout."
|
|
code = 401
|
|
|
|
app = Flask(__name__)
|
|
bp = Blueprint("test", __name__)
|
|
api = ExternalApi(bp)
|
|
|
|
@api.route("/force-logout")
|
|
class ForceLogout(Resource): # type: ignore
|
|
def get(self): # type: ignore
|
|
raise UnauthorizedAndForceLogout()
|
|
|
|
app.register_blueprint(bp, url_prefix="/api")
|
|
client = app.test_client()
|
|
|
|
# Set cookies first
|
|
client.set_cookie(COOKIE_NAME_ACCESS_TOKEN, "test_access_token")
|
|
client.set_cookie(COOKIE_NAME_CSRF_TOKEN, "test_csrf_token")
|
|
client.set_cookie(COOKIE_NAME_REFRESH_TOKEN, "test_refresh_token")
|
|
|
|
# Make request that should trigger cookie clearing
|
|
res = client.get("/api/force-logout")
|
|
|
|
# Verify response
|
|
assert res.status_code == 401
|
|
data = res.get_json()
|
|
assert data["code"] == "unauthorized_and_force_logout"
|
|
assert data["status"] == 401
|
|
assert "WWW-Authenticate" in res.headers
|
|
|
|
# Verify Set-Cookie headers are present to clear cookies
|
|
set_cookie_headers = res.headers.getlist("Set-Cookie")
|
|
assert len(set_cookie_headers) == 3, f"Expected 3 Set-Cookie headers, got {len(set_cookie_headers)}"
|
|
|
|
# Verify each cookie is being cleared (empty value and expired)
|
|
cookie_names_found = set()
|
|
for cookie_header in set_cookie_headers:
|
|
# Check for cookie names
|
|
if COOKIE_NAME_ACCESS_TOKEN in cookie_header:
|
|
cookie_names_found.add(COOKIE_NAME_ACCESS_TOKEN)
|
|
assert '""' in cookie_header or "=" in cookie_header # Empty value
|
|
assert "Expires=Thu, 01 Jan 1970" in cookie_header # Expired
|
|
elif COOKIE_NAME_CSRF_TOKEN in cookie_header:
|
|
cookie_names_found.add(COOKIE_NAME_CSRF_TOKEN)
|
|
assert '""' in cookie_header or "=" in cookie_header
|
|
assert "Expires=Thu, 01 Jan 1970" in cookie_header
|
|
elif COOKIE_NAME_REFRESH_TOKEN in cookie_header:
|
|
cookie_names_found.add(COOKIE_NAME_REFRESH_TOKEN)
|
|
assert '""' in cookie_header or "=" in cookie_header
|
|
assert "Expires=Thu, 01 Jan 1970" in cookie_header
|
|
|
|
# Verify all three cookies are present
|
|
assert len(cookie_names_found) == 3
|
|
assert COOKIE_NAME_ACCESS_TOKEN in cookie_names_found
|
|
assert COOKIE_NAME_CSRF_TOKEN in cookie_names_found
|
|
assert COOKIE_NAME_REFRESH_TOKEN in cookie_names_found
|
|
|
|
|
|
class _PassthroughFormatter:
|
|
def finalize(self, _e: Exception, data: dict[str, object], _status_code: int) -> dict[str, object]:
|
|
return data
|
|
|
|
|
|
def test_missing_flask_restx_private_hook_fails_at_startup(monkeypatch: pytest.MonkeyPatch):
|
|
"""The guard exists so a flask-restx upgrade breaks construction, not the first 404."""
|
|
monkeypatch.delattr(Api, "_should_use_fr_error_handler")
|
|
|
|
with pytest.raises(RuntimeError, match="_should_use_fr_error_handler"):
|
|
ExternalApi(Blueprint("guard", __name__), error_body_formatter=_PassthroughFormatter())
|
|
|
|
|
|
def _unused_resolver(exception: type[Exception]) -> tuple[int, str]:
|
|
raise AssertionError(f"no documented exceptions expected, got {exception}")
|
|
|
|
|
|
_ERROR_BODY_REF = {"$ref": "#/components/schemas/ErrorResponse"}
|
|
|
|
|
|
def test_attach_error_response_schema_documents_the_error_body_on_every_error_status():
|
|
explicit = {"application/json": {"schema": {"$ref": "#/components/schemas/Custom"}}}
|
|
document: dict[str, object] = {
|
|
"paths": {
|
|
"/items": {
|
|
"get": {
|
|
"responses": {
|
|
"200": {"description": "OK"},
|
|
"302": {"description": "Redirect"},
|
|
"400": {"description": "Bad", "content": {"application/json": {}}},
|
|
"404": {"description": "Missing"},
|
|
"409": {"description": "Conflict", "content": explicit},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
attach_error_response_schema(document, ErrorResponse, _unused_resolver)
|
|
|
|
responses = document["paths"]["/items"]["get"]["responses"] # type: ignore[index]
|
|
assert responses["400"]["content"] == {"application/json": {"schema": _ERROR_BODY_REF}}
|
|
assert responses["404"]["content"] == {"application/json": {"schema": _ERROR_BODY_REF}}
|
|
# A declared body never replaces the error body the handlers can still send.
|
|
assert responses["409"]["content"] == {
|
|
"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/Custom"}, _ERROR_BODY_REF]}}
|
|
}
|
|
# Generic errors can answer any operation, so every operation documents an open default.
|
|
assert responses["default"] == {
|
|
"description": "Error",
|
|
"content": {"application/json": {"schema": _ERROR_BODY_REF}},
|
|
}
|
|
assert "content" not in responses["200"]
|
|
assert "content" not in responses["302"]
|
|
schema = document["components"]["schemas"]["ErrorResponse"] # type: ignore[index]
|
|
assert schema["required"] == ["code", "message", "status"]
|
|
|
|
|
|
def test_attach_error_response_schema_leaves_documents_without_paths_alone():
|
|
document: dict[str, object] = {"info": {"title": "empty"}}
|
|
|
|
attach_error_response_schema(document, ErrorResponse, _unused_resolver)
|
|
|
|
assert document == {"info": {"title": "empty"}}
|
|
|
|
|
|
class _DocumentedError(BaseHTTPException):
|
|
error_code = "documented_error"
|
|
code = 400
|
|
|
|
|
|
class _ItemMissingError(BaseHTTPException):
|
|
error_code = "item_missing"
|
|
description = "Item is missing."
|
|
code = 404
|
|
|
|
|
|
class _WrapperDeniedError(BaseHTTPException):
|
|
error_code = "wrapper_denied"
|
|
code = 401
|
|
|
|
|
|
class _GuardDeniedError(BaseHTTPException):
|
|
error_code = "guard_denied"
|
|
code = 403
|
|
|
|
|
|
class _ConflictDetails(BaseModel):
|
|
server_hash: str
|
|
|
|
|
|
class _ConflictError(BaseHTTPException):
|
|
error_code = "documented_conflict"
|
|
code = 409
|
|
details_model = _ConflictDetails
|
|
|
|
|
|
def _guarded[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
|
@wraps(view)
|
|
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
return view(*args, **kwargs)
|
|
|
|
return document_errors(decorated, _WrapperDeniedError)
|
|
|
|
|
|
def _documented_operation(api: ExternalApi, *exceptions: type[Exception]) -> dict:
|
|
app = Flask(__name__)
|
|
ns = Namespace("documented", path="/")
|
|
|
|
class GuardedResource(Resource):
|
|
pass
|
|
|
|
document_errors(GuardedResource, _GuardDeniedError)
|
|
|
|
@ns.route("/documented")
|
|
class Documented(GuardedResource):
|
|
@ns.doc("get_documented")
|
|
@ns.response(404, "Item missing")
|
|
@error_responses(*exceptions)
|
|
@_guarded
|
|
def get(self):
|
|
return {}
|
|
|
|
api.add_namespace(ns)
|
|
app.register_blueprint(api.blueprint)
|
|
with app.test_request_context("/"):
|
|
return Swagger(api).as_dict()
|
|
|
|
|
|
def _responses(document: dict) -> dict:
|
|
return document["paths"]["/documented"]["get"]["responses"]
|
|
|
|
|
|
def _error_options(document: dict, status: str, body_ref: dict[str, str] = _ERROR_BODY_REF) -> list[str]:
|
|
"""The component names a status documents, in order, before the open error body that ends them."""
|
|
schema = _responses(document)[status]["content"]["application/json"]["schema"]
|
|
options = schema.get("anyOf", [schema])
|
|
# A generic error can answer any documented status, so the open body is always the last option.
|
|
assert options[-1] == body_ref
|
|
return [option["$ref"].rsplit("/", 1)[-1] for option in options[:-1]]
|
|
|
|
|
|
def _documented_names_for(*exceptions: type[Exception]) -> dict[str, bool]:
|
|
return {f"{exception.__module__}:{exception.__qualname__}": True for exception in exceptions}
|
|
|
|
|
|
def _resolve_by_class(exception: type[Exception]) -> tuple[int, str]:
|
|
assert issubclass(exception, BaseHTTPException)
|
|
assert exception.code is not None
|
|
return exception.code, exception.error_code
|
|
|
|
|
|
def test_document_errors_keeps_hidden_views_and_methods_hidden():
|
|
class Partly(Resource):
|
|
__apidoc__ = {"get": False, "post": {"description": "create"}}
|
|
|
|
document_errors(Partly, _GuardDeniedError)
|
|
|
|
assert Partly.__apidoc__["get"] is False
|
|
assert Partly.__apidoc__["post"]["description"] == "create"
|
|
assert Partly.__apidoc__["post"]["vendor"][ERROR_CODES_VENDOR_KEY] == _documented_names_for(_GuardDeniedError)
|
|
assert Partly.__apidoc__["delete"]["vendor"][ERROR_CODES_VENDOR_KEY] == _documented_names_for(_GuardDeniedError)
|
|
|
|
class Hidden(Resource):
|
|
__apidoc__ = False
|
|
|
|
def hidden_view():
|
|
return {}
|
|
|
|
hidden_view.__apidoc__ = False # type: ignore[attr-defined]
|
|
|
|
assert document_errors(Hidden, _GuardDeniedError).__apidoc__ is False
|
|
assert document_errors(hidden_view, _GuardDeniedError).__apidoc__ is False # type: ignore[attr-defined]
|
|
|
|
|
|
def test_error_responses_document_handler_wrapper_and_class_errors_by_code():
|
|
document = _documented_operation(
|
|
ExternalApi(Blueprint("documented", __name__)), _DocumentedError, _ItemMissingError
|
|
)
|
|
|
|
assert _error_options(document, "400") == ["DocumentedError"]
|
|
assert _error_options(document, "404") == ["ItemMissingError"]
|
|
assert _responses(document)["404"]["description"] == "Item missing"
|
|
assert _error_options(document, "401") == ["WrapperDeniedError"]
|
|
assert _responses(document)["401"]["description"] == "Unauthorized"
|
|
assert _error_options(document, "403") == ["GuardDeniedError"]
|
|
# Generic codes (validation, framework errors) are not listed; the open default covers them.
|
|
assert _responses(document)["default"]["content"] == {"application/json": {"schema": _ERROR_BODY_REF}}
|
|
assert document["components"]["schemas"]["ItemMissingError"] == {
|
|
"allOf": [
|
|
_ERROR_BODY_REF,
|
|
{
|
|
"properties": {
|
|
"code": {"const": "item_missing", "type": "string"},
|
|
"status": {"const": 404, "type": "integer"},
|
|
},
|
|
"required": ["code", "status"],
|
|
"type": "object",
|
|
},
|
|
],
|
|
"description": "Item is missing.",
|
|
"title": "ItemMissingError",
|
|
}
|
|
|
|
|
|
def test_openapi_surface_resolves_codes_like_its_formatter():
|
|
from controllers.openapi import _OpenApi
|
|
from controllers.openapi._errors import OpenApiErrorFormatter
|
|
|
|
document = _documented_operation(
|
|
_OpenApi(Blueprint("documented_openapi", __name__), error_body_formatter=OpenApiErrorFormatter()),
|
|
_DocumentedError,
|
|
)
|
|
|
|
body_ref = {"$ref": "#/components/schemas/ErrorBody"}
|
|
assert _error_options(document, "400", body_ref) == ["DocumentedError"]
|
|
component = document["components"]["schemas"]["DocumentedError"]
|
|
assert component["allOf"][0] == body_ref
|
|
assert component["allOf"][1]["properties"]["code"] == {"const": "documented_error", "type": "string"}
|
|
|
|
|
|
@pytest.mark.parametrize("exception", [ValueError, NotFound, Unauthorized])
|
|
def test_error_responses_rejects_generic_errors(exception: type[Exception]):
|
|
with pytest.raises(ValueError, match="generic errors are implied"):
|
|
error_responses(exception)
|
|
|
|
|
|
def test_error_responses_requires_an_error_code():
|
|
class _Placeholder(BaseHTTPException):
|
|
code = 404
|
|
|
|
with pytest.raises(ValueError, match="must declare an error_code: _Placeholder"):
|
|
error_responses(_Placeholder)
|
|
|
|
|
|
class _SurfaceOnlyError(Exception):
|
|
pass
|
|
|
|
|
|
def test_surface_specific_handlers_are_implicit_codes():
|
|
api = ExternalApi(Blueprint("surface_handlers", __name__))
|
|
api.document_error_handler(_SurfaceOnlyError, status=409, code="surface_conflict")
|
|
|
|
document = _documented_operation(api, _ConflictError)
|
|
|
|
# Handler-emitted codes stay generic: the contract check admits them, the document does not list them.
|
|
assert _error_options(document, "409") == ["DocumentedConflictError"]
|
|
assert api.implicit_error_codes(409) == {"conflict", "surface_conflict"}
|
|
# The cached answer is a copy, and a later handler declaration refreshes it.
|
|
api.implicit_error_codes(409).add("mutated")
|
|
api.document_error_handler(_SurfaceOnlyError, status=409, code="surface_conflict_v2")
|
|
assert api.implicit_error_codes(409) == {"conflict", "surface_conflict_v2"}
|
|
|
|
|
|
def test_error_components_are_shared_across_operations_and_carry_details():
|
|
document: dict[str, object] = {
|
|
"paths": {
|
|
"/items": {
|
|
"post": {"responses": {}, "x-dify-error-codes": _documented_names_for(_ConflictError)},
|
|
"put": {"responses": {}, "x-dify-error-codes": _documented_names_for(_ConflictError)},
|
|
}
|
|
}
|
|
}
|
|
|
|
attach_error_response_schema(document, ErrorResponse, _resolve_by_class)
|
|
|
|
component_ref = {"$ref": "#/components/schemas/DocumentedConflictError"}
|
|
for method in ("post", "put"):
|
|
response = document["paths"]["/items"][method]["responses"]["409"] # type: ignore[index]
|
|
# The open error body stays beside the component: a generic error can answer 409 too.
|
|
assert response == {
|
|
"description": "Conflict",
|
|
"content": {"application/json": {"schema": {"anyOf": [component_ref, _ERROR_BODY_REF]}}},
|
|
}
|
|
schemas = document["components"]["schemas"] # type: ignore[index]
|
|
assert schemas["DocumentedConflictError"]["allOf"][1]["properties"]["details"] == {
|
|
"$ref": "#/components/schemas/_ConflictDetails"
|
|
}
|
|
assert schemas["_ConflictDetails"]["required"] == ["server_hash"]
|
|
|
|
|
|
def test_error_responses_union_explicit_schema_with_documented_codes():
|
|
explicit = {"application/json": {"schema": {"$ref": "#/components/schemas/Custom"}}}
|
|
document: dict[str, object] = {
|
|
"paths": {
|
|
"/items": {
|
|
"get": {
|
|
"responses": {"409": {"description": "Conflict", "content": explicit}},
|
|
"x-dify-error-codes": _documented_names_for(_ConflictError),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
attach_error_response_schema(document, ErrorResponse, _resolve_by_class)
|
|
|
|
assert document["paths"]["/items"]["get"]["responses"]["409"]["content"] == { # type: ignore[index]
|
|
"application/json": {
|
|
"schema": {
|
|
"anyOf": [
|
|
{"$ref": "#/components/schemas/Custom"},
|
|
{"$ref": "#/components/schemas/DocumentedConflictError"},
|
|
_ERROR_BODY_REF,
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
def test_documented_status_keeps_its_other_media_types():
|
|
content = {
|
|
"application/octet-stream": {"schema": {"type": "string", "format": "binary"}},
|
|
"application/json": {"schema": {"$ref": "#/components/schemas/Custom"}, "example": {"error": "x"}},
|
|
}
|
|
document: dict[str, object] = {
|
|
"paths": {
|
|
"/items": {
|
|
"get": {
|
|
"responses": {"409": {"description": "Conflict", "content": content}},
|
|
"x-dify-error-codes": _documented_names_for(_ConflictError),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
attach_error_response_schema(document, ErrorResponse, _resolve_by_class)
|
|
|
|
assert document["paths"]["/items"]["get"]["responses"]["409"]["content"] == { # type: ignore[index]
|
|
"application/octet-stream": {"schema": {"type": "string", "format": "binary"}},
|
|
"application/json": {
|
|
"example": {"error": "x"},
|
|
"schema": {
|
|
"anyOf": [
|
|
{"$ref": "#/components/schemas/Custom"},
|
|
{"$ref": "#/components/schemas/DocumentedConflictError"},
|
|
_ERROR_BODY_REF,
|
|
]
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def test_details_model_definitions_must_not_collide_with_different_schemas():
|
|
document: dict[str, object] = {
|
|
"components": {"schemas": {"_ConflictDetails": {"type": "object", "properties": {"other": {}}}}},
|
|
"paths": {"/items": {"get": {"responses": {}, "x-dify-error-codes": _documented_names_for(_ConflictError)}}},
|
|
}
|
|
|
|
with pytest.raises(ValueError, match="_ConflictDetails .*collides with a different component"):
|
|
attach_error_response_schema(document, ErrorResponse, _resolve_by_class)
|
|
|
|
|
|
class _EnumConflictDetails(BaseModel):
|
|
state: Literal["retry", "configure", 1]
|
|
|
|
|
|
class _EnumConflictError(BaseHTTPException):
|
|
error_code = "enum_conflict"
|
|
code = 409
|
|
details_model = _EnumConflictDetails
|
|
|
|
|
|
def test_details_model_reuses_registered_schema_with_reordered_enum_values() -> None:
|
|
# Response schema registration sorts enums; their order does not change the contract.
|
|
schema = _EnumConflictDetails.model_json_schema(mode="serialization")
|
|
schema["properties"]["state"]["enum"].reverse()
|
|
document: dict[str, object] = {
|
|
"components": {"schemas": {"_EnumConflictDetails": schema}},
|
|
"paths": {
|
|
"/items": {"get": {"responses": {}, "x-dify-error-codes": _documented_names_for(_EnumConflictError)}}
|
|
},
|
|
}
|
|
|
|
attach_error_response_schema(document, ErrorResponse, _resolve_by_class)
|
|
|
|
schemas = document["components"]["schemas"] # type: ignore[index]
|
|
assert schemas["EnumConflictError"]["allOf"][1]["properties"]["details"] == {
|
|
"$ref": "#/components/schemas/_EnumConflictDetails"
|
|
}
|
|
|
|
|
|
def test_error_component_names_must_not_collide():
|
|
document: dict[str, object] = {
|
|
"components": {"schemas": {"DocumentedConflictError": {"type": "object"}}},
|
|
"paths": {"/items": {"get": {"responses": {}, "x-dify-error-codes": _documented_names_for(_ConflictError)}}},
|
|
}
|
|
|
|
with pytest.raises(ValueError, match="DocumentedConflictError .*collides"):
|
|
attach_error_response_schema(document, ErrorResponse, _resolve_by_class)
|
|
|
|
|
|
class _ConflictAsBadRequest(BaseHTTPException):
|
|
error_code = "documented_conflict"
|
|
code = 400
|
|
|
|
|
|
class _ConflictWithoutDetails(BaseHTTPException):
|
|
error_code = "documented_conflict"
|
|
code = 409
|
|
|
|
|
|
def test_error_code_maps_to_one_status_per_document():
|
|
document: dict[str, object] = {
|
|
"paths": {
|
|
"/items": {
|
|
"get": {"responses": {}, "x-dify-error-codes": _documented_names_for(_ConflictError)},
|
|
"post": {"responses": {}, "x-dify-error-codes": _documented_names_for(_ConflictAsBadRequest)},
|
|
}
|
|
}
|
|
}
|
|
|
|
with pytest.raises(ValueError, match="'documented_conflict' answers 400 on POST /items but 409 on GET /items"):
|
|
attach_error_response_schema(document, ErrorResponse, _resolve_by_class)
|
|
|
|
|
|
def test_error_code_classes_must_agree_on_details():
|
|
document: dict[str, object] = {
|
|
"paths": {
|
|
"/items": {
|
|
"get": {
|
|
"responses": {},
|
|
"x-dify-error-codes": _documented_names_for(_ConflictError, _ConflictWithoutDetails),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
with pytest.raises(ValueError, match="'documented_conflict' declares different details models"):
|
|
attach_error_response_schema(document, ErrorResponse, _resolve_by_class)
|
|
|
|
|
|
def test_resolves_errors_in_flask_restx_dispatch_order():
|
|
"""Flask-RESTX picks the first registered handler that matches, not the most specific one."""
|
|
from core.errors.error import AppInvokeQuotaExceededError
|
|
|
|
api = ExternalApi(Blueprint("dispatch_order", __name__))
|
|
|
|
# ValueError is registered before AppInvokeQuotaExceededError (a ValueError), so it wins.
|
|
assert api.resolve_error(AppInvokeQuotaExceededError) == (400, "invalid_param")
|
|
assert api.resolve_error(_DocumentedError) == (400, "documented_error")
|
|
|
|
|
|
def test_framework_error_subclasses_are_implicit_codes():
|
|
from flask import request
|
|
|
|
app = Flask(__name__)
|
|
blueprint = Blueprint("missing_upload", __name__)
|
|
api = ExternalApi(blueprint)
|
|
|
|
@api.route("/upload")
|
|
class Upload(Resource):
|
|
def post(self):
|
|
request.files["pkg"] # a missing field raises BadRequestKeyError
|
|
return {}
|
|
|
|
app.register_blueprint(blueprint)
|
|
response = app.test_client().post("/upload", data={})
|
|
|
|
body = response.get_json()
|
|
assert response.status_code == 400
|
|
assert body["code"] == "bad_request_key_error"
|
|
assert body["code"] in api.implicit_error_codes(400)
|
|
|
|
|
|
def test_relayed_http_error_emits_the_standard_body_with_runtime_code_and_status():
|
|
from libs.exception import RelayedHTTPError
|
|
|
|
error = RelayedHTTPError(status=409, error_code="downstream_conflict", message="busy", details={"retry": 3})
|
|
|
|
assert error.code == 409
|
|
assert error.data == {"code": "downstream_conflict", "message": "busy", "status": 409, "details": {"retry": 3}}
|
|
|
|
|
|
def test_documenting_relayed_errors_adds_nothing_beyond_the_default_error():
|
|
from libs.exception import RelayedHTTPError
|
|
|
|
error_responses(RelayedHTTPError) # accepted although its class keeps the placeholder code
|
|
document: dict[str, object] = {
|
|
"paths": {
|
|
"/items": {
|
|
"post": {
|
|
"responses": {"400": {"description": "Bad"}},
|
|
"x-dify-error-codes": _documented_names_for(_ConflictError, RelayedHTTPError),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
attach_error_response_schema(document, ErrorResponse, _resolve_by_class)
|
|
|
|
operation = document["paths"]["/items"]["post"] # type: ignore[index]
|
|
responses = operation["responses"]
|
|
assert responses["400"]["content"] == {"application/json": {"schema": _ERROR_BODY_REF}}
|
|
assert responses["default"]["content"] == {"application/json": {"schema": _ERROR_BODY_REF}}
|
|
assert responses["409"]["content"]["application/json"]["schema"] == {
|
|
"anyOf": [{"$ref": "#/components/schemas/DocumentedConflictError"}, _ERROR_BODY_REF]
|
|
}
|
|
assert operation["x-dify-relays-errors"] is True
|
|
|
|
|
|
def test_keeps_placeholder_error_code_spots_undeclared_business_errors():
|
|
from libs.exception import RelayedHTTPError
|
|
|
|
class _Placeholder(BaseHTTPException):
|
|
code = 404
|
|
|
|
assert keeps_placeholder_error_code(_Placeholder)
|
|
assert not keeps_placeholder_error_code(_ItemMissingError)
|
|
assert not keeps_placeholder_error_code(RelayedHTTPError)
|
|
assert not keeps_placeholder_error_code(NotFound)
|
|
|
|
|
|
class _BootstrapMissingError(BaseHTTPException):
|
|
error_code = "bootstrap_missing"
|
|
description = "Bootstrap has not run."
|
|
code = 401
|
|
|
|
|
|
def test_generic_errors_are_implicit_codes_and_never_listed():
|
|
api = ExternalApi(Blueprint("generic_errors", __name__))
|
|
api.document_generic_errors(_BootstrapMissingError)
|
|
|
|
document = _documented_operation(api, _BootstrapMissingError, _DocumentedError)
|
|
|
|
assert api.is_generic_error(_BootstrapMissingError)
|
|
assert not api.is_generic_error(_DocumentedError)
|
|
assert "bootstrap_missing" in api.implicit_error_codes(401)
|
|
assert "bootstrap_missing" not in api.implicit_error_codes(400)
|
|
# The operation keeps its real business errors and documents nothing for the generic one.
|
|
assert _error_options(document, "400") == ["DocumentedError"]
|
|
assert "BootstrapMissingError" not in document["components"]["schemas"]
|
|
assert _error_options(document, "401") == ["WrapperDeniedError"]
|
|
|
|
|
|
@pytest.mark.parametrize("exception", [ValueError, NotFound])
|
|
def test_document_generic_errors_takes_business_shaped_errors_only(exception: type[Exception]):
|
|
api = ExternalApi(Blueprint("generic_shape", __name__))
|
|
|
|
with pytest.raises(ValueError, match="generic already"):
|
|
api.document_generic_errors(exception)
|
|
|
|
|
|
def test_document_generic_errors_requires_an_error_code():
|
|
class _Placeholder(BaseHTTPException):
|
|
code = 401
|
|
|
|
api = ExternalApi(Blueprint("generic_placeholder", __name__))
|
|
|
|
with pytest.raises(ValueError, match="must declare an error_code"):
|
|
api.document_generic_errors(_Placeholder)
|