Configuration¶
There are two ways to configure a client, and they compose:
GrpcClientConfig— a stdlib dataclass describing one channel. This is whatGrpcClientandChannelPoolactually consume.- A settings object — anything carrying the fields of
GrpcClientSettingsProtocol.GrpcClientFactorymaps it onto a pool, a balancer, a health checker and an interceptor chain.
GrpcClientConfig¶
from grpc_client_kit import ConnectivityConfig, GrpcClientConfig
config = GrpcClientConfig(
target="api.internal:50051", # host:port, or a gRPC resolver URI
insecure=False, # False + no credentials = system trust store
credentials=grpc.ssl_channel_credentials(),
options=[("grpc.max_receive_message_length", 8 * 1024 * 1024)],
compression=grpc.Compression.Gzip,
connectivity=ConnectivityConfig(keepalive_time=30.0), # None = gRPC's defaults
)
insecure=True together with credentials raises ValueError at
construction — a channel cannot be both. With insecure=False and no
credentials, grpc.ssl_channel_credentials() is used, i.e. the system trust
store.
Reuse one credentials object rather than rebuilding it per call: gRPC credentials define no equality, so the pool compares them by identity and a fresh object is a fresh channel. See Channel identity.
connectivity spells the keepalive and reconnect channel arguments in seconds
instead of milliseconds; explicit options win over it, key by key, and the
composed list is what the channel is opened with. See
Keepalive and reconnect backoff.
Targets are validated before a channel is created, which turns a typo into an
error at configuration time instead of an opaque UNAVAILABLE at the first
RPC — see Target validation.
Settings objects¶
The factory reads a settings object through structural protocols, so a
pydantic model, a dataclass or a plain class all work. Protocol fields are
declared as read-only properties rather than attributes, which makes them
covariant: a pydantic model may narrow strategy to a Literal and still
satisfy the protocol.
These fields are required (GrpcClientSettingsProtocol):
| Field | Type | Meaning |
|---|---|---|
target |
str \| None |
Single target; mutually exclusive with targets |
targets |
list[str] \| None |
Targets to load-balance across |
insecure |
bool |
Plaintext channels |
tracing_enabled |
bool |
Add the tracing layer |
metrics_enabled |
bool |
Add the metrics layer |
logging_enabled |
bool |
Add the logging layer |
pool |
block or None |
Channel pool |
timeout |
block or None |
Timeouts |
retry |
block or None |
Retries |
circuit_breaker |
block or None |
Circuit breaker |
balancer |
block or None |
Load balancer |
health_checker |
block or None |
Health checker |
A None block means "do not add that layer at all", not "use defaults".
No timeout block, no timeout interceptor — and therefore no deadline.
These are optional and read through getattr, so a minimal settings
object stays valid: credentials, options, compression
(GrpcChannelExtrasProtocol), plus sensitive_headers and
metrics_registry (GrpcObservabilityExtrasProtocol). Both protocols are
exported from grpc_client_kit like the rest, along with
FullGrpcClientSettingsProtocol for settings that carry everything.
connectivity is read the same way — a ConnectivityConfig under that name
tunes the channels, its absence leaves
gRPC's defaults.
Splitting the optional fields off is what keeps
isinstance(settings, GrpcClientSettingsProtocol) a meaningful check instead
of one that fails on fields nobody defines.
A settings object end to end¶
import grpc
class UpstreamSettings:
target = None
targets = ["api-1.prod:443", "api-2.prod:443"]
insecure = False
credentials = grpc.ssl_channel_credentials()
tracing_enabled = True
metrics_enabled = True
logging_enabled = True
metrics_registry = my_registry
class pool:
max_channels_per_target = 4
idle_timeout = 300.0
class timeout:
default = 10.0
class retry:
max_attempts = 3
initial_backoff = 0.1
max_backoff = 5.0
backoff_multiplier = 2.0
class circuit_breaker:
fail_threshold = 5
recovery_timeout = 60.0
half_open_max_calls = 1
class balancer:
strategy = "round_robin"
weights = None
class health_checker:
check_interval = 30.0
timeout = 5.0
The protocols name only what a settings object must carry; the rest of each
block is picked up when it happens to be there. timeout requires default
and reads per_method too; the retry block's jitter, retryable_codes,
retry_streaming, idempotent_methods and on_retry are optional in the
same way, as are the circuit_breaker block's max_methods and the
health_checker block's service. Two more optional blocks join on the same
duck-typed terms — a wait_for_ready block (default, per_method,
require_deadline) and a deadline_budget block (reserve_for_next) — so
every one of the five resilience layers is reachable from
settings alone. The observability layers read sensitive_headers,
sensitive_methods, sensitive_patterns, log_request_payload,
log_response_payload, enable_method_label and success_log_level off the
settings object itself, next to the three flags. What still needs a
hand-built chain, as described in Interceptors,
is anything that is an object rather than a value — an on_retry callback, a
registry of your own for one layer, an interceptor in the inner slot:
chain = build_interceptors(
observability=ObservabilityConfig(service_name="users.v1.Users"),
timeout=TimeoutConfig(default=5.0, per_method={"/users.v1.Users/Export": 60.0}),
deadline_budget=DeadlineBudgetConfig(),
wait_for_ready=WaitForReadyConfig(),
retry=RetryConfig(max_attempts=3),
)
client = GrpcClient(UserStub, config=config, pool=pool, interceptors=chain)
Not through create_client(interceptors=...). That argument adds custom
layers to the outer slot of the chain the factory
builds — above logging, tracing, metrics and the timeout — which is the wrong
side of every layer a deadline-shaping interceptor needs to read.
The required surface of the settings object is validated when the factory is
constructed: a missing required field raises TypeError naming it. The
optional blocks stay optional, which also means a typo in an optional
field name silently yields the default — pydantic users should set
model_config = ConfigDict(extra="forbid") on their settings models so typos
fail at model construction instead. The shipped models below do.
From the environment¶
grpc_client_kit.settings (the settings extra, which pulls in pydantic) is
that settings shape written down once. BaseGrpcClientSettings satisfies
GrpcClientSettingsProtocol and carries every optional block the factory
reads, with the kit's own defaults and bounds, so the factory takes an
instance as it is. Every class in the module is a plain BaseModel — none of
them reads the environment on its own. Nest one per upstream under your
service's BaseSettings and let that class own the environment:
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from grpc_client_kit import GrpcClientFactory
from grpc_client_kit.settings import BaseGrpcClientSettings
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_nested_delimiter="__")
users_grpc: BaseGrpcClientSettings = Field(default_factory=BaseGrpcClientSettings)
orders_grpc: BaseGrpcClientSettings = Field(default_factory=BaseGrpcClientSettings)
settings = Settings()
async with GrpcClientFactory(settings=settings.users_grpc) as factory:
users = factory.create_client(UserStub)
USERS_GRPC__TARGET=users.internal:50051
USERS_GRPC__COMPRESSION=gzip
USERS_GRPC__CONNECTIVITY__KEEPALIVE_TIME=15
USERS_GRPC__TIMEOUT__DEFAULT=5
USERS_GRPC__TIMEOUT__PER_METHOD='{"/users.v1.Users/Export": 60}'
USERS_GRPC__RETRY__MAX_ATTEMPTS=5
USERS_GRPC__RETRY__RETRYABLE_CODES='["UNAVAILABLE", "ABORTED"]'
USERS_GRPC__CIRCUIT_BREAKER__FAIL_THRESHOLD=7
USERS_GRPC__SUCCESS_LOG_LEVEL=DEBUG
A variable reaches exactly one section, through its prefix; a bare TARGET
or MAX_ATTEMPTS in the pod reaches nothing, because no section scrapes the
environment by itself. A pool several upstreams share is a section of its
own — grpc_pool: BaseChannelPoolSettings next to them, GRPC_POOL__* —
handed to the pool provider
rather than read from any one upstream. A block that defaults to None — retry,
circuit_breaker, wait_for_ready, deadline_budget, balancer,
health_checker, connectivity — comes into being the moment one of its
variables is set; pool and timeout are present by default, and
TIMEOUT__DEFAULT=0 switches the deadline off. Unknown fields are refused, so
a misspelled variable fails at load rather than yielding a default.
Values an operator writes: compression and status codes by name (gzip,
UNAVAILABLE), the log level by name or number, the strategy as
round_robin / random / weighted, and dictionaries and lists as JSON.
For a hand-built chain, each section becomes the dataclass it mirrors with
to_config(), and the top-level model becomes the channel config the same
way — with the one object that cannot come from an environment handed in:
upstream = settings.users_grpc
config = upstream.to_config(credentials=grpc.ssl_channel_credentials(ca_bytes))
chain = build_interceptors(
timeout=upstream.timeout.to_config(),
retry=upstream.retry.to_config(),
)
Runtime objects — channel credentials, metrics registries, on_retry — are
not settings and have no field in the models: through the factory the
registry comes from GrpcClientFactory(metrics=...) (or per client from
create_client(metrics=...)), and a hand-built chain sets them on the configs
to_config() returns. The models mirror the dataclasses
field for field, defaults included, and the kit's own test suite pins the two
to each other, so a knob added to RetryConfig cannot go missing from
BaseRetrySettings.
Optional dependencies¶
| Extra | Pulls in | Enables |
|---|---|---|
health |
grpcio-health-checking |
HealthChecker, health-aware balancing and pooling |
tracing |
opentelemetry-api |
AsyncClientTracingInterceptor (a pass-through without it) |
metrics |
prometheus-client |
GrpcClientMetrics, the Prometheus collector; a custom registry needs no extra |
deadline |
deadline-budget |
deadline budget propagation (the layer is skipped without it) |
settings |
pydantic |
BaseGrpcClientSettings and the section models |
dishka |
dishka |
the providers owning the factory's lifecycle |
observability |
metrics + tracing |
both of the above |
all |
deadline + dishka + health + metrics + settings + tracing |
everything |
deadline is deliberately not part of observability: propagating a
budget is resilience, not telemetry, and an observability extra should not pull
in a dependency that changes what calls do.
import grpc_client_kit never requires an extra. HealthChecker is the one
gated export: it is resolved on first attribute access, and without
[health] that access raises an ImportError naming the extra to install.
grpc_client_kit.settings, grpc_client_kit.metrics and
grpc_client_kit.dishka are modules rather than exports and import their
dependency when they are imported; without [settings], [metrics] or
[dishka] that import raises an ImportError naming the extra.