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

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

@ -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",
]
```

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

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

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

@ -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` | 是否启用内存限流 |

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

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

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

Loading…
Cancel
Save