Add HTTP exception helpers and update models

Introduced utils/http/http_exceptions.py with common HTTP exception helpers for FastAPI. Updated main.py to use a global exception handler that logs and hides internal errors. Refined models/README.md to document new models and relationships, including PhysicalFile and UploadSession, and updated DTO and enum documentation. Simplified ThemeResponse in models/color.py. Improved models/download.py with type annotations, index changes, and import optimizations. Fixed a parameter type in clean.py.

Co-Authored-By: 砂糖橘 <54745033+Foxerine@users.noreply.github.com>
This commit is contained in:
2025-12-25 15:48:21 +08:00
parent 44a8959aa5
commit 5835b4c626
6 changed files with 301 additions and 76 deletions

View File

@@ -34,7 +34,7 @@ def parse_args() -> argparse.Namespace:
help='仅列出将要删除的文件,不实际删除')
return parser.parse_args()
def confirm_action(message: str, auto_yes: str = False) -> bool:
def confirm_action(message: str, auto_yes: bool = False) -> bool:
if auto_yes:
return True
return input(f"{message} (y/N): ").lower() == 'y'

22
main.py
View File

@@ -1,11 +1,15 @@
from fastapi import FastAPI
from typing import NoReturn
from fastapi import FastAPI, Request
from utils.conf import appmeta
from utils.http.http_exceptions import raise_internal_error
from utils.lifespan import lifespan
from models.database import init_db
from models.migration import migration
from utils.JWT import JWT
from routers import router
from loguru import logger as l
# 添加初始化数据库启动项
lifespan.add_startup(init_db)
@@ -24,11 +28,21 @@ app = FastAPI(
debug=appmeta.debug,
)
@app.exception_handler(Exception)
async def handle_unexpected_exceptions(request: Request, exc: Exception) -> NoReturn:
"""
捕获所有未经处理的fastapi异常,防止敏感信息泄露。
"""
l.exception(exc)
l.error(f"An unhandled exception occurred for request: {request.method} {request.url.path}")
raise_internal_error()
# 挂载路由
app.include_router(router)
# 防止直接运行 main.py
if __name__ == "__main__":
from loguru import logger
logger.error("请用 fastapi ['dev', 'run'] 命令启动服务")
exit(1)
l.error("请用 fastapi ['dev', 'run'] 命令启动服务")
exit(1)

View File

@@ -18,7 +18,8 @@ models/
├── user_authn.py # 用户 WebAuthn 凭证
├── group.py # 用户组模型
├── policy.py # 存储策略模型
├── object.py # 统一对象模型(文件/目录
├── physical_file.py # 物理文件模型(文件去重
├── object.py # 统一对象模型(文件/目录)+ 上传会话
├── share.py # 分享模型
├── tag.py # 标签模型
├── download.py # 离线下载任务
@@ -32,7 +33,7 @@ models/
├── storage_pack.py # 容量包模型
├── webdav.py # WebDAV 账户模型
├── color.py # 主题颜色 DTO
├── response.py # 响应 DTO
├── model_base.py # 响应基类 DTO
└── database.py # 数据库连接配置
```
@@ -118,7 +119,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
| `username` | `str` | 用户名,唯一,不可更改 |
| `nickname` | `str?` | 用户昵称 |
| `password` | `str` | 密码(加密后) |
| `status` | `bool` | 用户状态True=正常False=封禁 |
| `status` | `UserStatus` | 用户状态active/admin_banned/system_banned |
| `storage` | `int` | 已用存储空间(字节) |
| `two_factor` | `str?` | 两步验证密钥 |
| `avatar` | `str` | 头像类型/地址 |
@@ -130,6 +131,10 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
| `group_id` | `UUID` | 所属用户组(外键) |
| `previous_group_id` | `UUID?` | 之前的用户组(用于过期后恢复) |
**关系**:
- `group`: 所属用户组
- `previous_group`: 之前的用户组(用于过期后恢复)
---
### 2. UserAuthnWebAuthn 凭证)
@@ -248,7 +253,38 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 8. Object统一对象
### 8. PhysicalFile物理文件
**表名**: `physicalfile`
**基类**: `UUIDTableBaseMixin`
表示磁盘上的实际文件。多个 Object 可以引用同一个 PhysicalFile实现文件共享而不复制物理文件。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | `UUID` | 物理文件 UUID主键 |
| `storage_path` | `str` | 物理存储路径(相对于存储策略根目录) |
| `size` | `int` | 文件大小(字节) |
| `checksum_md5` | `str?` | MD5 校验和(用于文件去重和完整性校验) |
| `policy_id` | `UUID` | 存储策略(外键) |
| `reference_count` | `int` | 引用计数(有多少个 Object 引用此物理文件) |
**索引**:
- `ix_physical_file_policy_path`: (policy_id, storage_path)
- `ix_physical_file_checksum`: (checksum_md5)
**关系**:
- `policy`: 存储策略
- `objects`: 引用此物理文件的所有逻辑对象(一对多)
**业务方法**:
- `increment_reference()`: 增加引用计数
- `decrement_reference()`: 减少引用计数
- `can_be_deleted`: 属性,是否可物理删除(引用计数为 0
---
### 9. Object统一对象
**表名**: `object`
**基类**: `UUIDTableBaseMixin`
@@ -261,23 +297,45 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
| `name` | `str` | 对象名称(文件名或目录名) |
| `type` | `ObjectType` | 对象类型file/folder |
| `password` | `str?` | 对象独立密码 |
| `source_name` | `str?` | 源文件名(仅文件) |
| `size` | `int` | 文件大小(字节),目录为 0 |
| `upload_session_id` | `str?` | 分块上传会话 ID |
| `physical_file_id` | `UUID?` | 关联的物理文件(仅文件有效,目录为 NULL |
| `parent_id` | `UUID?` | 父目录外键NULL 表示根目录) |
| `owner_id` | `UUID` | 所有者用户(外键) |
| `policy_id` | `UUID` | 存储策略(外键) |
| `is_banned` | `bool` | 是否被封禁 |
| `banned_at` | `datetime?` | 封禁时间 |
| `banned_by` | `UUID?` | 封禁操作者 UUID |
| `ban_reason` | `str?` | 封禁原因 |
**约束**:
- 同一父目录下名称唯一
- 同一父目录下名称唯一owner_id + parent_id + name
- 名称不能包含斜杠
**索引**:
- `ix_object_owner_updated`: (owner_id, updated_at)
- `ix_object_parent_updated`: (parent_id, updated_at)
- `ix_object_owner_type`: (owner_id, type)
- `ix_object_owner_size`: (owner_id, size)
**关系**:
- `file_metadata`: 一对一关联 FileMetadata
- `physical_file`: 关联的物理文件(仅文件有效)
- `owner`: 所有者用户
- `banner`: 封禁操作者
- `parent`: 父目录(自引用)
- `children`: 子对象列表(自引用)
- `source_links`: 源链接列表
- `shares`: 分享列表
**业务属性**:
- `source_name`: 向后兼容属性,返回物理文件的存储路径
- `is_file`: 是否为文件
- `is_folder`: 是否为目录
---
### 9. FileMetadata文件元数据
### 10. FileMetadata文件元数据
**表名**: `filemetadata`
**基类**: `UUIDTableBaseMixin`
@@ -286,21 +344,53 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
|------|------|------|
| `id` | `UUID` | 主键 |
| `object_id` | `UUID` | 关联的对象(外键,唯一) |
| `width` | `int?` | 图片/视频宽度 |
| `height` | `int?` | 图片/视频高度 |
| `width` | `int?` | 图片/视频宽度(像素) |
| `height` | `int?` | 图片/视频高度(像素) |
| `duration` | `float?` | 音视频时长(秒) |
| `mime_type` | `str?` | MIME类型 |
| `bit_rate` | `int?` | 比特率 |
| `sample_rate` | `int?` | 采样率 |
| `channels` | `int?` | 音频通道数 |
| `codec` | `str?` | 编解码器 |
| `frame_rate` | `float?` | 视频帧率 |
| `orientation` | `int?` | 图片方向 |
| `has_thumbnail` | `bool` | 是否有缩略图 |
| `bitrate` | `int?` | 比特率kbps |
| `mime_type` | `str?` | MIME 类型 |
| `checksum_md5` | `str?` | MD5 校验和 |
| `checksum_sha256` | `str?` | SHA256 校验和 |
**关系**:
- `object`: 关联的 Object一对一
---
### 10. SourceLink源链接
### 11. UploadSession上传会话
**表名**: `uploadsession`
**基类**: `UUIDTableBaseMixin`
用于管理分片上传的会话状态。会话有效期为 24 小时,过期后自动失效。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | `UUID` | 会话 UUID主键 |
| `file_name` | `str` | 原始文件名 |
| `file_size` | `int` | 文件总大小(字节) |
| `chunk_size` | `int` | 分片大小(字节) |
| `total_chunks` | `int` | 总分片数 |
| `uploaded_chunks` | `int` | 已上传分片数 |
| `uploaded_size` | `int` | 已上传大小(字节) |
| `storage_path` | `str?` | 文件存储路径 |
| `expires_at` | `datetime` | 会话过期时间 |
| `owner_id` | `UUID` | 上传者用户(外键) |
| `parent_id` | `UUID` | 目标父目录(外键) |
| `policy_id` | `UUID` | 存储策略(外键) |
**关系**:
- `owner`: 上传者用户
- `parent`: 目标父目录
- `policy`: 存储策略
**业务属性**:
- `is_expired`: 会话是否已过期
- `is_complete`: 上传是否完成
---
### 12. SourceLink源链接
**表名**: `sourcelink`
**基类**: `TableBaseMixin`
@@ -314,7 +404,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 11. Share分享
### 13. Share分享
**表名**: `share`
**基类**: `TableBaseMixin`
@@ -336,7 +426,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 12. Report举报
### 14. Report举报
**表名**: `report`
**基类**: `TableBaseMixin`
@@ -350,7 +440,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 13. Tag标签
### 15. Tag标签
**表名**: `tag`
**基类**: `TableBaseMixin`
@@ -369,7 +459,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 14. Task任务
### 16. Task任务
**表名**: `task`
**基类**: `TableBaseMixin`
@@ -391,7 +481,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 15. TaskProps任务属性
### 17. TaskProps任务属性
**表名**: `taskprops`
**基类**: `TableBaseMixin`(主键为外键 task_id
@@ -405,7 +495,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 16. Download离线下载
### 18. Download离线下载
**表名**: `download`
**基类**: `UUIDTableBaseMixin`
@@ -437,7 +527,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 17. DownloadAria2InfoAria2下载信息
### 19. DownloadAria2InfoAria2下载信息
**表名**: `downloadaria2info`
**基类**: `TableBaseMixin`(主键为外键 download_id
@@ -457,7 +547,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 18. DownloadAria2FileAria2下载文件
### 20. DownloadAria2FileAria2下载文件
**表名**: `downloadaria2file`
**基类**: `TableBaseMixin`
@@ -474,7 +564,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 19. Node节点
### 21. Node节点
**表名**: `node`
**基类**: `TableBaseMixin`
@@ -499,7 +589,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 20. Aria2ConfigurationAria2配置
### 22. Aria2ConfigurationAria2配置
**表名**: `aria2configuration`
**基类**: `TableBaseMixin`
@@ -516,7 +606,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 21. Order订单
### 23. Order订单
**表名**: `order`
**基类**: `TableBaseMixin`
@@ -536,7 +626,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 22. Redeem兑换码
### 24. Redeem兑换码
**表名**: `redeem`
**基类**: `TableBaseMixin`
@@ -552,7 +642,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 23. StoragePack容量包
### 25. StoragePack容量包
**表名**: `storagepack`
**基类**: `TableBaseMixin`
@@ -568,7 +658,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 24. WebDAVWebDAV 账户)
### 26. WebDAVWebDAV 账户)
**表名**: `webdav`
**基类**: `TableBaseMixin`
@@ -587,7 +677,7 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
---
### 25. Setting系统设置
### 27. Setting系统设置
**表名**: `setting`
**基类**: `TableBaseMixin`
@@ -636,6 +726,22 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
└───────────────────────────────────────────────────────────────────┘
```
**新增关系**:
```
┌───────────────────────────────────────────────────────────────────┐
│ 一对多关系(新增) │
├───────────────────────────────────────────────────────────────────┤
│ │
│ PhysicalFile ◄──────────────────> Object (多个) │
│ physical_file_id (FK) 文件去重多个Object可引用 │
│ 同一个PhysicalFile │
│ │
│ User ◄──────────────────────────> UploadSession │
│ owner_id (FK) 用户的上传会话列表 │
│ │
└───────────────────────────────────────────────────────────────────┘
```
| 主表 | 从表 | 外键 | 说明 |
|------|------|------|------|
| Group | GroupOptions | `group_id` (unique) | 每个用户组有且仅有一个选项配置 |
@@ -693,7 +799,10 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase
| **User** | Task | `user_id` | 用户的任务 |
| **User** | WebDAV | `user_id` | 用户的 WebDAV 账户 |
| **User** | UserAuthn | `user_id` | 用户的 WebAuthn 凭证 |
| **User** | UploadSession | `owner_id` | 用户的上传会话 |
| **Policy** | Object | `policy_id` | 存储策略下的对象 |
| **Policy** | PhysicalFile | `policy_id` | 存储策略下的物理文件 |
| **PhysicalFile** | Object | `physical_file_id` | 物理文件被多个逻辑对象引用(文件去重) |
| **Object** | Object | `parent_id` | 目录的子文件/子目录(自引用) |
| **Object** | SourceLink | `object_id` | 文件的源链接 |
| **Object** | Share | `object_id` | 对象的分享 |
@@ -811,6 +920,36 @@ class PolicyType(StrEnum):
S3 = "s3" # S3 兼容存储
```
### StorageType
```python
class StorageType(StrEnum):
LOCAL = "local" # 本地存储
QINIU = "qiniu" # 七牛云
TENCENT = "tencent" # 腾讯云
ALIYUN = "aliyun" # 阿里云
ONEDRIVE = "onedrive" # OneDrive
GOOGLE_DRIVE = "google_drive" # Google Drive
DROPBOX = "dropbox" # Dropbox
WEBDAV = "webdav" # WebDAV
REMOTE = "remote" # 远程存储
```
### UserStatus
```python
class UserStatus(StrEnum):
ACTIVE = "active" # 正常
ADMIN_BANNED = "admin_banned" # 管理员封禁
SYSTEM_BANNED = "system_banned" # 系统封禁
```
### CaptchaType
```python
class CaptchaType(StrEnum):
DEFAULT = "default" # 默认验证码
GCAPTCHA = "gcaptcha" # Google reCAPTCHA
CLOUD_FLARE_TURNSTILE = "cloudflare turnstile" # Cloudflare Turnstile
```
### ThemeType
```python
class ThemeType(StrEnum):
@@ -893,6 +1032,9 @@ class OrderStatus(StrEnum):
| `UserSettingResponse` | 用户设置响应 |
| `WebAuthnInfo` | WebAuthn 信息 |
| `AuthnResponse` | WebAuthn 响应 |
| `UserAdminUpdateRequest` | 管理员更新用户请求 |
| `UserCalibrateResponse` | 用户存储校准响应 |
| `UserAdminDetailResponse` | 管理员用户详情响应 |
### 用户组相关
@@ -918,14 +1060,38 @@ class OrderStatus(StrEnum):
| `DirectoryResponse` | 目录响应 |
| `ObjectMoveRequest` | 移动对象请求 |
| `ObjectDeleteRequest` | 删除对象请求 |
| `ObjectCopyRequest` | 复制对象请求 |
| `ObjectRenameRequest` | 重命名对象请求 |
| `ObjectPropertyResponse` | 对象基本属性响应 |
| `ObjectPropertyDetailResponse` | 对象详细属性响应 |
| `PolicyResponse` | 存储策略响应 |
### 其他
### 上传相关
| DTO | 说明 |
|-----|------|
| `CreateUploadSessionRequest` | 创建上传会话请求 |
| `UploadSessionResponse` | 上传会话响应 |
| `UploadChunkResponse` | 上传分片响应 |
| `CreateFileRequest` | 创建空白文件请求 |
### 管理员文件管理
| DTO | 说明 |
|-----|------|
| `AdminFileResponse` | 管理员文件响应 |
| `FileBanRequest` | 文件封禁请求 |
| `AdminFileListResponse` | 管理员文件列表响应 |
### 系统设置
| DTO | 说明 |
|-----|------|
| `SiteConfigResponse` | 站点配置响应 |
| `ThemeResponse` | 主题颜色响应 |
| `SettingItem` | 设置项 |
| `SettingsUpdateRequest` | 更新设置请求 |
| `SettingsGetResponse` | 获取设置响应 |
---

View File

@@ -3,29 +3,5 @@ from .base import SQLModelBase
class ThemeResponse(SQLModelBase):
"""主题响应 DTO"""
primary: str = "#3f51b5"
"""主色调"""
secondary: str = "#f50057"
"""次要色"""
accent: str = "#9c27b0"
"""强调色"""
dark: str = "#1d1d1d"
"""深色"""
dark_page: str = "#121212"
"""深色页面背景"""
positive: str = "#21ba45"
"""正面/成功色"""
negative: str = "#c10015"
"""负面/错误色"""
info: str = "#31ccec"
"""信息色"""
warning: str = "#f2c037"
"""警告色"""
pass

View File

@@ -1,5 +1,5 @@
from enum import StrEnum
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Annotated
from uuid import UUID
from sqlmodel import Field, Relationship, UniqueConstraint, Index
@@ -7,6 +7,10 @@ from sqlmodel import Field, Relationship, UniqueConstraint, Index
from .base import SQLModelBase
from .mixin import UUIDTableBaseMixin, TableBaseMixin
if TYPE_CHECKING:
from .user import User
from .task import Task
from .node import Node
class DownloadStatus(StrEnum):
"""下载状态枚举"""
@@ -24,18 +28,12 @@ class DownloadType(StrEnum):
pass
if TYPE_CHECKING:
from .user import User
from .task import Task
from .node import Node
# ==================== Aria2 信息模型 ====================
class DownloadAria2InfoBase(SQLModelBase):
"""Aria2下载信息基础模型"""
info_hash: str | None = Field(default=None, max_length=40)
info_hash: Annotated[str | None, Field(max_length=40)] = None
"""InfoHashBT种子"""
piece_length: int = 0
@@ -118,11 +116,10 @@ class Download(DownloadBase, UUIDTableBaseMixin):
__table_args__ = (
UniqueConstraint("node_id", "g_id", name="uq_download_node_gid"),
Index("ix_download_status", "status"),
Index("ix_download_user_status", "user_id", "status"),
)
status: DownloadStatus = Field(default=DownloadStatus.RUNNING, sa_column_kwargs={"server_default": "'running'"})
status: DownloadStatus = Field(default=DownloadStatus.RUNNING, sa_column_kwargs={"server_default": "'running'"}, index=True)
"""下载状态"""
type: int = Field(default=0, sa_column_kwargs={"server_default": "0"})
@@ -196,5 +193,4 @@ class Download(DownloadBase, UUIDTableBaseMixin):
node: "Node" = Relationship(back_populates="downloads")
"""执行下载的节点"""

View File

@@ -0,0 +1,73 @@
from typing import Any, NoReturn
from fastapi import HTTPException
from starlette.status import (
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_402_PAYMENT_REQUIRED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
HTTP_409_CONFLICT,
HTTP_429_TOO_MANY_REQUESTS,
HTTP_500_INTERNAL_SERVER_ERROR,
HTTP_501_NOT_IMPLEMENTED,
HTTP_503_SERVICE_UNAVAILABLE,
HTTP_504_GATEWAY_TIMEOUT,
)
# --- 400 ---
def ensure_request_param(to_check: Any, detail: str) -> None:
"""
Ensures a parameter exists. If not, raises a 400 Bad Request.
This function returns None if the check passes.
"""
if not to_check:
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=detail)
def raise_bad_request(detail: str = '') -> NoReturn:
"""Raises an HTTP 400 Bad Request exception."""
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=detail)
def raise_unauthorized(detail: str) -> NoReturn:
"""Raises an HTTP 401 Unauthorized exception."""
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=detail)
def raise_insufficient_quota(detail: str = "积分不足,请充值") -> NoReturn:
"""Raises an HTTP 402 Payment Required exception."""
raise HTTPException(status_code=HTTP_402_PAYMENT_REQUIRED, detail=detail)
def raise_forbidden(detail: str) -> NoReturn:
"""Raises an HTTP 403 Forbidden exception."""
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail=detail)
def raise_not_found(detail: str) -> NoReturn:
"""Raises an HTTP 404 Not Found exception."""
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail=detail)
def raise_conflict(detail: str) -> NoReturn:
"""Raises an HTTP 409 Conflict exception."""
raise HTTPException(status_code=HTTP_409_CONFLICT, detail=detail)
def raise_too_many_requests(detail: str) -> NoReturn:
"""Raises an HTTP 429 Too Many Requests exception."""
raise HTTPException(status_code=HTTP_429_TOO_MANY_REQUESTS, detail=detail)
# --- 500 ---
def raise_internal_error(detail: str = "服务器出现故障,请稍后再试或联系管理员") -> NoReturn:
"""Raises an HTTP 500 Internal Server Error exception."""
raise HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail=detail)
def raise_not_implemented(detail: str = "尚未支持这种方法") -> NoReturn:
"""Raises an HTTP 501 Not Implemented exception."""
raise HTTPException(status_code=HTTP_501_NOT_IMPLEMENTED, detail=detail)
def raise_service_unavailable(detail: str) -> NoReturn:
"""Raises an HTTP 503 Service Unavailable exception."""
raise HTTPException(status_code=HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
def raise_gateway_timeout(detail: str) -> NoReturn:
"""Raises an HTTP 504 Gateway Timeout exception."""
raise HTTPException(status_code=HTTP_504_GATEWAY_TIMEOUT, detail=detail)