Files
dify/api/tests/unit_tests/libs/test_external_api.py

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)