feat: add gateway client with env-driven assembly

Includes config aggregation for multi-source env keys, from_env and
from_settings factories with explicit shared-backend injection,
gather_bounded, top-level exports, tightened import-linter layers with
the gate removed from the Makefile, and the finalized .env.example.
This commit is contained in:
2026-07-20 07:47:05 -04:00
parent 936895919c
commit 7b9815f4bc
30 changed files with 1701 additions and 253 deletions
+65 -26
View File
@@ -19,7 +19,6 @@ from polygateway.errors import (
TransientError,
)
from polygateway.middleware.retry import RetryMW
from polygateway.ports import GateState
from polygateway.sources import RoundRobinSelector, SourceCooldownMemo
from polygateway.types import (
BackpressurePolicy,
@@ -37,18 +36,28 @@ _NO_GLOBAL = GlobalLimits(max_concurrency=0, rpm=0, tpm=0)
def _src(name, **overrides):
base = dict(
name=name, provider="openai", base_url="https://gw.example/v1",
api_key="sk", model="m", timeout_s=10.0,
)
base = {
"name": name,
"provider": "openai",
"base_url": "https://gw.example/v1",
"api_key": "sk",
"model": "m",
"timeout_s": 10.0,
}
base.update(overrides)
return SourceConfig(**base)
def _ok(content="ok"):
return TransportResult(
content=content, thinking="", prompt_tokens=10, completion_tokens=5,
usage_source="measured", ttft_ms=12.0, max_inter_token_ms=3.0, raw={},
content=content,
thinking="",
prompt_tokens=10,
completion_tokens=5,
usage_source="measured",
ttft_ms=12.0,
max_inter_token_ms=3.0,
raw={},
)
@@ -79,23 +88,42 @@ class FakeSleep:
self.delays.append(seconds)
def _harness(sources, script, *, clock=None, max_attempts=3, quota_full="wait",
global_limits=_NO_GLOBAL, rng=lambda: 0.0):
def _harness(
sources,
script,
*,
clock=None,
max_attempts=3,
quota_full="wait",
global_limits=_NO_GLOBAL,
rng=lambda: 0.0,
):
clock = clock or FakeClock()
limiter = InMemoryLimiter(
scope="llm", sources={s.name: s for s in sources},
global_limits=global_limits, lease_ttl_s=100.0, now=clock,
scope="llm",
sources={s.name: s for s in sources},
global_limits=global_limits,
lease_ttl_s=100.0,
now=clock,
)
gate = InMemoryGate(config=_BREAKER, now=clock)
transport = FakeTransport(script)
sleep = FakeSleep()
mw = RetryMW(
scope="llm", sources=sources, selector=RoundRobinSelector(),
limiter=limiter, gate=gate, transport=transport,
scope="llm",
sources=sources,
selector=RoundRobinSelector(),
limiter=limiter,
gate=gate,
transport=transport,
retry=RetryPolicy(max_attempts=max_attempts, backoff_base_s=2.0, backoff_max_s=30.0),
backpressure=BackpressurePolicy(stall_window_s=300.0, poll_interval_s=0.01),
quota_full=quota_full, cooldown_memo=SourceCooldownMemo(now=clock),
emitter=None, now=clock, sleep=sleep, rng=rng,
quota_full=quota_full,
cooldown_memo=SourceCooldownMemo(now=clock),
emitter=None,
now=clock,
sleep=sleep,
rng=rng,
)
return mw, limiter, gate, transport, sleep, clock
@@ -140,7 +168,8 @@ class TestRetryAndFailover:
async def test_max_attempts_is_total_attempts(self):
mw, _, _, transport, _, _ = _harness(
[_src("a")], [TransientError("1"), TransientError("2"), TransientError("3")],
[_src("a")],
[TransientError("1"), TransientError("2"), TransientError("3")],
max_attempts=3,
)
with pytest.raises(AllSourcesExhausted) as ei:
@@ -196,9 +225,7 @@ class TestScopeUnavailable:
async def test_all_sources_circuit_open(self):
clock = FakeClock()
script = [TransientError(str(i)) for i in range(9)]
mw, _, gate, _, _, _ = _harness(
[_src("a")], script, clock=clock, max_attempts=99
)
mw, _, gate, _, _, _ = _harness([_src("a")], script, clock=clock, max_attempts=99)
# 3 次失败后 a 开路 → 第 4 次尝试选不到源且 gate_rejections==全部 → CircuitOpen
with pytest.raises(CircuitOpenError) as ei:
await mw(_REQ)
@@ -225,8 +252,11 @@ class TestScopeUnavailable:
src = _src("a", max_concurrency=1)
clock = FakeClock()
limiter = InMemoryLimiter(
scope="llm", sources={"a": src}, global_limits=_NO_GLOBAL,
lease_ttl_s=100.0, now=clock,
scope="llm",
sources={"a": src},
global_limits=_NO_GLOBAL,
lease_ttl_s=100.0,
now=clock,
)
held = await limiter.try_acquire("a", 0)
released = {"done": False}
@@ -239,12 +269,20 @@ class TestScopeUnavailable:
gate = InMemoryGate(config=_BREAKER, now=clock)
transport = FakeTransport([_ok()])
mw = RetryMW(
scope="llm", sources=[src], selector=RoundRobinSelector(),
limiter=limiter, gate=gate, transport=transport,
scope="llm",
sources=[src],
selector=RoundRobinSelector(),
limiter=limiter,
gate=gate,
transport=transport,
retry=RetryPolicy(max_attempts=3, backoff_base_s=2.0, backoff_max_s=30.0),
backpressure=BackpressurePolicy(stall_window_s=300.0, poll_interval_s=0.01),
quota_full="wait", cooldown_memo=SourceCooldownMemo(now=clock),
emitter=None, now=clock, sleep=sleep_and_release, rng=lambda: 0.0,
quota_full="wait",
cooldown_memo=SourceCooldownMemo(now=clock),
emitter=None,
now=clock,
sleep=sleep_and_release,
rng=lambda: 0.0,
)
resp = await mw(_REQ)
assert resp.content == "ok" and released["done"]
@@ -265,7 +303,8 @@ class TestCancellation:
mw, _, gate, _, _, _ = _harness(
[_src("a")],
[TransientError("1"), TransientError("2"), TransientError("3"), "hang"],
clock=clock, max_attempts=99,
clock=clock,
max_attempts=99,
)
# 三连失败开路
with pytest.raises(CircuitOpenError):