feat(openapi): add canonical ErrorBody model and error-code enum

This commit is contained in:
GareArc 2026-06-10 01:57:11 -07:00
parent e3cfc4d40f
commit 3f53fa605e
No known key found for this signature in database
2 changed files with 99 additions and 0 deletions

View File

@ -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

View File

@ -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"