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, ConfigDict, Field from pytest_mock import MockerFixture 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 _as_mapping(value: object) -> dict[str, object]: """Narrow an untyped Swagger document node to a string-keyed mapping.""" if not isinstance(value, dict): msg = f"expected a mapping, got {type(value).__name__}" raise TypeError(msg) return {str(key): item for key, item in value.items()} def _as_items(value: object) -> list[object]: """Narrow an untyped Swagger document node to a list.""" if not isinstance(value, list): msg = f"expected a list, got {type(value).__name__}" raise TypeError(msg) return list(value) def _create_api_app() -> Flask: app = Flask(__name__) bp = Blueprint("t", __name__) api = ExternalApi(bp) @api.route("/bad-request") class Bad(Resource): def get(self) -> None: raise BadRequest("invalid input") @api.route("/unauth") class Unauth(Resource): def get(self) -> None: raise Unauthorized("auth required") @api.route("/value-error") class ValErr(Resource): def get(self) -> None: raise ValueError("boom") @api.route("/quota") class Quota(Resource): def get(self) -> None: raise AppInvokeQuotaExceededError("quota exceeded") @api.route("/general") class Gen(Resource): def get(self) -> None: raise RuntimeError("oops") @api.route("/plugin-runtime-error") class PluginRuntime(Resource): def get(self) -> None: 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) -> None: 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) -> None: e = BadRequest() # Coerce a mapping description to trigger param error shaping e.description = {"field": "is required"} # pyrefly: ignore[bad-assignment] 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) -> None: raise _BearerRateLimited(23) app.register_blueprint(bp, url_prefix="/api") return app def test_external_api_error_handlers_basic_paths() -> None: 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() -> None: 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: MockerFixture) -> None: 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() -> None: 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() -> None: # 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() -> None: class ExplicitRetryAfter(ServiceUnavailable): headers = {"retry-after": "23"} app = Flask(__name__) api = ExternalApi(app) @api.route("/pending") class Pending(Resource): def get(self) -> None: 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() -> None: challenges = [WWWAuthenticate("Basic", {"realm": "api"}), WWWAuthenticate("Bearer", {"realm": "api"})] app = Flask(__name__) api = ExternalApi(app) @api.route("/unauth") class Unauth(Resource): def get(self) -> None: 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() -> None: """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) -> None: """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(), # pyrefly: ignore[bad-argument-type] ) 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"} class _Schema(BaseModel): """The slice of a JSON-Schema node the documented responses are read through.""" model_config = ConfigDict(populate_by_name=True) ref: str | None = Field(default=None, alias="$ref") anyOf: list["_Schema"] = Field(default_factory=list) class _MediaType(BaseModel): schema_: _Schema | None = Field(default=None, alias="schema") class _Response(BaseModel): description: str | None = None content: dict[str, _MediaType] = Field(default_factory=dict) class _Operation(BaseModel): responses: dict[str, _Response] = Field(default_factory=dict) class _PathItem(BaseModel): get: _Operation | None = None class _Components(BaseModel): schemas: dict[str, object] = Field(default_factory=dict) class _Document(BaseModel): paths: dict[str, _PathItem] = Field(default_factory=dict) components: _Components = Field(default_factory=_Components) def test_attach_error_response_schema_documents_the_error_body_on_every_error_status() -> None: 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() -> None: 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]) -> _Document: 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) -> dict[str, object]: return {} api.add_namespace(ns) blueprint = api.blueprint assert isinstance(blueprint, Blueprint) app.register_blueprint(blueprint) with app.test_request_context("/"): return _Document.model_validate(Swagger(api).as_dict()) def _responses(document: _Document) -> dict[str, _Response]: operation = document.paths["/documented"].get assert operation is not None return operation.responses def _error_options(document: _Document, 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_ assert schema is not None options = schema.anyOf or [schema] # A generic error can answer any documented status, so the open body is always the last option. assert options[-1].model_dump(by_alias=True, exclude_defaults=True) == body_ref names: list[str] = [] for option in options[:-1]: assert option.ref is not None names.append(option.ref.rsplit("/", 1)[-1]) return names 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() -> None: class Partly(Resource): __apidoc__ = {"get": False, "post": {"description": "create"}} document_errors(Partly, _GuardDeniedError) apidoc = _as_mapping(Partly.__apidoc__) assert apidoc["get"] is False post_doc = _as_mapping(apidoc["post"]) assert post_doc["description"] == "create" post_vendor = _as_mapping(post_doc["vendor"]) assert post_vendor[ERROR_CODES_VENDOR_KEY] == _documented_names_for(_GuardDeniedError) delete_doc = _as_mapping(apidoc["delete"]) delete_vendor = _as_mapping(delete_doc["vendor"]) assert delete_vendor[ERROR_CODES_VENDOR_KEY] == _documented_names_for(_GuardDeniedError) class Hidden(Resource): __apidoc__ = False def hidden_view() -> dict[str, object]: 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() -> None: 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. default_schema = _responses(document)["default"].content["application/json"].schema_ assert default_schema is not None assert default_schema.model_dump(by_alias=True, exclude_defaults=True) == _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() -> None: 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 = _as_mapping(document.components.schemas["DocumentedError"]) all_of = _as_items(component["allOf"]) assert all_of[0] == body_ref properties = _as_mapping(_as_mapping(all_of[1])["properties"]) assert properties["code"] == {"const": "documented_error", "type": "string"} @pytest.mark.parametrize("exception", [ValueError, NotFound, Unauthorized]) def test_error_responses_rejects_generic_errors(exception: type[Exception]) -> None: with pytest.raises(ValueError, match="generic errors are implied"): error_responses(exception) def test_error_responses_requires_an_error_code() -> None: 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() -> None: 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() -> None: 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() -> None: 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() -> None: 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() -> None: 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() -> None: 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() -> None: 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() -> None: 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() -> None: """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() -> None: from flask import request app = Flask(__name__) blueprint = Blueprint("missing_upload", __name__) api = ExternalApi(blueprint) @api.route("/upload") class Upload(Resource): def post(self) -> dict[str, object]: 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() -> None: 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() -> None: 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() -> None: 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() -> None: 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]) -> None: 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() -> None: 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)