feat: 统一封装框架 HTTP 错误响应

main v0.2.0
NoahLan 2 weeks ago
parent 3eb3cae909
commit 49a0a491e8

@ -40,7 +40,7 @@ scripts\iti.cmd install
```toml ```toml
dependencies = [ 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",
] ]
``` ```

@ -16,7 +16,7 @@ framework_git:
framework_tag: framework_tag:
type: str type: str
help: iTi-Flask Git tag help: iTi-Flask Git tag
default: v0.1.1 default: v0.2.0
include_system: include_system:
type: bool type: bool
@ -31,4 +31,4 @@ system_git:
system_tag: system_tag:
type: str type: str
help: iTi-System Git tag help: iTi-System Git tag
default: v0.1.1 default: v0.2.0

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "{{ project_slug | replace('_', '-') }}" name = "{{ project_slug | replace('_', '-') }}"
version = "0.1.0" version = "0.2.0"
description = "{{ project_name }}" description = "{{ project_name }}"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

@ -50,7 +50,8 @@ iTi-Flask 是 FastAPI 框架基座。
``` ```
成功时 `code` 固定为 `200` 成功时 `code` 固定为 `200`
业务错误保留原始业务码,例如 `403`、`404`、`429`。 业务错误、参数错误、框架 HTTP 错误都保留原始业务码,例如 `403`、`404`、`405`、`429`。
框架默认提供 JSON 错误响应,不提供服务端 HTML 错误页。
服务间 API 也默认使用 envelope。 服务间 API 也默认使用 envelope。
框架服务客户端会自动识别 envelope 和 HTTP status。 框架服务客户端会自动识别 envelope 和 HTTP status。

@ -64,7 +64,7 @@ MYSQL_DATABASE=iti_dev
| `cors_origins` | CORS origin 列表 | | `cors_origins` | CORS origin 列表 |
| `health_enabled` | 是否注册 `/health``/ready` | | `health_enabled` | 是否注册 `/health``/ready` |
| `ready_check_db` | `/ready` 是否执行 DB ping | | `ready_check_db` | `/ready` 是否执行 DB ping |
| `response_envelope_http_status` | envelope 错误时使用的 HTTP 状态码,默认 200 | | `response_envelope_http_status` | envelope 响应使用的 HTTP 状态码,默认 200 |
| `response_envelope_enabled` | 是否启用自动 envelope | | `response_envelope_enabled` | 是否启用自动 envelope |
| `raw_response_paths` | 跳过自动 envelope 的路径,支持 `*` | | `raw_response_paths` | 跳过自动 envelope 的路径,支持 `*` |
| `ratelimit_enabled` | 是否启用内存限流 | | `ratelimit_enabled` | 是否启用内存限流 |

@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com> # SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com>
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
__version__ = "0.1.0" __version__ = "0.2.0"

@ -3,6 +3,7 @@ from __future__ import annotations
import logging import logging
import time import time
import uuid import uuid
from http import HTTPStatus
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from contextvars import ContextVar from contextvars import ContextVar
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -25,6 +26,7 @@ from fastapi.routing import APIRoute
from fastapi.routing import request_response from fastapi.routing import request_response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import Response from starlette.responses import Response
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
@ -188,6 +190,16 @@ def init_error_handlers(app: FastAPI) -> None:
content=fail("参数验证错误", code=422, data=exc.errors()), 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) @app.exception_handler(SQLAlchemyError)
async def handle_db_error(request: Request, exc: SQLAlchemyError): async def handle_db_error(request: Request, exc: SQLAlchemyError):
request.state.response_code = 500 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: def to_plain_data(value: Any) -> Any:
if is_dataclass(value): if is_dataclass(value):
return asdict(value) return asdict(value)

@ -1,4 +1,4 @@
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.responses import PlainTextResponse from starlette.responses import PlainTextResponse
@ -36,6 +36,10 @@ class RoutesModule:
def boom(): def boom():
raise BizError("业务失败", code=400) 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")]) @router.get("/limited", dependencies=[limit("1 per minute")])
def limited(): def limited():
return ok() return ok()
@ -72,6 +76,31 @@ def test_envelope_and_error_handlers():
assert response.json()["message"] == "业务失败" 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(): def test_auto_envelope_wraps_plain_json_and_raw_can_skip():
client = TestClient(make_app(raw_response_paths=["/health", "/ready", "/raw-path"])) client = TestClient(make_app(raw_response_paths=["/health", "/ready", "/raw-path"]))

Loading…
Cancel
Save