fix: consolidate remaining assembly validation into GatewaySettings
Round two of the from_env-only validation problem. Fifteen checks still lived in the env parsing functions: six enum domains, the redis_url requirement for redis-backed limiter/breaker/cache, cache namespace and TTL, telemetry path and DSN, non-negative structured retries and non-blank scope. from_settings and direct construction bypassed all of them. The five asserts in client.py that claimed config had already validated redis_url and the telemetry targets now hold on every path, so they revert to what CLAUDE.md permits: internal invariant declarations that also narrow the Optional for type checkers. Their comments now name the method that guarantees them, since the previous wording is exactly what went stale. Postgres DSNs built by hand now get the SQLAlchemy +driver suffix stripped the way from_env has always stripped it, with a warning so the rewrite is not silent. The env path strips earlier, so it stays quiet.
This commit is contained in:
@@ -31,7 +31,7 @@ from polygateway.types import (
|
||||
SourceConfig,
|
||||
)
|
||||
|
||||
__version__ = "1.0.1"
|
||||
__version__ = "1.0.2"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROFILES",
|
||||
|
||||
@@ -259,7 +259,7 @@ def _build_limiter(settings: GatewaySettings, sources: list[SourceConfig]) -> Ra
|
||||
if settings.limiter_backend == "redis":
|
||||
from polygateway.backends.redis.limiter import RedisLimiter
|
||||
|
||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
||||
assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
|
||||
return RedisLimiter.from_url(
|
||||
settings.redis_url,
|
||||
scope=settings.scope,
|
||||
@@ -279,7 +279,7 @@ def _build_breaker(settings: GatewaySettings) -> ProviderGate:
|
||||
if settings.breaker_backend == "redis":
|
||||
from polygateway.backends.redis.breaker import RedisGate
|
||||
|
||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
||||
assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
|
||||
return RedisGate.from_url(settings.redis_url, config=settings.breaker, scope=settings.scope)
|
||||
return InMemoryGate(config=settings.breaker)
|
||||
|
||||
@@ -299,7 +299,7 @@ def _build_cache(settings: GatewaySettings) -> CacheBackend | None:
|
||||
return InMemoryCache()
|
||||
from polygateway.backends.redis_cache import RedisCache
|
||||
|
||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
||||
assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
|
||||
return RedisCache.from_url(settings.redis_url)
|
||||
|
||||
|
||||
@@ -309,11 +309,11 @@ def _build_telemetry(settings: GatewaySettings) -> TelemetryRecorder | None:
|
||||
if settings.telemetry_backend == "postgres":
|
||||
from polygateway.telemetry.postgres import PostgresRecorder
|
||||
|
||||
assert settings.telemetry_pg_dsn is not None # 内部不变量: config 已校验
|
||||
assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证
|
||||
return PostgresRecorder(settings.telemetry_pg_dsn)
|
||||
from polygateway.telemetry.sqlite import SQLiteRecorder
|
||||
|
||||
assert settings.telemetry_sqlite_path is not None # 内部不变量: config 已校验
|
||||
assert settings.telemetry_sqlite_path is not None # 内部不变量: _validate_telemetry 已保证
|
||||
return SQLiteRecorder(settings.telemetry_sqlite_path)
|
||||
|
||||
|
||||
|
||||
+85
-14
@@ -16,6 +16,7 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dotenv import dotenv_values
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.types import (
|
||||
BackpressurePolicy,
|
||||
@@ -47,6 +48,12 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
|
||||
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
|
||||
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
|
||||
_QUOTA_FULL = frozenset({"wait", "fail_fast"})
|
||||
# 后端合法域: env 解析与构造期校验共用一份定义,避免两处分叉
|
||||
_LIMITER_BACKENDS = frozenset({"memory", "redis"})
|
||||
_BREAKER_BACKENDS = frozenset({"memory", "redis"})
|
||||
_CACHE_BACKENDS = frozenset({"redis", "memory", "none"})
|
||||
_TELEMETRY_BACKENDS = frozenset({"sqlite", "postgres", "none"})
|
||||
_REDIS_DEPENDENT_BACKENDS = ("limiter_backend", "breaker_backend", "cache_backend")
|
||||
# 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源)
|
||||
_DEFAULT_STALL_WINDOW_S = 300.0
|
||||
_DEFAULT_POLL_INTERVAL_S = 0.05
|
||||
@@ -122,15 +129,72 @@ class GatewaySettings:
|
||||
lease_ttl_s: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._validate_sources()
|
||||
self._validate_identity()
|
||||
self._validate_backends()
|
||||
self._validate_cache()
|
||||
self._validate_telemetry()
|
||||
self._validate_lease()
|
||||
self._validate_stall()
|
||||
self._validate_probe()
|
||||
|
||||
def _validate_sources(self) -> None:
|
||||
"""零源的配置装出来选源必然失败,构造期即拒。"""
|
||||
def _validate_identity(self) -> None:
|
||||
"""本类自身字段的基本域: 空 scope 会污染遥测与缓存命名空间;零源必然选源失败。"""
|
||||
if not self.scope.strip():
|
||||
raise ValueError("GatewaySettings.scope 不能为空")
|
||||
if not self.sources:
|
||||
raise ValueError("GatewaySettings.sources 不能为空: 至少一个源")
|
||||
if self.structured_max_retries < 0:
|
||||
raise ValueError(f"structured_max_retries 不能为负: {self.structured_max_retries}")
|
||||
|
||||
def _validate_backends(self) -> None:
|
||||
"""后端选择必须落在合法域内,取 redis 的还必须有连接串。
|
||||
|
||||
域外取值此前只有 `from_env` 拦得住,直接构造会一路走到 `client.py` 的
|
||||
`_build_*`,落进 else 分支静默不建后端,或撞上那里的断言。
|
||||
"""
|
||||
for field, allowed in (
|
||||
("limiter_backend", _LIMITER_BACKENDS),
|
||||
("breaker_backend", _BREAKER_BACKENDS),
|
||||
("cache_backend", _CACHE_BACKENDS),
|
||||
("telemetry_backend", _TELEMETRY_BACKENDS),
|
||||
("selector", _SELECTORS),
|
||||
("quota_full", _QUOTA_FULL),
|
||||
):
|
||||
value = getattr(self, field)
|
||||
if value not in allowed:
|
||||
raise ValueError(f"{field} 非法值 {value!r};允许: {sorted(allowed)}")
|
||||
on_redis = [f for f in _REDIS_DEPENDENT_BACKENDS if getattr(self, f) == "redis"]
|
||||
if on_redis and self.redis_url is None:
|
||||
raise ValueError(f"{'、'.join(on_redis)} 取 redis 时必须提供 redis_url")
|
||||
|
||||
def _validate_cache(self) -> None:
|
||||
"""启用缓存必须有命名空间与正 TTL(缺命名空间即失去租户隔离,会毒化缓存)。"""
|
||||
if self.cache_backend == "none":
|
||||
return
|
||||
if not self.cache_namespace:
|
||||
raise ValueError("启用缓存时 cache_namespace 不能为空: 缓存 key 靠它做租户隔离")
|
||||
if self.cache_ttl_s is None or self.cache_ttl_s <= 0:
|
||||
raise ValueError(f"cache_ttl_s 必须 > 0(禁止永不过期): {self.cache_ttl_s}")
|
||||
|
||||
def _validate_telemetry(self) -> None:
|
||||
"""遥测后端各自的落点必填;顺带剥掉 asyncpg 不认的 SQLAlchemy 驱动后缀。
|
||||
|
||||
剥而不是拒: 两条装配路对同一 DSN 应产出同一结果。但不静默——`from_env`
|
||||
那条路在 `_load_pg_dsn` 就剥干净了,能走到这里的只有手工构造的调用方,
|
||||
他有权知道库动了他给的值。
|
||||
"""
|
||||
if self.telemetry_backend == "sqlite" and not self.telemetry_sqlite_path:
|
||||
raise ValueError("telemetry_backend=sqlite 时必须提供 telemetry_sqlite_path")
|
||||
if self.telemetry_backend != "postgres":
|
||||
return
|
||||
if not self.telemetry_pg_dsn:
|
||||
raise ValueError("telemetry_backend=postgres 时必须提供 telemetry_pg_dsn")
|
||||
stripped = _strip_dsn_driver(self.telemetry_pg_dsn)
|
||||
if stripped != self.telemetry_pg_dsn:
|
||||
logger.warning(
|
||||
"telemetry_pg_dsn 含 SQLAlchemy 驱动后缀(asyncpg 不认),已剥为 {}", stripped
|
||||
)
|
||||
object.__setattr__(self, "telemetry_pg_dsn", stripped)
|
||||
|
||||
def _validate_lease(self) -> None:
|
||||
"""调用超时须 ≤ permit 租约 TTL,防租约先于请求过期使并发超出配额。"""
|
||||
@@ -318,17 +382,15 @@ def _load_choice(env: Mapping[str, str], key: str, allowed: frozenset[str], defa
|
||||
|
||||
|
||||
def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||
limiter_backend = _load_choice(
|
||||
env, "PGW_LIMITER_BACKEND", frozenset({"memory", "redis"}), "memory"
|
||||
)
|
||||
breaker_backend = _load_choice(
|
||||
env, "PGW_BREAKER_BACKEND", frozenset({"memory", "redis"}), "memory"
|
||||
)
|
||||
# 合法域与构造期守卫共用常量;此处的检查保留是为了报错能点出 env 键名,
|
||||
# 构造期那道点的是字段名(两类调用方各看得懂自己那套)
|
||||
limiter_backend = _load_choice(env, "PGW_LIMITER_BACKEND", _LIMITER_BACKENDS, "memory")
|
||||
breaker_backend = _load_choice(env, "PGW_BREAKER_BACKEND", _BREAKER_BACKENDS, "memory")
|
||||
_, cache_backend = _require(env, "PGW_CACHE_BACKEND")
|
||||
_, telemetry_backend = _require(env, "PGW_TELEMETRY_BACKEND")
|
||||
if cache_backend not in ("redis", "memory", "none"):
|
||||
if cache_backend not in _CACHE_BACKENDS:
|
||||
raise ValueError(f"PGW_CACHE_BACKEND 非法值 {cache_backend!r}")
|
||||
if telemetry_backend not in ("sqlite", "postgres", "none"):
|
||||
if telemetry_backend not in _TELEMETRY_BACKENDS:
|
||||
raise ValueError(f"PGW_TELEMETRY_BACKEND 非法值 {telemetry_backend!r}")
|
||||
redis_url = env.get("REDIS_URL") or None
|
||||
if "redis" in (limiter_backend, breaker_backend) and redis_url is None:
|
||||
@@ -350,13 +412,22 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _load_pg_dsn(env: Mapping[str, str]) -> str:
|
||||
"""读取 Postgres DSN 并剥 SQLAlchemy 风格驱动后缀(asyncpg 不认 `+driver`)。"""
|
||||
_, dsn = _require(env, "PGW_TELEMETRY_PG_DSN")
|
||||
def _strip_dsn_driver(dsn: str) -> str:
|
||||
"""剥 SQLAlchemy 风格的 `+driver` 后缀(asyncpg 不认);已干净的原样返回。"""
|
||||
scheme, sep, rest = dsn.partition("://")
|
||||
return f"{scheme.partition('+')[0]}{sep}{rest}"
|
||||
|
||||
|
||||
def _load_pg_dsn(env: Mapping[str, str]) -> str:
|
||||
"""读取 Postgres DSN 并剥驱动后缀。
|
||||
|
||||
env 路在此剥干净,构造期那道就无事可做——三项目 `.env` 里的 SQLAlchemy
|
||||
写法不会每次装配都刷一条 warning。
|
||||
"""
|
||||
_, dsn = _require(env, "PGW_TELEMETRY_PG_DSN")
|
||||
return _strip_dsn_driver(dsn)
|
||||
|
||||
|
||||
def _load_cache_keys(
|
||||
env: Mapping[str, str], cache_backend: str, redis_url: str | None
|
||||
) -> dict[str, object]:
|
||||
|
||||
Reference in New Issue
Block a user