按 openapi-internal.yaml 落地两个端点与鉴权,全部离线可测。
**鉴权(app/api/auth.py)**是这一层的安全边界,三条纪律各有测试:
- **强制校验 aud 与 iss**(交给 pyjwt 强制,不解出来自己比——自己比容易漏掉
「claim 不存在」那一路)。这是独立密钥对方案唯一的意义所在:Java 侧现有的
JwtVerifier 只验签名、不校验 aud,正因如此才必须独立密钥;若本服务也只验签名,
那对独立密钥就白搭了。
- **额外强制 typ=service**,作为「有人误把用户密钥配到这里」的第二道防线。
- **缺公钥时全拒而非全过**。配置缺失必须立刻暴露,而不是变成一扇敞开的门;
与 Java 侧 InternalAuthFilter 未配置 internal-token 时全拒 /internal/** 一致。
- 失败一律返回同一个笼统原因,**不告诉调用方是签名错/过期/aud 不符**——对方是
我们自己的服务,真正原因在日志里;细节回给调用方只会在将来某天变成提示。
**三类失败分开**(契约核心)。第三类「任务失败」走 200 + state=failed + failure,
因为那种情况下 HTTP 调用是成功的;做成非 2xx 会让「查不到任务」与「任务失败了」
难以区分。另外两处刻意选择:
- 服务商 429 **不透传**,一律 502 + provider 信封。429 既可能是「等几秒」也可能是
「等到明天」,状态码表达不了这个区别,retry 才是权威信号。
- 超出模型限制的请求归 service/invalid_request 而非 provider——请求**根本没发出去**,
归到 provider 会让调用方以为是上游问题而去退避重试,而重试同样的参数永远不会变好。
- 服务商 404(含超出其 7 天保留期)归 service/task_not_found:调用方要做的是
**停止轮询**,不是退避重试。
- 接管 FastAPI 默认的 {"detail": [...]} 校验错误,否则调用方要处理三种形状,
而第三种还是框架实现细节。
**契约漂移守卫(tests/api/test_contract_drift.py,14 项)**:本项目开工审计已证明
「没有门禁的契约必然漂移」——四模块快照锁实际不存在,白名单 42/41 之误四份文档
一路不可见整整一个迭代。内部契约同样需要门禁。比对路径/方法/字段集/枚举/必填项,
**不比对字节**(永远不会相同,只会制造一个天天红然后被 skip 绕过的门禁)。
写这个守卫时它立刻抓到一处问题,但**问题在我的内省方式而不在应用**:本版本
FastAPI 的 include_router 会包一层内部 wrapper,路由并不平铺在 app.routes 上。
已改用 app.openapi()——它是「究竟暴露了什么」的权威视图,且不依赖框架内部结构,
否则这个门禁会在升级框架时莫名失效,而一个悄悄失效的门禁比没有门禁更糟。
**新增 pyproject.toml**:`Depends()` 放在参数默认值里正是 FastAPI 的标准写法,
ruff 的 B008 在此是误报。按 ruff 正规方式声明这些调用可安全求值,而不是在每个
端点上撒 noqa——后者迟早被用来压制真正的问题。
schemas 用 extra="forbid":拼错的字段应当立刻报错而非被静默忽略,后者表现为
「我传了 durationSec 却没生效」这类极难查的问题。测试覆盖了 camelCase 与
user_id(契约刻意不含它)两种被拒情形。
新增依赖 fastapi / uvicorn / pyjwt[crypto];测试 127 → 166。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""两类错误的异常与信封(契约 `openapi-internal.yaml` 的核心设计)。
|
||||
|
||||
**两类失败必须分开**,因为调用方对它们的反应完全不同:
|
||||
|
||||
- `service`(Java↔Python):我方 bug 或配置问题。除 `internal_error` /
|
||||
`provider_unavailable` 外都不该重试——重试一个拼错的请求体不会变好。
|
||||
- `provider`(Python↔服务商):以 `retry` 字段为准,调用方不该自己解读厂商码。
|
||||
|
||||
还有**第三类不在这里**:任务本身失败(生成跑完但失败了)走 `200 + state=failed`
|
||||
+ `failure`。因为那种情况下 **HTTP 调用是成功的**,失败的是任务。做成错误响应会
|
||||
让「查不到任务」与「任务失败了」难以区分。
|
||||
|
||||
判别字段是 `error.kind`,**不是 HTTP 状态码**:状态码在两类之间会撞(502 既可能是
|
||||
服务商挂了也可能是我们打错地址),而 `kind` 不会。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from app.domain.generation import Failure
|
||||
|
||||
|
||||
class ServiceErrorCode(Enum):
|
||||
"""`service` 类错误码,与契约的枚举逐一对应。"""
|
||||
|
||||
#: 服务票缺失、过期、签名错、aud/typ 不符。细节只进日志。
|
||||
UNAUTHENTICATED = ("unauthenticated", 401)
|
||||
#: 请求体不合契约,或参数超出该模型的限制(本地预校验拦下)。
|
||||
INVALID_REQUEST = ("invalid_request", 400)
|
||||
#: 路径里的服务商任务 ID 不存在(含超过服务商 7 天保留期)。
|
||||
TASK_NOT_FOUND = ("task_not_found", 404)
|
||||
#: 适配器未装配(如缺服务商凭证)或 media_kind 无对应适配器。
|
||||
PROVIDER_UNAVAILABLE = ("provider_unavailable", 503)
|
||||
#: 本服务 bug。
|
||||
INTERNAL_ERROR = ("internal_error", 500)
|
||||
|
||||
def __init__(self, code: str, http_status: int):
|
||||
self.code = code
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
class ServiceError(Exception):
|
||||
"""Java↔Python 之间的问题。"""
|
||||
|
||||
def __init__(self, code: ServiceErrorCode, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
def to_envelope(self) -> dict:
|
||||
return {
|
||||
"error": {
|
||||
"kind": "service",
|
||||
"code": self.code.code,
|
||||
"message": self.message,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ProviderError(Exception):
|
||||
"""Python↔服务商之间的问题,携带完整分类。
|
||||
|
||||
字段与 `Failure` 一致**是刻意的**:调用方因此可以复用同一段处置逻辑,不必为
|
||||
「提交时被限流」和「任务失败于限流」写两套分支。
|
||||
"""
|
||||
|
||||
#: 502:本服务本身没问题,是上游调用没成功。
|
||||
#: 刻意不透传服务商的状态码——429 既可能是「等几秒」也可能是「等到明天」,
|
||||
#: 状态码表达不了这个区别,`retry` 才是权威信号。
|
||||
HTTP_STATUS = 502
|
||||
|
||||
def __init__(self, failure: Failure):
|
||||
super().__init__(failure.message or failure.provider_code)
|
||||
self.failure = failure
|
||||
|
||||
def to_envelope(self) -> dict:
|
||||
return {
|
||||
"error": {
|
||||
"kind": "provider",
|
||||
"retry": self.failure.retry.value,
|
||||
"user_facing": self.failure.user_facing.value,
|
||||
"provider_code": self.failure.provider_code,
|
||||
"message": self.failure.message,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user