feat: add the {SCOPE}__CIRCUIT_OPEN admission policy key

Limiter rejections have always chosen between waiting and failing fast;
breaker rejections had no such choice. The new key is the missing cell
of that matrix, shaped exactly like QUOTA_FULL so there is nothing new
to learn. It defaults to fail_fast: flipping the default would move
every existing deployment's worst-case wall clock from milliseconds to
the stall window, which is the wrong direction to impose on anyone.
Single-source scopes are the ones that want wait, and they now have a
way to say so.

The two keys stay separate despite sharing a domain, because a full
quota is "queue for your share" (your turn always comes) while an open
circuit is "wait for the source to recover" (it might not).

Policy validation collapses into SourceAdmission, the only consumer.
The three client constructors used to each carry their own copy of the
quota_full check; adding a second key there would have made eight
copies of the same two lines. Rejection timing and message are
unchanged -- admission is built inside those constructors.

This commit only wires the key through; the control flow that reads it
lands next.
This commit is contained in:
2026-08-20 00:17:46 -04:00
parent 8edd3fb2cd
commit eb956b2cdf
7 changed files with 44 additions and 8 deletions
+3 -2
View File
@@ -101,6 +101,7 @@ class EmbeddingClient:
retry: RetryPolicy,
backpressure: BackpressurePolicy,
quota_full: str = "wait",
circuit_open: str = "fail_fast",
telemetry: TelemetryRecorder | None = None,
pricing: PricingTable | None = None,
text_cap: int | None = None,
@@ -113,8 +114,6 @@ class EmbeddingClient:
) -> None:
if batch_size < 1:
raise ValueError("batch_size 必须 ≥ 1")
if quota_full not in ("wait", "fail_fast"):
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
if expected_dim is not None and expected_dim < 1:
raise ValueError("expected_dim 必须 ≥ 1")
self._scope = scope
@@ -145,6 +144,7 @@ class EmbeddingClient:
breaker=self._breaker,
backpressure=backpressure,
quota_full=quota_full,
circuit_open=circuit_open,
now=now,
sleep=sleep,
rng=rng,
@@ -500,6 +500,7 @@ class EmbeddingClient:
retry=gw.retry,
backpressure=gw.backpressure,
quota_full=gw.quota_full,
circuit_open=gw.circuit_open,
telemetry=telemetry if telemetry is not None else _build_telemetry(gw),
pricing=PricingTable.from_file(gw.pricing_path)
if gw.pricing_path is not None