Compare commits
13 Commits
v1.0.0
...
afd6101c08
| Author | SHA1 | Date | |
|---|---|---|---|
| afd6101c08 | |||
| 726f26d8bd | |||
| c9fdff9d55 | |||
| a65b504a3d | |||
| 8c9e1179bc | |||
| b693d442f5 | |||
| 64d0fac879 | |||
| 8b8f396486 | |||
| b8f738f8cb | |||
| 91671a77df | |||
| f92065bc0b | |||
| f17044dead | |||
| f9995ef61b |
@@ -1,5 +1,38 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.2(2026-07-30)
|
||||||
|
|
||||||
|
1.0.1 的续作:那一版把三条跨字段守卫收进构造期后,独立验证发现 `from_env` 上还留着同一类的 15 条校验与 4 条规范化,一并收拢。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **后端选择与条件必填项在任何构造路径上都校验。** 以下此前只有 `from_env` 拦得住,`from_settings()` 与直接构造一律放行:`limiter_backend`/`breaker_backend`/`cache_backend`/`telemetry_backend`/`selector`/`quota_full` 六个字段的合法域;取 `redis` 的后端必须有 `redis_url`;启用缓存必须有 `cache_namespace` 与正 `cache_ttl_s`;`telemetry_backend` 取 `sqlite`/`postgres` 时对应的路径/DSN 必填;`structured_max_retries` 非负;`scope` 非空。
|
||||||
|
- **`client.py` 五处断言的前提现在真的成立。** `assert settings.redis_url is not None # 内部不变量: config 已校验` 之类的注释此前在 `from_settings` 路上是假的:断言开启时抛不含任何字段信息的 `AssertionError`,`python -O` 下断言被移除、错误退化为 redis 库抛出的连接串解析异常。注释已改为点明由哪个校验方法保证。
|
||||||
|
- **构造路补齐了 `from_env` 一直在做的规范化**,两条装配路对同一输入产出同一个值:
|
||||||
|
- `scope` 小写并去空白。它直接进 Redis key(`pgw:limit:{scope}:…`、`pgw:gate:{scope}:…`),此前一个进程走 `from_env("LLM")` 拿到 `llm`、另一个直接构造传 `"LLM"`,**同一逻辑 scope 的限流与熔断状态会分裂到两套命名空间**,各记各的配额与熔断状态,分布式治理静默失效且不报错。
|
||||||
|
- `redis_url`、`pricing_path` 的空串归 `None`。留着空串会骗过 `is None` 判断,把错误推迟成 redis 客户端的连接串解析异常或 `Is a directory: '.'`。
|
||||||
|
- Postgres DSN 剥掉 SQLAlchemy 驱动后缀(`postgresql+asyncpg://…` 的 `+asyncpg` asyncpg 不认)。这一条剥的时候会发一条 warning——库动了调用方给的值,不该静默;日志只出现 scheme 段,DSN 带密码,整串不进日志。经 `from_env` 装配的不受影响也不会有这条 warning(`_load_pg_dsn` 早就剥干净了)。
|
||||||
|
- **`EmbeddingSettings` 的 `batch_size` / `expected_dim` 域校验也移入构造期**,此前只有 `EmbeddingSettings.from_env` 校验,直接构造出 `batch_size=-3` 要到 `EmbeddingClient` 构造时才 fail-loud。
|
||||||
|
|
||||||
|
### 行为收紧(下游请读)
|
||||||
|
|
||||||
|
同 1.0.1:经 `from_env()` 装配的调用方**不受影响**。手工构造 `GatewaySettings` 或对它 `dataclasses.replace` 的调用方,若配置组合非法,现在会在构造期抛 `ValueError` 并点出字段名,而不是留到运行时表现为静默不建后端、裸 `AssertionError` 或第三方库的天书报错。
|
||||||
|
|
||||||
|
**一处静默改值需要留意**:此前手工构造传 `scope="LLM"`(非全小写)的调用方,升级后 scope 会被规范化为 `llm`,**Redis key 随之从 `pgw:limit:LLM:…` 切到 `pgw:limit:llm:…`**。这正是本次要修的问题——旧行为下这批 key 与 `from_env` 装配的进程根本不在同一命名空间;但切换发生的那一刻,旧键上的在途租约会被遗弃,靠 TTL 自愈。滚动升级期间建议留意限流配额短暂偏松。
|
||||||
|
|
||||||
|
## 1.0.1(2026-07-30)
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **装配守卫在任何构造路径上都生效,不再只在 `from_env` 上。** 三条跨字段不变量(源 `timeout_s` ≤ `lease_ttl_s`、`stall_window_s` ≥ 最大源 TTFT、`probe_ttl_s` ≥ 最慢源 `timeout_s` + 5)原先只在 `GatewaySettings.from_env` 里校验,而装配有两条官方路——走 `from_settings()` 或直接构造能装出违反不变量的配置且不报错,故障留到运行时才表现为:租约先于请求过期使并发悄悄超出配额、正常慢首包被误判卡死掐断、半开探针在途即被接管。守卫已收进 `GatewaySettings.__post_init__`,与 `types.py` 各子配置一致,三个 client(Gateway/Ocr/Embedding)的全部工厂一并覆盖。
|
||||||
|
- 新增 `sources` 非空校验。此前零源配置只在 `from_env` 路径被拦,直接构造可装出必然选源失败的 client。
|
||||||
|
|
||||||
|
### 行为收紧(下游请读)
|
||||||
|
|
||||||
|
直接构造 `GatewaySettings` 或对它做 `dataclasses.replace` 时,若上述组合非法,**现在会在构造期抛 `ValueError`**,而不是留到运行时。经 `from_env()` 装配的调用方**不受影响**——那条路本就跑这些守卫。手工拼配置(如从 YAML 读出后构造)的调用方若此前撞上过上述任一故障,升级后会在启动时立即得到点名字段的报错。
|
||||||
|
|
||||||
|
守卫报错文案的**补救建议**改为点字段名(`lease_ttl_s`、`backpressure.stall_window_s`、`breaker.probe_ttl_s`)。原文案已点出字段名,但建议部分给的是环境变量键(如"调大 `PGW_LEASE_TTL_S`"),而不走 env 的调用方从没设过那些键。键名映射见 `.env.example` 与 wiki `参考-配置键`。
|
||||||
|
|
||||||
## 1.0.0(2026-07-22)
|
## 1.0.0(2026-07-22)
|
||||||
|
|
||||||
首个正式版。统一 LLM/VLM/OCR/Embedding 调度与中转库,治理单位为一次模型调用;经 GovDoc-SaaS 与 CHSAnalyzer 两个真实项目全量迁移验收(ARCHITECTURE §11)。
|
首个正式版。统一 LLM/VLM/OCR/Embedding 调度与中转库,治理单位为一次模型调用;经 GovDoc-SaaS 与 CHSAnalyzer 两个真实项目全量迁移验收(ARCHITECTURE §11)。
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ project_root/
|
|||||||
| 三项目迁移文档(ARCHITECTURE §11 的展开,库设计的常驻约束) | `research-wiki/migrations/`(govdoc-saas / video-tree-trm5 / chsanalyzer) |
|
| 三项目迁移文档(ARCHITECTURE §11 的展开,库设计的常驻约束) | `research-wiki/migrations/`(govdoc-saas / video-tree-trm5 / chsanalyzer) |
|
||||||
| 功能设计文档(每次实现新功能时新增) | `research-wiki/designs/` |
|
| 功能设计文档(每次实现新功能时新增) | `research-wiki/designs/` |
|
||||||
| 实现计划 | `research-wiki/plans/` |
|
| 实现计划 | `research-wiki/plans/` |
|
||||||
|
| **用户文档站**(Gitea Wiki,Diátaxis 四区)结构/更新时机/写作纪律 | `research-wiki/docs-convention.md`;**发版或公共行为变更必须按其 §2 清单同步 wiki 与 CHANGELOG,版本 bump 提交不得裸发** |
|
||||||
| 治理网关参考实现 | `reference/Video-Tree-TRM5/adapters/`(llm/breaker/streaming/redis_cache/telemetry) |
|
| 治理网关参考实现 | `reference/Video-Tree-TRM5/adapters/`(llm/breaker/streaming/redis_cache/telemetry) |
|
||||||
| 分布式限流/熔断参考实现 | `reference/CHSAnalyzer/app/coordination/`(limiter+Lua/provider_gate)与 `app/providers/governance.py` |
|
| 分布式限流/熔断参考实现 | `reference/CHSAnalyzer/app/coordination/`(limiter+Lua/provider_gate)与 `app/providers/governance.py` |
|
||||||
| 错误分类参考 | `reference/CHSAnalyzer/app/domain/errors.py` |
|
| 错误分类参考 | `reference/CHSAnalyzer/app/domain/errors.py` |
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# PolyGateway
|
||||||
|
|
||||||
|
实验室统一的大语言模型调度与中转库:LLM / VLM / OCR / Embedding 四类调用共用同一套生产级治理栈——多源多账号、限流、错误分类重试、熔断、响应缓存、流式看门狗、遥测与成本。治理单位是**一次模型调用**;任务编排、业务解析、图像预处理都留在业务侧。
|
||||||
|
|
||||||
|
> 由三个真实项目(GovDoc-SaaS / CHSAnalyzer / Video-Tree-TRM5)各自手写的治理栈提炼而来,并以"能否全量迁移回这三个项目"作为验收标准。v1.0.0 已通过 GovDoc 与 CHSAnalyzer 两项目的全量迁移验收(约 −6800 行项目侧治理代码由本库继任)。
|
||||||
|
|
||||||
|
## 为什么需要它
|
||||||
|
|
||||||
|
每个接入大模型的项目都会重写同一批东西:重试循环、429 处理、熔断器、SSE 解析、遥测埋点——写三遍就有三份 bug。本库把这些收敛为一份经过压测验证的实现:
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 多源多账号 | `{SCOPE}__{PROVIDER}__{N}__*` 配置任意多源;健康感知选源(EWMA×在途 P2C)自动避开坏源 |
|
||||||
|
| 限流 | 并发/RPM/TPM × 全局/单源六道闸;TPM 预扣入场、按实际用量结算退款;Redis 后端跨进程原子(Lua) |
|
||||||
|
| 错误分类重试 | 一切失败落入四分类(见下),由分类决定重试/换源/熔断;429 属 pushback 不消耗重试预算;退避含 jitter 且尊重 Retry-After |
|
||||||
|
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增 |
|
||||||
|
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
|
||||||
|
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace/租户 + salt,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
|
||||||
|
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
|
||||||
|
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 18 字段;SQLite / Postgres 后端;按价格表折算成本;多模态内容摘要落库不存原图 |
|
||||||
|
| 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 |
|
||||||
|
| OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` |
|
||||||
|
| Embedding | 分批、维度校验、与 chat 同一治理栈 |
|
||||||
|
|
||||||
|
**降级方向是铁律**:缓存/遥测后端掉线 → 静默降级(warning);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
发布在实验室 Gitea PyPI(公开包,匿名可装):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
||||||
|
"polygateway[redis,postgres,structured]==1.0.*"
|
||||||
|
```
|
||||||
|
|
||||||
|
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
|
||||||
|
|
||||||
|
| extra | 内容 | 何时需要 |
|
||||||
|
|---|---|---|
|
||||||
|
| `redis` | redis-py | Redis 限流/熔断/缓存后端 |
|
||||||
|
| `postgres` | asyncpg | Postgres 遥测后端 |
|
||||||
|
| `structured` | json-repair | 结构化输出的修复策略 |
|
||||||
|
| `sdk` | openai | 可选的 SDK transport(默认手写 httpx,不需要) |
|
||||||
|
|
||||||
|
要求 Python ≥ 3.11。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 配置 `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LLM__MINIMAX__1__BASE_URL=https://your-gateway/v1
|
||||||
|
LLM__MINIMAX__1__API_KEY=sk-xxx
|
||||||
|
LLM__MINIMAX__1__MODEL=MiniMax-M3
|
||||||
|
LLM__MINIMAX__1__TIMEOUT_S=120
|
||||||
|
LLM_MAX_RETRIES=3
|
||||||
|
LLM_RETRY_BASE_DELAY=2.0
|
||||||
|
LLM_RETRY_MAX_DELAY=30.0
|
||||||
|
LLM_CIRCUIT_BREAKER_THRESHOLD=5
|
||||||
|
LLM_CIRCUIT_BREAKER_COOLDOWN=60
|
||||||
|
PGW_LIMITER_BACKEND=memory
|
||||||
|
PGW_BREAKER_BACKEND=memory
|
||||||
|
PGW_CACHE_BACKEND=none
|
||||||
|
PGW_TELEMETRY_BACKEND=none
|
||||||
|
```
|
||||||
|
|
||||||
|
缺任何关键键都会在装配时报错——本库禁止默认值兜底掩盖配置缺失。
|
||||||
|
|
||||||
|
### 2. 发起治理调用
|
||||||
|
|
||||||
|
```python
|
||||||
|
from polygateway import GatewayClient
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
client = GatewayClient.from_env("LLM") # 读 .env 装配整套治理栈
|
||||||
|
try:
|
||||||
|
resp = await client.chat([{"role": "user", "content": "你好"}])
|
||||||
|
print(resp.content, resp.source_name, resp.latency_ms)
|
||||||
|
finally:
|
||||||
|
await client.aclose() # 归还连接与治理后端资源
|
||||||
|
```
|
||||||
|
|
||||||
|
`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制。
|
||||||
|
|
||||||
|
### 3. OCR 与 Embedding
|
||||||
|
|
||||||
|
```python
|
||||||
|
from polygateway import EmbeddingClient
|
||||||
|
from polygateway.ocr import OcrClient
|
||||||
|
|
||||||
|
ocr = OcrClient.from_env("OCR") # OCR__MONKEY__1__* 多源
|
||||||
|
text = await ocr.recognize_text(image_bytes) # 文本转录
|
||||||
|
layout = await ocr.parse_layout(image_bytes) # 版面解析(带 bbox 的元素列表)
|
||||||
|
health = await ocr.check_health() # 逐源预检 {"monkey_1": True, ...}
|
||||||
|
|
||||||
|
embed = EmbeddingClient.from_env("EMBED") # EMBED__*__* + EMBED__BATCH_SIZE
|
||||||
|
vectors = (await embed.embed(["文本 a", "文本 b"])).vectors
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 业务侧异常处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
from polygateway import GatewayUnavailableError, RequestRejectedError
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await client.chat(messages)
|
||||||
|
except GatewayUnavailableError as exc:
|
||||||
|
# 整个 scope 暂时无源可用: 延期重投,不消耗业务失败预算
|
||||||
|
schedule_retry(after_s=exc.retry_after_s) # exc.reason / exc.per_source_reasons 供诊断
|
||||||
|
except RequestRejectedError:
|
||||||
|
... # 请求本身有问题(400/格式拒绝): 不重试,直接失败
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误模型(四分类)
|
||||||
|
|
||||||
|
一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码:
|
||||||
|
|
||||||
|
| 分类 | 含义 | 库内行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| `TransientError` | 超时/5xx/网络抖动/截断流 | 换源重试 + 退避 |
|
||||||
|
| `SourceDeadError` | 401/403/欠费(429+insufficient_quota) | 立即熔断该源 + 换源 |
|
||||||
|
| `RequestRejectedError` | 400/内容拒绝/本地格式拒绝 | 不重试不换源,快速失败 |
|
||||||
|
| `ResultInvalidError` | 调用成功但结果不合格(坏 JSON/维度不符/坏 bbox) | 不熔断("坏结果 ≠ 坏服务"),按策略有界重问或上抛 |
|
||||||
|
|
||||||
|
预算耗尽/全源熔断时抛 `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted`),携带 `scope` / `reason` / `retry_after_s` / `per_source_reasons`,供任务队列做延期重投。
|
||||||
|
|
||||||
|
## 配置参考
|
||||||
|
|
||||||
|
配置只有两条装配路径:`from_env()`(读 `.env`/环境变量)或构造函数全量注入(测试/高级);库内部任何组件不自读环境变量。键名全集见 [.env.example](.env.example),约定速览:
|
||||||
|
|
||||||
|
| 键形态 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD ∈ BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/TRUST_ENV |
|
||||||
|
| `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
|
||||||
|
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
|
||||||
|
| `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | `memory`(单进程)或 `redis`(跨进程共享,需 `REDIS_URL`) |
|
||||||
|
| `PGW_CACHE_BACKEND` | `none` / `redis`(需 `PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`) |
|
||||||
|
| `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) |
|
||||||
|
|
||||||
|
`SCOPE` 是逻辑角色(LLM/VLM/OCR/EMBED/JUDGE/SEARCH…任意大写名),同一进程可按角色装配多个 client,各自独立配置与治理状态。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
端口适配器 + 中间件洋葱:决策逻辑一份,状态存储可插拔。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[业务代码] --> B[GatewayClient]
|
||||||
|
B --> C[缓存 MW] --> D[遥测 MW] --> E[重试/选源/限流/熔断 MW]
|
||||||
|
E --> F[Transport httpx]
|
||||||
|
F --> G[(上游网关)]
|
||||||
|
E -.端口.-> H[(内存 / Redis 后端)]
|
||||||
|
D -.端口.-> I[(SQLite / Postgres)]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 模块 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `types.py` / `errors.py` / `ports.py` | 内核:冻结类型、四分类异常、全部 Protocol(最内层,不依赖任何实现) |
|
||||||
|
| `middleware/` | 治理算法(重试/限流/熔断/缓存/遥测),只面向端口 |
|
||||||
|
| `transports/` | 协议细节:OpenAI 兼容 SSE、MonkeyOCR 双端点;错误翻译在此层 |
|
||||||
|
| `backends/` | 限流/熔断/缓存的内存与 Redis 实现(同一契约测试套件双后端共用) |
|
||||||
|
| `telemetry/` | SQLite / Postgres 遥测后端 |
|
||||||
|
| `structured/` | 结构化输出策略 |
|
||||||
|
|
||||||
|
依赖纪律由 import-linter 机械化执法(`make lint`)。完整架构决策(D1-D14 含论证过程)见 [research-wiki/ARCHITECTURE.md](research-wiki/ARCHITECTURE.md)。
|
||||||
|
|
||||||
|
## 可靠性证据
|
||||||
|
|
||||||
|
行为不是宣称出来的,是压测出来的(数字见 `research-wiki/findings/`):
|
||||||
|
|
||||||
|
| 场景 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 故障混编 soak(坏 key/黑洞/慢源/限流源混合,8000 调用) | 成功率 98.96%,坏源吸流被压制,真实源零误熔 |
|
||||||
|
| OCR 故障池 soak(1500 调用,redis 双后端跨进程) | 成功率 99.73%,13 项不变量全过(租约归零/探针不悬挂/零取消泄漏等) |
|
||||||
|
| 两项目全量迁移回归 | 原测试全绿 + 真实链路冒烟 + 50 样本批跑 100% 解析 |
|
||||||
|
|
||||||
|
时间语义测试(租约过期、窗口滚动、半开探针)全部真实等待不缩放;Redis/Postgres 测试打真实实验室后端,不 mock Lua。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda create -n PolyGateway python=3.11 && conda activate PolyGateway
|
||||||
|
make install # editable 安装(dev + 全部 extras)
|
||||||
|
make test # pytest + 覆盖率(目标 ≥80%)
|
||||||
|
make lint # ruff + import-linter
|
||||||
|
make ci # 只读全量验证
|
||||||
|
```
|
||||||
|
|
||||||
|
测试组织:`tests/{unit,integration,e2e}` + 双后端契约测试;并发/取消/降级方向是一等测试对象。压测 harness 在 `tools/soak/`。贡献流程与项目纪律见 [CLAUDE.md](CLAUDE.md)。
|
||||||
|
|
||||||
|
## 文档导航
|
||||||
|
|
||||||
|
| 想了解 | 看 |
|
||||||
|
|---|---|
|
||||||
|
| 全部架构决策及理由(单一事实源) | `research-wiki/ARCHITECTURE.md` |
|
||||||
|
| 里程碑与状态 | `research-wiki/ROADMAP.md` |
|
||||||
|
| 项目迁移指南(删除清单/组件映射/行为审计) | `research-wiki/migrations/` |
|
||||||
|
| 每个功能的设计与验收记录 | `research-wiki/designs/`、`research-wiki/findings/` |
|
||||||
|
| 版本变更 | [CHANGELOG.md](CHANGELOG.md) |
|
||||||
|
|
||||||
|
## 兼容性承诺
|
||||||
|
|
||||||
|
`LLMResponse` 等被下游消费的公共类型,字段**只增不删不改名**且新增字段必带默认值;`{SCOPE}__{PROVIDER}__{N}__{FIELD}` 与平铺韧性键名(`LLM_TIMEOUT` 等)沿用三项目既有习惯,不做破坏性改名。实验室内部库,随实验室项目需求演进。
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "polygateway"
|
name = "polygateway"
|
||||||
version = "1.0.0"
|
version = "1.0.2"
|
||||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# GatewaySettings 跨字段不变量守卫的生效范围
|
||||||
|
|
||||||
|
- **日期**: 2026-07-29;**状态**: **已批准并实施**(2026-07-29 人类门通过;§8 结论见文末)
|
||||||
|
- **范围拍板**(用户 2026-07-29): 功能对齐社区 PR#1,但按本库规范重写;顺带销掉 PR#1 遗留的两个缺陷
|
||||||
|
- **上游依据**: ARCHITECTURE §7.3 契约补强 G6(装配期守卫,"违反直接报错拒绝装配")、§9 配置聚合、CLAUDE.md §4.5(装配只有两条路)、`types.py` 同族 frozen dataclass 的既有校验笔迹
|
||||||
|
|
||||||
|
## 1. 缺陷取证(全部本地实测,worktree @ f76a89b 与 main 对照)
|
||||||
|
|
||||||
|
`GatewaySettings` 有三条**跨字段**不变量——单个字段合法、组合起来才非法,因此 `types.py` 各子配置的 `__post_init__` 管不到,只能在聚合层管:
|
||||||
|
|
||||||
|
| 不变量 | 现居位置 | 违反后的运行时后果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 源 `timeout_s` ≤ `lease_ttl_s` | `_guard_lease`,仅 `from_env` 调用 | 租约先于请求过期,名额被放给他人 → 实际并发超配额,击穿网关 |
|
||||||
|
| `backpressure.stall_window_s` ≥ 最大源 `ttft_timeout_s` | `_guard_stall`,仅 `from_env` 调用 | 正常慢首包被误判卡死掐断 |
|
||||||
|
| `breaker.probe_ttl_s` ≥ 最慢源 `timeout_s` + 5 | `_load_breaker` 内联,仅 `from_env` 路径 | 半开探针在途即被接管(M2 设计 §3 原文) |
|
||||||
|
|
||||||
|
三条守卫都只挂在 `from_env` 上,而 CLAUDE.md §4.5 规定装配有**两条**官方路。走 `from_settings()` 能装出违反上述任一条的配置且不报错——类可以合法地存在于它自己 docstring 声称不可能的状态。
|
||||||
|
|
||||||
|
实测(在 PR#1 分支上,即已修前两条之后):
|
||||||
|
|
||||||
|
| 构造方式 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| `replace(base, breaker=replace(base.breaker, probe_ttl_s=1.0))`(最慢 timeout 120s) | **未拦截**,装配成功 |
|
||||||
|
| `replace(base, sources=())` | `ValueError: max() arg is an empty sequence` —— 内置异常泄漏,既不点字段也不说原因 |
|
||||||
|
|
||||||
|
第一条说明 PR#1 的搬迁不完整:它的全部论证同等适用于 `probe_ttl_s`,却只搬了两条。第二条是 PR#1 **新引入**的失败模式——`max()` 此前只在 `_load_sources` 保证非空之后才执行,守卫上移到构造期后失去了这个前提。
|
||||||
|
|
||||||
|
另有一条隐性不变量此前从未表达:**`sources` 不得为空**。`from_env` 路径由 `_load_sources` 显式拦截,直接构造路径无人把关,零源的 client 装出来后选源必然失败。
|
||||||
|
|
||||||
|
## 2. 备选方案对比
|
||||||
|
|
||||||
|
| 方案 | 做法 | 权衡 |
|
||||||
|
|---|---|---|
|
||||||
|
| **A. `__post_init__` 集中校验(推荐)** | 三条跨字段守卫 + 空源检查全部收进 `GatewaySettings.__post_init__`,拆为 `_validate_sources/_validate_lease/_validate_stall/_validate_probe` 私有方法 | 与 `types.py` 同族五个 frozen dataclass 的既有笔迹完全一致;一处覆盖全部构造路径(六个工厂 + 直接构造 + `dataclasses.replace`);代价是收紧了构造承诺(见 §4) |
|
||||||
|
| B. 各工厂入口显式调用 `settings.validate()` | 三个 client × 两个工厂,六处各加一行 | 不改构造承诺,零 breaking;但六处要永久保持同步,新增第四个 client 时必漏——正是"每个调用方各维护一份副本"的毛病挪进库里。且 `dataclasses.replace` 仍能绕过。**否决** |
|
||||||
|
| C. 公共 `settings.validate()`,由调用方自愿调 | 提供校验入口,不强制 | 把类不变量降级成"建议";违反 P5 防御性(外部输入校验后使用)与 ARCHITECTURE §7.3"违反直接报错拒绝装配"。**否决** |
|
||||||
|
|
||||||
|
方案 A 与 `SourceConfig.__post_init__` 同构。选它的核心理由不是"少写五行",是**不变量的归属**:这三条约束是 `GatewaySettings` 这个类的定义的一部分,不是 `from_env` 这个函数的输入检查。放在函数里,类就失去了自我描述能力。
|
||||||
|
|
||||||
|
### 2.1 子决策:守卫的代码形态
|
||||||
|
|
||||||
|
`config.py` 现有 `_guard_lease(settings)` / `_guard_stall(settings)` 两个模块级函数,把自身实例传回给模块级函数是绕路。`types.py` 的既有做法是私有方法(`SourceConfig` 拆三个 `_validate_*`)。**改为私有方法**,与同族一致;模块级 `_guard_*` 一并删除(无其他调用点)。
|
||||||
|
|
||||||
|
### 2.2 子决策:`probe_ttl_s` 的派生逻辑留在哪
|
||||||
|
|
||||||
|
`_load_breaker` 对该字段做了两件事:未配置时**派生**(`max(2*slowest, cooldown_s, probe_floor)`,派生规则本身保证守卫恒成立)、显式配置时**校验**。派生需要读 env,必须留在 `_load_breaker`;校验上移到 `__post_init__` 后,`_load_breaker` 内联的那份校验删除(避免同一约束两处维护)。派生分支上移后仍恒过,无行为变化。
|
||||||
|
|
||||||
|
### 2.3 子决策:错误消息里是否列 env 键名
|
||||||
|
|
||||||
|
**不列。** 三条理由:(1) `types.py` 全部校验消息只点字段名,是既有笔迹;(2) 守卫现在服务两类调用方,env 键对手工拼 settings 的那类是不可执行的建议;(3) 键名的单一事实源是 `.env.example` 与 wiki `参考-配置键`,消息里复制一份即双处维护。消息格式沿用既有句式:`字段名(值)须 …;调大 X 或调小 Y`。
|
||||||
|
|
||||||
|
> 与 PR#1 的差异:PR#1 选择"点字段名 + 括号附 env 键",单行超 100 字符且把 `{SCOPE}__{PROVIDER}__{N}__TIMEOUT_S` 模板塞进运行时消息。本方案只留字段名。
|
||||||
|
|
||||||
|
## 3. 行为审计(逐条标注)
|
||||||
|
|
||||||
|
不是从 `reference/` 迁移,是既有模块的行为收紧,故审计对象为现有 `from_env` 路径的全部可观测行为:
|
||||||
|
|
||||||
|
| 现有行为 | 处置 |
|
||||||
|
|---|---|
|
||||||
|
| `from_env` 装配非法 lease/stall 组合 → `ValueError` | **保留**(改由 `__post_init__` 抛,时机提前到 `cls(...)` 那一行,对调用方不可见) |
|
||||||
|
| `from_env` 配了过小 `PROBE_TTL_S` → `ValueError` | **保留**(同上,消息中不再含 env 键名 —— 有意变更,§2.3) |
|
||||||
|
| `from_env` 未配 `PROBE_TTL_S` → 派生值 | **保留**,派生规则一字不改 |
|
||||||
|
| `from_env` 未配任何源 → `ValueError: scope X 未配置任何源` | **保留**,`_load_sources` 的检查不动(它能给出键名模板,信息量高于构造期检查) |
|
||||||
|
| 直接构造/`replace` 出非法组合 → 静默成功 | **有意替换**为构造期 `ValueError`(本设计的目的) |
|
||||||
|
| 直接构造空 `sources` → 静默成功 | **有意替换**为构造期 `ValueError`,消息点明"至少一个源" |
|
||||||
|
| 三条守卫的异常类型 `ValueError` | **保留**。装配期错误不入 `errors.py` 四分类(四分类描述的是一次调用的失败),与 `_load_sources`/`types.py` 既有装配错误一致 |
|
||||||
|
| `GatewaySettings` 字段名与类型 | **不动**。迁移兼容约束(CLAUDE.md §4.3 例外条款)只增不删不改名,本次零字段变更 |
|
||||||
|
|
||||||
|
**有意放弃**:不提供 `strict=False` 之类的逃生开关。装出必然故障的配置没有正当用例。
|
||||||
|
|
||||||
|
## 4. 对下游的承诺变化(人类门要审的就是这条)
|
||||||
|
|
||||||
|
| 调用方式 | 影响 |
|
||||||
|
|---|---|
|
||||||
|
| `GatewayClient.from_env()` / `OcrClient.from_env()` / `EmbeddingClient.from_env()` | **零影响**,该路径本就跑这些守卫 |
|
||||||
|
| `*.from_settings(settings)`,settings 来自 `from_env` | **零影响** |
|
||||||
|
| 手工构造 `GatewaySettings(...)` 或 `dataclasses.replace(...)`,组合合法 | **零影响** |
|
||||||
|
| 手工构造/`replace`,组合非法 | **行为变更**:构造期抛 `ValueError`,不再留到运行时表现为超配额/误判卡死/探针被接管 |
|
||||||
|
|
||||||
|
已知受影响的下游:CHSAnalyzer 重建中的 YAML → 直接构造 → `from_settings()` 路径(PR#1 提交者正是在此撞上的)。该路径若配置合法则不受影响,若非法则从"静默故障"变为"启动即报错"——方向是收益。
|
||||||
|
|
||||||
|
版本:**1.0.1**(patch,用户 2026-07-29 拍板)。设计初稿曾建议 minor(构造期新抛 `ValueError` 是可观测的收紧),用户判定受影响面仅限"手工拼出非法配置"这一本就故障的路径,按修复发 patch。CHANGELOG 必须把行为收紧单列小节,不能只混在"修复"里——patch 号不会给下游预警,changelog 是唯一的告知渠道。
|
||||||
|
|
||||||
|
发版时按 `docs-convention` §2 末行过发布清单;wiki `参考-配置键` 页现有表述("须 ≤ `PGW_LEASE_TTL_S`""须 ≥ 最大源 TTFT")与新行为一致,**无需改动内容**。
|
||||||
|
|
||||||
|
## 5. 非功能维度
|
||||||
|
|
||||||
|
| 维度 | 回答 |
|
||||||
|
|---|---|
|
||||||
|
| 并发与取消 | **不适用但需写明**:`__post_init__` 是同步纯计算(只读自身字段做比较),无 I/O、无 await、无锁,不存在取消穿透点。不引入任何全局状态,纯 asyncio 中立铁律不受影响 |
|
||||||
|
| 降级方向 | 装配期校验属**准入侧**,按库铁律"报错而非放行"。无后端依赖,无降级分支 |
|
||||||
|
| 幂等与重复 | `__post_init__` 不修改任何字段(frozen 也不允许),重复构造同一配置得同一结果;校验本身无副作用 |
|
||||||
|
| 持久化与原子性 | 不适用,配置对象不落盘 |
|
||||||
|
| 性能 | 每次构造增加三次 `max()` 遍历 sources(典型 1-4 个源)。`GatewaySettings` 只在装配期构造,不在请求路径上,可忽略 |
|
||||||
|
|
||||||
|
## 6. 测试策略
|
||||||
|
|
||||||
|
`tests/unit/test_config.py` 新增一个测试类,覆盖矩阵为 **4 条不变量 × 2 条构造路径**:
|
||||||
|
|
||||||
|
| 用例 | 断言 |
|
||||||
|
|---|---|
|
||||||
|
| 三条守卫各自:`dataclasses.replace` 构造出违反组合 | 抛 `ValueError`,消息含对应字段名 |
|
||||||
|
| 三条守卫各自:边界值恰好相等(`timeout_s == lease_ttl_s` 等) | **构造成功**——守卫收紧的是错的那些,不是所有直接构造 |
|
||||||
|
| `sources=()` | 抛 `ValueError`,消息点明"至少一个源",**且不是 `max() arg is an empty sequence`** |
|
||||||
|
| `GatewayClient.from_settings(非法 settings)` | 抛 `ValueError`。**注意抛点**:方案 A 之下非法实例根本无法存在,异常发生在实参求值(构造 settings)那一刻,不在工厂内部——这正是构造期把关换来的性质,测试 docstring 须写明,以免后人误读为工厂自带校验 |
|
||||||
|
| `OcrSettings` / `EmbeddingSettings` 直接构造包着非法 gateway | 抛 `ValueError`(证明三条 client 线一并覆盖) |
|
||||||
|
| 既有 447 passed / 34 skipped | 全绿,零回归 |
|
||||||
|
|
||||||
|
TDD 顺序:先写测试跑出预期失败(预计 3 条守卫 + 空源 + from_settings 端到端 共失败 6 条以上),再实现,再全绿。测试不新增 mock,全部用既有 `_env()` helper 构造真实 settings 再派生。
|
||||||
|
|
||||||
|
## 7. 与 PR#1 的关系
|
||||||
|
|
||||||
|
功能对齐,不是推翻。PR#1 的问题诊断完全正确,本设计沿用其核心结论(守卫属于类不变量,应在构造期生效),差异集中在:
|
||||||
|
|
||||||
|
| 维度 | PR#1 | 本设计 |
|
||||||
|
|---|---|---|
|
||||||
|
| 覆盖的不变量 | 2 条 | 4 条(补 `probe_ttl_s`、空 sources) |
|
||||||
|
| 代码形态 | 保留模块级 `_guard_*(settings)` | 改为 `_validate_*` 私有方法,同 `SourceConfig` |
|
||||||
|
| docstring | 引用 `CLAUDE.md §4.5`(下游读者看不到该文件)、带论证口吻 | 只引 ARCHITECTURE §7.3 与自身概念,解释"为什么"不复述辩论 |
|
||||||
|
| 错误消息 | 字段名 + 附 env 键模板 | 只点字段名(§2.3) |
|
||||||
|
| 测试 | 4 条,全走 `replace`,其中 1 条同义反复 | 覆盖 4 不变量 × 2 路径 + 边界值 + 三条 client 线 |
|
||||||
|
| 导入位置 | 两处函数内 `import dataclasses` | 文件顶部 |
|
||||||
|
|
||||||
|
合并后应关闭 PR#1 并在其中说明:诊断被采纳,实现按库内规范重写并扩展了覆盖范围。
|
||||||
|
|
||||||
|
## 8. 人类拍板结论(2026-07-29)
|
||||||
|
|
||||||
|
| 问题 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| 主决策 | **接受方案 A**,构造期强制,承诺收紧 |
|
||||||
|
| 范围 | **全量**:`probe_ttl_s` 与空 sources 一并纳入 |
|
||||||
|
| 消息文案 | **去掉 env 键名**,只点字段名(§2.3) |
|
||||||
|
| 版本 | **1.0.1**(patch);初稿建议的 minor 被否,理由与代偿见 §4 |
|
||||||
|
| PR#1 处置 | 重写合并后关闭并说明,诊断归功于提交者 |
|
||||||
|
|
||||||
|
## 9. 实施与验证留痕
|
||||||
|
|
||||||
|
实施于 `fix/settings-invariant-guards`(5 commits)。TDD 证据:新测试类先 **6 failed / 3 passed**(3 条为边界护栏,本就应过),实现后全绿。
|
||||||
|
|
||||||
|
独立 verifier(全新上下文)核验结论 **可以合并,无阻塞**,其中两项证据值得留档:
|
||||||
|
|
||||||
|
- **变异测试 12/12 全杀**:逐个破坏实现(删各 `_validate_*` 调用、`>`↔`>=`、`<`↔`<=`、删空源检查、`_PROBE_GRACE_S` 归零)均有测试失败,无一存活。边界侧用例(恰好相等必过)对每条守卫都真实有效,差一错误可捕获。
|
||||||
|
- **无热路径回归**:库内**没有任何地方**构造或 `replace` `GatewaySettings`(`src/` 中 4 处 `dataclasses.replace` 全在 `middleware/structured.py`,作用于 `ChatRequest`/`LLMResponse`)。单次构造实测 1.45 µs,装配期一次性成本。`pickle`/`deepcopy` 不触发 `__post_init__`,只有 `replace` 触发——序列化往返既无额外开销也不构成二次守卫点。
|
||||||
|
|
||||||
|
### 9.1 verifier 发现的同族遗漏(范围外,另起任务)
|
||||||
|
|
||||||
|
`GatewaySettings` 仍有 **14 条校验只挂在 `from_env`**,直接构造/`replace` 全部放行,与本设计所修的是同一个 bug 类:`limiter/breaker/cache_backend=redis` 但 `redis_url=None`、`telemetry_backend=sqlite/postgres` 但 path/dsn 为 None、`selector`/`quota_full`/各 backend 的枚举合法性、`structured_max_retries` 负值、`scope` 空串等。
|
||||||
|
|
||||||
|
严重性高于本次所修的三条,因为 `client.py:262/282/302/312/316` 有 5 处 `assert ... # 内部不变量: config 已校验` **明文依赖这个前提**,而该前提在 `from_settings` 路上为假:断言开启时抛裸 `AssertionError`(不点字段不说原因),`python -O` 下断言消失、错误退化为 redis 库抛出的天书。后者同时违反 CLAUDE.md §4.3"禁止 assert 承担生产校验"。
|
||||||
|
|
||||||
|
**有意不纳入本次交付**(避免任务外扩张),另起任务处理。
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# GatewaySettings 装配校验补齐(第二轮)
|
||||||
|
|
||||||
|
- **日期**: 2026-07-30;**状态**: **已批准并实施**(2026-07-30 人类门通过;§9 结论、§10 实施留痕)
|
||||||
|
- **缘起**: [2026-07-29-settings-invariant-guards-design.md](2026-07-29-settings-invariant-guards-design.md) §9.1 —— 独立 verifier 在第一轮交付后发现,`from_env` 上还留着一批同族校验;本设计是那一轮的续作,**同一个 bug 类的剩余部分**
|
||||||
|
- **上游依据**: 第一轮设计 §2 已批准的方案 A(不变量归属于类,不归属于某个工厂);CLAUDE.md §4.3(assert 仅用于内部不变量)、§4.5(装配只有两条路)
|
||||||
|
|
||||||
|
## 1. 待收拢的校验清单(逐条实测确认只在 `from_env` 生效)
|
||||||
|
|
||||||
|
### A. 枚举合法域(6 条)
|
||||||
|
|
||||||
|
| 字段 | 合法域 | 现居 |
|
||||||
|
|---|---|---|
|
||||||
|
| `limiter_backend` / `breaker_backend` | `{memory, redis}` | `_load_pgw`(经 `_load_choice`) |
|
||||||
|
| `cache_backend` | `{redis, memory, none}` | `_load_pgw` 内联 |
|
||||||
|
| `telemetry_backend` | `{sqlite, postgres, none}` | `_load_pgw` 内联 |
|
||||||
|
| `selector` | `_SELECTORS` | `from_env` 调 `_load_choice` |
|
||||||
|
| `quota_full` | `_QUOTA_FULL` | `from_env` 调 `_load_choice` |
|
||||||
|
|
||||||
|
直接构造传 `selector="random"` 或 `cache_backend="rediss"` 一律放行,后果是装配时落进 `_build_*` 的 else 分支或静默不建后端。
|
||||||
|
|
||||||
|
### B. 条件必填(7 条,跨字段)
|
||||||
|
|
||||||
|
| 条件 | 要求 | 违反后果 |
|
||||||
|
|---|---|---|
|
||||||
|
| `limiter_backend`/`breaker_backend`/`cache_backend` 取 `redis` | `redis_url` 非空 | **见 §2**,最严重 |
|
||||||
|
| `cache_backend != "none"` | `cache_namespace` 非空 | 缓存 key 失去租户隔离——踩"无缓存毒化"铁律 |
|
||||||
|
| `cache_backend != "none"` | `cache_ttl_s > 0` | `from_env` 明令禁止的"永不过期"从另一条路进来 |
|
||||||
|
| `telemetry_backend == "sqlite"` | `telemetry_sqlite_path` 非空 | 断言炸或写空路径 |
|
||||||
|
| `telemetry_backend == "postgres"` | `telemetry_pg_dsn` 非空 | 同上 |
|
||||||
|
|
||||||
|
### C. 标量域(2 条)
|
||||||
|
|
||||||
|
`structured_max_retries ≥ 0`;`scope` 非空(空 scope 会污染遥测与缓存命名空间)。
|
||||||
|
|
||||||
|
## 2. 为什么这批比第一轮更严重:`client.py` 的断言前提为假
|
||||||
|
|
||||||
|
`client.py` 有 5 处断言**明文声称这个前提已经成立**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert settings.redis_url is not None # 内部不变量: config 已校验
|
||||||
|
```
|
||||||
|
|
||||||
|
位置:`client.py:262/282/302`(redis_url)、`:312`(pg_dsn)、`:316`(sqlite_path)。走 `from_settings` 时该注释是假的,verifier 实测:
|
||||||
|
|
||||||
|
| 运行方式 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 断言开启 | `AssertionError()` —— 裸断言,不点字段、不说原因 |
|
||||||
|
| `python -O` | 断言消失,退化为 redis 库的 `ValueError: Redis URL must specify one of the following schemes...` |
|
||||||
|
|
||||||
|
后者正是 CLAUDE.md §4.3 禁止的"assert 承担生产校验"。
|
||||||
|
|
||||||
|
**但注意结论的方向**:这 5 处 assert 本身不是要修的东西——它们要的前提是对的,错的是没人保证这个前提。§4 给出处置。
|
||||||
|
|
||||||
|
## 3. 方案
|
||||||
|
|
||||||
|
沿用第一轮已批准的方案 A,不重新论证:全部收进 `GatewaySettings.__post_init__`,新增三个私有方法与既有四个并列。
|
||||||
|
|
||||||
|
| 方法 | 覆盖 |
|
||||||
|
|---|---|
|
||||||
|
| `_validate_backends` | A 类 6 条枚举 + B 类 redis_url 三条件 |
|
||||||
|
| `_validate_cache` | `cache_namespace` 非空、`cache_ttl_s > 0`(仅 `cache_backend != "none"` 时) |
|
||||||
|
| `_validate_telemetry` | sqlite path / postgres dsn 条件必填 + §5 的 DSN 形态 |
|
||||||
|
|
||||||
|
标量两条(`structured_max_retries`、`scope`)并入 `_validate_sources` 改名后的 `_validate_identity`,与 `SourceConfig._validate_identity` 同名同职。
|
||||||
|
|
||||||
|
枚举合法域上提为模块级 frozenset 常量(`_LIMITER_BACKENDS` 等),`_load_pgw` 与 `__post_init__` 共用一份,消除现有的内联字面量重复。
|
||||||
|
|
||||||
|
**否决的替代**:在 `_build_limiter`/`_build_cache` 等工厂函数里逐个补显式检查。理由同第一轮 §2 方案 B——校验散落在消费点,每加一个后端就多一处要同步,且 `dataclasses.replace` 仍绕过。
|
||||||
|
|
||||||
|
## 4. 5 处 assert 的处置:**保留,不改**
|
||||||
|
|
||||||
|
修好构造期校验后,`settings.redis_url is not None` 就真的成了内部不变量——CLAUDE.md §4.3 原文"assert 仅用于内部不变量"说的正是这种用法,同时它给类型检查器收窄了 `str | None`。此时删掉 assert 反而丢失类型信息,改成 `raise` 则是在防御一个已被构造期排除的情况(死代码)。
|
||||||
|
|
||||||
|
**要改的是注释**:`# 内部不变量: config 已校验` 应点明由谁保证,例如 `# 内部不变量: GatewaySettings._validate_backends 已保证`。前一轮的教训就是这类注释会随时间变成谎言。
|
||||||
|
|
||||||
|
## 5. Postgres DSN:校验而非规范化(本轮唯一的新决策)
|
||||||
|
|
||||||
|
`_load_pg_dsn` 对 `from_env` 读到的 DSN 做了**规范化**:剥掉 SQLAlchemy 风格的 `+asyncpg` 驱动后缀(asyncpg 不认)。直接构造那条路不会剥,`postgresql+asyncpg://...` 会原样送进 asyncpg 然后在首次写遥测时才炸。
|
||||||
|
|
||||||
|
| 选项 | 权衡 |
|
||||||
|
|---|---|
|
||||||
|
| A. 构造期校验,含 `+driver` 即报错 | 显式,库不碰用户给的值;但两条装配路对同一输入接受度不同 |
|
||||||
|
| B. 构造期静默剥后缀 | 两条路完全对齐;但 frozen 类在构造期悄悄改字段,调用方不知情 |
|
||||||
|
| **C. 构造期剥后缀 + `logger.warning`(用户 2026-07-30 拍板)** | 两条路行为对齐,同时不静默——调用方在日志里看得见库动了他的值,想根治就自己改 DSN |
|
||||||
|
|
||||||
|
选 C。实现要点:`object.__setattr__` 改 frozen 字段(`SourceConfig` 无此先例,但 frozen 的约束是对**外部**不可变,构造期规范化是既有 dataclass 惯用法);warning 走 loguru(核心依赖,库内 `ocr.py:183`/`embedding.py:318` 同款用法)。
|
||||||
|
|
||||||
|
**warning 不会打扰 env 用户**:`_load_pg_dsn` 保留现有的剥离逻辑,`from_env` 传给构造函数时 DSN 已经干净,`__post_init__` 无事可做。只有手工构造传了带后缀的 DSN 才会触发。三项目 `.env` 里那些 SQLAlchemy 写法不会每次装配刷一条 warning。
|
||||||
|
|
||||||
|
代价是同一件事有两处剥离逻辑。用同一个模块级 helper `_strip_dsn_driver(dsn)` 供两处调用,避免实现分叉。
|
||||||
|
|
||||||
|
## 6. 行为审计
|
||||||
|
|
||||||
|
| 现有行为 | 处置 |
|
||||||
|
|---|---|
|
||||||
|
| `from_env` 对上述 15 条的校验与报错 | **全部保留**,时机提前到 `cls(...)`;`_load_*` 内联检查删除,避免同一约束两处维护 |
|
||||||
|
| `_load_pg_dsn` 剥 `+driver` | **保留**,继续只在 env 路径生效(§5) |
|
||||||
|
| `_load_choice` 的 `default` 语义(键缺失时取默认) | **保留**,那是 env 解析职责,不是不变量 |
|
||||||
|
| `_load_breaker` 的有效阈值派生 `max(配置值, 源级并发×2)` | **有意保留在 env 层**(verifier 二次核验点名,记此备案免成"第五批")。它是**派生**不是校验/规范化:两路产出确实不同(env 装配 threshold=5/并发=100 得 200,直接构造得 5),但派生依赖的是"用户没显式表态时库替他选一个合理值"的 env 语义;代码构造那条路,调用方给什么就是什么表态。其跨字段下限风险由 `_validate_probe` 在构造期兜底 |
|
||||||
|
| 直接构造出上述任一非法组合 → 静默成功 | **有意替换**为构造期 `ValueError` |
|
||||||
|
| `client.py` 5 处 assert | **保留**,仅改注释(§4) |
|
||||||
|
| 异常类型 | 一律 `ValueError`,与第一轮及既有装配错误一致 |
|
||||||
|
|
||||||
|
**有意放弃**:不校验 `pricing_path` 指向的文件是否存在(I/O 不属于配置校验,`PricingTable.from_file` 自会报错);不强制 `cache_backend == "none"` 时 namespace/ttl 必须为 None(多余字段无害)。
|
||||||
|
|
||||||
|
## 7. 非功能维度
|
||||||
|
|
||||||
|
与第一轮同构,不重复论证:`__post_init__` 纯同步计算无 I/O(不适用并发/取消/持久化);装配期属准入侧,报错不放行;`__post_init__` 不改字段故幂等。**性能**:新增约 10 次字符串比较,第一轮实测单次构造 1.45 µs 且库内无热路径构造 `GatewaySettings`,可忽略。
|
||||||
|
|
||||||
|
## 8. 测试策略
|
||||||
|
|
||||||
|
`tests/unit/test_config.py::TestCrossFieldInvariants` 扩充(不新建类,同族不变量归一处):
|
||||||
|
|
||||||
|
| 用例组 | 断言 |
|
||||||
|
|---|---|
|
||||||
|
| 6 条枚举各一条非法值 | 抛 `ValueError`,消息含字段名与合法域 |
|
||||||
|
| redis_url 三条件(limiter/breaker/cache 各一) | 抛 `ValueError`,消息点明需要 `redis_url` |
|
||||||
|
| cache namespace 缺失 / ttl ≤ 0 | 抛 `ValueError` |
|
||||||
|
| telemetry sqlite path / pg dsn 缺失 | 抛 `ValueError` |
|
||||||
|
| `structured_max_retries=-1`、`scope=""` | 抛 `ValueError` |
|
||||||
|
| pg dsn 含 `+asyncpg`(直接构造) | 后缀被剥,字段值为干净 DSN,且发出一条 warning(用 `caplog`/loguru sink 断言) |
|
||||||
|
| pg dsn 干净(直接构造)、或经 `from_env` 传入 | **不发** warning——env 路已在 `_load_pg_dsn` 剥过,不该刷噪音 |
|
||||||
|
| 合法组合(每种 backend 组合各一) | 构造成功——收紧的是错的那些 |
|
||||||
|
| **回归护栏**:`GatewayClient.from_settings` 走 redis 三后端的合法配置 | 装配成功,证明 assert 前提真的被保证了 |
|
||||||
|
|
||||||
|
TDD:先跑出红,预计 ≥14 条失败。要求同第一轮——每条实现改动都要有对应测试能杀死它。
|
||||||
|
|
||||||
|
版本:**1.0.2**(patch),CHANGELOG 同样单列"行为收紧"小节。
|
||||||
|
|
||||||
|
## 9. 人类拍板结论(2026-07-30)
|
||||||
|
|
||||||
|
| 问题 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| §5 DSN 处置 | **选 C**:构造期剥后缀 + `logger.warning`。不静默改用户的值,也不让两条装配路产出不一致 |
|
||||||
|
| §4 assert 处置 | **保留,只改注释**,点明由哪个方法保证前提 |
|
||||||
|
| 方案主体 | 沿用第一轮已批准的方案 A,无需重新论证 |
|
||||||
|
| 版本 | 1.0.2(patch) |
|
||||||
|
| **范围追加**(实施中经 verifier 发现后拍板) | G1-G4 四条同族遗漏一并纳入本轮;G1 的 scope 规范化取**静默**小写+strip(不告警——`from_env` 一直静默小写,scope 大小写不承载语义) |
|
||||||
|
|
||||||
|
## 10. 实施留痕
|
||||||
|
|
||||||
|
分支 `fix/settings-invariants-round-2`。TDD 两段:主体 15 条先 **16 failed**、G1-G4 追加 **9 failed**,实现后全绿(547 passed / 14 skipped,1.0.1 基线 516)。
|
||||||
|
|
||||||
|
### 10.1 独立 verifier 的关键发现
|
||||||
|
|
||||||
|
第一次核验判**有阻塞**,已修:
|
||||||
|
|
||||||
|
- **阻塞(本轮新引入)**:DSN 剥离的 warning 打印了完整连接串,**含明文密码**,而库内此前从无任何地方打印连接串——违反 P5。已改为只报 scheme 段变化,并补回归测试断言密码与 host/path 不进日志。
|
||||||
|
- **变异测试 27/28 被杀**,唯一存活的是 `_load_pgw` 里 `PGW_CACHE_BACKEND` 域检查删掉后仍全绿(该 env 层 raise 零覆盖)。已补 `test_cache_backend_whitelist`,与既有 `test_telemetry_backend_whitelist` 对称。
|
||||||
|
- **assert 处置经独立核验成立**:遍历所有可达构造路径均无法制造 assert 失败,`python -O` 下同样在构造期被拦(旧病症消失);唯一能触发的是 `object.__new__` 绕过 `__post_init__` 的人造路径,非公共 API。
|
||||||
|
- **frozen 语义无副作用**:`object.__setattr__` 后 `hash`/相等性/集合去重正常,`replace` 幂等不重复告警,`pickle`/`deepcopy` 不触发 `__post_init__` 故不重复告警,对外仍抛 `FrozenInstanceError`。
|
||||||
|
|
||||||
|
### 10.2 G1-G4:第三批遗漏(已纳入本轮)
|
||||||
|
|
||||||
|
verifier 通读 `_load_*` 后发现,除设计 §1 的 15 条外还有四条**规范化**只在 env 路生效——与本轮所修的 DSN 是同一类:
|
||||||
|
|
||||||
|
| | 内容 | 危害 |
|
||||||
|
|---|---|---|
|
||||||
|
| G1 | `scope` 小写化 | **最严重**:scope 进 Redis key,大小写不一致使限流/熔断状态分裂到两套命名空间,分布式治理静默失效 |
|
||||||
|
| G2 | `redis_url` 空串归 None | 空串骗过 `is None`,退化为 redis 客户端的连接串天书报错——正是本轮 CHANGELOG 声称已消除的那种 |
|
||||||
|
| G3 | `pricing_path` 空串归 None | 退化为 `Is a directory: '.'` |
|
||||||
|
| G4 | `EmbeddingSettings.batch_size`/`expected_dim` 域 | 该类无 `__post_init__`;晚一步到 client 构造才 fail-loud |
|
||||||
|
|
||||||
|
统一收进新增的 `GatewaySettings._normalize()`(在全部 `_validate_*` 之前跑)与 `EmbeddingSettings.__post_init__`。DSN 后缀因需看 backend 且需告警,规范化留在 `_validate_telemetry`。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:settings-invariant-guards
|
||||||
|
title: "GatewaySettings 跨字段不变量守卫的生效范围"
|
||||||
|
date: 2026-07-29
|
||||||
|
---
|
||||||
|
|
||||||
|
# GatewaySettings 跨字段不变量守卫的生效范围
|
||||||
|
|
||||||
|
全文见 [2026-07-29-settings-invariant-guards-design.md](2026-07-29-settings-invariant-guards-design.md)。
|
||||||
|
|
||||||
|
- **缘起**: 社区 PR#1 指出装配守卫只挂在 `from_env`,走 CLAUDE.md §4.5 的另一条官方路 `from_settings()` 能装出违反类不变量的配置且不报错。诊断采纳,实现按库内规范重写并扩大覆盖。
|
||||||
|
- **选定方案**: A——四条跨字段不变量(lease/stall/probe_ttl/sources 非空)全部收进 `GatewaySettings.__post_init__`,拆 `_validate_*` 私有方法,与 `types.py` 同族五个 frozen dataclass 的既有笔迹一致;模块级 `_guard_lease/_guard_stall` 删除。
|
||||||
|
- **关键理由**: 这三条约束是**类的定义**的一部分,不是 `from_env` 的输入检查;放在函数里类就失去自我描述能力。构造期一处覆盖六个工厂 + 直接构造 + `dataclasses.replace`。
|
||||||
|
- **被否决备选**: B 六个工厂各调 `validate()`(六处永久同步,新增 client 必漏,`replace` 仍绕过);C 公共 `validate()` 自愿调用(把不变量降级为建议,违反 P5 与 ARCH §7.3"拒绝装配")。
|
||||||
|
- **补 PR#1 的两个缺口**(实测):`probe_ttl_s ≥ 最慢 timeout + 5` 仍只在 `from_env`(直接构造未拦截);守卫上移后 `sources=()` 泄漏内置异常 `max() arg is an empty sequence`。
|
||||||
|
- **承诺变化**: 经 `from_env` 装配的调用方零影响;手工构造/`replace` 出非法组合者由静默故障改为构造期 `ValueError`。发版走 1.0.1(patch,用户拍板;CHANGELOG 单列"行为收紧"小节代替版本号预警),wiki `参考-配置键` 表述与新行为一致无需改。
|
||||||
|
- **子决策**: 错误消息只点字段名不列 env 键(`types.py` 既有笔迹 + 键名单一事实源在 `.env.example`/wiki)。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:settings-invariants-round-2
|
||||||
|
title: "GatewaySettings 装配校验补齐(第二轮)"
|
||||||
|
date: 2026-07-30
|
||||||
|
---
|
||||||
|
|
||||||
|
# GatewaySettings 装配校验补齐(第二轮)
|
||||||
|
|
||||||
|
全文见 [2026-07-30-settings-invariants-round-2-design.md](2026-07-30-settings-invariants-round-2-design.md)。第一轮见 [settings-invariant-guards](settings-invariant-guards.md)。
|
||||||
|
|
||||||
|
- **缘起**: 第一轮交付后独立 verifier 发现 `from_env` 上还留着 15 条同族校验(枚举合法域 6、条件必填 7、标量域 2),`from_settings` 与直接构造全部放行。
|
||||||
|
- **严重性高于第一轮**: `client.py:262/282/302/312/316` 有 5 处 `assert ... # 内部不变量: config 已校验` 明文依赖这个前提;实测断言开启抛裸 `AssertionError`,`python -O` 下退化为 redis 库天书。
|
||||||
|
- **方案**: 沿用第一轮已批准的方案 A,不重新论证;新增 `_validate_backends/_validate_cache/_validate_telemetry`,枚举合法域上提为模块级常量供 `_load_pgw` 与构造期共用。
|
||||||
|
- **assert 处置**: **保留不改**——前提一旦由构造期保证,它就是 CLAUDE.md §4.3 认可的内部不变量用法且给类型检查器收窄 `str | None`;只改那句会变成谎言的注释,点明由哪个方法保证。
|
||||||
|
- **本轮唯一新决策**: `_load_pg_dsn` 剥 `+asyncpg` 驱动后缀是**规范化**不是校验,直接构造那条路不会剥。选校验拒绝(显式)而非构造期 `object.__setattr__` 剥后缀(在用户背后改 frozen 字段)。两条路接受度不同是有意的:env 路要吃三项目历史遗留的 SQLAlchemy DSN 写法,代码构造路没有历史包袱。
|
||||||
|
- **版本**: 1.0.2(patch),CHANGELOG 单列"行为收紧"小节。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 文档组织与维护约定(Gitea Wiki)
|
||||||
|
|
||||||
|
> **定位**: 用户文档站 = Gitea Wiki(`https://gitea.iomgaa.online/iomgaa/PolyGateway/wiki`);本文规定它的结构、更新时机与写作纪律。研发知识(设计/决策/验收)仍归 `research-wiki/`,两者职责不重叠。
|
||||||
|
|
||||||
|
## 1. 结构:Diátaxis 四区(2026-07-23 建站,17 页)
|
||||||
|
|
||||||
|
| 区 | 页面 | 职责(读者此刻要干什么) | 禁止 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 教程 | `教程-十分钟接入` | 新手被领着走通一遍 | 塞选项枚举与原理论述 |
|
||||||
|
| 指南(How-to) | `指南-{多源与选源,限流与熔断,响应缓存,遥测与成本,结构化输出,OCR,Embedding,迁移既有项目}` | 一页一任务:配置片段+行为+坑 | 重复参考区的全量表 |
|
||||||
|
| 参考 | `参考-{公共API,配置键,异常}` | 查表:签名/字段/键,**以源码实测为准** | 叙述与劝导 |
|
||||||
|
| 解释 | `解释-{架构,错误四分类,治理行为,降级与取消}` | 讲为什么;机制挂回压测病灶 | 写成使用说明 |
|
||||||
|
|
||||||
|
导航:`Home.md`(按意图分流表)+ `_Sidebar.md`(全页目录);页间互链用 Gitea `[[双括号]]` 语法。
|
||||||
|
|
||||||
|
## 2. 更新时机(与代码变更绑定,发版检查清单)
|
||||||
|
|
||||||
|
| 变更类型 | 必须同步的页 |
|
||||||
|
|---|---|
|
||||||
|
| 新公共 API / 新能力 | 对应指南页(新增或扩写)+ `参考-公共API` + 侧边栏 + CHANGELOG |
|
||||||
|
| 新增/改名配置键 | `参考-配置键` + 相关指南页的配置片段 + 主仓库 `.env.example` |
|
||||||
|
| 治理行为变更(重试/熔断/选源语义) | `解释-治理行为` + 受影响指南页;若改公共承诺另走 brainstorming 流程 |
|
||||||
|
| 新异常/分类语义调整 | `参考-异常` + `解释-错误四分类` |
|
||||||
|
| **发版(任何版本号)** | `Home.md` 版本号与安装命令 + 主仓库 `CHANGELOG.md` + `README.md` 版本相关处;过一遍上面各行 |
|
||||||
|
|
||||||
|
**门**: 版本 bump 的提交不允许单独存在——同一次交付里必须包含对应的 wiki/CHANGELOG 同步(发布检查清单第一项)。
|
||||||
|
|
||||||
|
## 3. 写作纪律
|
||||||
|
|
||||||
|
- 中文;表格优先;单个代码块 ≤ 15 行;每个配置片段可直接复制运行。
|
||||||
|
- **事实以源码为准**:参考区改动前先对照 `__init__.py` 导出面、`client.py`/`ocr.py`/`embedding.py` 签名与 `.env.example`;不确定就实测,不凭记忆写。
|
||||||
|
- 深度内容(决策论证、迁移全文、验收数字)**只放指针**指向主仓库 `research-wiki/`,不复制——避免双处维护同一事实。
|
||||||
|
- API 参考坚持**手写精选**(公共面小 + 只增不删承诺,手写比自动生成可读且低维护);若公共面显著膨胀再评估 mkdocstrings。
|
||||||
|
|
||||||
|
## 4. 更新操作
|
||||||
|
|
||||||
|
Wiki 是独立 git 仓库,两种改法:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.iomgaa.online/iomgaa/PolyGateway.wiki.git # 批量改: clone→编辑→push
|
||||||
|
# 或在 Gitea 网页 Wiki 页面上直接编辑(单页小改)
|
||||||
|
```
|
||||||
|
|
||||||
|
文件名即页名(中文文件名);`Home.md` 是落地页,`_Sidebar.md` 是导航,新增页必须同步进侧边栏与 Home 分流表。凭据在本机 osxkeychain(git)与 `~/.pypirc`(twine)。
|
||||||
@@ -90,6 +90,16 @@
|
|||||||
"id": "finding:m4-acceptance",
|
"id": "finding:m4-acceptance",
|
||||||
"label": "M4 迁移验收(GovDoc+CHS)",
|
"label": "M4 迁移验收(GovDoc+CHS)",
|
||||||
"type": "finding"
|
"type": "finding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:settings-invariant-guards",
|
||||||
|
"label": "GatewaySettings 跨字段不变量守卫的生效范围",
|
||||||
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:settings-invariants-round-2",
|
||||||
|
"label": "GatewaySettings 装配校验补齐(第二轮)",
|
||||||
|
"type": "design"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
# Research Wiki 索引
|
# Research Wiki 索引
|
||||||
|
|
||||||
> 自动生成,更新时间:2026-07-22 14:36 UTC
|
> 自动生成,更新时间:2026-07-30 04:44 UTC
|
||||||
|
|
||||||
## design (10)
|
## design (14)
|
||||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
||||||
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
||||||
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
||||||
- [2026-07-21-m3-ocr-design](designs/2026-07-21-m3-ocr-design.md) `design:2026-07-21-m3-ocr-design`
|
- [2026-07-21-m3-ocr-design](designs/2026-07-21-m3-ocr-design.md) `design:2026-07-21-m3-ocr-design`
|
||||||
- [2026-07-22-m4-migration-design](designs/2026-07-22-m4-migration-design.md) `design:2026-07-22-m4-migration-design`
|
- [2026-07-22-m4-migration-design](designs/2026-07-22-m4-migration-design.md) `design:2026-07-22-m4-migration-design`
|
||||||
|
- [2026-07-29-settings-invariant-guards-design](designs/2026-07-29-settings-invariant-guards-design.md) `design:2026-07-29-settings-invariant-guards-design`
|
||||||
|
- [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design`
|
||||||
|
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
||||||
|
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
||||||
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
|
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
|
||||||
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
|
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
|
||||||
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
||||||
|
|||||||
@@ -46,3 +46,7 @@
|
|||||||
- [2026-07-22 09:33 UTC] 重建索引: 32 篇页面
|
- [2026-07-22 09:33 UTC] 重建索引: 32 篇页面
|
||||||
- [2026-07-22 14:36 UTC] 新增 finding: M4 迁移验收(GovDoc+CHS) (finding:m4-acceptance)
|
- [2026-07-22 14:36 UTC] 新增 finding: M4 迁移验收(GovDoc+CHS) (finding:m4-acceptance)
|
||||||
- [2026-07-22 14:36 UTC] 重建索引: 34 篇页面
|
- [2026-07-22 14:36 UTC] 重建索引: 34 篇页面
|
||||||
|
- [2026-07-30 03:44 UTC] 新增 design: GatewaySettings 跨字段不变量守卫的生效范围 (design:settings-invariant-guards)
|
||||||
|
- [2026-07-30 03:44 UTC] 重建索引: 36 篇页面
|
||||||
|
- [2026-07-30 04:44 UTC] 新增 design: GatewaySettings 装配校验补齐(第二轮) (design:settings-invariants-round-2)
|
||||||
|
- [2026-07-30 04:44 UTC] 重建索引: 38 篇页面
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from polygateway.types import (
|
|||||||
SourceConfig,
|
SourceConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.0.2"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_PROFILES",
|
"DEFAULT_PROFILES",
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ def _build_limiter(settings: GatewaySettings, sources: list[SourceConfig]) -> Ra
|
|||||||
if settings.limiter_backend == "redis":
|
if settings.limiter_backend == "redis":
|
||||||
from polygateway.backends.redis.limiter import RedisLimiter
|
from polygateway.backends.redis.limiter import RedisLimiter
|
||||||
|
|
||||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
|
||||||
return RedisLimiter.from_url(
|
return RedisLimiter.from_url(
|
||||||
settings.redis_url,
|
settings.redis_url,
|
||||||
scope=settings.scope,
|
scope=settings.scope,
|
||||||
@@ -279,7 +279,7 @@ def _build_breaker(settings: GatewaySettings) -> ProviderGate:
|
|||||||
if settings.breaker_backend == "redis":
|
if settings.breaker_backend == "redis":
|
||||||
from polygateway.backends.redis.breaker import RedisGate
|
from polygateway.backends.redis.breaker import RedisGate
|
||||||
|
|
||||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
|
||||||
return RedisGate.from_url(settings.redis_url, config=settings.breaker, scope=settings.scope)
|
return RedisGate.from_url(settings.redis_url, config=settings.breaker, scope=settings.scope)
|
||||||
return InMemoryGate(config=settings.breaker)
|
return InMemoryGate(config=settings.breaker)
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ def _build_cache(settings: GatewaySettings) -> CacheBackend | None:
|
|||||||
return InMemoryCache()
|
return InMemoryCache()
|
||||||
from polygateway.backends.redis_cache import RedisCache
|
from polygateway.backends.redis_cache import RedisCache
|
||||||
|
|
||||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
|
||||||
return RedisCache.from_url(settings.redis_url)
|
return RedisCache.from_url(settings.redis_url)
|
||||||
|
|
||||||
|
|
||||||
@@ -309,11 +309,11 @@ def _build_telemetry(settings: GatewaySettings) -> TelemetryRecorder | None:
|
|||||||
if settings.telemetry_backend == "postgres":
|
if settings.telemetry_backend == "postgres":
|
||||||
from polygateway.telemetry.postgres import PostgresRecorder
|
from polygateway.telemetry.postgres import PostgresRecorder
|
||||||
|
|
||||||
assert settings.telemetry_pg_dsn is not None # 内部不变量: config 已校验
|
assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证
|
||||||
return PostgresRecorder(settings.telemetry_pg_dsn)
|
return PostgresRecorder(settings.telemetry_pg_dsn)
|
||||||
from polygateway.telemetry.sqlite import SQLiteRecorder
|
from polygateway.telemetry.sqlite import SQLiteRecorder
|
||||||
|
|
||||||
assert settings.telemetry_sqlite_path is not None # 内部不变量: config 已校验
|
assert settings.telemetry_sqlite_path is not None # 内部不变量: _validate_telemetry 已保证
|
||||||
return SQLiteRecorder(settings.telemetry_sqlite_path)
|
return SQLiteRecorder(settings.telemetry_sqlite_path)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+165
-44
@@ -16,6 +16,7 @@ from dataclasses import dataclass
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from dotenv import dotenv_values
|
from dotenv import dotenv_values
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
@@ -47,10 +48,17 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
|
|||||||
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
|
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
|
||||||
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
|
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
|
||||||
_QUOTA_FULL = frozenset({"wait", "fail_fast"})
|
_QUOTA_FULL = frozenset({"wait", "fail_fast"})
|
||||||
|
# 后端合法域: env 解析与构造期校验共用一份定义,避免两处分叉
|
||||||
|
_LIMITER_BACKENDS = frozenset({"memory", "redis"})
|
||||||
|
_BREAKER_BACKENDS = frozenset({"memory", "redis"})
|
||||||
|
_CACHE_BACKENDS = frozenset({"redis", "memory", "none"})
|
||||||
|
_TELEMETRY_BACKENDS = frozenset({"sqlite", "postgres", "none"})
|
||||||
|
_REDIS_DEPENDENT_BACKENDS = ("limiter_backend", "breaker_backend", "cache_backend")
|
||||||
# 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源)
|
# 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源)
|
||||||
_DEFAULT_STALL_WINDOW_S = 300.0
|
_DEFAULT_STALL_WINDOW_S = 300.0
|
||||||
_DEFAULT_POLL_INTERVAL_S = 0.05
|
_DEFAULT_POLL_INTERVAL_S = 0.05
|
||||||
_DEFAULT_LEASE_TTL_S = 1500.0 # CHS _DEFAULT_LEASE_TTL_MS 同源
|
_DEFAULT_LEASE_TTL_S = 1500.0 # CHS _DEFAULT_LEASE_TTL_MS 同源
|
||||||
|
_PROBE_GRACE_S = 5.0 # 半开探针租约相对最慢调用的清理宽限(CHS container.py:274-275)
|
||||||
|
|
||||||
|
|
||||||
def _cast(raw: str, kind: str, key: str) -> object:
|
def _cast(raw: str, kind: str, key: str) -> object:
|
||||||
@@ -88,7 +96,16 @@ def _require(env: Mapping[str, str], *keys: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GatewaySettings:
|
class GatewaySettings:
|
||||||
"""一个 scope 的完整装配配置;构造经 from_env 聚合并通过全部守卫。"""
|
"""一个 scope 的完整装配配置;**任何**构造路径都通过全部装配守卫(ARCH §7.3)。
|
||||||
|
|
||||||
|
守卫校验的是**跨字段**不变量: 单看一个字段都合法,组合起来才会在运行时
|
||||||
|
咬人(租约先于请求过期、正常慢首包被误判卡死、半开探针在途被接管)。
|
||||||
|
types.py 各子配置的 `__post_init__` 只看得见自己的字段,故由本类把关。
|
||||||
|
|
||||||
|
放在 `__post_init__` 而非某个工厂里: 这些约束是本类定义的一部分,不是
|
||||||
|
某个入口的输入检查。挂在构造期,直接构造、`dataclasses.replace` 与全部
|
||||||
|
装配工厂一并覆盖;挂在工厂里则每加一个工厂就多一处要同步。
|
||||||
|
"""
|
||||||
|
|
||||||
scope: str
|
scope: str
|
||||||
sources: tuple[SourceConfig, ...]
|
sources: tuple[SourceConfig, ...]
|
||||||
@@ -111,6 +128,123 @@ class GatewaySettings:
|
|||||||
structured_max_retries: int
|
structured_max_retries: int
|
||||||
lease_ttl_s: float
|
lease_ttl_s: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self._normalize()
|
||||||
|
self._validate_identity()
|
||||||
|
self._validate_backends()
|
||||||
|
self._validate_cache()
|
||||||
|
self._validate_telemetry()
|
||||||
|
self._validate_lease()
|
||||||
|
self._validate_stall()
|
||||||
|
self._validate_probe()
|
||||||
|
|
||||||
|
def _normalize(self) -> None:
|
||||||
|
"""把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
|
||||||
|
|
||||||
|
`scope` 最要紧: 它直接进 Redis key(`pgw:limit:{scope}:…`/`pgw:gate:{scope}:…`)。
|
||||||
|
一个进程走 `from_env("LLM")` 拿到 "llm"、另一个直接构造传 "LLM",同一逻辑
|
||||||
|
scope 的限流与熔断状态会分裂到两套命名空间,各记各的,治理静默失效且不报错。
|
||||||
|
|
||||||
|
空串归 None 同理: 留着空串会骗过 `is None` 判断,把错误推迟到 redis 客户端
|
||||||
|
抛连接串解析异常。`telemetry_pg_dsn` 的驱动后缀因为要看 backend 且需告警,
|
||||||
|
规范化留在 `_validate_telemetry`。
|
||||||
|
"""
|
||||||
|
normalized_scope = self.scope.strip().lower()
|
||||||
|
if normalized_scope != self.scope:
|
||||||
|
object.__setattr__(self, "scope", normalized_scope)
|
||||||
|
for field in ("redis_url", "pricing_path"):
|
||||||
|
if getattr(self, field) == "":
|
||||||
|
object.__setattr__(self, field, None)
|
||||||
|
|
||||||
|
def _validate_identity(self) -> None:
|
||||||
|
"""本类自身字段的基本域: 空 scope 会污染遥测与缓存命名空间;零源必然选源失败。"""
|
||||||
|
if not self.scope.strip():
|
||||||
|
raise ValueError("GatewaySettings.scope 不能为空")
|
||||||
|
if not self.sources:
|
||||||
|
raise ValueError("GatewaySettings.sources 不能为空: 至少一个源")
|
||||||
|
if self.structured_max_retries < 0:
|
||||||
|
raise ValueError(f"structured_max_retries 不能为负: {self.structured_max_retries}")
|
||||||
|
|
||||||
|
def _validate_backends(self) -> None:
|
||||||
|
"""后端选择必须落在合法域内,取 redis 的还必须有连接串。
|
||||||
|
|
||||||
|
域外取值此前只有 `from_env` 拦得住,直接构造会一路走到 `client.py` 的
|
||||||
|
`_build_*`,落进 else 分支静默不建后端,或撞上那里的断言。
|
||||||
|
"""
|
||||||
|
for field, allowed in (
|
||||||
|
("limiter_backend", _LIMITER_BACKENDS),
|
||||||
|
("breaker_backend", _BREAKER_BACKENDS),
|
||||||
|
("cache_backend", _CACHE_BACKENDS),
|
||||||
|
("telemetry_backend", _TELEMETRY_BACKENDS),
|
||||||
|
("selector", _SELECTORS),
|
||||||
|
("quota_full", _QUOTA_FULL),
|
||||||
|
):
|
||||||
|
value = getattr(self, field)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(f"{field} 非法值 {value!r};允许: {sorted(allowed)}")
|
||||||
|
on_redis = [f for f in _REDIS_DEPENDENT_BACKENDS if getattr(self, f) == "redis"]
|
||||||
|
if on_redis and self.redis_url is None:
|
||||||
|
raise ValueError(f"{'、'.join(on_redis)} 取 redis 时必须提供 redis_url")
|
||||||
|
|
||||||
|
def _validate_cache(self) -> None:
|
||||||
|
"""启用缓存必须有命名空间与正 TTL(缺命名空间即失去租户隔离,会毒化缓存)。"""
|
||||||
|
if self.cache_backend == "none":
|
||||||
|
return
|
||||||
|
if not self.cache_namespace:
|
||||||
|
raise ValueError("启用缓存时 cache_namespace 不能为空: 缓存 key 靠它做租户隔离")
|
||||||
|
if self.cache_ttl_s is None or self.cache_ttl_s <= 0:
|
||||||
|
raise ValueError(f"cache_ttl_s 必须 > 0(禁止永不过期): {self.cache_ttl_s}")
|
||||||
|
|
||||||
|
def _validate_telemetry(self) -> None:
|
||||||
|
"""遥测后端各自的落点必填;顺带剥掉 asyncpg 不认的 SQLAlchemy 驱动后缀。
|
||||||
|
|
||||||
|
剥而不是拒: 两条装配路对同一 DSN 应产出同一结果。但不静默——`from_env`
|
||||||
|
那条路在 `_load_pg_dsn` 就剥干净了,能走到这里的只有手工构造的调用方,
|
||||||
|
他有权知道库动了他给的值。
|
||||||
|
"""
|
||||||
|
if self.telemetry_backend == "sqlite" and not self.telemetry_sqlite_path:
|
||||||
|
raise ValueError("telemetry_backend=sqlite 时必须提供 telemetry_sqlite_path")
|
||||||
|
if self.telemetry_backend != "postgres":
|
||||||
|
return
|
||||||
|
if not self.telemetry_pg_dsn:
|
||||||
|
raise ValueError("telemetry_backend=postgres 时必须提供 telemetry_pg_dsn")
|
||||||
|
stripped = _strip_dsn_driver(self.telemetry_pg_dsn)
|
||||||
|
if stripped != self.telemetry_pg_dsn:
|
||||||
|
# 只报 scheme 段: DSN 带密码,整串不得进日志(P5 敏感信息只走 .env)
|
||||||
|
logger.warning(
|
||||||
|
"telemetry_pg_dsn 的 scheme 含 asyncpg 不认的驱动后缀,已由 {} 剥为 {}",
|
||||||
|
self.telemetry_pg_dsn.partition("://")[0],
|
||||||
|
stripped.partition("://")[0],
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "telemetry_pg_dsn", stripped)
|
||||||
|
|
||||||
|
def _validate_lease(self) -> None:
|
||||||
|
"""调用超时须 ≤ permit 租约 TTL,防租约先于请求过期使并发超出配额。"""
|
||||||
|
slowest = max(s.timeout_s for s in self.sources)
|
||||||
|
if slowest > self.lease_ttl_s:
|
||||||
|
raise ValueError(
|
||||||
|
f"源最大 timeout_s({slowest})超过 permit 租约 lease_ttl_s"
|
||||||
|
f"({self.lease_ttl_s});调大 lease_ttl_s 或调小源的 timeout_s"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_stall(self) -> None:
|
||||||
|
"""stall 窗口须 ≥ 最慢源 TTFT 上限,防把正常慢首包误判为卡死。"""
|
||||||
|
ttfts = [s.ttft_timeout_s for s in self.sources if s.ttft_timeout_s is not None]
|
||||||
|
if ttfts and self.backpressure.stall_window_s < max(ttfts):
|
||||||
|
raise ValueError(
|
||||||
|
f"backpressure.stall_window_s({self.backpressure.stall_window_s})须 ≥ "
|
||||||
|
f"最大源 ttft_timeout_s({max(ttfts)});调大 stall_window_s 或调小 ttft_timeout_s"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_probe(self) -> None:
|
||||||
|
"""半开探针租约须撑过一次最慢调用,否则探针在途即被接管(M2 设计 §3)。"""
|
||||||
|
floor = max(s.timeout_s for s in self.sources) + _PROBE_GRACE_S
|
||||||
|
if self.breaker.probe_ttl_s < floor:
|
||||||
|
raise ValueError(
|
||||||
|
f"breaker.probe_ttl_s({self.breaker.probe_ttl_s})须 ≥ 最慢源 "
|
||||||
|
f"timeout_s + {_PROBE_GRACE_S}({floor});调大 probe_ttl_s 或调小源的 timeout_s"
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(
|
def from_env(
|
||||||
cls,
|
cls,
|
||||||
@@ -119,7 +253,7 @@ class GatewaySettings:
|
|||||||
*,
|
*,
|
||||||
env_file: str = ".env",
|
env_file: str = ".env",
|
||||||
) -> GatewaySettings:
|
) -> GatewaySettings:
|
||||||
"""聚合 env(缺省 .env + os.environ,后者优先)并执行装配守卫。"""
|
"""聚合 env(缺省 .env + os.environ,后者优先);守卫由 `__post_init__` 执行。"""
|
||||||
if env is None:
|
if env is None:
|
||||||
env = {
|
env = {
|
||||||
k: v for k, v in {**dotenv_values(env_file), **os.environ}.items() if v is not None
|
k: v for k, v in {**dotenv_values(env_file), **os.environ}.items() if v is not None
|
||||||
@@ -129,7 +263,7 @@ class GatewaySettings:
|
|||||||
global_limits = _load_global_limits(scope_u, env)
|
global_limits = _load_global_limits(scope_u, env)
|
||||||
retry = _load_retry(scope_u, env)
|
retry = _load_retry(scope_u, env)
|
||||||
breaker = _load_breaker(scope_u, env, sources, global_limits)
|
breaker = _load_breaker(scope_u, env, sources, global_limits)
|
||||||
settings = cls(
|
return cls(
|
||||||
scope=scope_u.lower(),
|
scope=scope_u.lower(),
|
||||||
sources=tuple(sources),
|
sources=tuple(sources),
|
||||||
global_limits=global_limits,
|
global_limits=global_limits,
|
||||||
@@ -140,9 +274,6 @@ class GatewaySettings:
|
|||||||
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
|
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
|
||||||
**_load_pgw(env),
|
**_load_pgw(env),
|
||||||
)
|
)
|
||||||
_guard_lease(settings)
|
|
||||||
_guard_stall(settings)
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
def _load_sources(scope: str, env: Mapping[str, str]) -> list[SourceConfig]:
|
def _load_sources(scope: str, env: Mapping[str, str]) -> list[SourceConfig]:
|
||||||
@@ -217,16 +348,12 @@ def _load_breaker(
|
|||||||
if concurrency > 0:
|
if concurrency > 0:
|
||||||
threshold = max(threshold, concurrency * 2)
|
threshold = max(threshold, concurrency * 2)
|
||||||
slowest = max(s.timeout_s for s in sources)
|
slowest = max(s.timeout_s for s in sources)
|
||||||
probe_floor = slowest + 5.0 # CHS container.py:274-275: 最慢调用 + 清理宽限
|
probe_floor = slowest + _PROBE_GRACE_S
|
||||||
probe = _first(env, f"{scope}__BREAKER__PROBE_TTL_S")
|
probe = _first(env, f"{scope}__BREAKER__PROBE_TTL_S")
|
||||||
if probe is not None:
|
if probe is not None:
|
||||||
|
# 配置值不在此校验: 探针租约下限是跨字段不变量,由 GatewaySettings._validate_probe
|
||||||
|
# 统一把关(否则直接构造那条装配路会绕过)
|
||||||
probe_ttl_s = float(_cast(probe[1], "float", probe[0]))
|
probe_ttl_s = float(_cast(probe[1], "float", probe[0]))
|
||||||
# 装配守卫(M2 设计 §3): 探针租约必须撑过一次最慢调用,否则半开探针在途即被接管
|
|
||||||
if probe_ttl_s < probe_floor:
|
|
||||||
raise ValueError(
|
|
||||||
f"probe_ttl_s({probe_ttl_s})须 ≥ 最大源 timeout_s + 5({probe_floor});"
|
|
||||||
f"调大 {probe[0]} 或调小源超时"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# 派生规则: 探针租约须撑过一次最慢调用,且不短于冷却期(第三项保证守卫恒成立)
|
# 派生规则: 探针租约须撑过一次最慢调用,且不短于冷却期(第三项保证守卫恒成立)
|
||||||
probe_ttl_s = max(2 * slowest, cooldown_s, probe_floor)
|
probe_ttl_s = max(2 * slowest, cooldown_s, probe_floor)
|
||||||
@@ -277,17 +404,15 @@ def _load_choice(env: Mapping[str, str], key: str, allowed: frozenset[str], defa
|
|||||||
|
|
||||||
|
|
||||||
def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||||
limiter_backend = _load_choice(
|
# 合法域与构造期守卫共用常量;此处的检查保留是为了报错能点出 env 键名,
|
||||||
env, "PGW_LIMITER_BACKEND", frozenset({"memory", "redis"}), "memory"
|
# 构造期那道点的是字段名(两类调用方各看得懂自己那套)
|
||||||
)
|
limiter_backend = _load_choice(env, "PGW_LIMITER_BACKEND", _LIMITER_BACKENDS, "memory")
|
||||||
breaker_backend = _load_choice(
|
breaker_backend = _load_choice(env, "PGW_BREAKER_BACKEND", _BREAKER_BACKENDS, "memory")
|
||||||
env, "PGW_BREAKER_BACKEND", frozenset({"memory", "redis"}), "memory"
|
|
||||||
)
|
|
||||||
_, cache_backend = _require(env, "PGW_CACHE_BACKEND")
|
_, cache_backend = _require(env, "PGW_CACHE_BACKEND")
|
||||||
_, telemetry_backend = _require(env, "PGW_TELEMETRY_BACKEND")
|
_, telemetry_backend = _require(env, "PGW_TELEMETRY_BACKEND")
|
||||||
if cache_backend not in ("redis", "memory", "none"):
|
if cache_backend not in _CACHE_BACKENDS:
|
||||||
raise ValueError(f"PGW_CACHE_BACKEND 非法值 {cache_backend!r}")
|
raise ValueError(f"PGW_CACHE_BACKEND 非法值 {cache_backend!r}")
|
||||||
if telemetry_backend not in ("sqlite", "postgres", "none"):
|
if telemetry_backend not in _TELEMETRY_BACKENDS:
|
||||||
raise ValueError(f"PGW_TELEMETRY_BACKEND 非法值 {telemetry_backend!r}")
|
raise ValueError(f"PGW_TELEMETRY_BACKEND 非法值 {telemetry_backend!r}")
|
||||||
redis_url = env.get("REDIS_URL") or None
|
redis_url = env.get("REDIS_URL") or None
|
||||||
if "redis" in (limiter_backend, breaker_backend) and redis_url is None:
|
if "redis" in (limiter_backend, breaker_backend) and redis_url is None:
|
||||||
@@ -309,13 +434,22 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_pg_dsn(env: Mapping[str, str]) -> str:
|
def _strip_dsn_driver(dsn: str) -> str:
|
||||||
"""读取 Postgres DSN 并剥 SQLAlchemy 风格驱动后缀(asyncpg 不认 `+driver`)。"""
|
"""剥 SQLAlchemy 风格的 `+driver` 后缀(asyncpg 不认);已干净的原样返回。"""
|
||||||
_, dsn = _require(env, "PGW_TELEMETRY_PG_DSN")
|
|
||||||
scheme, sep, rest = dsn.partition("://")
|
scheme, sep, rest = dsn.partition("://")
|
||||||
return f"{scheme.partition('+')[0]}{sep}{rest}"
|
return f"{scheme.partition('+')[0]}{sep}{rest}"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_pg_dsn(env: Mapping[str, str]) -> str:
|
||||||
|
"""读取 Postgres DSN 并剥驱动后缀。
|
||||||
|
|
||||||
|
env 路在此剥干净,构造期那道就无事可做——三项目 `.env` 里的 SQLAlchemy
|
||||||
|
写法不会每次装配都刷一条 warning。
|
||||||
|
"""
|
||||||
|
_, dsn = _require(env, "PGW_TELEMETRY_PG_DSN")
|
||||||
|
return _strip_dsn_driver(dsn)
|
||||||
|
|
||||||
|
|
||||||
def _load_cache_keys(
|
def _load_cache_keys(
|
||||||
env: Mapping[str, str], cache_backend: str, redis_url: str | None
|
env: Mapping[str, str], cache_backend: str, redis_url: str | None
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
@@ -339,26 +473,6 @@ def _load_structured_retries(env: Mapping[str, str]) -> int:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _guard_lease(settings: GatewaySettings) -> None:
|
|
||||||
"""装配守卫: 调用超时须 ≤ permit 租约 TTL,防租约先于请求过期(ARCH §7.3)。"""
|
|
||||||
slowest = max(s.timeout_s for s in settings.sources)
|
|
||||||
if slowest > settings.lease_ttl_s:
|
|
||||||
raise ValueError(
|
|
||||||
f"源最大 timeout_s({slowest})超过 permit 租约 TTL({settings.lease_ttl_s});"
|
|
||||||
f"调大 PGW_LEASE_TTL_S 或调小超时"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _guard_stall(settings: GatewaySettings) -> None:
|
|
||||||
"""装配守卫: stall 窗口须 ≥ 最慢源 TTFT 上限,防把正常慢首包误判为卡死(ARCH §7.3)。"""
|
|
||||||
ttfts = [s.ttft_timeout_s for s in settings.sources if s.ttft_timeout_s is not None]
|
|
||||||
if ttfts and settings.backpressure.stall_window_s < max(ttfts):
|
|
||||||
raise ValueError(
|
|
||||||
f"stall_window_s({settings.backpressure.stall_window_s})须 ≥ 最大源 "
|
|
||||||
f"ttft_timeout_s({max(ttfts)});调大 BACKPRESSURE__STALL_WINDOW_S 或调小 TTFT"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_lease_ttl(env: Mapping[str, str]) -> float:
|
def _load_lease_ttl(env: Mapping[str, str]) -> float:
|
||||||
found = _first(env, "PGW_LEASE_TTL_S")
|
found = _first(env, "PGW_LEASE_TTL_S")
|
||||||
return float(_cast(found[1], "float", found[0])) if found else _DEFAULT_LEASE_TTL_S
|
return float(_cast(found[1], "float", found[0])) if found else _DEFAULT_LEASE_TTL_S
|
||||||
@@ -378,6 +492,13 @@ class EmbeddingSettings:
|
|||||||
normalize: bool = False
|
normalize: bool = False
|
||||||
expected_dim: int | None = None
|
expected_dim: int | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""自身字段的域校验;内嵌的 gateway 由 `GatewaySettings.__post_init__` 自己把关。"""
|
||||||
|
if self.batch_size < 1:
|
||||||
|
raise ValueError(f"EmbeddingSettings.batch_size 必须 ≥ 1: {self.batch_size}")
|
||||||
|
if self.expected_dim is not None and self.expected_dim < 1:
|
||||||
|
raise ValueError(f"EmbeddingSettings.expected_dim 必须 ≥ 1: {self.expected_dim}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(
|
def from_env(
|
||||||
cls,
|
cls,
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ def _env(sources: dict[int, str], **extra: str) -> dict[str, str]:
|
|||||||
f"OCR__MONKEY__{n}__API_KEY": "none",
|
f"OCR__MONKEY__{n}__API_KEY": "none",
|
||||||
f"OCR__MONKEY__{n}__MODEL": "monkey-ocr",
|
f"OCR__MONKEY__{n}__MODEL": "monkey-ocr",
|
||||||
f"OCR__MONKEY__{n}__TIMEOUT_S": "300",
|
f"OCR__MONKEY__{n}__TIMEOUT_S": "300",
|
||||||
|
# 服务在 LAN,开发机若开着系统代理(httpx trust_env 读 macOS 系统配置,
|
||||||
|
# 不是环境变量),代理会对内网地址回 403 —— 与本文件 raw httpx 用例
|
||||||
|
# 显式传 trust_env=False 同因
|
||||||
|
f"OCR__MONKEY__{n}__TRUST_ENV": "false",
|
||||||
}
|
}
|
||||||
env.update(extra)
|
env.update(extra)
|
||||||
return env
|
return env
|
||||||
|
|||||||
+303
-2
@@ -1,8 +1,13 @@
|
|||||||
"""config.py 配置聚合测试(设计 §8): 多源命名、键优先级、缺失报错。"""
|
"""config.py 配置聚合测试(设计 §8): 多源命名、键优先级、缺失报错。"""
|
||||||
|
|
||||||
import pytest
|
import contextlib
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
from polygateway.config import GatewaySettings
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from polygateway.client import GatewayClient
|
||||||
|
from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
|
||||||
|
|
||||||
_BASE_ENV = {
|
_BASE_ENV = {
|
||||||
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
|
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
|
||||||
@@ -19,6 +24,17 @@ _BASE_ENV = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _captured_warnings():
|
||||||
|
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
yield messages
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
|
||||||
|
|
||||||
def _env(**overrides):
|
def _env(**overrides):
|
||||||
env = dict(_BASE_ENV)
|
env = dict(_BASE_ENV)
|
||||||
env.update({k: v for k, v in overrides.items() if v is not None})
|
env.update({k: v for k, v in overrides.items() if v is not None})
|
||||||
@@ -155,6 +171,11 @@ class TestAssemblyGuards:
|
|||||||
s2 = GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="0"))
|
s2 = GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="0"))
|
||||||
assert s2.structured_max_retries == 0
|
assert s2.structured_max_retries == 0
|
||||||
|
|
||||||
|
def test_negative_structured_retries_rejected_with_env_key(self):
|
||||||
|
"""env 层的检查保留是为了报错能点出键名(构造期那道点的是字段名)。"""
|
||||||
|
with pytest.raises(ValueError, match="PGW_STRUCTURED_MAX_RETRIES"):
|
||||||
|
GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="-1"))
|
||||||
|
|
||||||
def test_cache_requires_namespace_and_ttl(self):
|
def test_cache_requires_namespace_and_ttl(self):
|
||||||
env = _env(PGW_CACHE_BACKEND="memory")
|
env = _env(PGW_CACHE_BACKEND="memory")
|
||||||
with pytest.raises(ValueError, match="NAMESPACE"):
|
with pytest.raises(ValueError, match="NAMESPACE"):
|
||||||
@@ -255,6 +276,11 @@ class TestAssemblyGuards:
|
|||||||
with pytest.raises(ValueError, match="TELEMETRY_BACKEND"):
|
with pytest.raises(ValueError, match="TELEMETRY_BACKEND"):
|
||||||
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="mysql"))
|
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="mysql"))
|
||||||
|
|
||||||
|
def test_cache_backend_whitelist(self):
|
||||||
|
"""对称于上一条: env 层的域检查保留是为了报错能点出键名,得有测试守着。"""
|
||||||
|
with pytest.raises(ValueError, match="CACHE_BACKEND"):
|
||||||
|
GatewaySettings.from_env("LLM", env=_env(PGW_CACHE_BACKEND="rediss"))
|
||||||
|
|
||||||
def test_pricing_path_optional(self):
|
def test_pricing_path_optional(self):
|
||||||
assert GatewaySettings.from_env("LLM", env=_env()).pricing_path is None
|
assert GatewaySettings.from_env("LLM", env=_env()).pricing_path is None
|
||||||
s = GatewaySettings.from_env("LLM", env=_env(PGW_PRICING_PATH="conf/prices.json"))
|
s = GatewaySettings.from_env("LLM", env=_env(PGW_PRICING_PATH="conf/prices.json"))
|
||||||
@@ -319,3 +345,278 @@ class TestOcrSettings:
|
|||||||
env = {k: v for k, v in self._OCR_ENV.items() if k != "OCR__MONKEY__1__BASE_URL"}
|
env = {k: v for k, v in self._OCR_ENV.items() if k != "OCR__MONKEY__1__BASE_URL"}
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
OcrSettings.from_env("OCR", env=env)
|
OcrSettings.from_env("OCR", env=env)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrossFieldInvariants:
|
||||||
|
"""四条跨字段不变量必须在**任何**构造路径上生效(设计 2026-07-29)。
|
||||||
|
|
||||||
|
这些约束单看一个字段都合法,组合起来才非法,因此 types.py 各子配置的
|
||||||
|
__post_init__ 看不见——只能由聚合层 GatewaySettings 把关。守卫若只挂在
|
||||||
|
from_env 上,from_settings 这条同等官方的装配路(CLAUDE.md §4.5)就能
|
||||||
|
装出违反不变量的配置,类会存在于自己 docstring 声称不可能的状态。
|
||||||
|
|
||||||
|
每条不变量测两侧: 越界必拒、边界值(恰好相等)必过——收紧的是错的组合,
|
||||||
|
不是所有直接构造。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _base(self, **overrides) -> GatewaySettings:
|
||||||
|
return GatewaySettings.from_env("LLM", env=_env(**overrides))
|
||||||
|
|
||||||
|
def _with_watchdog(self) -> GatewaySettings:
|
||||||
|
"""带看门狗的基准: TTFT/inter-token 成对配置才满足 SourceConfig 不变式。"""
|
||||||
|
return self._base(
|
||||||
|
**{
|
||||||
|
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
|
||||||
|
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
|
||||||
|
"LLM__BACKPRESSURE__STALL_WINDOW_S": "300",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# —— 源超时 ≤ permit 租约 TTL(ARCH §7.3: 防租约先于请求过期,并发悄悄超配额)——
|
||||||
|
|
||||||
|
def test_lease_rejects_timeout_above_ttl_on_direct_construction(self):
|
||||||
|
base = self._base() # 源 timeout_s=120
|
||||||
|
with pytest.raises(ValueError, match="lease_ttl_s"):
|
||||||
|
dataclasses.replace(base, lease_ttl_s=1.0)
|
||||||
|
|
||||||
|
def test_lease_accepts_timeout_equal_to_ttl(self):
|
||||||
|
base = self._base()
|
||||||
|
assert dataclasses.replace(base, lease_ttl_s=120.0).lease_ttl_s == 120.0
|
||||||
|
|
||||||
|
# —— stall 窗口 ≥ 最大源 TTFT(ARCH §7.3: 防正常慢首包被误判卡死掐断)——
|
||||||
|
|
||||||
|
def test_stall_rejects_window_below_max_ttft_on_direct_construction(self):
|
||||||
|
base = self._with_watchdog() # 源 ttft_timeout_s=30
|
||||||
|
narrowed = dataclasses.replace(base.backpressure, stall_window_s=20.0)
|
||||||
|
with pytest.raises(ValueError, match="stall_window_s"):
|
||||||
|
dataclasses.replace(base, backpressure=narrowed)
|
||||||
|
|
||||||
|
def test_stall_accepts_window_equal_to_max_ttft(self):
|
||||||
|
base = self._with_watchdog()
|
||||||
|
exact = dataclasses.replace(base.backpressure, stall_window_s=30.0)
|
||||||
|
assert dataclasses.replace(base, backpressure=exact).backpressure.stall_window_s == 30.0
|
||||||
|
|
||||||
|
# —— 探针租约 ≥ 最慢源超时 + 5(M2 设计 §3: 防半开探针在途即被接管)——
|
||||||
|
|
||||||
|
def test_probe_rejects_ttl_below_floor_on_direct_construction(self):
|
||||||
|
base = self._base() # 最慢 timeout_s=120,故下限 125
|
||||||
|
shortened = dataclasses.replace(base.breaker, probe_ttl_s=100.0)
|
||||||
|
with pytest.raises(ValueError, match="probe_ttl_s"):
|
||||||
|
dataclasses.replace(base, breaker=shortened)
|
||||||
|
|
||||||
|
def test_probe_accepts_ttl_at_floor(self):
|
||||||
|
base = self._base()
|
||||||
|
at_floor = dataclasses.replace(base.breaker, probe_ttl_s=125.0)
|
||||||
|
assert dataclasses.replace(base, breaker=at_floor).breaker.probe_ttl_s == 125.0
|
||||||
|
|
||||||
|
# —— sources 非空 ——
|
||||||
|
|
||||||
|
def test_empty_sources_rejected_with_actionable_message(self):
|
||||||
|
"""零源装出来的 client 选源必然失败;消息须点明原因,不能泄漏 max() 的内置异常。"""
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
dataclasses.replace(base, sources=())
|
||||||
|
assert "至少一个源" in str(exc.value)
|
||||||
|
assert "empty sequence" not in str(exc.value)
|
||||||
|
|
||||||
|
# —— 装配路径覆盖 ——
|
||||||
|
|
||||||
|
def test_factory_cannot_receive_invalid_settings(self):
|
||||||
|
"""from_settings 这条路吃不到非法配置。
|
||||||
|
|
||||||
|
异常实际抛在实参求值(构造 settings)那一刻,而不是工厂内部——这正是
|
||||||
|
把守卫放构造期换来的性质: 非法实例根本不存在,无需每个工厂各自设防。
|
||||||
|
"""
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="lease_ttl_s"):
|
||||||
|
GatewayClient.from_settings(dataclasses.replace(base, lease_ttl_s=1.0))
|
||||||
|
|
||||||
|
def test_ocr_settings_cannot_wrap_invalid_gateway(self):
|
||||||
|
"""OcrSettings/EmbeddingSettings 只是包一层 GatewaySettings,自动继承同一把关。"""
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="lease_ttl_s"):
|
||||||
|
OcrSettings(gateway=dataclasses.replace(base, lease_ttl_s=1.0))
|
||||||
|
|
||||||
|
# —— 第二轮(设计 2026-07-30): 后端枚举合法域 ——
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "bad_value"),
|
||||||
|
[
|
||||||
|
("limiter_backend", "rediss"),
|
||||||
|
("breaker_backend", "sqlite"),
|
||||||
|
("cache_backend", "postgres"),
|
||||||
|
("telemetry_backend", "redis"),
|
||||||
|
("selector", "random"),
|
||||||
|
("quota_full", "block"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_enum_field_rejects_value_outside_domain(self, field, bad_value):
|
||||||
|
"""域外取值此前只有 from_env 拦得住,直接构造会落进 _build_* 的 else 分支。"""
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match=field):
|
||||||
|
dataclasses.replace(base, **{field: bad_value})
|
||||||
|
|
||||||
|
# —— 条件必填: 取 redis 的后端必须有 redis_url ——
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["limiter_backend", "breaker_backend"])
|
||||||
|
def test_redis_backend_requires_redis_url(self, field):
|
||||||
|
"""client.py 的 assert settings.redis_url is not None 依赖的正是这条。"""
|
||||||
|
base = self._base() # redis_url=None
|
||||||
|
with pytest.raises(ValueError, match="redis_url"):
|
||||||
|
dataclasses.replace(base, **{field: "redis"})
|
||||||
|
|
||||||
|
def test_redis_cache_requires_redis_url(self):
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="redis_url"):
|
||||||
|
dataclasses.replace(base, cache_backend="redis", cache_namespace="ns", cache_ttl_s=60)
|
||||||
|
|
||||||
|
# —— 条件必填: 启用缓存必须有命名空间与正 TTL ——
|
||||||
|
|
||||||
|
def test_cache_requires_namespace(self):
|
||||||
|
"""缺命名空间即失去租户隔离,踩"无缓存毒化"铁律。"""
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="cache_namespace"):
|
||||||
|
dataclasses.replace(base, cache_backend="memory", cache_ttl_s=60)
|
||||||
|
|
||||||
|
def test_cache_ttl_must_be_positive(self):
|
||||||
|
"""from_env 明令禁止的"永不过期"不能从另一条路进来。"""
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="cache_ttl_s"):
|
||||||
|
dataclasses.replace(base, cache_backend="memory", cache_namespace="ns", cache_ttl_s=0)
|
||||||
|
|
||||||
|
# —— 条件必填: 遥测后端各自的落点 ——
|
||||||
|
|
||||||
|
def test_sqlite_telemetry_requires_path(self):
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="telemetry_sqlite_path"):
|
||||||
|
dataclasses.replace(base, telemetry_backend="sqlite")
|
||||||
|
|
||||||
|
def test_postgres_telemetry_requires_dsn(self):
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="telemetry_pg_dsn"):
|
||||||
|
dataclasses.replace(base, telemetry_backend="postgres")
|
||||||
|
|
||||||
|
# —— 标量域 ——
|
||||||
|
|
||||||
|
def test_negative_structured_retries_rejected(self):
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="structured_max_retries"):
|
||||||
|
dataclasses.replace(base, structured_max_retries=-1)
|
||||||
|
|
||||||
|
def test_blank_scope_rejected(self):
|
||||||
|
"""空 scope 会污染遥测与缓存命名空间。"""
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="scope"):
|
||||||
|
dataclasses.replace(base, scope=" ")
|
||||||
|
|
||||||
|
# —— 合法组合仍可构造(收紧的是错的那些)——
|
||||||
|
|
||||||
|
def test_full_redis_stack_constructible(self):
|
||||||
|
base = self._base()
|
||||||
|
settings = dataclasses.replace(
|
||||||
|
base,
|
||||||
|
limiter_backend="redis",
|
||||||
|
breaker_backend="redis",
|
||||||
|
cache_backend="redis",
|
||||||
|
cache_namespace="ns",
|
||||||
|
cache_ttl_s=60,
|
||||||
|
redis_url="redis://127.0.0.1:6379/3",
|
||||||
|
)
|
||||||
|
assert settings.cache_ttl_s == 60 and settings.redis_url is not None
|
||||||
|
|
||||||
|
# —— Postgres DSN: 剥 SQLAlchemy 驱动后缀并出声(设计 §5 方案 C)——
|
||||||
|
|
||||||
|
def test_sqlalchemy_dsn_suffix_stripped_with_warning(self):
|
||||||
|
"""asyncpg 不认 `+driver`;库替调用方剥掉,但不静默——日志里看得见。"""
|
||||||
|
base = self._base()
|
||||||
|
with _captured_warnings() as warnings:
|
||||||
|
settings = dataclasses.replace(
|
||||||
|
base,
|
||||||
|
telemetry_backend="postgres",
|
||||||
|
telemetry_pg_dsn="postgresql+asyncpg://u:s3cret@h/db",
|
||||||
|
)
|
||||||
|
assert settings.telemetry_pg_dsn == "postgresql://u:s3cret@h/db"
|
||||||
|
assert any("asyncpg" in m for m in warnings)
|
||||||
|
|
||||||
|
def test_dsn_warning_does_not_leak_credentials(self):
|
||||||
|
"""DSN 带密码,日志只能出现 scheme 段(P5: 敏感信息只走 .env)。"""
|
||||||
|
base = self._base()
|
||||||
|
with _captured_warnings() as warnings:
|
||||||
|
dataclasses.replace(
|
||||||
|
base,
|
||||||
|
telemetry_backend="postgres",
|
||||||
|
telemetry_pg_dsn="postgresql+asyncpg://u:s3cret@h/db",
|
||||||
|
)
|
||||||
|
assert warnings and not any("s3cret" in m or "@h/db" in m for m in warnings)
|
||||||
|
|
||||||
|
def test_env_path_strips_dsn_without_warning(self):
|
||||||
|
"""env 路已在 _load_pg_dsn 剥过,不该给三项目的历史 DSN 写法刷噪音。"""
|
||||||
|
with _captured_warnings() as warnings:
|
||||||
|
settings = GatewaySettings.from_env(
|
||||||
|
"LLM",
|
||||||
|
env=_env(
|
||||||
|
PGW_TELEMETRY_BACKEND="postgres",
|
||||||
|
PGW_TELEMETRY_PG_DSN="postgresql+asyncpg://u@h/db",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert settings.telemetry_pg_dsn == "postgresql://u@h/db"
|
||||||
|
assert not warnings
|
||||||
|
|
||||||
|
# —— 构造期规范化: env 路一直在做的,构造路也要做(否则两条路产出不同的值)——
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["LLM", " llm ", " LLM "])
|
||||||
|
def test_scope_normalized_on_direct_construction(self, raw):
|
||||||
|
"""scope 直接进 Redis key(pgw:limit:{scope}:…)。
|
||||||
|
|
||||||
|
大小写不一致会让同一逻辑 scope 的限流/熔断状态分裂到两套命名空间——
|
||||||
|
两边各记各的配额与熔断状态,分布式治理静默失效且不报错。
|
||||||
|
"""
|
||||||
|
base = self._base()
|
||||||
|
assert dataclasses.replace(base, scope=raw).scope == "llm"
|
||||||
|
|
||||||
|
def test_blank_redis_url_normalized_to_none(self):
|
||||||
|
"""空串此前只有 env 路归 None,构造路留着它骗过 `is None` 判断。"""
|
||||||
|
base = self._base()
|
||||||
|
assert dataclasses.replace(base, redis_url="").redis_url is None
|
||||||
|
|
||||||
|
def test_blank_redis_url_still_blocks_redis_backend(self):
|
||||||
|
"""归 None 后必须落进条件必填,而不是放行到 redis 库去抛连接串天书。"""
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="redis_url"):
|
||||||
|
dataclasses.replace(base, limiter_backend="redis", redis_url="")
|
||||||
|
|
||||||
|
def test_blank_pricing_path_normalized_to_none(self):
|
||||||
|
base = self._base()
|
||||||
|
assert dataclasses.replace(base, pricing_path="").pricing_path is None
|
||||||
|
|
||||||
|
# —— EmbeddingSettings 自身的字段域(此前只有 from_env 校验)——
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [0, -3])
|
||||||
|
def test_embedding_settings_rejects_non_positive_batch_size(self, bad):
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="batch_size"):
|
||||||
|
EmbeddingSettings(gateway=base, batch_size=bad)
|
||||||
|
|
||||||
|
def test_embedding_settings_rejects_non_positive_expected_dim(self):
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="expected_dim"):
|
||||||
|
EmbeddingSettings(gateway=base, batch_size=8, expected_dim=0)
|
||||||
|
|
||||||
|
def test_embedding_settings_accepts_valid_values(self):
|
||||||
|
base = self._base()
|
||||||
|
settings = EmbeddingSettings(gateway=base, batch_size=8, expected_dim=1024)
|
||||||
|
assert settings.batch_size == 8 and settings.expected_dim == 1024
|
||||||
|
|
||||||
|
# —— 回归护栏: client.py 的 assert 前提确实被保证了 ——
|
||||||
|
|
||||||
|
def test_factory_accepts_valid_redis_stack(self):
|
||||||
|
"""补齐校验后,client.py:262/282/302 的 assert 退回成纯内部不变量声明。"""
|
||||||
|
base = self._base()
|
||||||
|
settings = dataclasses.replace(
|
||||||
|
base,
|
||||||
|
limiter_backend="redis",
|
||||||
|
breaker_backend="redis",
|
||||||
|
redis_url="redis://127.0.0.1:6379/3",
|
||||||
|
)
|
||||||
|
client = GatewayClient.from_settings(settings)
|
||||||
|
assert client is not None
|
||||||
|
|||||||
@@ -358,6 +358,11 @@ class TestEmbeddingSettings:
|
|||||||
s = EmbeddingSettings.from_env("EMBED", env=env)
|
s = EmbeddingSettings.from_env("EMBED", env=env)
|
||||||
assert s.normalize is True and s.expected_dim == 768
|
assert s.normalize is True and s.expected_dim == 768
|
||||||
|
|
||||||
|
def test_expected_dim_must_be_positive(self):
|
||||||
|
"""env 层的检查保留是为了报错能点出键名(构造期那道点的是字段名)。"""
|
||||||
|
with pytest.raises(ValueError, match="EXPECTED_DIM"):
|
||||||
|
EmbeddingSettings.from_env("EMBED", env={**self._ENV, "EMBED__EXPECTED_DIM": "0"})
|
||||||
|
|
||||||
def test_from_settings_assembles_client(self):
|
def test_from_settings_assembles_client(self):
|
||||||
s = EmbeddingSettings.from_env("EMBED", env=self._ENV)
|
s = EmbeddingSettings.from_env("EMBED", env=self._ENV)
|
||||||
client = EmbeddingClient.from_settings(s)
|
client = EmbeddingClient.from_settings(s)
|
||||||
|
|||||||
Reference in New Issue
Block a user