grpc-client-kit for AI agents¶
One page holding everything a coding assistant needs to configure and drive grpc-client-kit correctly, plus a map of where the rest of the documentation keeps the details it leaves out. Give an agent this page rather than the whole site.
| Package | grpc-client-kit on PyPI, import root grpc_client_kit |
| Requires | Python 3.12+, grpcio 1.78+ — and nothing else on a bare install |
| Install | pip install grpc-client-kit · extras: health, tracing, metrics, deadline, observability (= metrics + tracing), all |
| Async | All of it. Every entry point is grpc.aio, and the pool, the balancers and the checker are coroutines. |
| Sync | None. There is no sync mirror and no thread-safe surface; this kit is for an event loop. |
| Source | https://github.com/bedrock-python/grpc-client-kit |
How to read this page¶
Every page of this site is also served as raw Markdown at its own URL with .md in place of
the trailing slash — this page is /agents.md, the resilience guide is
/guide/resilience.md — so anything the map below points at can be fetched as plain text
rather than scraped out of HTML. The Copy page control at the top of a page does the same
thing for a human with a chat window open. The one exception is the API reference: its
Markdown is a handful of instructions to a docstring renderer rather than the API, so it
carries neither the control nor a .md twin — read it as HTML, or read the docstrings in the
source, which is where the reasoning behind every decision below actually lives.
Top to bottom before writing code. Rules that hold or break the code is the section correctness lives in — those are the things the library will not save you from. Every name used below is in the public API; if you need something not listed here, fetch the page the documentation map points at rather than guessing a method that sounds plausible.
Scope¶
It does hold gRPC channels in a pool keyed by everything gRPC bakes into a channel at
creation, pick a target from a list of addresses (round-robin, random, weighted), probe those
addresses over grpc.health.v1 and let health decide eligibility, and wrap every outgoing
call in a fixed-order chain of interceptors — logging, tracing, metrics, a whole-call
deadline, request-budget propagation, wait-for-ready, retries, a circuit breaker — that
covers all four RPC kinds, streams included.
It does not implement a gRPC server, generate stubs or touch .proto files; it does not
resolve DNS or watch a service registry; it does not do connection-level load balancing
(gRPC core does that better, see Native gRPC or the kit?); it does
not schedule anything of its own beyond the health loop; it does not start a request budget
for you; it never closes a channel you are holding; and it has no sync API.
Mental model¶
Four nouns, each with a different lifetime.
ChannelPoolowns the channels. One per process. A channel is filed under aChannelKey— target,insecure, credentials, options, compression and a token for the interceptor chain — becausegrpc.aiobinds every one of those to a channel when it is created and none of them can be changed afterwards. Two callers share a channel only when all six match.GrpcClientConfigdescribes how one channel is built: address, security, raw channeloptions, compression, andconnectivity(keepalive and reconnect backoff spelled in seconds instead of asgrpc.*_msargument pairs).GrpcClient[T]owns nothing. It picks a target — fromconfig.targetor from a balancer — asks the pool for the matching channel and constructs a stub. Creating one per request is cheap; closing one closes nothing.GrpcClientFactorywires the rest from one settings object: the pool, a balancer oversettings.targets, aHealthChecker, and one interceptor chain per target. It owns the pool only when it created the pool itself.
The interceptor chain is the other half of the design. grpc.aio applies a client
interceptor list from the outside in, and InterceptorChainBuilder fixes the order because
position decides what a layer observes and what it repeats:
extra outer → logging → tracing → metrics → timeout → deadline budget
→ wait-for-ready → retry → circuit breaker → extra inner
A logical interceptor (AsyncClientInterceptor / AsyncAroundClientInterceptor) is one
implementation covering all four RPC kinds. A channel, however, files interceptors into four
lists by class and puts each entry in the first list it matches, so a chain reaches the
channel as four thin adapters per layer — build_interceptors and flatten_interceptors
do that expansion, and logical_interceptor(entry) maps an adapter back.
Two facts about grpc.aio explain most of the code: a continuation resolves to a Call the
moment the RPC is created, never raises, and looks identical for a call that will fail —
so the outcome only appears when that Call is awaited or iterated. ClientCall.invoke_unary
and ClientCall.invoke_stream are where that is handled once, for every layer.
Wiring¶
The smallest correct client:
import asyncio
from grpc_client_kit import ChannelPool, GrpcClient, GrpcClientConfig
async def main() -> None:
async with ChannelPool() as pool: # owns the channels
client = GrpcClient(
GreeterStub, # your generated stub class
config=GrpcClientConfig(target="localhost:50051", insecure=True),
pool=pool,
)
async with client as stub: # yields the STUB, not the client
print(await stub.Greet(GreetRequest(name="world")))
asyncio.run(main())
A hand-built chain, which is what you need for per-method budgets — request-budget propagation and wait-for-ready are reachable from a settings object too, through their own optional blocks:
from grpc_client_kit import (
ChannelPool,
CircuitBreakerConfig,
DeadlineBudgetConfig,
GrpcClient,
GrpcClientConfig,
ObservabilityConfig,
RetryConfig,
TimeoutConfig,
WaitForReadyConfig,
build_interceptors,
)
chain = build_interceptors(
observability=ObservabilityConfig(service_name="orders.v1.Orders", logging=True),
timeout=TimeoutConfig(default=5.0, per_method={"/orders.v1.Orders/Export": 120.0}),
deadline_budget=DeadlineBudgetConfig(), # needs the [deadline] extra
wait_for_ready=WaitForReadyConfig(),
retry=RetryConfig(max_attempts=3),
circuit_breaker=CircuitBreakerConfig(fail_threshold=5),
)
pool = ChannelPool()
client = GrpcClient(
OrdersStub,
config=GrpcClientConfig(target="orders.internal:50051"),
pool=pool,
interceptors=chain, # built once, reused: it is part of the pool key
)
And the factory, which builds all of that per target from one settings object:
from grpc_client_kit import GrpcClientFactory
async with GrpcClientFactory(settings=settings) as factory: # starts health checks, waits for the first pass
users = factory.create_client(UserStub)
async with users as stub:
await stub.GetUser(request)
The API¶
Everything below is importable from grpc_client_kit unless a row says otherwise.
The four objects¶
| Name | Signature | Notes |
|---|---|---|
ChannelPool |
(max_channels_per_target=1, idle_timeout=300.0, health_checker=None, metrics=None) |
max_channels_per_target <= 0 raises ValueError |
GrpcClientConfig |
(target=None, insecure=False, credentials=None, options=None, compression=None, connectivity=None) |
mutable dataclass; insecure=True with credentials raises ValueError |
GrpcClient[T] |
(stub_class, config, pool, balancer=None, interceptors=None, interceptor_factory=None) |
generic in the stub type |
GrpcClientFactory |
(settings=None, pool=None, shutdown_grace=5.0, ready_timeout=10.0) |
settings validated eagerly against GrpcClientSettingsProtocol |
| Method | Returns | What it does |
|---|---|---|
await pool.get_channel(target, insecure=False, credentials=None, options=None, compression=None, interceptors=None, key=None) |
grpc.aio.Channel |
round-robin among healthy channels of that identity; RuntimeError while the pool is draining |
pool.make_key(target, ...) |
ChannelKey |
validates the target and builds the identity; cache it and pass it back as key= |
await pool.close_all(grace=None) |
None |
closes every channel; the pool stays usable |
await pool.health_check(target=None) |
bool |
the pool itself, or one target through its checker |
await pool.update_channel_health(target, is_healthy) |
None |
flags every pooled channel of that address |
await client.connect() |
T |
picks a target, gets the channel, returns a new stub |
client.interceptors_for(target) |
list[grpc.aio.ClientInterceptor] |
the cached chain for one target |
await client.circuit_breaker_states() |
dict[str, dict[str, Any]] |
target → method → status, for chains already built |
factory.create_client(stub_class, target=None, service_name=None, metrics=None, interceptors=None) |
GrpcClient[T] |
service_name defaults to stub_class.__name__ |
await factory.wait_until_ready(timeout=None) |
bool |
True immediately when no checker is configured |
await factory.close(grace=None) |
None |
stops the checker; closes the pool only if the factory made it |
factory.health_checker |
HealthChecker | None |
property |
await factory.circuit_breaker_states() |
dict[str, dict[str, Any]] |
keyed "service -> target" |
Context managers: async with pool yields the pool and closes it with no grace on exit;
async with client yields the stub and closes nothing; async with factory yields the
factory, starts the checker and waits for its first pass, and on exit closes with
shutdown_grace.
Channel construction¶
| Name | Fields (defaults) |
|---|---|
ConnectivityConfig |
keepalive_time=30.0, keepalive_timeout=10.0, permit_without_calls=False, max_pings_without_data=2, initial_reconnect_backoff=1.0, min_reconnect_backoff=None, max_reconnect_backoff=30.0 — all seconds; None keeps gRPC's own default |
ChannelKey |
target, insecure, credentials, options, compression, interceptors_token; ChannelKey.build(...) classmethod |
GrpcClientConfig.channel_options() returns explicit options first and verbatim, then the
arguments connectivity stands for minus any key the caller already used.
ConnectivityConfig.to_options() is that derived list on its own. Both are pure functions in
a fixed order, which is what keeps a channel's identity stable.
The chain¶
build_interceptors(
timeout=None, retry=None, circuit_breaker=None, observability=None,
extra_interceptors=None, extra_inner_interceptors=None,
deadline_budget=None, wait_for_ready=None,
) -> list[grpc.aio.ClientInterceptor]
InterceptorChainBuilder() is the same thing incrementally: .with_observability(config),
.with_resilience(timeout=None, retry=None, circuit_breaker=None, deadline_budget=None, wait_for_ready=None),
.with_extra_outer(seq), .with_extra_inner(seq), .with_custom(seq) (an alias of
with_extra_outer), .build(). The order is a property of build(), not of the call
sequence.
| Config | Fields (defaults) |
|---|---|
TimeoutConfig |
default=10.0, per_method={} — budget of the whole call; None/0 disables |
RetryConfig |
max_attempts=3, initial_backoff=0.1, max_backoff=10.0, backoff_multiplier=2.0, jitter=0.1, retryable_codes=None, retry_streaming=False, idempotent_methods=None, on_retry=None, metrics=None |
CircuitBreakerConfig |
fail_threshold=5, recovery_timeout=60.0, half_open_max_calls=1, max_methods=1000, metrics=None |
WaitForReadyConfig |
default=True, per_method={}, require_deadline=True |
DeadlineBudgetConfig |
reserve_for_next=0.0 — needs the deadline extra |
ObservabilityConfig |
tracing=False, metrics=False, logging=True, service_name="unknown", metrics_registry=None, sensitive_methods=None, sensitive_patterns=None, sensitive_headers=None, log_request_payload=False, log_response_payload=False, enable_method_label=True, success_log_level=logging.INFO |
Layer classes, all exported and all usable on their own:
AsyncClientContextInterceptor(metadata_provider), AsyncLoggingInterceptor,
AsyncTimeoutInterceptor, AsyncWaitForReadyInterceptor, AsyncRetryInterceptor,
AsyncCircuitBreakerInterceptor. The breaker exposes await get_states() returning
dict[str, CircuitBreakerStatus], alongside the CircuitState enum
(CLOSED / OPEN / HALF_OPEN).
Writing a layer¶
| Name | Use |
|---|---|
AsyncAroundClientInterceptor |
subclass and write one async def around_call(self, call) -> AsyncIterator[None] that yields exactly once — the whole RPC happens at the yield, a response stream to its last item included. Both sides of the yield share one contextvars.Context; nothing raised after it reaches the caller |
AsyncClientInterceptor |
subclass and implement async def intercept(self, call) when the call must be issued by hand, re-issued, or not issued at all |
ClientCall |
method (already decoded str), rpc_type, details, request, request_streaming, response_streaming, response, underlying_call, await invoke_unary(), await invoke_stream() |
flatten_interceptors(interceptors) |
expands a mixed chain into what a channel accepts |
logical_interceptor(entry) |
an adapter back to its interceptor; anything else unchanged |
Rewrite the call before the yield with call.details = call.details._replace(timeout=...);
raise before the yield to refuse the call outright. After the yield the outcome is already
decided: swallowing an exception is not supported, and neither is raising one — see rule 14.
Balancing¶
| Name | Signature |
|---|---|
create_balancer(targets, config=None, health_checker=None) |
round-robin unless config says otherwise |
LoadBalancerConfig |
strategy=LoadBalancingStrategy.ROUND_ROBIN, weights=None |
LoadBalancingStrategy |
ROUND_ROBIN / RANDOM / WEIGHTED (a StrEnum: "round_robin", "random", "weighted") |
RoundRobinLoadBalancer(targets, health_checker=None) |
re-gathers health on every pick |
RandomLoadBalancer(targets, health_checker=None) |
caches the healthy set for 1 s |
WeightedLoadBalancer(targets, weights, health_checker=None) |
ditto; a missing weight is 1.0, negatives and an all-zero total raise ValueError |
await balancer.select_target() |
the address, or NoHealthyTargetsError |
balancer.report_failure(target, quarantine=5.0) |
takes a target out of the rotation now, without waiting for a probe |
Health¶
HealthChecker needs the health extra and resolves on first attribute access, so
import grpc_client_kit works without it.
HealthChecker(
check_interval=30.0,
timeout=5.0,
max_backoff=300.0,
on_status_change=None,
insecure=False,
credentials=None,
pool=None,
fail_fast_callback=False,
options=None,
compression=None,
service="",
)
await start(targets), await stop(timeout=5.0), await wait_until_ready(timeout=None) -> bool,
await check_health(target) -> bool (one probe, published like any other),
await is_healthy(target) -> bool (from cache), is_running property. service="" asks
about the server as a whole; naming a service asks about that service alone.
Deadline budgets¶
| Name | What it is |
|---|---|
use_budget(budget) |
context manager installing a budget for the current task; use_budget(None) detaches |
current_budget() |
the installed budget, or None |
DeadlineBudgetProtocol |
timeout_for_call(call_name, reserve_for_next=0.0), remaining(), expired() — runtime_checkable, so any object of that shape works |
Protocols and helpers¶
Settings and collaborator protocols, all runtime_checkable and all exported:
GrpcClientSettingsProtocol, ChannelPoolSettingsProtocol, TimeoutSettingsProtocol,
RetrySettingsProtocol, CircuitBreakerSettingsProtocol, LoadBalancerSettingsProtocol,
HealthCheckerSettingsProtocol, ChannelProviderProtocol, HealthCheckerProtocol,
HealthStatusCallbackProtocol, GrpcClientMetricsProtocol, RetryMetricsProtocol,
CircuitBreakerMetricsProtocol, and the three that describe the optional settings blocks —
GrpcChannelExtrasProtocol, GrpcObservabilityExtrasProtocol,
FullGrpcClientSettingsProtocol. Also metadata_to_dict(metadata) and __version__.
Names that exist but are not re-exported at package level — import them from the module named beside them:
| Name | Module |
|---|---|
DEFAULT_RETRYABLE_CODES |
grpc_client_kit.interceptors |
AsyncDeadlineBudgetInterceptor, HAS_DEADLINE_BUDGET |
grpc_client_kit.interceptors.deadline |
AsyncClientTracingInterceptor, HAS_TRACING |
grpc_client_kit.interceptors.tracing |
AsyncClientMetricsInterceptor, HAS_METRICS |
grpc_client_kit.interceptors.metrics |
AsyncPassiveOutlierInterceptor, DEFAULT_QUARANTINE_SECONDS |
grpc_client_kit.interceptors.outlier |
validate_target, MIN_PORT, MAX_PORT |
grpc_client_kit.validation |
create_aio_channel |
grpc_client_kit.utils |
ChannelWrapper and chain_token in grpc_client_kit.channel, and MethodCircuitState in
grpc_client_kit.interceptors.circuit_breaker, are internals left out of the public surface on
purpose: exporting them would freeze those implementations into the compatibility contract. Do
not build on them — a breaker's state is read through get_states(), which returns
CircuitBreakerStatus snapshots.
Settings objects¶
GrpcClientFactory reads a settings object structurally, so a pydantic model, a dataclass or
a plain class with nested classes all work. Required (GrpcClientSettingsProtocol):
target, targets, insecure, tracing_enabled, metrics_enabled, logging_enabled,
pool, circuit_breaker, retry, timeout, balancer, health_checker. A missing one
raises TypeError naming it, at construction.
Read with getattr and therefore optional: credentials, options, compression,
connectivity, metrics_registry, sensitive_headers, enable_method_label,
success_log_level, plus two whole blocks — wait_for_ready (default, per_method,
require_deadline) and deadline_budget (reserve_for_next). The retry block's jitter,
retryable_codes, retry_streaming, idempotent_methods and on_retry, the
circuit_breaker block's max_methods and the health_checker block's service are picked
up the same way. Because they are read with getattr, a typo in an optional name silently
yields the default — pydantic users should set extra="forbid".
Per-method budgets are the one thing settings cannot express: timeout carries only
default. That needs a hand-built chain.
Rules that hold or break the code¶
async with clientyields the stub, and closes nothing.GrpcClient.__aexit__deliberately leaves the channel alone — channels belong to the pool. OnlyChannelPool.close_all()closes anything, andGrpcClientFactorycloses the pool only when it created the pool itself. A factory handedpool=borrows it.- The pool never closes an idle channel.
idle_timeoutbecomesgrpc.client_idle_timeout_msand gRPC core parks the connection once no RPC is active, redialling transparently on the next call. That is why a stub you hold stays valid for life and a week-long stream is safe.idle_timeout=0or less disables parking entirely. close_all()drains the pool, it does not retire it. Every channel is closed, the pool stays usable, and itsasync withblock can be entered again. Aget_channelissued while the drain runs raisesRuntimeErrorrather than handing out a channel the pool no longer tracks.- A timeout is the budget of the entire call, retries included. The retry layer converts
it into a monotonic deadline once, on entry, and issues each attempt with what is left;
max_attempts × timeoutis not how long a call can take. A backoff that would outlive the budget abandons the retry and re-raises the original error. - A
Nonesettings block means "do not add that layer", never "use defaults". Notimeoutblock means no timeout interceptor and therefore no deadline at all. So doesTimeoutConfig(default=None)with noper_methodentries — a pass-through layer would cost a hop per call and, since the chain is part of the pool key, a separate channel. DEFAULT_RETRYABLE_CODESis{UNAVAILABLE, RESOURCE_EXHAUSTED}and nothing else.INTERNAL,UNKNOWN,ABORTEDandDEADLINE_EXCEEDEDare never retried unless you put them inretryable_codes. Even the two defaults are a compromise: a server dying mid-handler surfaces asUNAVAILABLE, and a handler may abort withRESOURCE_EXHAUSTEDafter a write. Where a duplicate is unaffordable, setidempotent_methods— once that whitelist exists, nothing outside it is retried, whatever the code.- Streaming requests are never retried; streaming responses need two opt-ins. A
stream-unary or stream-stream call's request iterator is consumed by the first attempt and
cannot be replayed. A unary-stream call is restartable only with
retry_streaming=Trueand the method listed inidempotent_methods— and a restart replays items the consumer has already seen, which is logged as a warning. - The breaker counts attempts, not calls, so
fail_thresholdmust exceedmax_attempts. The breaker is innermost and the retry layer above it manufactures the attempts, so one logical call contributes up tomax_attemptsconsecutive failures. Withmax_attempts=3, fail_threshold=2a single unlucky call opens its own circuit and the caller is toldCircuitBreakerOpenErrorinstead of what the server actually said. A tripped circuit is never retried, even though its error carriesUNAVAILABLE. - A budget only ever tightens a deadline. Every layer that touches the deadline takes the smaller of what it finds and what it knows; a request with 50 seconds left does not entitle a method configured for 5 to more than 5.
- Nothing propagates a deadline until you install a budget. The kit supplies the
contextvar and the interceptor;
use_budget(BudgetContext.create(...))is the caller's job, normally at the point a request enters the process. Without it the layer is a pass-through that touches no call. Without thedeadlineextra the layer is not in the chain at all — logged as a warning, never raised. Per-call caps are keyed by the full method name,/package.Service/Method, and nothing else. - A task created outside a
use_budgetblock carries no budget, however deep inside the block itsawaithappens:asynciocopies the context atcreate_tasktime. - The interceptor chain is part of the channel's identity — build it once per target.
The token is per interceptor instance, so a chain rebuilt per call mints a new identity
every time and the pool opens a channel per RPC.
GrpcClientcaches whatinterceptor_factoryreturns, keyed by target; do the same by hand. For the same reason, reuse one credentials object:ChannelKeycompares credentials by identity, because gRPC credentials define no equality. - Never hand a logical interceptor straight to a channel.
grpc.aioraisesValueError. Pass hand-assembled chains throughflatten_interceptorsfirst. And an interceptor you write against gRPC's ownintercept_*methods that inherits all four base classes is registered for unary-unary only, silently — the channel files each entry into the first list it matches. around_callcannot change a call's outcome after theyield, and both sides of it share one context. Whatever the teardown raises — a metrics push to a collector that went away, aContextVar.resetthat was refused — is logged at ERROR against the method and dropped, identically for all four RPC kinds; only cancellation still propagates. And a token minted before theyieldmay be reset after it on every kind, because the kit pins acontextvars.Contextper call for the three whose teardown finishes in another task. That costs anasyncio.Taskor two per call, per layer; unary-unary pins nothing and pays nothing.create_client(interceptors=...)puts your layers in the outer slot, above logging, tracing, metrics and the timeout. That is right for metadata injection and wrong for anything that has to read or reshape the deadline; those belong in a hand-built chain given toGrpcClient(interceptors=...).interceptorsandinterceptor_factoryare mutually exclusive, and so arebalancerandconfig.target. Both pairs raiseValueErrorat construction rather than picking a winner at run time. A sharedinterceptorslist also means one circuit breaker shared across every target of that client; the factory always passes a factory instead.- An unchecked target is not a healthy target. Every target reads unhealthy until the
first health pass lands, so enter the factory's
async with(orawait checker.wait_until_ready()) before the first RPC, or every fresh pod fails its first call withNoHealthyTargetsError. A checker that was never started raisesHealthCheckerNotRunningErrorfromis_healthy, and balancers gather health withreturn_exceptions=True, so that mistake otherwise looks exactly like a cluster that is entirely down. - A port is always required. Targets are validated before a channel exists, more
strictly than gRPC — which silently falls back to 443 for a portless target.
[::1]:50051must be bracketed;http://is rejected by name. - Never stack kit retries on a native
retryPolicy. Service-config retries run inside the channel, below every interceptor, so the two multiply: 3 × 3 = 9 requests reach the server, invisibly to the kit's logs and metrics.GrpcClientwarns once when it sees both. Native retries without kit retries are fully supported. - Batteries are opt-in, and a missing one is a warning, not an error.
import grpc_client_kitnever reaches for an extra.HealthCheckerresolves on first attribute access and raisesImportErrornaming[health]— anImportErrorand not anAttributeError, so a broken install says so rather than looking like a name that never existed. The cost is thathasattr(grpc_client_kit, "HealthChecker")propagates that ImportError instead of returningFalse, and so doesgetattrwith a default; probe withimportlib.util.find_spec("grpc_health")or catch the ImportError. Tracing, metrics and the deadline budget layers are left out of the chain, with a log line, when their extra is absent — the chain still builds and the calls still run. - There is no sync API and no thread safety. Everything here assumes one event loop.
Common mistakes¶
# WRONG — treating the client as the owner of the connection
async with GrpcClient(Stub, config=config, pool=pool) as client:
await client.GetUser(request) # `client` here is the stub, not the client
# ...and expecting this to have closed anything
# RIGHT
async with ChannelPool() as pool: # the pool owns the channels
client = GrpcClient(Stub, config=config, pool=pool)
async with client as stub: # yields the stub
await stub.GetUser(request)
# WRONG — a per-attempt timeout, and a threshold below the attempt count
TimeoutConfig(default=10.0) # "so 3 attempts get 30 seconds"
CircuitBreakerConfig(fail_threshold=2) # with max_attempts=3: one call opens its own circuit
# RIGHT — 10 seconds is the whole call, and the breaker outlives a single call
TimeoutConfig(default=10.0) # retries are issued out of these 10 seconds
RetryConfig(max_attempts=3)
CircuitBreakerConfig(fail_threshold=5) # > max_attempts
# WRONG — widening the retryable set to "be resilient"
RetryConfig(retryable_codes={grpc.StatusCode.INTERNAL, grpc.StatusCode.UNAVAILABLE})
# INTERNAL is raised by the handler: the write has very likely been applied already.
# RIGHT — widen per method, behind the whitelist
RetryConfig(
retryable_codes={grpc.StatusCode.INTERNAL, grpc.StatusCode.UNAVAILABLE},
idempotent_methods={"/users.v1.Users/GetUser"}, # nothing outside this is retried
)
# WRONG — a fresh chain per call: a new channel identity, so a new channel, per RPC
async def get_stub(pool, target):
chain = build_interceptors(retry=RetryConfig())
return UserStub(await pool.get_channel(target, interceptors=chain))
# RIGHT — build once, reuse; or let GrpcClient cache it per target
chain = build_interceptors(retry=RetryConfig())
client = GrpcClient(UserStub, config=config, pool=pool, interceptors=chain)
# WRONG — a logical interceptor handed to a channel, which raises ValueError
grpc.aio.insecure_channel("host:50051", interceptors=[MyAroundInterceptor()])
# RIGHT — expand it into the four adapters a channel files correctly
from grpc_client_kit import flatten_interceptors
grpc.aio.insecure_channel("host:50051", interceptors=flatten_interceptors([MyAroundInterceptor()]))
# WRONG — configuring the layer and expecting deadlines to propagate
build_interceptors(deadline_budget=DeadlineBudgetConfig())
await client_a.fetch(...) # no budget installed: nothing is trimmed
# RIGHT — the caller installs the budget; the layer spends it
from deadline_budget import BudgetContext
from grpc_client_kit import use_budget
with use_budget(BudgetContext.create(total_seconds=3.0)):
await client_a.fetch(...) # 3.0s
await client_b.charge(...) # whatever the first call left
# WRONG — a factory used without its context manager
factory = GrpcClientFactory(settings=settings)
client = factory.create_client(UserStub) # logs a warning; health checks are NOT running
# RIGHT — entering the block starts the checker and waits for its first pass
async with GrpcClientFactory(settings=settings) as factory:
client = factory.create_client(UserStub)
Errors¶
GrpcClientKitError is the base of every failure the kit raises on its own authority, as
opposed to a grpc.aio.AioRpcError carrying a server's status. except GrpcClientKitError
catches exactly the local family.
| Error | Import from | Means |
|---|---|---|
GrpcClientKitError |
grpc_client_kit |
base class; catch it to separate local refusals from server answers |
NoHealthyTargetsError |
grpc_client_kit |
the balancer had nothing eligible left; .targets lists what it tried. Raised before any channel is touched |
HealthCheckerNotRunningError |
grpc_client_kit |
health was asked for a target with no verdict and no loop to produce one; also a RuntimeError; .target names it. Deliberately outside the health extra so catching it needs no extra |
CircuitBreakerOpenError |
grpc_client_kit |
the circuit for this method is open, or every half-open trial slot is taken. Also an AioRpcError carrying UNAVAILABLE, so logs, spans and metrics see a failed call — but metrics label it status="rejected", never "error" |
DeadlineBudgetExhaustedError |
grpc_client_kit |
the request budget was spent, so the RPC was never created. Also an AioRpcError carrying DEADLINE_EXCEEDED, with the budget library's DeadlineExceededError as __cause__ |
Configuration mistakes raise plain ValueError at construction — a malformed target,
insecure=True with credentials, both a balancer and a config.target, both interceptors
and an interceptor_factory, a negative backoff, a non-positive fail_threshold,
contradictory reconnect bounds. A settings object missing a required field raises TypeError
naming the field. HealthChecker without the health extra raises ImportError naming the
extra.
Documentation map¶
Fetch a page when the task is the one named beside it.
| Page | Read it when |
|---|---|
| Overview | placing the library at all: what it is, the extras, a first call |
| Quick start | writing the first integration: three objects, the factory, the two error families |
| Configuration | building GrpcClientConfig or a settings object, and picking extras |
| Channels & pooling | channel identity, keepalive and reconnect tuning, pool limits, who closes what |
| Interceptors | the chain order, how it reaches the channel, writing your own layer |
| Resilience | call budgets, wait-for-ready, retry safety, sizing the breaker against the retries |
| Deadline budgets | propagating the caller's remaining time; tasks, fan-out and per-call caps |
| Load balancing | strategies, health-narrowed eligibility, passive quarantine |
| Native gRPC or the kit? | deciding which layer owns LB, retries, idling and health |
| Health checking | the probe loop, cold starts, backoff, status callbacks |
| Observability | what a log record, a metric sample and a span actually contain |
| Advanced | target validation, plain grpc.aio interceptors, dependency injection |
| API reference | an exact signature, field or docstring — HTML only, see above |
| Changelog | what changed between versions |