docs: record the circuit-open wait policy and the retry_after contract

README gains the key with the reason a single-source scope wants wait,
and the price of choosing it. .env.example carries the same warning
since README points at it as the full key list. ARCHITECTURE 7.4 records
why the missing cell is unrelated to source count -- and why keying on
len(sources) would be the worse debt -- plus the six-exit retry_after_s
contract and the admission convergence; 9 registers the key.

CHANGELOG stays unreleased per the release checklist: the version bump
belongs to the release run, not here. Its "read this first" section
covers the half-open retry_after_s change, which is visible even on the
default fail_fast setting.
This commit is contained in:
2026-08-20 00:36:36 -04:00
parent 2a9bc44abf
commit d9ceaecf20
4 changed files with 44 additions and 2 deletions
+6 -1
View File
@@ -48,7 +48,12 @@ LLM_CIRCUIT_BREAKER_COOLDOWN=60 # 或 LLM__BREAKER__COOLDOWN_S
# LLM__BREAKER__MAX_COOLDOWN_S=300 # 开路指数退避封顶(缺省 max(300, cooldown)) # LLM__BREAKER__MAX_COOLDOWN_S=300 # 开路指数退避封顶(缺省 max(300, cooldown))
# ── AIMD 自适应并发(M2.5,库常量非 env 键): 每源初始 8,429 ×0.5,成功 +1/limit, # ── AIMD 自适应并发(M2.5,库常量非 env 键): 每源初始 8,429 ×0.5,成功 +1/limit,
# ── ceiling = max(64, 源级 MAX_CONCURRENCY);禁用需构造函数注入自定义 pacer ── # ── ceiling = max(64, 源级 MAX_CONCURRENCY);禁用需构造函数注入自定义 pacer ──
# LLM__QUOTA_FULL=wait # wait(默认) | fail_fast # LLM__QUOTA_FULL=wait # 配额满: wait(默认) | fail_fast
# ── 熔断全拒时的处置(issue #14)。单源 scope 建议 wait: 只有一个源时
# ── "停用这个源"等于"整个 scope 停服",fail_fast 会让开路期间的每次调用
# ── 在几毫秒内死掉且 MAX_ATTEMPTS 一格用不上。wait 不削弱保护(等待期照样
# ── 不发请求),只是把最坏墙钟拉长到 BACKPRESSURE__STALL_WINDOW_S ──
# LLM__CIRCUIT_OPEN=fail_fast # 熔断开路: fail_fast(默认) | wait
# ══ 装配选择(PGW_*)══ # ══ 装配选择(PGW_*)══
PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis) PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis)
+22
View File
@@ -1,5 +1,27 @@
# Changelog # Changelog
## 未发布(预计 1.3.0)
熔断开路时,调用方第一次可以选择**等**而不是当场失败(issue #14)。此前准入侧有一格是空的:限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 `wait`),熔断门拒绝时**只有 fail-fast 一档且不可配**——而两者在准入语义上是同构的,都没发出请求、都带着"稍后再来"的提示。新键 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 补上这一格,形状与 `QUOTA_FULL` 逐项对齐。
**缺省是 `fail_fast`,即今天的行为**,存量部署无需改动任何配置。要改的是单源 scope:熔断的设计前提是"这个源坏了,把流量导到别的源",只配了一个源时这个前提不成立,同一段代码做的事就变成"这个源坏了,所以整个 scope 停止服务"。提交方实测:中转抖动 36 秒(22 次尝试 / 19 次 503)触发失败率通道开路,随后 30 次调用全部在 7-74 毫秒内失败,`MAX_ATTEMPTS=8` 一格没用上,一条跑了 3 小时 18 分钟的实验臂当场报废。配 `wait` 之后,熔断对配额和钱包的保护完整保留(等待期照样一个请求都不发),改变的只是调用方当场死还是排队等;代价是单次调用最坏墙钟被拉长到 `STALL_WINDOW_S`(缺省 300 秒),密钥失效这类一击即熔的情形同样要等满——库无法区分"密钥坏了"和"中转抖了",选 `wait` 就是声明"宁可等也不当场死"。
### 请先读这一条: `retry_after_s` 在半开状态下的取值变了(缺省档同样生效)
`retry_after_s` 从来没有写下来的定义,于是两个后端各自发挥、互相漂移。现在它只回答一个问题:**距离确定可再试的时刻还有多久**。健康与准入允许 → `0.0`;开路 → 剩余冷却;**半开(探针在途)→ `0.0`**,因为探针随时可能出结果,不存在确定的时刻——而 `0 = 可立即重试` 本就是这个字段的既有约定。
变更点在半开:此前返回的是**探针租约剩余**。那是个死锁保护参数,派生自 `max(2 × 最慢源 TIMEOUT_S, COOLDOWN_S, TIMEOUT_S + 5)`,与"这个源多久能恢复"没有任何因果关系。`TIMEOUT_S=300` 的部署里它是 600 秒,而冷却期只有 60 秒。**照它延期重投的下游,等的是一个物理上无意义的数。**
更重的后果在库内,提交方也没发现:这个值被写进了源冷却备忘,而备忘的 `set_until` 取更晚者、不可回退。于是——源开路、冷却到期、调用①拿到探针、并发的调用②被拒并给该源记下 600 秒本地冷却、调用①的探针成功、门恢复 CLOSED——**本进程此后仍然跳过这个健康的源将近 10 分钟**。单源下每次调用照旧抛 `CircuitOpenError`;多源部署同样中招,只是别的源接住了流量,池子越大越隐蔽。修正后备忘写进的是一个已经过期的时刻,自动回到"只记开路的确定冷却期"。
同批统一了两个后端在**六个出口**上的口径。其中四处是既有的分叉:Redis 在授予探针时返回探针 TTL、在写回被 fencing 拒时返回租约剩余,而内存后端一直返回 0。契约测试此前只钉了"第二个进入者会被拒绝",从没钉过它拿到的是什么数,这个盲区把分叉掩护到了今天。
### 其他
- `_pick_runnable`/`_on_no_runnable` 此前在 chat/embedding/OCR 三条治理循环里各存一份逐字复制,现收敛为 `middleware/admission.py::SourceAdmission` 一份。行为不变——差异用注入表达(调用内降权传空计数时恒等、AIMD pacer 为 `None` 时跳过),`permit` 结算的 warning 文案由三种归一为一种。
- `GatewayUnavailableError` 的文档收回了重试职责:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于**任务级**重试,语义不同。此前那句"业务侧 catch 本类做延期重投"读起来像在鼓励每个下游各写一份重试逻辑,而两边各写一份必然漂移。
## 1.2.3(2026-08-19) ## 1.2.3(2026-08-19)
遥测表 `llm_calls` 的结构变更从此**由下游掌控**(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 `ALTER TABLE ADD COLUMN`,而补列**没有任何开关**——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:`ALTER` 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 `await`),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。 遥测表 `llm_calls` 的结构变更从此**由下游掌控**(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 `ALTER TABLE ADD COLUMN`,而补列**没有任何开关**——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:`ALTER` 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 `await`),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。
+9 -1
View File
@@ -400,7 +400,7 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
|---|---| |---|---|
| `{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/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) | | `{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/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) |
| `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) | | `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) | | `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` / `CIRCUIT_OPEN` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
| `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) | | `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) |
| `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | `memory`(单进程)或 `redis`(跨进程共享,需 `REDIS_URL`) | | `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | `memory`(单进程)或 `redis`(跨进程共享,需 `REDIS_URL`) |
| `PGW_CACHE_BACKEND` | `none` / `memory` / `redis`;非 `none` 时需 `PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`(须 > 0) | | `PGW_CACHE_BACKEND` | `none` / `memory` / `redis`;非 `none` 时需 `PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`(须 > 0) |
@@ -409,6 +409,14 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
| `PGW_TELEMETRY_TEXT_CAP` | 可选正整数:遥测落库正文的字符上限(作用于每条消息的文本 `content`、多模态 part 的 `text``response``thinking`);**不设 = 不截断**,详见[合规下游的推荐配置](#6-合规下游的推荐配置) | | `PGW_TELEMETRY_TEXT_CAP` | 可选正整数:遥测落库正文的字符上限(作用于每条消息的文本 `content`、多模态 part 的 `text``response``thinking`);**不设 = 不截断**,详见[合规下游的推荐配置](#6-合规下游的推荐配置) |
| `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) | | `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) |
**`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(缺省 `fail_fast`)——单源 scope 请配 `wait`**
熔断的设计前提是"这个源坏了,把流量导到别的源"。**只配了一个源时这个前提不成立**,同一段代码做的事变成"这个源坏了,所以整个 scope 停止服务":开路期间每一次调用都在几毫秒内失败,`MAX_ATTEMPTS` 一格用不上,一个网络包都没发出去。中转抖动几十秒就足以打断一条跑了几小时的长任务。
`wait` 档改变的**只是**"调用方当场失败还是排队等":等待期间照样一个请求都不发,熔断对配额和钱包的保护完整保留。代价是单次调用的最坏墙钟被拉长到 `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S`(缺省 300 秒)——包括密钥失效(401/403)这种一击即熔的情形,库无法区分"密钥坏了"和"中转抖了",选 `wait` 就是声明"宁可等也不要当场死"。多源部署保持 `fail_fast`:有源可换时,换源比等待快。
该键与 `{SCOPE}__QUOTA_FULL` 同形但**不可互相替代**:配额满是"排队等自己的份额"(必然轮到),熔断开路是"等这个源恢复"(未必恢复),所以两者分开配置。
两个易被忽略的源级键:`MISSING_DONE` 决定 SSE 缺 `[DONE]` 时的处置(`retry` 默认判瞬时重试 / `salvage` 收下已收内容并把用量可信度降为 `estimated`;零内容恒 `retry`,不受该键影响);`EXTRA_BODY` 是该源**恒定**的采样参数(JSON 对象串,并入请求体,优先级低于 `chat(overlay=...)`),禁用键 `model` / `messages` / `stream` / `stream_options` 配了直接报错,OCR 与 EMBED scope 不消费该键(配了忽略并 warning)。 两个易被忽略的源级键:`MISSING_DONE` 决定 SSE 缺 `[DONE]` 时的处置(`retry` 默认判瞬时重试 / `salvage` 收下已收内容并把用量可信度降为 `estimated`;零内容恒 `retry`,不受该键影响);`EXTRA_BODY` 是该源**恒定**的采样参数(JSON 对象串,并入请求体,优先级低于 `chat(overlay=...)`),禁用键 `model` / `messages` / `stream` / `stream_options` 配了直接报错,OCR 与 EMBED scope 不消费该键(配了忽略并 warning)。
`SCOPE` 是逻辑角色(LLM/VLM/OCR/EMBED/JUDGE/SEARCH…任意大写名),同一进程可按角色装配多个 client,各自独立配置与治理状态。 `SCOPE` 是逻辑角色(LLM/VLM/OCR/EMBED/JUDGE/SEARCH…任意大写名),同一进程可按角色装配多个 client,各自独立配置与治理状态。
+7
View File
@@ -472,6 +472,12 @@ flowchart TB
**M2.5 双通道开路(2026-07-21,设计 designs/2026-07-21-m25-resilience-design.md;对 CHS 连续失败语义的有意扩展)**: P6 压测实证纯连续失败语义对"高失败率但偶尔成功"的半死源失明(10% 成功率源永不开路,吃掉 76% 尝试)。判据改为满足任一即开路——① 连续失败 ≥ 阈值(CHS 兼容,保留);② 窗口(双 30s 桶,服务器钟)样本 ≥ `min_calls`(缺省 10)且失败率 ≥ `fail_rate`(缺省 0.6)。**429 不入两通道**(限速是背压不是源故障,Envoy outlier detection 同款;交健康选源软处理);ResultInvalid/网关健康拒绝不计窗口样本(坏结果 ≠ 坏服务)。开路时长指数递增 `cooldown × 2^(streak-1)` 封顶 `max_cooldown_s`(缺省 max(300, cooldown)),仅率通道开路与探针失败重开递增 streak(连续通道误熔健康源的代价封顶单次 cooldown);CLOSED 稳定满 2×cooldown_eff 后首次成功衰减归零。探针撞 429 按无果归还语义放下家接管。原则沉淀: **治理状态的粒度必须等于配额的粒度**(限流/账号退避按配额主体建 key;缓存 key 含租户同理)。 **M2.5 双通道开路(2026-07-21,设计 designs/2026-07-21-m25-resilience-design.md;对 CHS 连续失败语义的有意扩展)**: P6 压测实证纯连续失败语义对"高失败率但偶尔成功"的半死源失明(10% 成功率源永不开路,吃掉 76% 尝试)。判据改为满足任一即开路——① 连续失败 ≥ 阈值(CHS 兼容,保留);② 窗口(双 30s 桶,服务器钟)样本 ≥ `min_calls`(缺省 10)且失败率 ≥ `fail_rate`(缺省 0.6)。**429 不入两通道**(限速是背压不是源故障,Envoy outlier detection 同款;交健康选源软处理);ResultInvalid/网关健康拒绝不计窗口样本(坏结果 ≠ 坏服务)。开路时长指数递增 `cooldown × 2^(streak-1)` 封顶 `max_cooldown_s`(缺省 max(300, cooldown)),仅率通道开路与探针失败重开递增 streak(连续通道误熔健康源的代价封顶单次 cooldown);CLOSED 稳定满 2×cooldown_eff 后首次成功衰减归零。探针撞 429 按无果归还语义放下家接管。原则沉淀: **治理状态的粒度必须等于配额的粒度**(限流/账号退避按配额主体建 key;缓存 key 含租户同理)。
**熔断拒绝补齐等待档(2026-08-19,issue #14,设计 `designs/2026-08-19-issue14-admission-wait-policy-design.md`;人类确认缺省与实施边界)**: 准入侧此前有一格是空的——限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 wait),熔断门拒时**只有 fail-fast 一档且不可配**。两者在准入语义上同构(都不发请求、都带 `retry_after` 提示),处置却分叉。补上 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(缺省 **fail_fast**,不跟随 quota_full——把最坏墙钟从毫秒抬到 stall 窗口是"快速失败 → 长时间挂起"这个最危险的方向,不能强加给存量下游)。`wait` 档下熔断的保护作用完整保留(等待期一个请求都不发),改变的只是调用方当场死还是排队等。**这一格的缺失与源数量无关**: 多源全部同时开路(共同上游挂掉、全网抖动)行为一模一样,单源只是把"全部开路"的概率从罕见变成必然;故实现上**严禁按池大小分叉**(`if len(sources) == 1` 会让行为随配置突变且无法组合测试)。等待时长按 `retry_after_s` 睡到冷却截止(而非 `poll_interval` 空转——60 秒冷却用 10ms 轮询是 6000 次往返 × 每个在途调用),抖动**上**加不缩放(对确定的截止时刻提前醒必然白醒),并夹到剩余 stall 预算,故单次调用最坏墙钟 = `stall_window_s` + 一个 poll 间隔,不随 `max_cooldown_s` 漂移。控制流必须**按拒绝原因分派**而非串行: 串行写法下 `circuit_open=wait` 不抛之后会掉进配额分支,`quota_full=fail_fast` 的调用方会收到 `reason=quota_exhausted` 而配额其实是满的。
**`retry_after_s` 的契约定死(同批,issue #14)**: 语义 = "距离**确定**可再试的时刻还有多久"。CLOSED/准入允许 → `0.0`(现在就能试);OPEN → 剩余冷却(确定时刻);**HALF_OPEN → `0.0`**——探针随时可能出结果,不存在确定时刻,而 `0 = 可立即重试` 本就是库既有约定。此前 HALF_OPEN 返回**探针租约剩余**,那是死锁保护参数(派生自 `max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)`),与"源多久能恢复"无因果关系: 现场 `TIMEOUT_S=300` 时它是 600s 而冷却只有 60s。**更重的后果不在对外报数而在库内**: 该值被喂进源冷却备忘(`SourceCooldownMemo.set_until` 取更晚者、不可回退),于是探针成功、门已恢复 CLOSED 之后,本进程仍跳过该源整整一个租约——单源下每次调用照旧判死,多源下则是"池子里少一个源"且被其他源接住流量所掩盖(issue 提交方未发现这一条)。修正后备忘写入的是已过期时刻,自动回归"只记 OPEN 的确定冷却期"。契约在**六个出口**上统一(memory 三处 + redis 六个 Lua 返回格),其中后四处是**既有的双后端分叉**(redis 在授予探针时返回 probe TTL、在 fencing 未命中时返回租约剩余,而 memory 一直是 0),由契约测试盲区掩护至今——旧用例只钉"第二个进入者被拒",从没钉它拿到什么数。
**准入逻辑三处收敛(同批)**: `_pick_runnable`/`_on_no_runnable` 此前在 `middleware/retry.py``embedding.py``ocr.py` 各存一份逐字复制(后两份是第一份的子集)。准入语义一直在演进(issue #8 的 stall 口径、M2.5 的 pacer、本次的等待档),每次都要三处同步。收敛为 `middleware/admission.py::SourceAdmission`,差异用注入表达而非分支: 调用内降权传空 `attempt_fails` 时恒等、AIMD pacer 为 `None` 时跳过。`QuotaGate`/`BreakerGate`/`AdaptivePacer` 由三条循环持有并与 admission **共享同一实例**(三处 `_attempt` 仍要用它们做记账写回与 `pacer.leave()`;pacer 有在途计数,分裂成两个计数器会让 admit/enter 与 leave 记到不同账上),`SourceCooldownMemo` 归 admission 独占。
### 7.5 响应缓存 ### 7.5 响应缓存
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling}))`,前缀 `pgw:cache:` **key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling}))`,前缀 `pgw:cache:`
@@ -578,6 +584,7 @@ src/polygateway/
- **per-scope 韧性配置(2026-07-20,CHS 迁移缺口 G4)**: 韧性参数支持按 scope 覆盖——`{SCOPE}__RETRY__MAX_ATTEMPTS` / `{SCOPE}__BREAKER__FAIL_THRESHOLD` / `{SCOPE}__BREAKER__COOLDOWN_S` / `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S` / `{SCOPE}__SELECTOR` / `{SCOPE}__GLOBAL__MAX_CONCURRENCY|RPM|TPM`(CHS 现状: VLM 与 OCR 两 scope 参数各异)。平铺键(`LLM_*`)是单 scope 场景的简写;两者并存时 scope 键优先。 - **per-scope 韧性配置(2026-07-20,CHS 迁移缺口 G4)**: 韧性参数支持按 scope 覆盖——`{SCOPE}__RETRY__MAX_ATTEMPTS` / `{SCOPE}__BREAKER__FAIL_THRESHOLD` / `{SCOPE}__BREAKER__COOLDOWN_S` / `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S` / `{SCOPE}__SELECTOR` / `{SCOPE}__GLOBAL__MAX_CONCURRENCY|RPM|TPM`(CHS 现状: VLM 与 OCR 两 scope 参数各异)。平铺键(`LLM_*`)是单 scope 场景的简写;两者并存时 scope 键优先。
- **装配只有两条路**: `GatewayClient.from_env()`/`from_settings(settings)`(工厂,覆盖 90% 用户;补上三项目每次手写、GovDoc 缺失的"配置→client"一段)或构造函数全量依赖注入(测试/高级用户)。库内部任何组件**不得自读环境变量**(显式优于隐式)。 - **装配只有两条路**: `GatewayClient.from_env()`/`from_settings(settings)`(工厂,覆盖 90% 用户;补上三项目每次手写、GovDoc 缺失的"配置→client"一段)或构造函数全量依赖注入(测试/高级用户)。库内部任何组件**不得自读环境变量**(显式优于隐式)。
- 后端选择即配置: 如 `PGW_LIMITER_BACKEND=memory|redis``PGW_TELEMETRY_BACKEND=sqlite|postgres``PGW_QUOTA_FULL=wait|fail_fast`(命名待 M1 设计文档定稿)。 - 后端选择即配置: 如 `PGW_LIMITER_BACKEND=memory|redis``PGW_TELEMETRY_BACKEND=sqlite|postgres``PGW_QUOTA_FULL=wait|fail_fast`(命名待 M1 设计文档定稿)。
- **`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(2026-08-19,issue #14)**: 熔断全拒时的处置,与 `{SCOPE}__QUOTA_FULL` 同形同族(上一条"后端选择即配置"里记的 `PGW_QUOTA_FULL` 是 M1 定稿前的暂拟名,实际落地为 scope 键 `{SCOPE}__QUOTA_FULL`)。缺省 **fail_fast** = 存量下游的控制流逐字不变;**单源 scope 应显式配 `wait`**。两键值域相同但语义不同故分列: 配额满是"排队等自己的份额"(必然轮到),熔断开路是"等这个源恢复"(未必恢复),调用方可能想要"配额满就等、源坏了就立刻失败"。落到 `GatewaySettings.circuit_open`(无默认值,与既有全部字段一致),校验收敛在唯一消费者 `SourceAdmission` 一处——三个客户端构造函数此前各带一份 `quota_full` 校验,再加一键就是八处复制。
- **`PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(2026-08-19,issue #13,D15)**: 可选键、**三态**——不设 = 按后端派生(sqlite→auto、postgres→manual),显式设置则两侧都可覆盖。派生只发生在 config 层一处,落到 `GatewaySettings.telemetry_auto_migrate`(无默认值,与既有全部字段一致;`telemetry_backend=none` 时无人消费,归一为 `False`),recorder 的 `auto_migrate` 是 keyword-only **必填**参数——关键行为参数不给默认值(P4),缺省规则也就不会与类签名漂移。 - **`PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(2026-08-19,issue #13,D15)**: 可选键、**三态**——不设 = 按后端派生(sqlite→auto、postgres→manual),显式设置则两侧都可覆盖。派生只发生在 config 层一处,落到 `GatewaySettings.telemetry_auto_migrate`(无默认值,与既有全部字段一致;`telemetry_backend=none` 时无人消费,归一为 `False`),recorder 的 `auto_migrate` 是 keyword-only **必填**参数——关键行为参数不给默认值(P4),缺省规则也就不会与类签名漂移。
- **`PGW_TELEMETRY_TEXT_CAP`(2026-08-19,issue #12)**: 可选正整数键、**二态**——不设 = 不截断(缺省)。与相邻的 `SCHEMA_MODE` 不同,这里"未设"本身就是最终答案,没有需要按后端派生的第二种缺省。落到 `GatewaySettings.telemetry_text_cap: int | None`(同样无默认值),`TelemetryEmitter.text_cap` 是 keyword-only 必填参数。值域(`> 0`)在 settings 与 emitter **两处**校验: 前者只管 env 一条路,而"构造函数全量注入"是库承诺的另一条公共装配路,`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。 - **`PGW_TELEMETRY_TEXT_CAP`(2026-08-19,issue #12)**: 可选正整数键、**二态**——不设 = 不截断(缺省)。与相邻的 `SCHEMA_MODE` 不同,这里"未设"本身就是最终答案,没有需要按后端派生的第二种缺省。落到 `GatewaySettings.telemetry_text_cap: int | None`(同样无默认值),`TelemetryEmitter.text_cap` 是 keyword-only 必填参数。值域(`> 0`)在 settings 与 emitter **两处**校验: 前者只管 env 一条路,而"构造函数全量注入"是库承诺的另一条公共装配路,`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。