fix: reparent governance backend failures under GatewayUnavailableError (issue #7)

A fail-closed limiter or breaker backend means the scope cannot emit a
single request, which is exactly scope-level unavailability. But the error
sat directly under PolyGatewayError, so a caller writing only
`except GatewayUnavailableError` dropped it into the catch-all branch:
Redis blips once and a backlog of tasks burns its business failure budget
into the dead letter queue, over a fault a restart would clear.

Three gate paths leak to callers rather than being absorbed by
_record_quietly (try_acquire, try_enter, progress_age_s); each is now
pinned by a test, since none of them had one before.

The two unknown-source sites move to SourceNotConfiguredError instead of
following along. They report a misconfigured source name, not an outage,
and letting them into the retryable family would be the mirror of the bug
being fixed here: the task would retry forever and never surface.
This commit is contained in:
2026-08-06 04:53:52 -04:00
parent dd540496a1
commit 45073486a7
13 changed files with 179 additions and 49 deletions
@@ -16,7 +16,11 @@ import pytest
from polygateway.backends.redis.breaker import RedisGate
from polygateway.backends.redis.limiter import RedisLimiter
from polygateway.client import GatewayClient
from polygateway.errors import AllSourcesExhausted, GovernanceBackendError
from polygateway.errors import (
AllSourcesExhausted,
GatewayUnavailableError,
GovernanceBackendError,
)
from polygateway.sources import RoundRobinSelector
from polygateway.types import (
BackpressurePolicy,
@@ -225,7 +229,11 @@ async def test_cancel_in_flight_releases_lease(clients):
async def test_redis_down_admission_fails_closed():
"""Redis 不可达 → 准入侧抛 GovernanceBackendError,绝不放行(库铁律)。"""
"""Redis 不可达 → 准入侧报错绝不放行(库铁律),且以 scope 级形态到达调用方。
issue #7: 调用方只写 `except GatewayUnavailableError` 就该覆盖后端故障——
真实 Redis 掉线是这条链路唯一的端到端证据,故断言收紧到 scope 级语义。
"""
import redis.asyncio as aioredis
dead = aioredis.from_url(
@@ -240,9 +248,12 @@ async def test_redis_down_admission_fails_closed():
lease_ttl_s=30.0,
)
gate = RedisGate(config=_CFG, redis=dead, scope="t-dead")
with pytest.raises(GovernanceBackendError):
await limiter.try_acquire("s1", 0)
with pytest.raises(GovernanceBackendError):
await gate.try_enter("s1", "w")
for call in (limiter.try_acquire("s1", 0), gate.try_enter("s1", "w")):
with pytest.raises(GatewayUnavailableError) as ei:
await call
assert isinstance(ei.value, GovernanceBackendError)
assert ei.value.reason == "governance_backend_down"
assert ei.value.scope == "t-dead"
assert ei.value.retry_after_s > 0
finally:
await dead.aclose()