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:
+65
-26
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user