From 49a0a491e81642bcfc23f75e904b3bb5373228d3 Mon Sep 17 00:00:00 2001 From: NoahLan <6995syu@163.com> Date: Sun, 10 May 2026 04:11:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E5=B0=81=E8=A3=85?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=20HTTP=20=E9=94=99=E8=AF=AF=E5=93=8D?= =?UTF-8?q?=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- copier-template/copier.yml | 4 ++-- copier-template/pyproject.toml.jinja | 2 +- docs/ARCHITECTURE.md | 3 ++- docs/CONFIGURATION.md | 2 +- iti/__about__.py | 2 +- iti/app.py | 33 ++++++++++++++++++++++++++++ tests/test_app.py | 31 +++++++++++++++++++++++++- 8 files changed, 71 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 15c6084..99e019a 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ scripts\iti.cmd install ```toml dependencies = [ - "iti-flask @ git+ssh://git@your-git/iTi/iTi-Flask.git@v0.1.1", + "iti-flask @ git+ssh://git@your-git/iTi/iTi-Flask.git@v0.2.0", ] ``` diff --git a/copier-template/copier.yml b/copier-template/copier.yml index 7703183..bd71896 100644 --- a/copier-template/copier.yml +++ b/copier-template/copier.yml @@ -16,7 +16,7 @@ framework_git: framework_tag: type: str help: iTi-Flask Git tag - default: v0.1.1 + default: v0.2.0 include_system: type: bool @@ -31,4 +31,4 @@ system_git: system_tag: type: str help: iTi-System Git tag - default: v0.1.1 + default: v0.2.0 diff --git a/copier-template/pyproject.toml.jinja b/copier-template/pyproject.toml.jinja index c9c375f..476e792 100644 --- a/copier-template/pyproject.toml.jinja +++ b/copier-template/pyproject.toml.jinja @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "{{ project_slug | replace('_', '-') }}" -version = "0.1.0" +version = "0.2.0" description = "{{ project_name }}" readme = "README.md" requires-python = ">=3.11" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ed9dc22..8af3447 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,7 +50,8 @@ iTi-Flask 是 FastAPI 框架基座。 ``` 成功时 `code` 固定为 `200`。 -业务错误保留原始业务码,例如 `403`、`404`、`429`。 +业务错误、参数错误、框架 HTTP 错误都保留原始业务码,例如 `403`、`404`、`405`、`429`。 +框架默认提供 JSON 错误响应,不提供服务端 HTML 错误页。 服务间 API 也默认使用 envelope。 框架服务客户端会自动识别 envelope 和 HTTP status。 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 47cf5c9..1e4d283 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -64,7 +64,7 @@ MYSQL_DATABASE=iti_dev | `cors_origins` | CORS origin 列表 | | `health_enabled` | 是否注册 `/health` 和 `/ready` | | `ready_check_db` | `/ready` 是否执行 DB ping | -| `response_envelope_http_status` | envelope 错误时使用的 HTTP 状态码,默认 200 | +| `response_envelope_http_status` | envelope 响应使用的 HTTP 状态码,默认 200 | | `response_envelope_enabled` | 是否启用自动 envelope | | `raw_response_paths` | 跳过自动 envelope 的路径,支持 `*` | | `ratelimit_enabled` | 是否启用内存限流 | diff --git a/iti/__about__.py b/iti/__about__.py index 96764d4..cfc734b 100644 --- a/iti/__about__.py +++ b/iti/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com> # # SPDX-License-Identifier: MIT -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/iti/app.py b/iti/app.py index aff3dd3..8378b91 100644 --- a/iti/app.py +++ b/iti/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import time import uuid +from http import HTTPStatus from collections.abc import Iterable, Mapping from contextvars import ContextVar from contextlib import asynccontextmanager @@ -25,6 +26,7 @@ from fastapi.routing import APIRoute from fastapi.routing import request_response from fastapi.responses import JSONResponse from pydantic import BaseModel +from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.responses import Response from sqlalchemy.exc import SQLAlchemyError @@ -188,6 +190,16 @@ def init_error_handlers(app: FastAPI) -> None: content=fail("参数验证错误", code=422, data=exc.errors()), ) + @app.exception_handler(StarletteHTTPException) + async def handle_http_error(request: Request, exc: StarletteHTTPException): + request.state.response_code = exc.status_code + message, data = _http_error_payload(exc) + return JSONResponse( + status_code=request.app.state.config.response_envelope_http_status, + content=fail(message, code=exc.status_code, data=data), + headers=exc.headers, + ) + @app.exception_handler(SQLAlchemyError) async def handle_db_error(request: Request, exc: SQLAlchemyError): request.state.response_code = 500 @@ -207,6 +219,27 @@ def init_error_handlers(app: FastAPI) -> None: ) +def _http_error_payload(exc: StarletteHTTPException) -> tuple[str, Any]: + detail = exc.detail + if isinstance(detail, str): + return detail, None + if detail is None: + return _http_status_phrase(exc.status_code), None + if isinstance(detail, Mapping): + for key in ("message", "detail", "error"): + value = detail.get(key) + if isinstance(value, str) and value: + return value, detail + return _http_status_phrase(exc.status_code), detail + + +def _http_status_phrase(status_code: int) -> str: + try: + return HTTPStatus(status_code).phrase + except ValueError: + return "HTTP Error" + + def to_plain_data(value: Any) -> Any: if is_dataclass(value): return asdict(value) diff --git a/tests/test_app.py b/tests/test_app.py index 17fca7b..673af96 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException from fastapi.testclient import TestClient from starlette.responses import PlainTextResponse @@ -36,6 +36,10 @@ class RoutesModule: def boom(): raise BizError("业务失败", code=400) + @router.get("/http-error") + def http_error(): + raise HTTPException(status_code=418, detail={"message": "茶壶错误"}) + @router.get("/limited", dependencies=[limit("1 per minute")]) def limited(): return ok() @@ -72,6 +76,31 @@ def test_envelope_and_error_handlers(): assert response.json()["message"] == "业务失败" +def test_http_errors_use_envelope(): + client = TestClient(make_app()) + + not_found = client.get("/") + assert not_found.status_code == 200 + assert not_found.json() == { + "data": None, + "code": 404, + "message": "Not Found", + } + + method_not_allowed = client.post("/demo") + assert method_not_allowed.status_code == 200 + assert method_not_allowed.json()["code"] == 405 + assert method_not_allowed.json()["message"] == "Method Not Allowed" + + http_error = client.get("/http-error") + assert http_error.status_code == 200 + assert http_error.json() == { + "data": {"message": "茶壶错误"}, + "code": 418, + "message": "茶壶错误", + } + + def test_auto_envelope_wraps_plain_json_and_raw_can_skip(): client = TestClient(make_app(raw_response_paths=["/health", "/ready", "/raw-path"]))