Environment settings¶
The part of a client that differs between dev, stage and prod — base URL, timeouts,
pool size, HTTP/2, retry and breaker policy — is exactly the part that has to come
from the environment. With clientwright[settings] that path is owned by the
library: one pydantic model per config dataclass, the same field names and the same
defaults, and to_config() to get the ClientConfig.
from pydantic_settings import BaseSettings, SettingsConfigDict
from clientwright import build
from clientwright.contrib.settings import BaseClientSettings
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_nested_delimiter="__")
warehouse: BaseClientSettings = BaseClientSettings()
identity: BaseClientSettings = BaseClientSettings()
settings = Settings()
client = build("httpx", settings.warehouse.to_config("warehouse"))
WAREHOUSE__BASE_URL=https://wh.example.com
WAREHOUSE__TIMEOUT__TOTAL=10
WAREHOUSE__TIMEOUT__CONNECT=2
WAREHOUSE__POOL__HTTP2=true
WAREHOUSE__RETRY__MAX_ATTEMPTS=5
WAREHOUSE__RETRY__RETRYABLE_STATUS=[429,500,502,503,504]
WAREHOUSE__CIRCUIT_BREAKER__FAIL_THRESHOLD=2
WAREHOUSE__ON_UNSUPPORTED=strict
One section per sub-config, named as the config names it:
| Section | Model | Builds |
|---|---|---|
| the client itself | BaseClientSettings |
ClientConfig |
timeout |
BaseTimeoutSettings |
TimeoutConfig |
pool |
BasePoolSettings |
PoolConfig |
retry |
BaseRetrySettings |
RetryConfig |
circuit_breaker |
BaseCircuitBreakerSettings |
CircuitBreakerConfig |
tls |
BaseTlsSettings |
TlsConfig |
proxy |
BaseProxySettings |
ProxyConfig |
observability |
BaseObservabilitySettings |
ObservabilityConfig |
The field names, types and defaults are the dataclasses' — the test suite asserts
BaseClientSettings().to_config("x") == ClientConfig(service_name="x") and compares
the field names of every pair, so the models cannot drift from
the config they describe.
Sections are models, not settings¶
Every class in clientwright.contrib.settings is a plain pydantic BaseModel,
never a BaseSettings. That is deliberate. A BaseSettings scrapes the environment
on its own, and one written per section scrapes it with no prefix — a bare
BASE_URL or TIMEOUT set anywhere in the pod lands in a nested client section
its parent never filled. A BaseModel is reachable only through the settings object
you nest it in, so the only way into warehouse.timeout.total is
WAREHOUSE__TIMEOUT__TOTAL.
The parent is yours: its prefix, its env_nested_delimiter, its .env file, how
many client sections it holds. Subclass a model to change a default for one
upstream, and narrow a section to None to switch it off:
from clientwright.contrib.settings import BaseClientSettings, BaseTimeoutSettings
class BatchApi(BaseClientSettings):
timeout: BaseTimeoutSettings = BaseTimeoutSettings(total=120.0)
retry: None = None
circuit_breaker: None = None
What maps how¶
service_nameis the argument ofto_config(), not a field. It is theservicelabel on every metric and log line — code identity, not something a deployment changes.UNSETsurvives. A knob the config leavesUNSETby default (timeout.read,timeout.attempt,pool.http2,pool.max_connections_per_host, ...) reaches the config asUNSETunless you set it — the adapter's native default, exactly as when you writeTimeoutConfig()yourself. An explicitnullis the dataclass' explicit "unbounded":WAREHOUSE__TIMEOUT__READ=nullneedsenv_parse_none_str="null"on the parent, while a whole section (WAREHOUSE__RETRY=null) is parsed as JSON and needs nothing.- Enums by value:
RETRY__MODE=delegated,CIRCUIT_BREAKER__KEY=origin_route,REDIRECTS=native,ON_UNSUPPORTED=strict. A typo fails at load. - Sets as JSON lists:
RETRY__RETRYABLE_STATUS=[429,500],RETRY__RETRYABLE_KINDS=["dns_error","connect_error"],OBSERVABILITY__SENSITIVE_QUERY_PARAMS=["token"]. success_log_levelby name or number:OBSERVABILITY__SUCCESS_LOG_LEVEL=DEBUG.headersandnativeas JSON objects:HEADERS={"User-Agent": "orders/2.3"},NATIVE={"client": {"trust_env": false}}— the latter is validated at build like any native passthrough.tls.certis a PEM path, or a JSON list of two or three paths for(cert, key)/(cert, key, password).proxyisNoneuntil any of its fields is set:PROXY__URL=http://proxy:3128creates the section;PROXY__FROM_ENV=truereads the environment's proxies.-
observability.url_maskeris a callable and has no environment spelling. Set it on the config afterwards:
Bounds are validated at load, with the field path in the error — timeout.total
must be positive, retry.max_attempts >= 1, proxy.url and proxy.from_env are
exclusive — so a bad value fails where the deployment set it, not three layers later.
The dataclass validates once more in to_config(), so nothing gets past both.
Which route¶
Two calls build a ClientConfig from settings, and both are exported from the package
root:
settings.warehouse.to_config("warehouse")for the shipped models above — the route for new code and for any service whose environment uses the nested sections.client_config_from_settings(settings.warehouse_api, "orders")for a settings model of your own with the flat, legacy attribute names —timeout_seconds,enable_http2, a four-fieldretrywithbackoff_multiplier, a three-fieldcircuit_breaker; anything satisfyingClientSettingsProtocol, no base class and no extra required. It exists for a service that has not moved, and the flat shape is not changing.
The two are not the same translation: the flat converter maps timeouts, pool size,
HTTP/2, verify, retry, breaker and the observability switches, and nothing else.
Handed a BaseClientSettings — structurally, anything with to_config(service_name),
SupportsToConfig — client_config_from_settings returns
settings.to_config(service_name) as is, so the mistaken call builds the config
to_config would have built, pool, TLS, proxy, headers, redirects and observability
included, rather than failing on the one retry field the two shapes spell differently
or quietly running on defaults for every nested section.