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
+2 -2
View File
@@ -15,7 +15,7 @@ import time
import uuid
from typing import TYPE_CHECKING
from polygateway.errors import GovernanceBackendError
from polygateway.errors import SourceNotConfiguredError
from polygateway.types import GlobalLimits, SourceConfig, SourceStats
if TYPE_CHECKING:
@@ -89,7 +89,7 @@ class InMemoryLimiter:
def _cfg(self, source_key: str) -> SourceConfig:
cfg = self._sources.get(source_key)
if cfg is None:
raise GovernanceBackendError(f"未知源 {source_key!r}(scope={self._scope})")
raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})")
return cfg
def _window(self) -> int:
+5 -5
View File
@@ -367,7 +367,7 @@ class RedisGate:
keys=[self._key(source_name)], args=[owner, self._probe_ttl_ms]
)
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 try_enter 失败: {exc}") from exc
raise GovernanceBackendError(f"熔断后端 try_enter 失败: {exc}", scope=self._scope) from exc
return self._decision(source_name, result)
async def record_success(
@@ -385,7 +385,7 @@ class RedisGate:
try:
result = await self._success_lua(keys=[self._key(entry.source_name)], args=args)
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 record_success 失败: {exc}") from exc
raise GovernanceBackendError(f"熔断后端 record_success 失败: {exc}", scope=self._scope) from exc
return self._update(result)
async def record_failure(
@@ -407,7 +407,7 @@ class RedisGate:
try:
result = await self._failure_lua(keys=[self._key(entry.source_name)], args=args)
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 record_failure 失败: {exc}") from exc
raise GovernanceBackendError(f"熔断后端 record_failure 失败: {exc}", scope=self._scope) from exc
return self._update(result)
async def release_probe(self, entry: GateDecision) -> GateUpdate:
@@ -419,7 +419,7 @@ class RedisGate:
keys=[self._key(entry.source_name)], args=[entry.epoch, entry.probe_owner]
)
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 release_probe 失败: {exc}") from exc
raise GovernanceBackendError(f"熔断后端 release_probe 失败: {exc}", scope=self._scope) from exc
return self._update(result)
async def retry_after_s(self, sources: tuple[str, ...]) -> float:
@@ -429,7 +429,7 @@ class RedisGate:
try:
result = await self._retry_after_lua(keys=[self._key(s) for s in sources])
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 retry_after_s 失败: {exc}") from exc
raise GovernanceBackendError(f"熔断后端 retry_after_s 失败: {exc}", scope=self._scope) from exc
return int(result) / 1000.0
async def aclose(self) -> None:
+8 -8
View File
@@ -22,7 +22,7 @@ from typing import TYPE_CHECKING
from loguru import logger
from redis.exceptions import RedisError
from polygateway.errors import GovernanceBackendError
from polygateway.errors import GovernanceBackendError, SourceNotConfiguredError
from polygateway.types import GlobalLimits, SourceConfig, SourceStats
if TYPE_CHECKING:
@@ -195,7 +195,7 @@ class RedisLimiter:
def _cfg(self, source_key: str) -> SourceConfig:
cfg = self._sources.get(source_key)
if cfg is None:
raise GovernanceBackendError(f"未知源 {source_key!r}(scope={self._scope})")
raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})")
return cfg
def _lease_keys(self, source_key: str) -> tuple[str, str]:
@@ -247,7 +247,7 @@ class RedisLimiter:
],
)
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 try_acquire 失败: {exc}") from exc
raise GovernanceBackendError(f"限流后端 try_acquire 失败: {exc}", scope=self._scope) from exc
if ok != 1:
return None
return _RedisPermit(self, source_key, lease_id, est_tokens, window)
@@ -265,14 +265,14 @@ class RedisLimiter:
try:
await self._release_lua(keys=[gl, sl], args=[lease_id])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 release 失败: {exc}") from exc
raise GovernanceBackendError(f"限流后端 release 失败: {exc}", scope=self._scope) from exc
async def _settle_tpm(self, source_key: str, delta: int, window: int) -> None:
wk = self._window_keys(source_key, window)
try:
await self._settle_lua(keys=[wk["g_tpm"], wk["s_tpm"]], args=[delta, _WINDOW_TTL_S])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 settle 失败: {exc}") from exc
raise GovernanceBackendError(f"限流后端 settle 失败: {exc}", scope=self._scope) from exc
async def source_stats(self, source_key: str) -> SourceStats:
"""当前窗口快照;读侧 clamp ≥0(展示口径,存储保留负值)。"""
@@ -283,7 +283,7 @@ class RedisLimiter:
wk = self._window_keys(source_key, window)
res = await self._stats_lua(keys=[sl, wk["s_rpm"], wk["s_tpm"]])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 source_stats 失败: {exc}") from exc
raise GovernanceBackendError(f"限流后端 source_stats 失败: {exc}", scope=self._scope) from exc
return SourceStats(
inflight=int(res[0]),
rpm_used=max(0, int(res[1])),
@@ -295,14 +295,14 @@ class RedisLimiter:
try:
await self._progress_mark_lua(keys=[self._progress_key()], args=[_PROGRESS_TTL_S])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 mark_progress 失败: {exc}") from exc
raise GovernanceBackendError(f"限流后端 mark_progress 失败: {exc}", scope=self._scope) from exc
async def progress_age_s(self) -> float:
"""距上次全局成功的秒数;仅键缺失(-1)= 从未进展 → inf(CHS limiter.py:208)。"""
try:
res = await self._progress_age_lua(keys=[self._progress_key()])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 progress_age_s 失败: {exc}") from exc
raise GovernanceBackendError(f"限流后端 progress_age_s 失败: {exc}", scope=self._scope) from exc
return float("inf") if int(res) == -1 else int(res) / 1000.0
async def aclose(self) -> None: