diff --git a/api/controllers/openapi/_errors.py b/api/controllers/openapi/_errors.py new file mode 100644 index 0000000000..eb9ee57a2a --- /dev/null +++ b/api/controllers/openapi/_errors.py @@ -0,0 +1,66 @@ +"""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). ``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. +""" + +from enum import StrEnum + +from pydantic import BaseModel + + +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 (carried by BaseHTTPException.error_code, values preserved + # from the existing wire contract) + 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" # legacy wire value — do not rename + 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 diff --git a/api/tests/unit_tests/controllers/openapi/test_error_contract.py b/api/tests/unit_tests/controllers/openapi/test_error_contract.py new file mode 100644 index 0000000000..0709d82cae --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_error_contract.py @@ -0,0 +1,33 @@ +"""Wire-contract tests for the canonical /openapi/v1 error body.""" + +from controllers.openapi._errors import ErrorBody, ErrorDetail, OpenApiErrorCode + + +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"