Skip to content

API Reference

Auto-generated from source using mkdocstrings.

grpc_client_kit

Batteries-optional async gRPC client toolkit.

Channel pooling with health monitoring, client-side load balancing (round-robin, random, weighted), resilience (retries, timeouts, circuit breakers) and observability (logging, OpenTelemetry tracing, metrics) — with every integration behind an extra.

AsyncAroundClientInterceptor

Bases: AsyncClientInterceptor, ABC

A logical interceptor that wraps calls instead of re-issuing them.

Subclasses implement around_call — an async generator that yields exactly once:

  • code before yield runs before the RPC is created, which is where the call details may still be rewritten;
  • the RPC happens at the yield, including full consumption of a streaming response;
  • code after yield, or in except / finally, runs once the call has finished.

A failure reaches the yield as grpc.aio.AioRpcError, mid-stream failures included, so an ordinary try/except around it covers the whole call. Refusing a call is a matter of raising before the yield: the RPC is then never created. Swallowing an exception is not supported — the call has already failed by then, and there is no response to return in its place.

Source code in grpc_client_kit/interceptors/base.py
class AsyncAroundClientInterceptor(AsyncClientInterceptor, abc.ABC):
    """A logical interceptor that wraps calls instead of re-issuing them.

    Subclasses implement `around_call` — an async generator that yields exactly once:

    - code before ``yield`` runs before the RPC is created, which is where the call details may
      still be rewritten;
    - the RPC happens at the ``yield``, including full consumption of a streaming response;
    - code after ``yield``, or in ``except`` / ``finally``, runs once the call has finished.

    A failure reaches the ``yield`` as `grpc.aio.AioRpcError`, mid-stream failures included, so an
    ordinary ``try/except`` around it covers the whole call. Refusing a call is a matter of raising
    before the ``yield``: the RPC is then never created. Swallowing an exception is not supported —
    the call has already failed by then, and there is no response to return in its place.
    """

    @abc.abstractmethod
    def around_call(self, call: ClientCall) -> AsyncIterator[None]:
        """Wrap one RPC (async generator: setup before ``yield``, teardown after)."""

    @functools.cached_property
    def _around(self) -> Callable[[ClientCall], contextlib.AbstractAsyncContextManager[None]]:
        # Built once per instance; asynccontextmanager itself makes a fresh manager per call.
        return contextlib.asynccontextmanager(self.around_call)

    async def intercept(self, call: ClientCall) -> Any:
        """Issue the call inside `around_call` (base plumbing; subclasses override the generator)."""
        around = self._around(call)
        await around.__aenter__()

        if call.response_streaming:
            try:
                stream = await call.invoke_stream()
            except BaseException as error:
                await around.__aexit__(type(error), error, error.__traceback__)
                raise
            # The teardown has to outlive this method: the call is not over until the last item is.
            # It must also not depend on the consumer finishing the iteration — a cancelled or
            # abandoned call fires its done callback, and that closes the teardown deterministically
            # instead of waiting for garbage collection.
            finalizer = _AroundFinalizer(around)
            _finalize_when_done(call.underlying_call, finalizer)
            return _closing_stream(finalizer, stream)

        if call.request_streaming:
            try:
                deferred = await call.invoke_unary()
            except BaseException as error:
                await around.__aexit__(type(error), error, error.__traceback__)
                raise
            # The outcome of a streaming-request call arrives after this task has returned (see
            # `invoke_unary`), so the teardown runs from an observer instead of from here.
            finalizer = _AroundFinalizer(around)
            _spawn_background(_finalize_unary_outcome(call, deferred, finalizer))
            return deferred

        try:
            response = await call.invoke_unary()
        except BaseException as error:
            await around.__aexit__(type(error), error, error.__traceback__)
            raise

        await around.__aexit__(None, None, None)
        return response

adapters cached property

The four single-ABC objects through which a channel registers this interceptor.

Built once per instance and kept: a chain's identity — and with it the channel pool's idea of which channels are interchangeable — is the identity of the objects in it.

around_call(call) abstractmethod

Wrap one RPC (async generator: setup before yield, teardown after).

Source code in grpc_client_kit/interceptors/base.py
@abc.abstractmethod
def around_call(self, call: ClientCall) -> AsyncIterator[None]:
    """Wrap one RPC (async generator: setup before ``yield``, teardown after)."""

intercept(call) async

Issue the call inside around_call (base plumbing; subclasses override the generator).

Source code in grpc_client_kit/interceptors/base.py
async def intercept(self, call: ClientCall) -> Any:
    """Issue the call inside `around_call` (base plumbing; subclasses override the generator)."""
    around = self._around(call)
    await around.__aenter__()

    if call.response_streaming:
        try:
            stream = await call.invoke_stream()
        except BaseException as error:
            await around.__aexit__(type(error), error, error.__traceback__)
            raise
        # The teardown has to outlive this method: the call is not over until the last item is.
        # It must also not depend on the consumer finishing the iteration — a cancelled or
        # abandoned call fires its done callback, and that closes the teardown deterministically
        # instead of waiting for garbage collection.
        finalizer = _AroundFinalizer(around)
        _finalize_when_done(call.underlying_call, finalizer)
        return _closing_stream(finalizer, stream)

    if call.request_streaming:
        try:
            deferred = await call.invoke_unary()
        except BaseException as error:
            await around.__aexit__(type(error), error, error.__traceback__)
            raise
        # The outcome of a streaming-request call arrives after this task has returned (see
        # `invoke_unary`), so the teardown runs from an observer instead of from here.
        finalizer = _AroundFinalizer(around)
        _spawn_background(_finalize_unary_outcome(call, deferred, finalizer))
        return deferred

    try:
        response = await call.invoke_unary()
    except BaseException as error:
        await around.__aexit__(type(error), error, error.__traceback__)
        raise

    await around.__aexit__(None, None, None)
    return response

AsyncCircuitBreakerInterceptor

Bases: AsyncAroundClientInterceptor

Async circuit breaker interceptor for gRPC clients.

This interceptor implements the Circuit Breaker pattern on a per-method basis. It prevents cascading failures by "opening" the circuit for methods that fail repeatedly, immediately rejecting subsequent calls until a recovery period has passed.

States: - CLOSED: Normal operation. Successes and failures are tracked. - OPEN: Service is failing. Calls are immediately rejected with CircuitBreakerOpenError. - HALF-OPEN: Limited trial calls are allowed to see if the service has recovered.

Scope

An interceptor is bound to one channel, hence to one target. Give each target its own instance (GrpcClientFactory does) and state is effectively per (target, method), so one failing backend cannot trip the breaker for its healthy peers.

What counts as a failure

Only the statuses in _should_record_failure — the ones that describe the server rather than the request. A malformed request or a rejected permission is the caller's problem and must not be able to trip a circuit. A cancellation is nobody's failure: asyncio.CancelledError derives from BaseException, so it passes this interceptor's handlers untouched and only the trial slot it was holding is given back.

Features: - Thread-safe and async-safe implementation. - Per-method state isolation. - LRU cache for method states to prevent memory leaks in long-running processes. - Observability via optional metrics registry.

Source code in grpc_client_kit/interceptors/circuit_breaker.py
class AsyncCircuitBreakerInterceptor(AsyncAroundClientInterceptor):
    """Async circuit breaker interceptor for gRPC clients.

    This interceptor implements the Circuit Breaker pattern on a per-method basis.
    It prevents cascading failures by "opening" the circuit for methods that fail
    repeatedly, immediately rejecting subsequent calls until a recovery period
    has passed.

    States:
    - CLOSED: Normal operation. Successes and failures are tracked.
    - OPEN: Service is failing. Calls are immediately rejected with CircuitBreakerOpenError.
    - HALF-OPEN: Limited trial calls are allowed to see if the service has recovered.

    Scope:
        An interceptor is bound to one channel, hence to one target. Give each target its own
        instance (``GrpcClientFactory`` does) and state is effectively per (target, method), so one
        failing backend cannot trip the breaker for its healthy peers.

    What counts as a failure:
        Only the statuses in `_should_record_failure` — the ones that describe the server rather
        than the request. A malformed request or a rejected permission is the caller's problem and
        must not be able to trip a circuit. A cancellation is nobody's failure: `asyncio.CancelledError`
        derives from `BaseException`, so it passes this interceptor's handlers untouched and only
        the trial slot it was holding is given back.

    Features:
    - Thread-safe and async-safe implementation.
    - Per-method state isolation.
    - LRU cache for method states to prevent memory leaks in long-running processes.
    - Observability via optional metrics registry.
    """

    def __init__(
        self,
        fail_threshold: int = 5,
        recovery_timeout: float = 60.0,
        half_open_max_calls: int = 1,
        max_methods: int = 1000,
        metrics: GrpcClientMetricsProtocol | None = None,
    ) -> None:
        """Initialize the circuit breaker interceptor.

        Args:
            fail_threshold: Number of failures before transitioning from CLOSED to OPEN.
            recovery_timeout: Seconds to wait in OPEN state before transitioning to HALF-OPEN.
            half_open_max_calls: Maximum number of concurrent trial calls allowed in HALF-OPEN.
            max_methods: Maximum number of distinct methods to track in LRU cache.
            metrics: Optional metrics registry for recording state transitions.

        Raises:
            ValueError: If any threshold or timeout is out of range.
        """
        if fail_threshold <= 0:
            raise ValueError("fail_threshold must be positive")
        if recovery_timeout < 0:
            raise ValueError("recovery_timeout must be non-negative")
        if half_open_max_calls <= 0:
            raise ValueError("half_open_max_calls must be positive")
        if max_methods <= 0:
            raise ValueError("max_methods must be positive")

        self._fail_threshold = fail_threshold
        self._recovery_timeout = recovery_timeout
        self._half_open_max_calls = half_open_max_calls
        self._max_methods = max_methods
        self._metrics = metrics
        # State transitions and rejections are an optional protocol extension of the registry,
        # checked once here rather than with hasattr magic on every transition.
        self._state_metrics: CircuitBreakerMetricsProtocol | None = (
            metrics if isinstance(metrics, CircuitBreakerMetricsProtocol) else None
        )

        # Map: method_name -> state data (using OrderedDict for LRU). No lock: every mutation of
        # the map happens in an await-free section, atomic with respect to the event loop.
        self._states: OrderedDict[str, MethodCircuitState] = OrderedDict()

    async def around_call(self, call: ClientCall) -> AsyncIterator[None]:
        """Refuse the call while the circuit is open, otherwise run it and record how it ended.

        Args:
            call: The call being guarded.

        Yields:
            Once, with the RPC in flight. For a streaming response the yield spans the whole stream,
            so a server that fails halfway through it still counts as a failure.

        Raises:
            CircuitBreakerOpenError: If the circuit for this method is open, or every half-open
                trial slot is taken. Raised before the ``yield``, so the RPC is never created and
                the rejected call never touches the network.
        """
        state = await self._get_method_state(call.method)
        trial = await self._admit(call.method, state)

        try:
            yield
        except grpc.aio.AioRpcError as error:
            if self._should_record_failure(_error_code(error)):
                await self._record_failure(call.method, state)
            raise
        # `asyncio.CancelledError` is a BaseException and so escapes both handlers on purpose: a
        # client that walked away has told us nothing about the health of the server.
        except Exception:
            await self._record_failure(call.method, state)
            raise
        else:
            await self._record_success(call.method, state)
        finally:
            if trial:
                await self._release_trial(state)

    async def get_states(self) -> dict[str, CircuitBreakerStatus]:
        """Get the current state of all monitored methods.

        Returns:
            A dictionary mapping method names to their status (state, failure_count, etc.).
        """
        return {
            method: CircuitBreakerStatus(
                state=state.state.value,
                failure_count=state.failure_count,
                last_failure_time=state.last_failure_time,
                half_open_calls=state.half_open_calls,
            )
            for method, state in self._states.items()
        }

    async def _get_method_state(self, method: str) -> MethodCircuitState:
        """Get or create state for a specific method, implementing LRU eviction.

        Args:
            method: Full gRPC method path.

        Returns:
            The state object for the method.
        """
        if method in self._states:
            # Move to end (mark as most recently used)
            self._states.move_to_end(method)
            return self._states[method]

        if len(self._states) >= self._max_methods:
            self._evict_one_state()

        state = MethodCircuitState()
        self._states[method] = state

        # Initial state metric
        self._update_state_metrics(method, CircuitState.CLOSED)

        return state

    def _evict_one_state(self) -> None:
        """Drop one state to stay under the limit, never a state that is still protecting.

        Evicting an OPEN circuit silently re-closes it: the next call to that method goes out to a
        backend the breaker had declared down, and it takes another `fail_threshold` real network
        failures to open it again — under method churn the breaker degrades into "let threshold
        calls through per LRU cycle". So the victim is the least recently used state that carries
        no signal: CLOSED with a clean failure count first, any CLOSED second, and when every state
        is protecting something the map grows past the limit instead — with a warning, since that
        is a memory bound consciously traded for correctness.
        """
        for candidate, state in self._states.items():
            if state.state is CircuitState.CLOSED and state.failure_count == 0:
                del self._states[candidate]
                return

        for candidate, state in self._states.items():
            if state.state is CircuitState.CLOSED:
                del self._states[candidate]
                return

        logger.warning(
            "Circuit breaker holds %d OPEN/HALF_OPEN states, exceeding max_methods=%d; "
            "growing past the limit instead of evicting a protecting circuit",
            len(self._states),
            self._max_methods,
        )

    def _update_state_metrics(self, method: str, state: CircuitState) -> None:
        """Report a state transition to a registry that opted into breaker metrics."""
        if self._state_metrics is None:
            return

        try:
            self._state_metrics.record_circuit_state(method, state.value)
        except Exception:
            logger.exception("Failed to record circuit breaker state for %s", method)

    def _record_rejection(self, method: str) -> None:
        """Report one locally refused call to a registry that opted into breaker metrics."""
        if self._state_metrics is None:
            return

        try:
            self._state_metrics.record_circuit_rejection(method)
        except Exception:
            logger.exception("Failed to record circuit breaker rejection for %s", method)

    async def _admit(self, method: str, state: MethodCircuitState) -> bool:
        """Decide whether the call may go out, claiming a half-open trial slot when it does.

        Args:
            method: Full gRPC method path, for the error and the logs.
            state: Circuit state of that method.

        Returns:
            True if the call was admitted as a half-open trial and owes the slot back.

        Raises:
            CircuitBreakerOpenError: If the circuit is open, or its trial slots are all taken.
        """
        # Await-free on purpose: atomic with respect to the event loop, no lock needed or useful.
        if state.state == CircuitState.OPEN:
            if time.time() - state.last_failure_time > self._recovery_timeout:
                logger.info("Circuit breaker for %s entering HALF-OPEN state", method)
                state.state = CircuitState.HALF_OPEN
                state.half_open_calls = 0
                self._update_state_metrics(method, CircuitState.HALF_OPEN)
            else:
                self._record_rejection(method)
                raise CircuitBreakerOpenError(method)

        if state.state != CircuitState.HALF_OPEN:
            return False

        if state.half_open_calls >= self._half_open_max_calls:
            logger.debug(
                "Circuit breaker for %s: half-open calls limit reached (%d >= %d)",
                method,
                state.half_open_calls,
                self._half_open_max_calls,
            )
            self._record_rejection(method)
            raise CircuitBreakerOpenError(method)

        state.half_open_calls += 1
        return True

    async def _release_trial(self, state: MethodCircuitState) -> None:
        """Give back the half-open slot a trial call was holding.

        A recorded outcome has already reset the counter on its way to CLOSED or OPEN, so the
        decrement only applies while the circuit is still half-open — which is the case when the
        trial ended in something that was not recorded at all, a cancellation above all.
        """
        if state.state == CircuitState.HALF_OPEN:
            state.half_open_calls = max(0, state.half_open_calls - 1)

    async def _record_success(self, method: str, state: MethodCircuitState) -> None:
        """Record a successful call and potentially transition to CLOSED."""
        if state.state == CircuitState.HALF_OPEN:
            logger.info("Circuit breaker for %s CLOSED after successful half-open test", method)
            state.state = CircuitState.CLOSED
            state.failure_count = 0
            state.half_open_calls = 0
            self._update_state_metrics(method, CircuitState.CLOSED)
        elif state.state == CircuitState.CLOSED:
            # Reset failure count to prevent gradual accumulation of old failures
            state.failure_count = 0

    async def _record_failure(self, method: str, state: MethodCircuitState) -> None:
        """Record a failed call and potentially transition to OPEN."""
        state.failure_count += 1
        state.last_failure_time = time.time()

        if state.state == CircuitState.HALF_OPEN:
            logger.warning("Circuit breaker for %s OPEN after failure in half-open state", method)
            state.state = CircuitState.OPEN
            state.half_open_calls = 0
            self._update_state_metrics(method, CircuitState.OPEN)
        elif state.state == CircuitState.CLOSED and state.failure_count >= self._fail_threshold:
            logger.warning("Circuit breaker for %s OPEN after %d failures", method, state.failure_count)
            state.state = CircuitState.OPEN
            self._update_state_metrics(method, CircuitState.OPEN)

    def _should_record_failure(self, code: grpc.StatusCode | None) -> bool:
        """Determine if a gRPC error code should count as a failure.

        Args:
            code: The status code of the failed call, or None when it carries none.

        Returns:
            True if the failure says something about the server's health.
        """
        return code in (
            grpc.StatusCode.UNAVAILABLE,
            grpc.StatusCode.DEADLINE_EXCEEDED,
            grpc.StatusCode.INTERNAL,
            grpc.StatusCode.RESOURCE_EXHAUSTED,
            grpc.StatusCode.ABORTED,
            grpc.StatusCode.UNKNOWN,
            grpc.StatusCode.DATA_LOSS,
        )

adapters cached property

The four single-ABC objects through which a channel registers this interceptor.

Built once per instance and kept: a chain's identity — and with it the channel pool's idea of which channels are interchangeable — is the identity of the objects in it.

__init__(fail_threshold=5, recovery_timeout=60.0, half_open_max_calls=1, max_methods=1000, metrics=None)

Initialize the circuit breaker interceptor.

Parameters:

Name Type Description Default
fail_threshold int

Number of failures before transitioning from CLOSED to OPEN.

5
recovery_timeout float

Seconds to wait in OPEN state before transitioning to HALF-OPEN.

60.0
half_open_max_calls int

Maximum number of concurrent trial calls allowed in HALF-OPEN.

1
max_methods int

Maximum number of distinct methods to track in LRU cache.

1000
metrics GrpcClientMetricsProtocol | None

Optional metrics registry for recording state transitions.

None

Raises:

Type Description
ValueError

If any threshold or timeout is out of range.

Source code in grpc_client_kit/interceptors/circuit_breaker.py
def __init__(
    self,
    fail_threshold: int = 5,
    recovery_timeout: float = 60.0,
    half_open_max_calls: int = 1,
    max_methods: int = 1000,
    metrics: GrpcClientMetricsProtocol | None = None,
) -> None:
    """Initialize the circuit breaker interceptor.

    Args:
        fail_threshold: Number of failures before transitioning from CLOSED to OPEN.
        recovery_timeout: Seconds to wait in OPEN state before transitioning to HALF-OPEN.
        half_open_max_calls: Maximum number of concurrent trial calls allowed in HALF-OPEN.
        max_methods: Maximum number of distinct methods to track in LRU cache.
        metrics: Optional metrics registry for recording state transitions.

    Raises:
        ValueError: If any threshold or timeout is out of range.
    """
    if fail_threshold <= 0:
        raise ValueError("fail_threshold must be positive")
    if recovery_timeout < 0:
        raise ValueError("recovery_timeout must be non-negative")
    if half_open_max_calls <= 0:
        raise ValueError("half_open_max_calls must be positive")
    if max_methods <= 0:
        raise ValueError("max_methods must be positive")

    self._fail_threshold = fail_threshold
    self._recovery_timeout = recovery_timeout
    self._half_open_max_calls = half_open_max_calls
    self._max_methods = max_methods
    self._metrics = metrics
    # State transitions and rejections are an optional protocol extension of the registry,
    # checked once here rather than with hasattr magic on every transition.
    self._state_metrics: CircuitBreakerMetricsProtocol | None = (
        metrics if isinstance(metrics, CircuitBreakerMetricsProtocol) else None
    )

    # Map: method_name -> state data (using OrderedDict for LRU). No lock: every mutation of
    # the map happens in an await-free section, atomic with respect to the event loop.
    self._states: OrderedDict[str, MethodCircuitState] = OrderedDict()

around_call(call) async

Refuse the call while the circuit is open, otherwise run it and record how it ended.

Parameters:

Name Type Description Default
call ClientCall

The call being guarded.

required

Yields:

Type Description
AsyncIterator[None]

Once, with the RPC in flight. For a streaming response the yield spans the whole stream,

AsyncIterator[None]

so a server that fails halfway through it still counts as a failure.

Raises:

Type Description
CircuitBreakerOpenError

If the circuit for this method is open, or every half-open trial slot is taken. Raised before the yield, so the RPC is never created and the rejected call never touches the network.

Source code in grpc_client_kit/interceptors/circuit_breaker.py
async def around_call(self, call: ClientCall) -> AsyncIterator[None]:
    """Refuse the call while the circuit is open, otherwise run it and record how it ended.

    Args:
        call: The call being guarded.

    Yields:
        Once, with the RPC in flight. For a streaming response the yield spans the whole stream,
        so a server that fails halfway through it still counts as a failure.

    Raises:
        CircuitBreakerOpenError: If the circuit for this method is open, or every half-open
            trial slot is taken. Raised before the ``yield``, so the RPC is never created and
            the rejected call never touches the network.
    """
    state = await self._get_method_state(call.method)
    trial = await self._admit(call.method, state)

    try:
        yield
    except grpc.aio.AioRpcError as error:
        if self._should_record_failure(_error_code(error)):
            await self._record_failure(call.method, state)
        raise
    # `asyncio.CancelledError` is a BaseException and so escapes both handlers on purpose: a
    # client that walked away has told us nothing about the health of the server.
    except Exception:
        await self._record_failure(call.method, state)
        raise
    else:
        await self._record_success(call.method, state)
    finally:
        if trial:
            await self._release_trial(state)

get_states() async

Get the current state of all monitored methods.

Returns:

Type Description
dict[str, CircuitBreakerStatus]

A dictionary mapping method names to their status (state, failure_count, etc.).

Source code in grpc_client_kit/interceptors/circuit_breaker.py
async def get_states(self) -> dict[str, CircuitBreakerStatus]:
    """Get the current state of all monitored methods.

    Returns:
        A dictionary mapping method names to their status (state, failure_count, etc.).
    """
    return {
        method: CircuitBreakerStatus(
            state=state.state.value,
            failure_count=state.failure_count,
            last_failure_time=state.last_failure_time,
            half_open_calls=state.half_open_calls,
        )
        for method, state in self._states.items()
    }

intercept(call) async

Issue the call inside around_call (base plumbing; subclasses override the generator).

Source code in grpc_client_kit/interceptors/base.py
async def intercept(self, call: ClientCall) -> Any:
    """Issue the call inside `around_call` (base plumbing; subclasses override the generator)."""
    around = self._around(call)
    await around.__aenter__()

    if call.response_streaming:
        try:
            stream = await call.invoke_stream()
        except BaseException as error:
            await around.__aexit__(type(error), error, error.__traceback__)
            raise
        # The teardown has to outlive this method: the call is not over until the last item is.
        # It must also not depend on the consumer finishing the iteration — a cancelled or
        # abandoned call fires its done callback, and that closes the teardown deterministically
        # instead of waiting for garbage collection.
        finalizer = _AroundFinalizer(around)
        _finalize_when_done(call.underlying_call, finalizer)
        return _closing_stream(finalizer, stream)

    if call.request_streaming:
        try:
            deferred = await call.invoke_unary()
        except BaseException as error:
            await around.__aexit__(type(error), error, error.__traceback__)
            raise
        # The outcome of a streaming-request call arrives after this task has returned (see
        # `invoke_unary`), so the teardown runs from an observer instead of from here.
        finalizer = _AroundFinalizer(around)
        _spawn_background(_finalize_unary_outcome(call, deferred, finalizer))
        return deferred

    try:
        response = await call.invoke_unary()
    except BaseException as error:
        await around.__aexit__(type(error), error, error.__traceback__)
        raise

    await around.__aexit__(None, None, None)
    return response

AsyncClientContextInterceptor

Bases: AsyncClientInterceptor

Async client interceptor that injects metadata from a provider.

This interceptor allows for dynamic metadata injection (e.g., authorization tokens, request IDs, tenant IDs) into every outgoing gRPC call.

Features: - Support for both synchronous and asynchronous metadata providers. - Automatic validation and normalization of gRPC metadata keys. - Automatic encoding of binary metadata values. - The same injection on all four RPC kinds, streams included. Injection used to reach unary-unary calls only: a channel files an interceptor by class, and an interceptor deriving from all four gRPC base classes lands in the first list alone (see base).

Note

The metadata is written onto the call details before the RPC exists, so this layer implements base.AsyncClientInterceptor.intercept and hands the call straight on instead of using the around_call seam. Wrapping a response stream to observe an outcome it has no use for would cost a generator hop per item, and that wrapper is not free in another way either: when a caller abandons a stream, closing it drives the GeneratorExit back into the wrapper, which the event loop reports as an error if it has closed the generator first (measured at loop shutdown as RuntimeError: generator didn't stop after athrow()).

Source code in grpc_client_kit/interceptors/context.py
class AsyncClientContextInterceptor(AsyncClientInterceptor):
    """Async client interceptor that injects metadata from a provider.

    This interceptor allows for dynamic metadata injection (e.g., authorization
    tokens, request IDs, tenant IDs) into every outgoing gRPC call.

    Features:
    - Support for both synchronous and asynchronous metadata providers.
    - Automatic validation and normalization of gRPC metadata keys.
    - Automatic encoding of binary metadata values.
    - The same injection on all four RPC kinds, streams included. Injection used to reach
      unary-unary calls only: a channel files an interceptor by class, and an interceptor deriving
      from all four gRPC base classes lands in the first list alone (see `base`).

    Note:
        The metadata is written onto the call details before the RPC exists, so this layer
        implements `base.AsyncClientInterceptor.intercept` and hands the call straight on instead of
        using the ``around_call`` seam. Wrapping a response stream to observe an outcome it has no
        use for would cost a generator hop per item, and that wrapper is not free in another way
        either: when a caller abandons a stream, closing it drives the ``GeneratorExit`` back into
        the wrapper, which the event loop reports as an error if it has closed the generator first
        (measured at loop shutdown as ``RuntimeError: generator didn't stop after athrow()``).
    """

    def __init__(
        self, metadata_provider: Callable[[], dict[str, str | bytes] | Awaitable[dict[str, str | bytes]]]
    ) -> None:
        """Initialize the context interceptor.

        Args:
            metadata_provider: A callable that returns a dictionary of metadata.
                               Can be a regular function or an async function.
        """
        self._provider = metadata_provider

    def _validate_metadata_key(self, key: str) -> str:
        """Validate and normalize metadata key.

        gRPC metadata keys must be:
        - ASCII lowercase
        - No uppercase, no unicode
        - Binary keys must end with '-bin'
        """
        key_lower = key.lower()
        try:
            key_lower.encode("ascii")
        except UnicodeEncodeError as e:
            raise ValueError(f"Metadata key must be ASCII: {key}") from e
        return key_lower

    def _encode_metadata_value(self, key: str, value: str | bytes) -> tuple[str, str | bytes]:
        """Encode metadata value based on key suffix.

        Binary metadata (keys ending with '-bin') can contain arbitrary bytes.
        Text metadata must be ASCII-only.
        """
        if key.endswith("-bin"):
            # Binary metadata - encode as bytes
            if isinstance(value, bytes):
                return (key, value)
            return (key, value.encode("utf-8"))
        else:
            # Text metadata - must be ASCII
            str_value = str(value)
            try:
                str_value.encode("ascii")
            except UnicodeEncodeError as e:
                raise ValueError(f"Non-binary metadata value must be ASCII (key={key}): {str_value}") from e
            return (key, str_value)

    async def _context_metadata(self) -> dict[str, str | bytes]:
        """Ask the provider for this call's metadata, tolerating a provider that fails.

        Returns:
            The provider's metadata, or an empty mapping if it raised. Injection is an enrichment:
            a broken provider must not decide the fate of the call it was supposed to describe.
        """
        try:
            provided = self._provider()
            metadata: dict[str, str | bytes] = await provided if inspect.isawaitable(provided) else provided
        except Exception:
            logger.exception("Metadata provider failed")
            return {}
        else:
            return metadata

    async def intercept(self, call: ClientCall) -> Any:
        """Attach the provider's metadata to the call and issue it.

        Args:
            call: The call to issue; its details are rewritten before the RPC exists.

        Returns:
            The response of a unary call, or the iterator of a streaming one, untouched.

        Raises:
            ValueError: If the provider returned a key or a value gRPC cannot carry. Raising before
                anything is issued means no call ever leaves with metadata that would be rejected
                further down.
        """
        context_metadata = await self._context_metadata()

        if context_metadata:
            metadata = list(call.details.metadata or [])
            for key, value in context_metadata.items():
                if value is not None:
                    key_normalized = self._validate_metadata_key(key)
                    encoded_key, encoded_value = self._encode_metadata_value(key_normalized, value)
                    metadata.append((encoded_key, encoded_value))

            call.details = call.details._replace(metadata=metadata)

        if call.response_streaming:
            return await call.invoke_stream()

        return await call.invoke_unary()

adapters cached property

The four single-ABC objects through which a channel registers this interceptor.

Built once per instance and kept: a chain's identity — and with it the channel pool's idea of which channels are interchangeable — is the identity of the objects in it.

__init__(metadata_provider)

Initialize the context interceptor.

Parameters:

Name Type Description Default
metadata_provider Callable[[], dict[str, str | bytes] | Awaitable[dict[str, str | bytes]]]

A callable that returns a dictionary of metadata. Can be a regular function or an async function.

required
Source code in grpc_client_kit/interceptors/context.py
def __init__(
    self, metadata_provider: Callable[[], dict[str, str | bytes] | Awaitable[dict[str, str | bytes]]]
) -> None:
    """Initialize the context interceptor.

    Args:
        metadata_provider: A callable that returns a dictionary of metadata.
                           Can be a regular function or an async function.
    """
    self._provider = metadata_provider

intercept(call) async

Attach the provider's metadata to the call and issue it.

Parameters:

Name Type Description Default
call ClientCall

The call to issue; its details are rewritten before the RPC exists.

required

Returns:

Type Description
Any

The response of a unary call, or the iterator of a streaming one, untouched.

Raises:

Type Description
ValueError

If the provider returned a key or a value gRPC cannot carry. Raising before anything is issued means no call ever leaves with metadata that would be rejected further down.

Source code in grpc_client_kit/interceptors/context.py
async def intercept(self, call: ClientCall) -> Any:
    """Attach the provider's metadata to the call and issue it.

    Args:
        call: The call to issue; its details are rewritten before the RPC exists.

    Returns:
        The response of a unary call, or the iterator of a streaming one, untouched.

    Raises:
        ValueError: If the provider returned a key or a value gRPC cannot carry. Raising before
            anything is issued means no call ever leaves with metadata that would be rejected
            further down.
    """
    context_metadata = await self._context_metadata()

    if context_metadata:
        metadata = list(call.details.metadata or [])
        for key, value in context_metadata.items():
            if value is not None:
                key_normalized = self._validate_metadata_key(key)
                encoded_key, encoded_value = self._encode_metadata_value(key_normalized, value)
                metadata.append((encoded_key, encoded_value))

        call.details = call.details._replace(metadata=metadata)

    if call.response_streaming:
        return await call.invoke_stream()

    return await call.invoke_unary()

AsyncClientInterceptor

Bases: ABC

A logical client interceptor: one implementation covering all four RPC kinds.

Subclasses implement intercept, which is handed a ClientCall and owns the call from there: it decides whether to issue it at all, how many times, and with which details. That is the hook for layers that re-issue calls — retries — or refuse them outright. Everything that only needs to wrap a call should subclass AsyncAroundClientInterceptor instead and write one generator.

Deliberately not a grpc.aio.ClientInterceptor: passing one straight to a channel raises ValueError from grpc instead of silently registering it for unary-unary calls only. Chains reach a channel through flatten_interceptors, which turns each logical interceptor into the four adapters a channel can file correctly.

Source code in grpc_client_kit/interceptors/base.py
class AsyncClientInterceptor(abc.ABC):
    """A logical client interceptor: one implementation covering all four RPC kinds.

    Subclasses implement `intercept`, which is handed a `ClientCall` and owns the call from there:
    it decides whether to issue it at all, how many times, and with which details. That is the hook
    for layers that re-issue calls — retries — or refuse them outright. Everything that only needs
    to *wrap* a call should subclass `AsyncAroundClientInterceptor` instead and write one generator.

    Deliberately not a `grpc.aio.ClientInterceptor`: passing one straight to a channel raises
    ``ValueError`` from grpc instead of silently registering it for unary-unary calls only. Chains
    reach a channel through `flatten_interceptors`, which turns each logical interceptor into the
    four adapters a channel can file correctly.
    """

    @abc.abstractmethod
    async def intercept(self, call: ClientCall) -> Any:
        """Run one RPC and return its outcome.

        Args:
            call: The call to run, and the handle used to issue it.

        Returns:
            For a unary response, the response message (`ClientCall.invoke_unary` returns it). For a
            streaming response, the response iterator — which must be built over an *already issued*
            call, i.e. over the result of `ClientCall.invoke_stream`.

        Raises:
            grpc.aio.AioRpcError: To fail the call, whether the error came off the wire or was
                raised by this interceptor without issuing anything.
        """

    @functools.cached_property
    def adapters(self) -> tuple[grpc.aio.ClientInterceptor, ...]:
        """The four single-ABC objects through which a channel registers this interceptor.

        Built once per instance and kept: a chain's identity — and with it the channel pool's idea
        of which channels are interchangeable — is the identity of the objects in it.
        """
        return (
            _UnaryUnaryAdapter(self),
            _UnaryStreamAdapter(self),
            _StreamUnaryAdapter(self),
            _StreamStreamAdapter(self),
        )

    async def _run_rpc(self, rpc_type: RpcType, continuation: Continuation, details: Any, request: Any) -> Any:
        """Turn one grpc ``intercept_*`` invocation into an `intercept` call (adapter plumbing)."""
        method = details.method
        call = ClientCall(
            method=method.decode("utf-8", errors="replace") if isinstance(method, bytes) else method,
            rpc_type=rpc_type,
            details=details,
            request=request,
            _continuation=continuation,
        )
        result = await self.intercept(call)

        if call.response_streaming:
            return result
        # grpc.aio wraps a bare response into a Call for unary-unary only; stream-unary passes it
        # to the caller untouched, where a message without ``__await__`` breaks the call and its
        # finalizer. Handing back the Call is correct for both, and it keeps the status and the
        # trailing metadata that a bare response drops.
        return call.underlying_call if call.underlying_call is not None else result

adapters cached property

The four single-ABC objects through which a channel registers this interceptor.

Built once per instance and kept: a chain's identity — and with it the channel pool's idea of which channels are interchangeable — is the identity of the objects in it.

intercept(call) abstractmethod async

Run one RPC and return its outcome.

Parameters:

Name Type Description Default
call ClientCall

The call to run, and the handle used to issue it.

required

Returns:

Type Description
Any

For a unary response, the response message (ClientCall.invoke_unary returns it). For a

Any

streaming response, the response iterator — which must be built over an already issued

Any

call, i.e. over the result of ClientCall.invoke_stream.

Raises:

Type Description
AioRpcError

To fail the call, whether the error came off the wire or was raised by this interceptor without issuing anything.

Source code in grpc_client_kit/interceptors/base.py
@abc.abstractmethod
async def intercept(self, call: ClientCall) -> Any:
    """Run one RPC and return its outcome.

    Args:
        call: The call to run, and the handle used to issue it.

    Returns:
        For a unary response, the response message (`ClientCall.invoke_unary` returns it). For a
        streaming response, the response iterator — which must be built over an *already issued*
        call, i.e. over the result of `ClientCall.invoke_stream`.

    Raises:
        grpc.aio.AioRpcError: To fail the call, whether the error came off the wire or was
            raised by this interceptor without issuing anything.
    """

AsyncLoggingInterceptor

Bases: AsyncAroundClientInterceptor

Async logging interceptor for gRPC clients with sensitive data filtering.

Provides comprehensive structured logging for every gRPC call, including start time, duration, status, and metadata.

Features: - Real Outcomes: The terminal record carries the status the caller observed, whether it arrived at the end of a unary call or in the middle of a response stream. - Sensitive Data Redaction: Automatically masks common authentication headers. - Method-Level Sensitivity: Entire methods or regex patterns can be marked as sensitive to prevent payload logging. - Request ID Correlation: Automatically extracts and includes request-id from metadata in all log entries. - Payload Logging: Optional logging of request and response bodies (truncated to 1KB). - LRU Cache: Efficiently caches sensitivity check results for method names. - Structured Logging: Uses the standard Python logging module with extra dict.

Note

Every log record gets a freshly built extra mapping derived from the call-wide fields (service, method, request id, sensitivity). Records therefore never inherit fields from an earlier stage of the same call, and handlers can retain the mapping they were given.

Source code in grpc_client_kit/interceptors/client_logging.py
class AsyncLoggingInterceptor(AsyncAroundClientInterceptor):
    """Async logging interceptor for gRPC clients with sensitive data filtering.

    Provides comprehensive structured logging for every gRPC call, including
    start time, duration, status, and metadata.

    Features:
    - **Real Outcomes**: The terminal record carries the status the caller observed, whether it
      arrived at the end of a unary call or in the middle of a response stream.
    - **Sensitive Data Redaction**: Automatically masks common authentication headers.
    - **Method-Level Sensitivity**: Entire methods or regex patterns can be marked
      as sensitive to prevent payload logging.
    - **Request ID Correlation**: Automatically extracts and includes `request-id`
      from metadata in all log entries.
    - **Payload Logging**: Optional logging of request and response bodies (truncated to 1KB).
    - **LRU Cache**: Efficiently caches sensitivity check results for method names.
    - **Structured Logging**: Uses the standard Python `logging` module with `extra` dict.

    Note:
        Every log record gets a freshly built `extra` mapping derived from the call-wide fields
        (service, method, request id, sensitivity). Records therefore never inherit fields from an
        earlier stage of the same call, and handlers can retain the mapping they were given.
    """

    def __init__(
        self,
        service_name: str,
        sensitive_methods: set[str] | None = None,
        sensitive_patterns: list[str] | None = None,
        sensitive_headers: set[str] | None = None,
        log_request_payload: bool = False,
        log_response_payload: bool = False,
        log_metadata: bool = True,
        max_cache_size: int = 1000,
        success_log_level: int = logging.INFO,
    ) -> None:
        """Initialize the logging interceptor.

        Args:
            service_name: Name of the service for log categorization.
            sensitive_methods: Set of full method names to treat as sensitive.
            sensitive_patterns: List of regex patterns for sensitive method names.
            sensitive_headers: Set of header names (case-insensitive) to redact.
            log_request_payload: Whether to log the request object.
            log_response_payload: Whether to log the response object.
            log_metadata: Whether to log gRPC metadata (redacted).
            max_cache_size: Size of the sensitivity check LRU cache.
            success_log_level: Level of the record a successful call emits. One INFO line per
                successful RPC is a flood at high QPS — drop this to ``logging.DEBUG`` there, or
                keep the default and set the logger's own level instead.
        """
        self._service_name = service_name
        self._sensitive_methods = sensitive_methods or set()
        self._sensitive_patterns = [re.compile(pattern) for pattern in (sensitive_patterns or [])]
        self._sensitive_headers = {h.lower() for h in (sensitive_headers or DEFAULT_SENSITIVE_HEADERS)}
        self._log_request_payload = log_request_payload
        self._log_response_payload = log_response_payload
        self._log_metadata = log_metadata
        self._success_log_level = success_log_level

        # The service name never changes, so neither does the logger: resolving it per call costs
        # an f-string plus logging's module lock, on the hot path, for nothing.
        self._log = logging.getLogger(f"grpc.client.{service_name}")

        # LRU cache for sensitivity checks. No lock on purpose: the critical section below contains
        # no await, so it is atomic with respect to the event loop — and asyncio.Lock offers no
        # cross-thread protection anyway. Keep it await-free or bring the lock back.
        self._sensitivity_cache: OrderedDict[str, bool] = OrderedDict()
        self._max_cache_size = max_cache_size

    def _is_sensitive(self, method: str) -> bool:
        """Check if method is sensitive, with LRU caching."""
        if method in self._sensitivity_cache:
            self._sensitivity_cache.move_to_end(method)
            return self._sensitivity_cache[method]

        is_sensitive = method in self._sensitive_methods or any(
            pattern.match(method) for pattern in self._sensitive_patterns
        )

        if len(self._sensitivity_cache) >= self._max_cache_size:
            self._sensitivity_cache.popitem(last=False)

        self._sensitivity_cache[method] = is_sensitive
        return is_sensitive

    def _extract_request_id(self, metadata: Mapping[str, str]) -> str | None:
        """Extract the request ID used for log correlation.

        Args:
            metadata: Metadata already normalized by
                :func:`~grpc_client_kit.utils.metadata_to_dict`.

        Returns:
            The correlation id, or None when the call carries none.
        """
        for key, value in metadata.items():
            if key.lower() in ("request-id", "x-request-id"):
                return value
        return None

    def _redact(self, metadata: Mapping[str, str]) -> dict[str, str]:
        """Return a copy of the metadata with sensitive header values masked."""
        return {key: ("***" if key.lower() in self._sensitive_headers else value) for key, value in metadata.items()}

    def _finish_extra(self, base_extra: _LogExtra, start_time: float, status: str) -> _LogExtra:
        """Build the `extra` mapping of a terminal record: call-wide fields plus duration/status."""
        return {
            **base_extra,
            "grpc.duration_ms": (time.perf_counter() - start_time) * 1000,
            "grpc.status": status,
        }

    def _failure_extra(
        self,
        base_extra: _LogExtra,
        start_time: float,
        error: BaseException,
        is_sensitive: bool,
    ) -> tuple[_LogExtra, bool, str]:
        """Describe a failed call.

        Args:
            base_extra: Call-wide log fields.
            start_time: Value of `time.perf_counter()` taken when the call started.
            error: The gRPC error raised by the call.
            is_sensitive: Whether the method is marked sensitive (suppresses error details).

        Returns:
            A tuple of the record's `extra` mapping, whether the failure deserves a stack
            trace, and the gRPC error details.
        """
        code_func = getattr(error, "code", None)
        code: grpc.StatusCode | None = code_func() if callable(code_func) else None
        details_func = getattr(error, "details", None)
        details = str(details_func() if callable(details_func) else error)

        extra = self._finish_extra(base_extra, start_time, code.name if code else "UNKNOWN")
        if not is_sensitive:
            extra["grpc.error"] = details

        return extra, code in _CRITICAL_STATUS_CODES or code is None, details

    def _log_start(
        self,
        log: logging.Logger,
        base_extra: _LogExtra,
        metadata: Mapping[str, str],
        call: ClientCall,
        is_sensitive: bool,
    ) -> None:
        """Emit the call-start debug record."""
        if is_sensitive:
            log.debug("gRPC call started (SENSITIVE)", extra=dict(base_extra))
            return

        extra = dict(base_extra)
        if self._log_metadata and metadata:
            extra["grpc.metadata"] = str(self._redact(metadata))

        if self._log_request_payload:
            if call.request_streaming:
                extra["grpc.request"] = "<stream_request>"
            else:
                extra["grpc.request"] = str(call.request)[:_MAX_PAYLOAD_CHARS]

        log.debug("gRPC call started", extra=extra)

    def _log_rpc_failure(
        self,
        log: logging.Logger,
        extra: _LogExtra,
        is_critical: bool,
        details: str,
        subject: str,
    ) -> None:
        """Emit the terminal record of a call that came back with a gRPC status."""
        if is_critical:
            log.exception("gRPC %s failed with critical error", subject, extra=extra)
        else:
            # error() instead of exception() to avoid long tracebacks for expected errors
            log.error("gRPC %s failed with status %s: %s", subject, extra["grpc.status"], details, extra=extra)

    def _log_success(
        self,
        log: logging.Logger,
        extra: _LogExtra,
        call: ClientCall,
        is_sensitive: bool,
        subject: str,
    ) -> None:
        """Emit the terminal record of a call that ended with OK."""
        if is_sensitive:
            log.log(self._success_log_level, "gRPC %s successful (SENSITIVE)", subject, extra=extra)
            return

        # A streaming response has no single payload to show: `call.response` stays None for it.
        if self._log_response_payload and not call.response_streaming:
            extra["grpc.response"] = str(call.response)[:_MAX_PAYLOAD_CHARS]
        log.log(self._success_log_level, "gRPC %s successful", subject, extra=extra)

    async def around_call(self, call: ClientCall) -> AsyncIterator[None]:
        """Log one RPC: a record when it starts, and exactly one saying how it ended."""
        log = self._log

        # A logger that will emit nothing must cost nothing: no metadata normalization, no
        # sensitivity lookup, no extra dicts. WARNING is the lowest level any record of this
        # interceptor is emitted at when things go wrong, so it is the gate.
        if not log.isEnabledFor(logging.WARNING):
            yield
            return

        is_sensitive = self._is_sensitive(call.method)

        # Metadata is normalized exactly once per call: both the correlation id and the redacted
        # metadata field read from this dict instead of walking the raw pairs again.
        metadata = metadata_to_dict(call.details.metadata)
        request_id = self._extract_request_id(metadata)

        base_extra: _LogExtra = {
            "grpc.service": self._service_name,
            "grpc.method": call.method,
        }
        if request_id:
            base_extra["request_id"] = request_id
        if is_sensitive:
            base_extra["grpc.sensitive"] = True

        # Formatting metadata and payloads is pure waste when DEBUG is off, and this is a hot path.
        if log.isEnabledFor(logging.DEBUG):
            self._log_start(log, base_extra, metadata, call, is_sensitive)

        # Streaming responses are reported as streams: they end when their last item is delivered.
        subject = "stream" if call.response_streaming else "call"
        start_time = time.perf_counter()

        try:
            yield
        except (asyncio.CancelledError, GeneratorExit):
            # GeneratorExit is a caller walking away from a stream, which ends the call as surely as
            # a cancellation does.
            if log.isEnabledFor(logging.INFO):
                log.info("gRPC %s cancelled", subject, extra=self._finish_extra(base_extra, start_time, "CANCELLED"))
            raise
        except CircuitBreakerOpenError as error:
            # A rejection by the local breaker is expected behaviour while the circuit recovers,
            # not a fresh server failure: one WARNING per rejection, never an ERROR flood that
            # drowns the handful of genuine transition records.
            extra = self._finish_extra(base_extra, start_time, "UNAVAILABLE")
            log.warning("gRPC %s refused by the open circuit breaker: %s", subject, error.details(), extra=extra)
            raise
        except grpc.aio.AioRpcError as error:
            extra, is_critical, details = self._failure_extra(base_extra, start_time, error, is_sensitive)
            self._log_rpc_failure(log, extra, is_critical, details, subject)
            raise
        except Exception as error:
            extra = self._finish_extra(base_extra, start_time, "INTERNAL")
            if not is_sensitive:
                extra["grpc.error"] = str(error)
            log.exception("gRPC %s failed with unexpected error", subject, extra=extra)
            raise
        else:
            if log.isEnabledFor(self._success_log_level):
                extra = self._finish_extra(base_extra, start_time, "OK")
                self._log_success(log, extra, call, is_sensitive, subject)

adapters cached property

The four single-ABC objects through which a channel registers this interceptor.

Built once per instance and kept: a chain's identity — and with it the channel pool's idea of which channels are interchangeable — is the identity of the objects in it.

__init__(service_name, sensitive_methods=None, sensitive_patterns=None, sensitive_headers=None, log_request_payload=False, log_response_payload=False, log_metadata=True, max_cache_size=1000, success_log_level=logging.INFO)

Initialize the logging interceptor.

Parameters:

Name Type Description Default
service_name str

Name of the service for log categorization.

required
sensitive_methods set[str] | None

Set of full method names to treat as sensitive.

None
sensitive_patterns list[str] | None

List of regex patterns for sensitive method names.

None
sensitive_headers set[str] | None

Set of header names (case-insensitive) to redact.

None
log_request_payload bool

Whether to log the request object.

False
log_response_payload bool

Whether to log the response object.

False
log_metadata bool

Whether to log gRPC metadata (redacted).

True
max_cache_size int

Size of the sensitivity check LRU cache.

1000
success_log_level int

Level of the record a successful call emits. One INFO line per successful RPC is a flood at high QPS — drop this to logging.DEBUG there, or keep the default and set the logger's own level instead.

INFO
Source code in grpc_client_kit/interceptors/client_logging.py
def __init__(
    self,
    service_name: str,
    sensitive_methods: set[str] | None = None,
    sensitive_patterns: list[str] | None = None,
    sensitive_headers: set[str] | None = None,
    log_request_payload: bool = False,
    log_response_payload: bool = False,
    log_metadata: bool = True,
    max_cache_size: int = 1000,
    success_log_level: int = logging.INFO,
) -> None:
    """Initialize the logging interceptor.

    Args:
        service_name: Name of the service for log categorization.
        sensitive_methods: Set of full method names to treat as sensitive.
        sensitive_patterns: List of regex patterns for sensitive method names.
        sensitive_headers: Set of header names (case-insensitive) to redact.
        log_request_payload: Whether to log the request object.
        log_response_payload: Whether to log the response object.
        log_metadata: Whether to log gRPC metadata (redacted).
        max_cache_size: Size of the sensitivity check LRU cache.
        success_log_level: Level of the record a successful call emits. One INFO line per
            successful RPC is a flood at high QPS — drop this to ``logging.DEBUG`` there, or
            keep the default and set the logger's own level instead.
    """
    self._service_name = service_name
    self._sensitive_methods = sensitive_methods or set()
    self._sensitive_patterns = [re.compile(pattern) for pattern in (sensitive_patterns or [])]
    self._sensitive_headers = {h.lower() for h in (sensitive_headers or DEFAULT_SENSITIVE_HEADERS)}
    self._log_request_payload = log_request_payload
    self._log_response_payload = log_response_payload
    self._log_metadata = log_metadata
    self._success_log_level = success_log_level

    # The service name never changes, so neither does the logger: resolving it per call costs
    # an f-string plus logging's module lock, on the hot path, for nothing.
    self._log = logging.getLogger(f"grpc.client.{service_name}")

    # LRU cache for sensitivity checks. No lock on purpose: the critical section below contains
    # no await, so it is atomic with respect to the event loop — and asyncio.Lock offers no
    # cross-thread protection anyway. Keep it await-free or bring the lock back.
    self._sensitivity_cache: OrderedDict[str, bool] = OrderedDict()
    self._max_cache_size = max_cache_size

around_call(call) async

Log one RPC: a record when it starts, and exactly one saying how it ended.

Source code in grpc_client_kit/interceptors/client_logging.py
async def around_call(self, call: ClientCall) -> AsyncIterator[None]:
    """Log one RPC: a record when it starts, and exactly one saying how it ended."""
    log = self._log

    # A logger that will emit nothing must cost nothing: no metadata normalization, no
    # sensitivity lookup, no extra dicts. WARNING is the lowest level any record of this
    # interceptor is emitted at when things go wrong, so it is the gate.
    if not log.isEnabledFor(logging.WARNING):
        yield
        return

    is_sensitive = self._is_sensitive(call.method)

    # Metadata is normalized exactly once per call: both the correlation id and the redacted
    # metadata field read from this dict instead of walking the raw pairs again.
    metadata = metadata_to_dict(call.details.metadata)
    request_id = self._extract_request_id(metadata)

    base_extra: _LogExtra = {
        "grpc.service": self._service_name,
        "grpc.method": call.method,
    }
    if request_id:
        base_extra["request_id"] = request_id
    if is_sensitive:
        base_extra["grpc.sensitive"] = True

    # Formatting metadata and payloads is pure waste when DEBUG is off, and this is a hot path.
    if log.isEnabledFor(logging.DEBUG):
        self._log_start(log, base_extra, metadata, call, is_sensitive)

    # Streaming responses are reported as streams: they end when their last item is delivered.
    subject = "stream" if call.response_streaming else "call"
    start_time = time.perf_counter()

    try:
        yield
    except (asyncio.CancelledError, GeneratorExit):
        # GeneratorExit is a caller walking away from a stream, which ends the call as surely as
        # a cancellation does.
        if log.isEnabledFor(logging.INFO):
            log.info("gRPC %s cancelled", subject, extra=self._finish_extra(base_extra, start_time, "CANCELLED"))
        raise
    except CircuitBreakerOpenError as error:
        # A rejection by the local breaker is expected behaviour while the circuit recovers,
        # not a fresh server failure: one WARNING per rejection, never an ERROR flood that
        # drowns the handful of genuine transition records.
        extra = self._finish_extra(base_extra, start_time, "UNAVAILABLE")
        log.warning("gRPC %s refused by the open circuit breaker: %s", subject, error.details(), extra=extra)
        raise
    except grpc.aio.AioRpcError as error:
        extra, is_critical, details = self._failure_extra(base_extra, start_time, error, is_sensitive)
        self._log_rpc_failure(log, extra, is_critical, details, subject)
        raise
    except Exception as error:
        extra = self._finish_extra(base_extra, start_time, "INTERNAL")
        if not is_sensitive:
            extra["grpc.error"] = str(error)
        log.exception("gRPC %s failed with unexpected error", subject, extra=extra)
        raise
    else:
        if log.isEnabledFor(self._success_log_level):
            extra = self._finish_extra(base_extra, start_time, "OK")
            self._log_success(log, extra, call, is_sensitive, subject)

intercept(call) async

Issue the call inside around_call (base plumbing; subclasses override the generator).

Source code in grpc_client_kit/interceptors/base.py
async def intercept(self, call: ClientCall) -> Any:
    """Issue the call inside `around_call` (base plumbing; subclasses override the generator)."""
    around = self._around(call)
    await around.__aenter__()

    if call.response_streaming:
        try:
            stream = await call.invoke_stream()
        except BaseException as error:
            await around.__aexit__(type(error), error, error.__traceback__)
            raise
        # The teardown has to outlive this method: the call is not over until the last item is.
        # It must also not depend on the consumer finishing the iteration — a cancelled or
        # abandoned call fires its done callback, and that closes the teardown deterministically
        # instead of waiting for garbage collection.
        finalizer = _AroundFinalizer(around)
        _finalize_when_done(call.underlying_call, finalizer)
        return _closing_stream(finalizer, stream)

    if call.request_streaming:
        try:
            deferred = await call.invoke_unary()
        except BaseException as error:
            await around.__aexit__(type(error), error, error.__traceback__)
            raise
        # The outcome of a streaming-request call arrives after this task has returned (see
        # `invoke_unary`), so the teardown runs from an observer instead of from here.
        finalizer = _AroundFinalizer(around)
        _spawn_background(_finalize_unary_outcome(call, deferred, finalizer))
        return deferred

    try:
        response = await call.invoke_unary()
    except BaseException as error:
        await around.__aexit__(type(error), error, error.__traceback__)
        raise

    await around.__aexit__(None, None, None)
    return response

AsyncRetryInterceptor

Bases: AsyncClientInterceptor

Async retry interceptor for gRPC clients.

Retries failed calls with exponential backoff and jitter, without ever extending the deadline of the call it is retrying.

Reading the outcome

In grpc.aio a continuation resolves to a Call the moment the RPC is created and never raises, so a retry layer that treats that object as the response sees every call succeed. This one goes through ClientCall.invoke_unary, which awaits the Call and therefore fails with the real status — that is the only reason a retry ever fires on a live connection.

Call budget

The relative timeout carried by the call details is the budget for the whole call, so it is converted into a deadline once, on entry. Every subsequent attempt is issued with the budget that is left (gRPC only understands relative timeouts, so the remainder is recomputed right before each attempt), and a retry is abandoned when the backoff alone would outlive the budget. Without this, N attempts of T seconds would silently stretch a T-second call to N * T.

Safety

Retrying an RPC that the server already executed duplicates its side effects, so what gets retried is deliberately narrow — but the default is a compromise, not a guarantee:

  • DEFAULT_RETRYABLE_CODES covers the statuses that usually mean the request never reached the handler. Usually is not always: a connection that dies mid-handler surfaces as UNAVAILABLE, and an application may abort with RESOURCE_EXHAUSTED after a write — in both cases a retry duplicates the request. Where a duplicate write is unaffordable, set idempotent_methods; with the whitelist in place, nothing outside it is retried.
  • Codes like INTERNAL, UNKNOWN, ABORTED or DEADLINE_EXCEEDED are worse still — the write has very likely been applied — and are never retried unless a caller opts in through retryable_codes.
  • idempotent_methods, when given, is a whitelist that applies to every call type: a unary-unary method outside of it is not retried even on a retryable code.
  • Streaming responses need that whitelist. Restarting a stream replays items the consumer has already seen, so retry_streaming alone is not enough to enable it.
  • Calls with a streaming request are never retried: the request iterator is consumed by the first attempt and cannot be replayed without buffering it whole.
  • Native gRPC retries (retryPolicy in a service config) run below this layer and multiply with it: kit attempts times native attempts reach the server. Configure one source of retries, not both — the client warns when it sees both.
Source code in grpc_client_kit/interceptors/retry.py
class AsyncRetryInterceptor(AsyncClientInterceptor):
    """Async retry interceptor for gRPC clients.

    Retries failed calls with exponential backoff and jitter, without ever extending the deadline
    of the call it is retrying.

    Reading the outcome:
        In grpc.aio a continuation resolves to a `Call` the moment the RPC is *created* and never
        raises, so a retry layer that treats that object as the response sees every call succeed.
        This one goes through `ClientCall.invoke_unary`, which awaits the Call and therefore fails
        with the real status — that is the only reason a retry ever fires on a live connection.

    Call budget:
        The relative timeout carried by the call details is the budget for the whole call, so it is
        converted into a deadline **once**, on entry. Every subsequent attempt is issued with the
        budget that is left (gRPC only understands relative timeouts, so the remainder is recomputed
        right before each attempt), and a retry is abandoned when the backoff alone would outlive the
        budget. Without this, N attempts of T seconds would silently stretch a T-second call to N * T.

    Safety:
        Retrying an RPC that the server already executed duplicates its side effects, so what gets
        retried is deliberately narrow — but the default is a compromise, not a guarantee:

        - `DEFAULT_RETRYABLE_CODES` covers the statuses that *usually* mean the request never
          reached the handler. Usually is not always: a connection that dies mid-handler surfaces
          as `UNAVAILABLE`, and an application may abort with `RESOURCE_EXHAUSTED` after a write —
          in both cases a retry duplicates the request. Where a duplicate write is unaffordable,
          set `idempotent_methods`; with the whitelist in place, nothing outside it is retried.
        - Codes like `INTERNAL`, `UNKNOWN`, `ABORTED` or `DEADLINE_EXCEEDED` are worse still — the
          write has very likely been applied — and are never retried unless a caller opts in
          through `retryable_codes`.
        - `idempotent_methods`, when given, is a whitelist that applies to **every** call type: a
          unary-unary method outside of it is not retried even on a retryable code.
        - Streaming responses need that whitelist. Restarting a stream replays items the consumer
          has already seen, so `retry_streaming` alone is not enough to enable it.
        - Calls with a streaming *request* are never retried: the request iterator is consumed by the
          first attempt and cannot be replayed without buffering it whole.
        - Native gRPC retries (`retryPolicy` in a service config) run *below* this layer and
          multiply with it: kit attempts times native attempts reach the server. Configure one
          source of retries, not both — the client warns when it sees both.
    """

    def __init__(
        self,
        max_attempts: int = 3,
        initial_backoff: float = 0.1,
        max_backoff: float = 10.0,
        backoff_multiplier: float = 2.0,
        jitter: float = 0.1,
        retryable_codes: set[grpc.StatusCode] | None = None,
        retry_streaming: bool = False,
        idempotent_methods: set[str] | None = None,
        on_retry: Callable[[str, int, grpc.StatusCode | None, float], Awaitable[None]] | None = None,
        metrics: RetryMetricsProtocol | None = None,
    ) -> None:
        """Initialize the retry interceptor.

        Args:
            max_attempts: Total number of attempts (including the first one).
            initial_backoff: Initial seconds to wait before first retry.
            max_backoff: Maximum seconds to wait between retries.
            backoff_multiplier: Factor by which backoff increases each attempt.
            jitter: Random variation factor (0.0 to 1.0) applied to backoff.
            retryable_codes: gRPC status codes that trigger a retry. Defaults to
                             `DEFAULT_RETRYABLE_CODES`; an empty set disables retries.
            retry_streaming: Whether to enable retries for Unary-Stream calls.
            idempotent_methods: Full method names that may be retried. Required for streaming
                                retries, and a whitelist for unary calls when given.
            on_retry: Optional async callback called on every retry attempt.
            metrics: Optional registry told about every scheduled retry. The request metrics sit
                above this layer and see one entry per *logical* call, so without this a retry
                storm — N wire attempts collapsing into one success — is invisible on a dashboard.

        Raises:
            ValueError: If any of the backoff parameters is out of range.
        """
        if max_attempts < 1:
            raise ValueError("max_attempts must be at least 1")
        if initial_backoff < 0:
            raise ValueError("initial_backoff must be non-negative")
        if max_backoff < 0:
            raise ValueError("max_backoff must be non-negative")
        if backoff_multiplier < 1:
            raise ValueError("backoff_multiplier must be at least 1")
        if not (0.0 <= jitter <= 1.0):
            raise ValueError("jitter must be between 0.0 and 1.0")

        self._max_attempts = max_attempts
        self._initial_backoff = initial_backoff
        self._max_backoff = max_backoff
        self._backoff_multiplier = backoff_multiplier
        self._jitter = jitter
        self._retry_streaming = retry_streaming
        self._on_retry = on_retry
        self._metrics = metrics
        self._idempotent_methods = idempotent_methods or set()
        # ``is None`` rather than a falsy check: an empty set is a valid "never retry" configuration.
        self._retryable_codes: frozenset[grpc.StatusCode] = (
            DEFAULT_RETRYABLE_CODES if retryable_codes is None else frozenset(retryable_codes)
        )

    async def intercept(self, call: ClientCall) -> Any:
        """Issue the call, retrying it as far as the configuration and the budget allow."""
        if call.response_streaming:
            return await self._start_stream(call)
        if call.request_streaming:
            # The request iterator is consumed by the first attempt and cannot be replayed, so a
            # streaming-request call is never retried. `invoke_unary` hands the Call back promptly
            # for these — awaiting the response here would deadlock the write()-style API.
            return await call.invoke_unary()

        return await self._unary_with_retries(call)

    def _deadline_from(self, client_call_details: Any) -> float | None:
        """Convert the relative timeout of the call into an absolute monotonic deadline.

        Args:
            client_call_details: Details of the call being intercepted.

        Returns:
            The monotonic deadline of the whole call, or None if the call has no budget.
        """
        timeout = getattr(client_call_details, "timeout", None)
        # ClientCallDetails is a structural type: anything that is not a real number (None, or a
        # placeholder from a custom call-details shim) means "no budget to divide".
        if not isinstance(timeout, (int, float)):
            return None

        return time.monotonic() + float(timeout)

    def _budget_left(self, deadline: float | None) -> float | None:
        """Return the seconds left before `deadline`, or None if the call has no budget."""
        if deadline is None:
            return None

        return deadline - time.monotonic()

    def _with_budget_left(self, client_call_details: Any, deadline: float | None) -> Any:
        """Re-express the remaining budget as the relative timeout that gRPC expects."""
        if deadline is None:
            return client_call_details

        return client_call_details._replace(timeout=max(deadline - time.monotonic(), 0.0))

    def _may_retry_unary(self, method: str) -> bool:
        """Check whether a unary response may be retried at all."""
        return not self._idempotent_methods or method in self._idempotent_methods

    async def _prepare_retry(
        self,
        method: str,
        attempt: int,
        code: grpc.StatusCode | None,
        deadline: float | None,
    ) -> bool:
        """Wait out the backoff for the upcoming attempt.

        Args:
            method: Full method name, for logging and the `on_retry` callback.
            attempt: Number of the upcoming attempt (1 is the first retry).
            code: Status code that caused the retry.
            deadline: Monotonic deadline of the whole call, if it has a budget.

        Returns:
            True if the retry may proceed, False if the remaining budget cannot cover it.
        """
        backoff = self._calculate_backoff(attempt)

        budget_left = self._budget_left(deadline)
        if budget_left is not None and budget_left <= backoff:
            logger.debug(
                "Not retrying %s: %.3fs of call budget left, backoff alone needs %.3fs",
                method,
                budget_left,
                backoff,
            )
            return False

        logger.info("Retry attempt %d for %s after error %s. Waiting %.2fs", attempt, method, code, backoff)

        if self._metrics is not None:
            try:
                service = method.strip("/").split("/")[0].split(".")[-1]
                self._metrics.record_retry(service, method, attempt, code.name if code else "UNKNOWN")
            except Exception:
                logger.exception("Failed to record retry metrics for %s", method)

        if self._on_retry:
            try:
                await self._on_retry(method, attempt, code, backoff)
            except Exception:
                logger.exception("on_retry callback failed for %s", method)

        await asyncio.sleep(backoff)
        return True

    def _should_retry(self, call: ClientCall, error: grpc.aio.AioRpcError, attempt: int) -> bool:
        """Decide whether a failed unary attempt may be repeated.

        Args:
            call: The call that failed.
            error: The error the attempt failed with.
            attempt: Number of retries already spent.

        Returns:
            True if another attempt is allowed by the codes, the whitelist and `max_attempts`.
        """
        # A rejection by the circuit breaker never touched the network, so repeating it can only
        # burn the budget: the breaker would refuse the retry for exactly the same reason.
        if isinstance(error, CircuitBreakerOpenError) or _status_code(error) not in self._retryable_codes:
            return False

        if not self._may_retry_unary(call.method):
            logger.debug("Retry for %s skipped (not in idempotent_methods whitelist)", call.method)
            return False

        if attempt + 1 >= self._max_attempts:
            logger.debug("Maximum retry attempts (%d) reached for %s", self._max_attempts, call.method)
            return False

        return True

    async def _unary_with_retries(self, call: ClientCall) -> Any:
        """Issue a unary-response call, repeating it while the failure and the budget allow.

        Args:
            call: The call to issue.

        Returns:
            The response of the first attempt that succeeded.

        Raises:
            grpc.aio.AioRpcError: The error of the last attempt, once no further one is allowed.
        """
        budget = self._deadline_from(call.details)
        original_details = call.details
        attempt = 0

        while True:
            try:
                return await call.invoke_unary()
            except grpc.aio.AioRpcError as error:
                if not self._should_retry(call, error, attempt):
                    raise

                attempt += 1
                if not await self._prepare_retry(call.method, attempt, _status_code(error), budget):
                    raise

            call.details = self._with_budget_left(original_details, budget)

    async def _start_stream(self, call: ClientCall) -> AsyncIterator[Any]:
        """Issue a streaming-response call and, where retries are allowed, make it restartable.

        The call is issued here rather than inside the returned generator: grpc.aio binds the `Call`
        an interceptor created to the iterator it hands back, and a lazily started stream leaves
        that binding empty.

        Args:
            call: The call to issue.

        Returns:
            The response iterator, restarting on retryable failures where that is permitted.
        """
        budget = self._deadline_from(call.details)
        original_details = call.details
        stream = await call.invoke_stream()

        if call.request_streaming or not self._retry_streaming:
            return stream
        if call.method not in self._idempotent_methods:
            logger.debug("Streaming retry for %s skipped (not in idempotent_methods whitelist)", call.method)
            return stream

        # A bare generator would leave the caller's status surface — code(), details(), cancel() —
        # wired to the first, possibly failed attempt: grpc binds it once, to whatever object this
        # interceptor returns. The wrapper keeps it wired to the attempt currently on the wire.
        restartable = _RestartingStreamCall(call)
        restartable.attach(self._restarting_stream(call, stream, original_details, budget, restartable))
        return restartable

    async def _restarting_stream(
        self,
        call: ClientCall,
        stream: AsyncIterator[Any],
        original_details: Any,
        budget: float | None,
        surface: _RestartingStreamCall | None = None,
    ) -> AsyncIterator[Any]:
        """Yield a response stream, restarting the whole stream on retryable failures.

        Args:
            call: The call being iterated, used to issue the replacement streams.
            stream: The response stream of the attempt already in flight.
            original_details: Call details of the first attempt, the base for every trimmed retry.
            budget: Monotonic deadline of the whole call, if it has one.
            surface: The caller-visible call wrapper, consulted so an explicit ``cancel()`` stops
                the restarting instead of being retried around.

        Yields:
            Items of the response stream. Items yielded before a restart are seen again.
        """
        attempt = 0
        current = stream

        while True:
            try:
                async for item in current:
                    yield item
            except grpc.aio.AioRpcError as error:
                code = _status_code(error)
                cancelled = surface is not None and surface.cancelled()
                if cancelled or code not in self._retryable_codes or attempt + 1 >= self._max_attempts:
                    raise

                attempt += 1
                if not await self._prepare_retry(call.method, attempt, code, budget):
                    raise

                logger.warning(
                    "Restarting stream %s from the beginning (attempt %d after %s): already yielded items repeat",
                    call.method,
                    attempt,
                    code,
                )
            else:
                return

            call.details = self._with_budget_left(original_details, budget)
            current = await call.invoke_stream()

    def _calculate_backoff(self, attempt: int) -> float:
        """Calculate exponential backoff with jitter."""
        backoff = self._initial_backoff * (self._backoff_multiplier ** (attempt - 1))

        # Apply jitter: (1 ± jitter) * backoff
        if self._jitter > 0:
            factor = 1.0 + random.uniform(-self._jitter, self._jitter)  # noqa: S311
            backoff *= factor

        return max(0.0, min(backoff, self._max_backoff))

adapters cached property

The four single-ABC objects through which a channel registers this interceptor.

Built once per instance and kept: a chain's identity — and with it the channel pool's idea of which channels are interchangeable — is the identity of the objects in it.

__init__(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)

Initialize the retry interceptor.

Parameters:

Name Type Description Default
max_attempts int

Total number of attempts (including the first one).

3
initial_backoff float

Initial seconds to wait before first retry.

0.1
max_backoff float

Maximum seconds to wait between retries.

10.0
backoff_multiplier float

Factor by which backoff increases each attempt.

2.0
jitter float

Random variation factor (0.0 to 1.0) applied to backoff.

0.1
retryable_codes set[StatusCode] | None

gRPC status codes that trigger a retry. Defaults to DEFAULT_RETRYABLE_CODES; an empty set disables retries.

None
retry_streaming bool

Whether to enable retries for Unary-Stream calls.

False
idempotent_methods set[str] | None

Full method names that may be retried. Required for streaming retries, and a whitelist for unary calls when given.

None
on_retry Callable[[str, int, StatusCode | None, float], Awaitable[None]] | None

Optional async callback called on every retry attempt.

None
metrics RetryMetricsProtocol | None

Optional registry told about every scheduled retry. The request metrics sit above this layer and see one entry per logical call, so without this a retry storm — N wire attempts collapsing into one success — is invisible on a dashboard.

None

Raises:

Type Description
ValueError

If any of the backoff parameters is out of range.

Source code in grpc_client_kit/interceptors/retry.py
def __init__(
    self,
    max_attempts: int = 3,
    initial_backoff: float = 0.1,
    max_backoff: float = 10.0,
    backoff_multiplier: float = 2.0,
    jitter: float = 0.1,
    retryable_codes: set[grpc.StatusCode] | None = None,
    retry_streaming: bool = False,
    idempotent_methods: set[str] | None = None,
    on_retry: Callable[[str, int, grpc.StatusCode | None, float], Awaitable[None]] | None = None,
    metrics: RetryMetricsProtocol | None = None,
) -> None:
    """Initialize the retry interceptor.

    Args:
        max_attempts: Total number of attempts (including the first one).
        initial_backoff: Initial seconds to wait before first retry.
        max_backoff: Maximum seconds to wait between retries.
        backoff_multiplier: Factor by which backoff increases each attempt.
        jitter: Random variation factor (0.0 to 1.0) applied to backoff.
        retryable_codes: gRPC status codes that trigger a retry. Defaults to
                         `DEFAULT_RETRYABLE_CODES`; an empty set disables retries.
        retry_streaming: Whether to enable retries for Unary-Stream calls.
        idempotent_methods: Full method names that may be retried. Required for streaming
                            retries, and a whitelist for unary calls when given.
        on_retry: Optional async callback called on every retry attempt.
        metrics: Optional registry told about every scheduled retry. The request metrics sit
            above this layer and see one entry per *logical* call, so without this a retry
            storm — N wire attempts collapsing into one success — is invisible on a dashboard.

    Raises:
        ValueError: If any of the backoff parameters is out of range.
    """
    if max_attempts < 1:
        raise ValueError("max_attempts must be at least 1")
    if initial_backoff < 0:
        raise ValueError("initial_backoff must be non-negative")
    if max_backoff < 0:
        raise ValueError("max_backoff must be non-negative")
    if backoff_multiplier < 1:
        raise ValueError("backoff_multiplier must be at least 1")
    if not (0.0 <= jitter <= 1.0):
        raise ValueError("jitter must be between 0.0 and 1.0")

    self._max_attempts = max_attempts
    self._initial_backoff = initial_backoff
    self._max_backoff = max_backoff
    self._backoff_multiplier = backoff_multiplier
    self._jitter = jitter
    self._retry_streaming = retry_streaming
    self._on_retry = on_retry
    self._metrics = metrics
    self._idempotent_methods = idempotent_methods or set()
    # ``is None`` rather than a falsy check: an empty set is a valid "never retry" configuration.
    self._retryable_codes: frozenset[grpc.StatusCode] = (
        DEFAULT_RETRYABLE_CODES if retryable_codes is None else frozenset(retryable_codes)
    )

intercept(call) async

Issue the call, retrying it as far as the configuration and the budget allow.

Source code in grpc_client_kit/interceptors/retry.py
async def intercept(self, call: ClientCall) -> Any:
    """Issue the call, retrying it as far as the configuration and the budget allow."""
    if call.response_streaming:
        return await self._start_stream(call)
    if call.request_streaming:
        # The request iterator is consumed by the first attempt and cannot be replayed, so a
        # streaming-request call is never retried. `invoke_unary` hands the Call back promptly
        # for these — awaiting the response here would deadlock the write()-style API.
        return await call.invoke_unary()

    return await self._unary_with_retries(call)

AsyncTimeoutInterceptor

Bases: AsyncClientInterceptor

Async timeout interceptor that sets the budget for a whole gRPC call.

The timeout set here is the budget for the entire logical call, not for a single network attempt. As the outermost resilience interceptor it runs once per call, so everything nested below it — retries in particular — has to fit into the budget it installs. AsyncRetryInterceptor honours this by converting the timeout into a deadline and shrinking it before every attempt.

Behavior: - If a call already carries a timeout, the smaller of the two wins: an explicit per-call deadline set by the caller can only tighten the configured one, never loosen it. - If no timeout is present, the configured one is applied. - A timeout of None or 0 means "no deadline"; calls of that method are left untouched. - The budget applies to all four RPC kinds. It used to reach unary-unary calls only: a channel files an interceptor by class, and an interceptor deriving from all four gRPC base classes lands in the first list alone (see base), which left every stream deadline-free.

Note

The budget is written onto the call details before the RPC exists, so this layer implements base.AsyncClientInterceptor.intercept and hands the call straight on instead of using the around_call seam. Wrapping a response stream to observe an outcome it has no use for would cost a generator hop per item, and that wrapper is not free in another way either: when a caller abandons a stream, closing it drives the GeneratorExit back into the wrapper, which the event loop reports as an error if it has closed the generator first (measured at loop shutdown as RuntimeError: generator didn't stop after athrow()).

Source code in grpc_client_kit/interceptors/timeout.py
class AsyncTimeoutInterceptor(AsyncClientInterceptor):
    """Async timeout interceptor that sets the budget for a whole gRPC call.

    The timeout set here is the budget for the **entire logical call**, not for a single network
    attempt. As the outermost resilience interceptor it runs once per call, so everything nested
    below it — retries in particular — has to fit into the budget it installs. `AsyncRetryInterceptor`
    honours this by converting the timeout into a deadline and shrinking it before every attempt.

    Behavior:
    - If a call already carries a timeout, the smaller of the two wins: an explicit per-call
      deadline set by the caller can only tighten the configured one, never loosen it.
    - If no timeout is present, the configured one is applied.
    - A timeout of ``None`` or ``0`` means "no deadline"; calls of that method are left untouched.
    - The budget applies to all four RPC kinds. It used to reach unary-unary calls only: a channel
      files an interceptor by class, and an interceptor deriving from all four gRPC base classes
      lands in the first list alone (see `base`), which left every stream deadline-free.

    Note:
        The budget is written onto the call details before the RPC exists, so this layer implements
        `base.AsyncClientInterceptor.intercept` and hands the call straight on instead of using the
        ``around_call`` seam. Wrapping a response stream to observe an outcome it has no use for
        would cost a generator hop per item, and that wrapper is not free in another way either:
        when a caller abandons a stream, closing it drives the ``GeneratorExit`` back into the
        wrapper, which the event loop reports as an error if it has closed the generator first
        (measured at loop shutdown as ``RuntimeError: generator didn't stop after athrow()``).
    """

    def __init__(
        self,
        default_timeout: float | None = 10.0,
        per_method_timeouts: dict[str, float | None] | None = None,
    ) -> None:
        """Initialize the timeout interceptor.

        Args:
            default_timeout: Total call budget in seconds for methods without a specific value.
                             ``None`` or ``0`` disables the default timeout.
            per_method_timeouts: Mapping of full method names to specific budgets. ``None`` or ``0``
                                 disables the timeout for that method, overriding `default_timeout`.

        Raises:
            ValueError: If any configured timeout is negative.
        """
        self._default_timeout = _normalize_timeout(default_timeout, "default_timeout")
        self._per_method_timeouts: dict[str, float | None] = {
            method: _normalize_timeout(timeout, f"timeout for method {method}")
            for method, timeout in (per_method_timeouts or {}).items()
        }

    def _with_budget(self, client_call_details: Any, method: str) -> Any:
        """Return call details carrying this method's call budget.

        Args:
            client_call_details: Details of the call being intercepted.
            method: Full method name, already decoded.

        Returns:
            The details to issue the call with; the argument itself when no budget applies.
        """
        # Looking the method up with a default keeps an explicit ``None`` override meaningful:
        # "method present with no timeout" must disable the budget, not fall back to the default.
        timeout = self._per_method_timeouts.get(method, self._default_timeout)

        if timeout is None:
            return client_call_details

        # Use the smaller of existing timeout and our configured timeout
        if getattr(client_call_details, "timeout", None) is not None:
            return client_call_details._replace(timeout=min(client_call_details.timeout, timeout))

        if getattr(client_call_details, "deadline", None) is not None:
            # Fallback for old/custom ClientCallDetails that might have deadline
            return client_call_details._replace(deadline=min(client_call_details.deadline, time.time() + timeout))

        # Try setting timeout first, then deadline if timeout doesn't exist
        try:
            return client_call_details._replace(timeout=timeout)
        except (AttributeError, TypeError):
            # For compatibility with older sync interceptors or custom ones
            return client_call_details._replace(deadline=time.time() + timeout)

    async def intercept(self, call: ClientCall) -> Any:
        """Give the call its budget and issue it.

        Args:
            call: The call to issue; its details are rewritten before the RPC exists.

        Returns:
            The response of a unary call, or the iterator of a streaming one, untouched.
        """
        call.details = self._with_budget(call.details, call.method)

        if call.response_streaming:
            return await call.invoke_stream()

        return await call.invoke_unary()

adapters cached property

The four single-ABC objects through which a channel registers this interceptor.

Built once per instance and kept: a chain's identity — and with it the channel pool's idea of which channels are interchangeable — is the identity of the objects in it.

__init__(default_timeout=10.0, per_method_timeouts=None)

Initialize the timeout interceptor.

Parameters:

Name Type Description Default
default_timeout float | None

Total call budget in seconds for methods without a specific value. None or 0 disables the default timeout.

10.0
per_method_timeouts dict[str, float | None] | None

Mapping of full method names to specific budgets. None or 0 disables the timeout for that method, overriding default_timeout.

None

Raises:

Type Description
ValueError

If any configured timeout is negative.

Source code in grpc_client_kit/interceptors/timeout.py
def __init__(
    self,
    default_timeout: float | None = 10.0,
    per_method_timeouts: dict[str, float | None] | None = None,
) -> None:
    """Initialize the timeout interceptor.

    Args:
        default_timeout: Total call budget in seconds for methods without a specific value.
                         ``None`` or ``0`` disables the default timeout.
        per_method_timeouts: Mapping of full method names to specific budgets. ``None`` or ``0``
                             disables the timeout for that method, overriding `default_timeout`.

    Raises:
        ValueError: If any configured timeout is negative.
    """
    self._default_timeout = _normalize_timeout(default_timeout, "default_timeout")
    self._per_method_timeouts: dict[str, float | None] = {
        method: _normalize_timeout(timeout, f"timeout for method {method}")
        for method, timeout in (per_method_timeouts or {}).items()
    }

intercept(call) async

Give the call its budget and issue it.

Parameters:

Name Type Description Default
call ClientCall

The call to issue; its details are rewritten before the RPC exists.

required

Returns:

Type Description
Any

The response of a unary call, or the iterator of a streaming one, untouched.

Source code in grpc_client_kit/interceptors/timeout.py
async def intercept(self, call: ClientCall) -> Any:
    """Give the call its budget and issue it.

    Args:
        call: The call to issue; its details are rewritten before the RPC exists.

    Returns:
        The response of a unary call, or the iterator of a streaming one, untouched.
    """
    call.details = self._with_budget(call.details, call.method)

    if call.response_streaming:
        return await call.invoke_stream()

    return await call.invoke_unary()

AsyncWaitForReadyInterceptor

Bases: AsyncClientInterceptor

Marks outgoing calls as willing to wait for the channel to be ready.

Behavior: - A call whose method resolves to True is issued with wait_for_ready=True: instead of failing while the channel is still connecting, it waits and runs as soon as it is up. - A call whose method resolves to False is issued fail-fast, explicitly. - None — globally or for one method — leaves the call exactly as it arrived. - A call whose caller already set wait_for_ready keeps that value. An explicit decision at the call site is more specific than a configured default, and it is the only way to opt one call out of a policy set for the whole client.

Default

True, and the pairing with a deadline is what makes that defensible. On its own the flag trades one failure mode for another: the call no longer fails fast, it waits — and a wait for a backend that never comes back is a hang, which is worse than the error it replaced. Bounded by a deadline the trade is one-sided: the call either connects and runs, or ends in DEADLINE_EXCEEDED after exactly the time it was allowed, which is what the caller asked for either way. So waiting is enabled only for calls that carry a deadline (see require_deadline), and a chain built by this kit carries one by default.

Note

The flag is written onto the call details before the RPC exists, so this layer implements base.AsyncClientInterceptor.intercept and hands the call straight on instead of using the around_call seam — for the reasons timeout.AsyncTimeoutInterceptor documents: a wrapper around a response stream would cost a generator hop per item and turns a caller abandoning that stream into noise at loop shutdown.

Source code in grpc_client_kit/interceptors/wait_for_ready.py
class AsyncWaitForReadyInterceptor(AsyncClientInterceptor):
    """Marks outgoing calls as willing to wait for the channel to be ready.

    Behavior:
    - A call whose method resolves to ``True`` is issued with ``wait_for_ready=True``: instead of
      failing while the channel is still connecting, it waits and runs as soon as it is up.
    - A call whose method resolves to ``False`` is issued fail-fast, explicitly.
    - ``None`` — globally or for one method — leaves the call exactly as it arrived.
    - A call whose caller already set ``wait_for_ready`` keeps that value. An explicit decision at
      the call site is more specific than a configured default, and it is the only way to opt one
      call out of a policy set for the whole client.

    Default:
        ``True``, and the pairing with a deadline is what makes that defensible. On its own the flag
        trades one failure mode for another: the call no longer fails fast, it waits — and a wait
        for a backend that never comes back is a hang, which is worse than the error it replaced.
        Bounded by a deadline the trade is one-sided: the call either connects and runs, or ends in
        ``DEADLINE_EXCEEDED`` after exactly the time it was allowed, which is what the caller asked
        for either way. So waiting is enabled only for calls that carry a deadline
        (see `require_deadline`), and a chain built by this kit carries one by default.

    Note:
        The flag is written onto the call details before the RPC exists, so this layer implements
        `base.AsyncClientInterceptor.intercept` and hands the call straight on instead of using the
        ``around_call`` seam — for the reasons `timeout.AsyncTimeoutInterceptor` documents: a
        wrapper around a response stream would cost a generator hop per item and turns a caller
        abandoning that stream into noise at loop shutdown.
    """

    def __init__(
        self,
        default: bool | None = True,
        per_method: dict[str, bool | None] | None = None,
        require_deadline: bool = True,
    ) -> None:
        """Initialize the wait-for-ready interceptor.

        Args:
            default: Value for methods without an entry in `per_method`. ``None`` leaves calls
                untouched, which switches the layer off without removing it from the chain.
            per_method: Values for individual methods, keyed by full method name
                (``/package.Service/Method``). An entry of ``None`` exempts that method from the
                default.
            require_deadline: Whether waiting is limited to calls that carry a deadline. Leave it on
                unless something else bounds the call, because an unbounded wait never ends by
                itself: the call sits there for as long as the backend stays unreachable.
        """
        self._default = default
        self._per_method: dict[str, bool | None] = dict(per_method or {})
        self._require_deadline = require_deadline
        self._warned_about_deadline = False

    def _warn_once(self, method: str) -> None:
        """Report the first call that was left fail-fast for want of a deadline.

        Once per interceptor, not once per call: the condition is a property of the configuration,
        so repeating it for every RPC would drown the log without adding anything.

        Args:
            method: The method whose call was left alone.
        """
        if self._warned_about_deadline:
            return

        self._warned_about_deadline = True
        logger.warning(
            "wait_for_ready is enabled but %s carries no deadline: the call is left fail-fast, "
            "because waiting for a channel with nothing to bound the wait never ends. "
            "Configure a timeout for it, or set require_deadline=False to wait anyway.",
            method,
        )

    def _with_wait_for_ready(self, client_call_details: Any, method: str) -> Any:
        """Return call details carrying this method's wait-for-ready setting.

        Args:
            client_call_details: Details of the call being intercepted.
            method: Full method name, already decoded.

        Returns:
            The details to issue the call with; the argument itself when nothing applies.
        """
        # Looking the method up with a default keeps an explicit ``None`` override meaningful:
        # "method present with no value" must exempt it, not fall back to the default.
        configured = self._per_method.get(method, self._default)

        if configured is None or getattr(client_call_details, "wait_for_ready", None) is not None:
            return client_call_details

        if configured and self._require_deadline and not self._has_deadline(client_call_details):
            self._warn_once(method)
            return client_call_details

        return client_call_details._replace(wait_for_ready=configured)

    def _has_deadline(self, client_call_details: Any) -> bool:
        """Whether the call is bounded, which is what makes waiting for a connection safe."""
        return isinstance(getattr(client_call_details, "timeout", None), (int, float))

    async def intercept(self, call: ClientCall) -> Any:
        """Apply this method's wait-for-ready setting and issue the call.

        Args:
            call: The call to issue; its details are rewritten before the RPC exists.

        Returns:
            The response of a unary call, or the iterator of a streaming one, untouched.
        """
        call.details = self._with_wait_for_ready(call.details, call.method)

        if call.response_streaming:
            return await call.invoke_stream()

        return await call.invoke_unary()

adapters cached property

The four single-ABC objects through which a channel registers this interceptor.

Built once per instance and kept: a chain's identity — and with it the channel pool's idea of which channels are interchangeable — is the identity of the objects in it.

__init__(default=True, per_method=None, require_deadline=True)

Initialize the wait-for-ready interceptor.

Parameters:

Name Type Description Default
default bool | None

Value for methods without an entry in per_method. None leaves calls untouched, which switches the layer off without removing it from the chain.

True
per_method dict[str, bool | None] | None

Values for individual methods, keyed by full method name (/package.Service/Method). An entry of None exempts that method from the default.

None
require_deadline bool

Whether waiting is limited to calls that carry a deadline. Leave it on unless something else bounds the call, because an unbounded wait never ends by itself: the call sits there for as long as the backend stays unreachable.

True
Source code in grpc_client_kit/interceptors/wait_for_ready.py
def __init__(
    self,
    default: bool | None = True,
    per_method: dict[str, bool | None] | None = None,
    require_deadline: bool = True,
) -> None:
    """Initialize the wait-for-ready interceptor.

    Args:
        default: Value for methods without an entry in `per_method`. ``None`` leaves calls
            untouched, which switches the layer off without removing it from the chain.
        per_method: Values for individual methods, keyed by full method name
            (``/package.Service/Method``). An entry of ``None`` exempts that method from the
            default.
        require_deadline: Whether waiting is limited to calls that carry a deadline. Leave it on
            unless something else bounds the call, because an unbounded wait never ends by
            itself: the call sits there for as long as the backend stays unreachable.
    """
    self._default = default
    self._per_method: dict[str, bool | None] = dict(per_method or {})
    self._require_deadline = require_deadline
    self._warned_about_deadline = False

intercept(call) async

Apply this method's wait-for-ready setting and issue the call.

Parameters:

Name Type Description Default
call ClientCall

The call to issue; its details are rewritten before the RPC exists.

required

Returns:

Type Description
Any

The response of a unary call, or the iterator of a streaming one, untouched.

Source code in grpc_client_kit/interceptors/wait_for_ready.py
async def intercept(self, call: ClientCall) -> Any:
    """Apply this method's wait-for-ready setting and issue the call.

    Args:
        call: The call to issue; its details are rewritten before the RPC exists.

    Returns:
        The response of a unary call, or the iterator of a streaming one, untouched.
    """
    call.details = self._with_wait_for_ready(call.details, call.method)

    if call.response_streaming:
        return await call.invoke_stream()

    return await call.invoke_unary()

ChannelKey dataclass

Full identity of a pooled channel.

Two callers may share a channel only when every field matches: gRPC binds credentials, options, compression and interceptors to a channel at creation time and none of them can be changed or added afterwards, so a channel built for one combination cannot serve another.

Attributes:

Name Type Description
target str

The target address (host:port).

insecure bool

Whether the channel is insecure.

credentials ChannelCredentials | None

TLS credentials, compared by identity because gRPC credentials define no equality — reuse one credentials object instead of rebuilding it per call.

options tuple[tuple[str, Any], ...] | None

gRPC channel options, normalized to a tuple and compared by value.

compression Compression | None

The channel compression setting.

interceptors_token str | None

Stable identity of the interceptor chain, or None when there is none.

Source code in grpc_client_kit/channel.py
@dataclass(frozen=True, slots=True)
class ChannelKey:
    """Full identity of a pooled channel.

    Two callers may share a channel only when every field matches: gRPC binds credentials, options,
    compression and interceptors to a channel at creation time and none of them can be changed or
    added afterwards, so a channel built for one combination cannot serve another.

    Attributes:
        target: The target address (host:port).
        insecure: Whether the channel is insecure.
        credentials: TLS credentials, compared by identity because gRPC credentials define no
            equality — reuse one credentials object instead of rebuilding it per call.
        options: gRPC channel options, normalized to a tuple and compared by value.
        compression: The channel compression setting.
        interceptors_token: Stable identity of the interceptor chain, or None when there is none.
    """

    target: str
    insecure: bool
    credentials: grpc.ChannelCredentials | None = None
    options: tuple[tuple[str, Any], ...] | None = None
    compression: grpc.Compression | None = None
    interceptors_token: str | None = None

    @classmethod
    def build(
        cls,
        target: str,
        insecure: bool = False,
        credentials: grpc.ChannelCredentials | None = None,
        options: list[tuple[str, Any]] | None = None,
        compression: grpc.Compression | None = None,
        interceptors: list[grpc.aio.ClientInterceptor] | None = None,
    ) -> ChannelKey:
        """Build a key from the arguments of a channel request.

        Args:
            target: The target address (host:port).
            insecure: Whether to use an insecure channel.
            credentials: Optional TLS credentials for a secure channel.
            options: Optional gRPC channel options.
            compression: Optional gRPC compression setting.
            interceptors: Optional interceptor chain to bind to the channel.

        Returns:
            The hashable identity of the requested channel.
        """
        return cls(
            target=target,
            insecure=insecure,
            credentials=credentials,
            options=tuple(options) if options is not None else None,
            compression=compression,
            interceptors_token=chain_token(interceptors),
        )

build(target, insecure=False, credentials=None, options=None, compression=None, interceptors=None) classmethod

Build a key from the arguments of a channel request.

Parameters:

Name Type Description Default
target str

The target address (host:port).

required
insecure bool

Whether to use an insecure channel.

False
credentials ChannelCredentials | None

Optional TLS credentials for a secure channel.

None
options list[tuple[str, Any]] | None

Optional gRPC channel options.

None
compression Compression | None

Optional gRPC compression setting.

None
interceptors list[ClientInterceptor] | None

Optional interceptor chain to bind to the channel.

None

Returns:

Type Description
ChannelKey

The hashable identity of the requested channel.

Source code in grpc_client_kit/channel.py
@classmethod
def build(
    cls,
    target: str,
    insecure: bool = False,
    credentials: grpc.ChannelCredentials | None = None,
    options: list[tuple[str, Any]] | None = None,
    compression: grpc.Compression | None = None,
    interceptors: list[grpc.aio.ClientInterceptor] | None = None,
) -> ChannelKey:
    """Build a key from the arguments of a channel request.

    Args:
        target: The target address (host:port).
        insecure: Whether to use an insecure channel.
        credentials: Optional TLS credentials for a secure channel.
        options: Optional gRPC channel options.
        compression: Optional gRPC compression setting.
        interceptors: Optional interceptor chain to bind to the channel.

    Returns:
        The hashable identity of the requested channel.
    """
    return cls(
        target=target,
        insecure=insecure,
        credentials=credentials,
        options=tuple(options) if options is not None else None,
        compression=compression,
        interceptors_token=chain_token(interceptors),
    )

ChannelPool

Bases: ChannelProviderProtocol

Advanced channel pool for gRPC channels.

Channels are pooled by their full identity (see :class:ChannelKey), not by target alone: asking for host:1 over TLS never returns the insecure channel someone else opened for the same address, and a client never inherits another client's interceptor chain.

Async-safe through per-identity locking to minimize contention and prevent deadlocks.

Source code in grpc_client_kit/channel.py
class ChannelPool(ChannelProviderProtocol):
    """Advanced channel pool for gRPC channels.

    Channels are pooled by their full identity (see :class:`ChannelKey`), not by target alone:
    asking for ``host:1`` over TLS never returns the insecure channel someone else opened for the
    same address, and a client never inherits another client's interceptor chain.

    Async-safe through per-identity locking to minimize contention and prevent deadlocks.
    """

    def __init__(
        self,
        max_channels_per_target: int = DEFAULT_MAX_CHANNELS_PER_TARGET,
        idle_timeout: float = DEFAULT_IDLE_TIMEOUT,
        health_checker: HealthCheckerProtocol | None = None,
        metrics: GrpcClientMetricsProtocol | None = None,
    ) -> None:
        """Initialize the channel pool.

        Args:
            max_channels_per_target: Maximum number of concurrent channels to keep per identity.
            idle_timeout: Seconds without active RPCs after which a channel parks its connection.
                Enforced by gRPC core (``grpc.client_idle_timeout_ms``), never by the pool closing
                channels: a parked channel reconnects transparently on the next call, so held stubs
                and long streams survive any idle period. ``0`` or less disables idling.
            health_checker: Optional health checker for monitoring target health.
            metrics: Optional metrics registry for pool statistics.

        Raises:
            ValueError: If max_channels_per_target is not positive.
        """
        if max_channels_per_target <= 0:
            raise ValueError("max_channels_per_target must be positive")

        self._max_channels_per_target = max_channels_per_target
        self._idle_timeout = idle_timeout
        self._health_checker = health_checker
        self._metrics = metrics

        self._entries: dict[ChannelKey, _PoolEntry] = {}
        self._pool_lock = asyncio.Lock()
        self._closing = False

    async def _checkout(self, key: ChannelKey) -> _PoolEntry:
        """Get or create the entry for a key and mark it as in use.

        Args:
            key: The channel identity.

        Returns:
            The entry, whose lock the caller must take before touching its channels.
        """
        async with self._pool_lock:
            entry = self._entries.get(key)
            if entry is None:
                entry = _PoolEntry()
                self._entries[key] = entry
            entry.users += 1
            return entry

    async def _checkin(self, key: ChannelKey, entry: _PoolEntry) -> None:
        """Release an entry and drop it if it became empty and unused.

        Args:
            key: The channel identity the entry was checked out under.
            entry: The entry to release.
        """
        async with self._pool_lock:
            entry.users -= 1
            # Nobody holds the entry at zero users, so reading `channels` without its lock is safe
            # and no coroutine can be waiting on the lock we are about to discard.
            if entry.users == 0 and not entry.channels and self._entries.get(key) is entry:
                entry.active = False
                del self._entries[key]

    def _update_pool_metrics(self) -> None:
        """Update pool usage metrics if available."""
        if not self._metrics:
            return

        total_channels = sum(len(entry.channels) for entry in self._entries.values())

        try:
            self._metrics.record_pool_stats(
                active_channels=total_channels,
                # One target can back several identities, so report distinct addresses.
                idle_targets=len({key.target for key in self._entries}),
            )
        except Exception:
            logger.exception("Failed to record pool metrics")

    def make_key(
        self,
        target: str,
        insecure: bool = False,
        credentials: grpc.ChannelCredentials | None = None,
        options: list[tuple[str, Any]] | None = None,
        compression: grpc.Compression | None = None,
        interceptors: list[grpc.aio.ClientInterceptor] | None = None,
    ) -> ChannelKey:
        """Validate a channel request and build its pool identity.

        The result is safe to cache and hand back through ``get_channel(key=...)``: a client whose
        target, configuration and chain never change should pay for validation and identity
        hashing once, not on every call.

        Args:
            target: The target address (host:port).
            insecure: Whether to use an insecure channel.
            credentials: Optional TLS credentials for a secure channel.
            options: Optional gRPC channel options.
            compression: Optional gRPC compression setting.
            interceptors: Optional interceptor chain to bind to the channel.

        Returns:
            The hashable identity the pool files this request under.
        """
        validate_target(target)
        return ChannelKey.build(
            target,
            insecure=insecure,
            credentials=credentials,
            options=self._with_idle_option(options),
            compression=compression,
            interceptors=interceptors,
        )

    def _with_idle_option(self, options: list[tuple[str, Any]] | None) -> list[tuple[str, Any]] | None:
        """Merge the pool's idle timeout into channel options, letting an explicit value win."""
        if self._idle_timeout <= 0:
            return options
        if options is not None and any(name == _IDLE_OPTION for name, _ in options):
            return options
        merged: list[tuple[str, Any]] = list(options) if options is not None else []
        merged.append((_IDLE_OPTION, int(self._idle_timeout * 1000)))
        return merged

    async def get_channel(
        self,
        target: str,
        insecure: bool = False,
        credentials: grpc.ChannelCredentials | None = None,
        options: list[tuple[str, Any]] | None = None,
        compression: grpc.Compression | None = None,
        interceptors: list[grpc.aio.ClientInterceptor] | None = None,
        key: ChannelKey | None = None,
    ) -> grpc.aio.Channel:
        """Get or create a gRPC channel for the requested channel identity.

        Implements round-robin selection among healthy channels of that identity. If all channels
        are unhealthy or the limit is reached, it will either create a new channel or return an
        existing one as a fallback.

        Args:
            target: The target address (host:port).
            insecure: Whether to use an insecure channel.
            credentials: Optional TLS credentials for a secure channel.
            options: Optional gRPC channel options.
            compression: Optional gRPC compression setting.
            interceptors: Optional list of interceptors to bind to the channel.
            key: Precomputed identity from :meth:`make_key`. When given, the target is not
                re-validated and the identity is not rebuilt; the other arguments must be the ones
                the key was built from.

        Returns:
            An async gRPC channel.

        Raises:
            RuntimeError: If the pool is closing.
        """
        if self._closing:
            raise RuntimeError("ChannelPool is closing")

        if key is None:
            key = self.make_key(
                target,
                insecure=insecure,
                credentials=credentials,
                options=options,
                compression=compression,
                interceptors=interceptors,
            )

        entry = await self._checkout(key)
        try:
            async with entry.lock:
                # close_all() may have detached this entry while we waited for the lock; handing
                # out a channel the pool no longer tracks would leak it past shutdown.
                if self._closing or not entry.active:
                    raise RuntimeError("ChannelPool is closing")

                wrapper = self._select_channel(entry)
                if wrapper is None:
                    wrapper = self._add_channel(entry, key, interceptors)
        finally:
            await self._checkin(key, entry)

        return wrapper.channel

    def _select_channel(self, entry: _PoolEntry) -> ChannelWrapper | None:
        """Pick the next healthy channel of an entry in round-robin order.

        Args:
            entry: The entry to select from; its lock must be held.

        Returns:
            The selected channel wrapper, or None if the entry has no healthy channel.
        """
        # The flag is written through the public update_channel_health seam, so it is honoured
        # regardless of whether this pool owns a checker — an external checker counts too.
        healthy = [w for w in entry.channels if w.is_healthy]
        if not healthy:
            return None

        position = entry.index % len(healthy)
        entry.index = (position + 1) % len(healthy)
        return healthy[position]

    def _add_channel(
        self,
        entry: _PoolEntry,
        key: ChannelKey,
        interceptors: list[grpc.aio.ClientInterceptor] | None,
    ) -> ChannelWrapper:
        """Grow an entry by one channel, or fall back to an existing one at the limit.

        The channel is built from the key it is filed under, so what the pool promises and what
        gRPC was asked for cannot drift apart.

        Args:
            entry: The entry to grow; its lock must be held.
            key: The identity of the channel to create.
            interceptors: The chain the key's token stands for.

        Returns:
            The channel wrapper to serve this request with.
        """
        if len(entry.channels) >= self._max_channels_per_target:
            # Every channel is unhealthy and the limit is reached: serve the oldest one anyway, it
            # may have recovered since the last health check and is better than no channel at all.
            return entry.channels[0]

        logger.info("Creating new gRPC channel for target: %s", key.target)
        channel = self._create_aio_channel(
            key.target,
            insecure=key.insecure,
            credentials=key.credentials,
            options=list(key.options) if key.options is not None else None,
            compression=key.compression,
            interceptors=interceptors,
        )
        wrapper = ChannelWrapper(channel=channel, target=key.target)
        entry.channels.append(wrapper)
        self._update_pool_metrics()
        return wrapper

    def _create_aio_channel(
        self,
        target: str,
        insecure: bool = False,
        credentials: grpc.ChannelCredentials | None = None,
        options: list[tuple[str, Any]] | None = None,
        compression: grpc.Compression | None = None,
        interceptors: list[grpc.aio.ClientInterceptor] | None = None,
    ) -> grpc.aio.Channel:
        """Internal helper to create a new async gRPC channel."""
        return create_aio_channel(
            target,
            insecure=insecure,
            credentials=credentials,
            options=options,
            compression=compression,
            interceptors=interceptors,
        )

    async def close_all(self, grace: float | None = None) -> None:
        """Close all pooled channels.

        The pool stays usable afterwards: this drains it rather than retiring it, so a shared pool
        can outlive one shutdown and an ``async with`` block can be entered again.

        Args:
            grace: Optional time to wait for active RPCs to finish.
        """
        self._closing = True
        try:
            async with self._pool_lock:
                entries = list(self._entries.values())
                self._entries.clear()

            wrappers: list[ChannelWrapper] = []
            for entry in entries:
                entry.active = False
                wrappers.extend(entry.channels)
                entry.channels.clear()

            if wrappers:
                logger.info("Closing %d pooled gRPC channels", len(wrappers))
                await asyncio.gather(*(w.channel.close(grace=grace) for w in wrappers), return_exceptions=True)

            self._update_pool_metrics()
        finally:
            self._closing = False

    async def health_check(self, target: str | None = None) -> bool:
        """Check if pool is healthy (ready to accept requests).

        Args:
            target: Optional target to check for reachability instead of the pool itself.

        Returns:
            True if the pool (or the given target) can serve requests.
        """
        if self._closing:
            return False

        if target and self._health_checker:
            return await self._health_checker.check_health(target)

        return True

    async def update_channel_health(self, target: str, is_healthy: bool) -> None:
        """Update health status for every pooled channel of a target.

        All channel identities sharing the address are updated, since health is a property of the
        server rather than of the channel configuration.

        Args:
            target: The target address (host:port).
            is_healthy: Whether the target is healthy.
        """
        async with self._pool_lock:
            checked_out = [(key, entry) for key, entry in self._entries.items() if key.target == target]
            for _, entry in checked_out:
                entry.users += 1

        try:
            for _, entry in checked_out:
                async with entry.lock:
                    for wrapper in entry.channels:
                        wrapper.is_healthy = is_healthy
        finally:
            for key, entry in checked_out:
                await self._checkin(key, entry)

    async def __aenter__(self) -> ChannelPool:
        return self

    async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        await self.close_all()

__init__(max_channels_per_target=DEFAULT_MAX_CHANNELS_PER_TARGET, idle_timeout=DEFAULT_IDLE_TIMEOUT, health_checker=None, metrics=None)

Initialize the channel pool.

Parameters:

Name Type Description Default
max_channels_per_target int

Maximum number of concurrent channels to keep per identity.

DEFAULT_MAX_CHANNELS_PER_TARGET
idle_timeout float

Seconds without active RPCs after which a channel parks its connection. Enforced by gRPC core (grpc.client_idle_timeout_ms), never by the pool closing channels: a parked channel reconnects transparently on the next call, so held stubs and long streams survive any idle period. 0 or less disables idling.

DEFAULT_IDLE_TIMEOUT
health_checker HealthCheckerProtocol | None

Optional health checker for monitoring target health.

None
metrics GrpcClientMetricsProtocol | None

Optional metrics registry for pool statistics.

None

Raises:

Type Description
ValueError

If max_channels_per_target is not positive.

Source code in grpc_client_kit/channel.py
def __init__(
    self,
    max_channels_per_target: int = DEFAULT_MAX_CHANNELS_PER_TARGET,
    idle_timeout: float = DEFAULT_IDLE_TIMEOUT,
    health_checker: HealthCheckerProtocol | None = None,
    metrics: GrpcClientMetricsProtocol | None = None,
) -> None:
    """Initialize the channel pool.

    Args:
        max_channels_per_target: Maximum number of concurrent channels to keep per identity.
        idle_timeout: Seconds without active RPCs after which a channel parks its connection.
            Enforced by gRPC core (``grpc.client_idle_timeout_ms``), never by the pool closing
            channels: a parked channel reconnects transparently on the next call, so held stubs
            and long streams survive any idle period. ``0`` or less disables idling.
        health_checker: Optional health checker for monitoring target health.
        metrics: Optional metrics registry for pool statistics.

    Raises:
        ValueError: If max_channels_per_target is not positive.
    """
    if max_channels_per_target <= 0:
        raise ValueError("max_channels_per_target must be positive")

    self._max_channels_per_target = max_channels_per_target
    self._idle_timeout = idle_timeout
    self._health_checker = health_checker
    self._metrics = metrics

    self._entries: dict[ChannelKey, _PoolEntry] = {}
    self._pool_lock = asyncio.Lock()
    self._closing = False

close_all(grace=None) async

Close all pooled channels.

The pool stays usable afterwards: this drains it rather than retiring it, so a shared pool can outlive one shutdown and an async with block can be entered again.

Parameters:

Name Type Description Default
grace float | None

Optional time to wait for active RPCs to finish.

None
Source code in grpc_client_kit/channel.py
async def close_all(self, grace: float | None = None) -> None:
    """Close all pooled channels.

    The pool stays usable afterwards: this drains it rather than retiring it, so a shared pool
    can outlive one shutdown and an ``async with`` block can be entered again.

    Args:
        grace: Optional time to wait for active RPCs to finish.
    """
    self._closing = True
    try:
        async with self._pool_lock:
            entries = list(self._entries.values())
            self._entries.clear()

        wrappers: list[ChannelWrapper] = []
        for entry in entries:
            entry.active = False
            wrappers.extend(entry.channels)
            entry.channels.clear()

        if wrappers:
            logger.info("Closing %d pooled gRPC channels", len(wrappers))
            await asyncio.gather(*(w.channel.close(grace=grace) for w in wrappers), return_exceptions=True)

        self._update_pool_metrics()
    finally:
        self._closing = False

get_channel(target, insecure=False, credentials=None, options=None, compression=None, interceptors=None, key=None) async

Get or create a gRPC channel for the requested channel identity.

Implements round-robin selection among healthy channels of that identity. If all channels are unhealthy or the limit is reached, it will either create a new channel or return an existing one as a fallback.

Parameters:

Name Type Description Default
target str

The target address (host:port).

required
insecure bool

Whether to use an insecure channel.

False
credentials ChannelCredentials | None

Optional TLS credentials for a secure channel.

None
options list[tuple[str, Any]] | None

Optional gRPC channel options.

None
compression Compression | None

Optional gRPC compression setting.

None
interceptors list[ClientInterceptor] | None

Optional list of interceptors to bind to the channel.

None
key ChannelKey | None

Precomputed identity from :meth:make_key. When given, the target is not re-validated and the identity is not rebuilt; the other arguments must be the ones the key was built from.

None

Returns:

Type Description
Channel

An async gRPC channel.

Raises:

Type Description
RuntimeError

If the pool is closing.

Source code in grpc_client_kit/channel.py
async def get_channel(
    self,
    target: str,
    insecure: bool = False,
    credentials: grpc.ChannelCredentials | None = None,
    options: list[tuple[str, Any]] | None = None,
    compression: grpc.Compression | None = None,
    interceptors: list[grpc.aio.ClientInterceptor] | None = None,
    key: ChannelKey | None = None,
) -> grpc.aio.Channel:
    """Get or create a gRPC channel for the requested channel identity.

    Implements round-robin selection among healthy channels of that identity. If all channels
    are unhealthy or the limit is reached, it will either create a new channel or return an
    existing one as a fallback.

    Args:
        target: The target address (host:port).
        insecure: Whether to use an insecure channel.
        credentials: Optional TLS credentials for a secure channel.
        options: Optional gRPC channel options.
        compression: Optional gRPC compression setting.
        interceptors: Optional list of interceptors to bind to the channel.
        key: Precomputed identity from :meth:`make_key`. When given, the target is not
            re-validated and the identity is not rebuilt; the other arguments must be the ones
            the key was built from.

    Returns:
        An async gRPC channel.

    Raises:
        RuntimeError: If the pool is closing.
    """
    if self._closing:
        raise RuntimeError("ChannelPool is closing")

    if key is None:
        key = self.make_key(
            target,
            insecure=insecure,
            credentials=credentials,
            options=options,
            compression=compression,
            interceptors=interceptors,
        )

    entry = await self._checkout(key)
    try:
        async with entry.lock:
            # close_all() may have detached this entry while we waited for the lock; handing
            # out a channel the pool no longer tracks would leak it past shutdown.
            if self._closing or not entry.active:
                raise RuntimeError("ChannelPool is closing")

            wrapper = self._select_channel(entry)
            if wrapper is None:
                wrapper = self._add_channel(entry, key, interceptors)
    finally:
        await self._checkin(key, entry)

    return wrapper.channel

health_check(target=None) async

Check if pool is healthy (ready to accept requests).

Parameters:

Name Type Description Default
target str | None

Optional target to check for reachability instead of the pool itself.

None

Returns:

Type Description
bool

True if the pool (or the given target) can serve requests.

Source code in grpc_client_kit/channel.py
async def health_check(self, target: str | None = None) -> bool:
    """Check if pool is healthy (ready to accept requests).

    Args:
        target: Optional target to check for reachability instead of the pool itself.

    Returns:
        True if the pool (or the given target) can serve requests.
    """
    if self._closing:
        return False

    if target and self._health_checker:
        return await self._health_checker.check_health(target)

    return True

make_key(target, insecure=False, credentials=None, options=None, compression=None, interceptors=None)

Validate a channel request and build its pool identity.

The result is safe to cache and hand back through get_channel(key=...): a client whose target, configuration and chain never change should pay for validation and identity hashing once, not on every call.

Parameters:

Name Type Description Default
target str

The target address (host:port).

required
insecure bool

Whether to use an insecure channel.

False
credentials ChannelCredentials | None

Optional TLS credentials for a secure channel.

None
options list[tuple[str, Any]] | None

Optional gRPC channel options.

None
compression Compression | None

Optional gRPC compression setting.

None
interceptors list[ClientInterceptor] | None

Optional interceptor chain to bind to the channel.

None

Returns:

Type Description
ChannelKey

The hashable identity the pool files this request under.

Source code in grpc_client_kit/channel.py
def make_key(
    self,
    target: str,
    insecure: bool = False,
    credentials: grpc.ChannelCredentials | None = None,
    options: list[tuple[str, Any]] | None = None,
    compression: grpc.Compression | None = None,
    interceptors: list[grpc.aio.ClientInterceptor] | None = None,
) -> ChannelKey:
    """Validate a channel request and build its pool identity.

    The result is safe to cache and hand back through ``get_channel(key=...)``: a client whose
    target, configuration and chain never change should pay for validation and identity
    hashing once, not on every call.

    Args:
        target: The target address (host:port).
        insecure: Whether to use an insecure channel.
        credentials: Optional TLS credentials for a secure channel.
        options: Optional gRPC channel options.
        compression: Optional gRPC compression setting.
        interceptors: Optional interceptor chain to bind to the channel.

    Returns:
        The hashable identity the pool files this request under.
    """
    validate_target(target)
    return ChannelKey.build(
        target,
        insecure=insecure,
        credentials=credentials,
        options=self._with_idle_option(options),
        compression=compression,
        interceptors=interceptors,
    )

update_channel_health(target, is_healthy) async

Update health status for every pooled channel of a target.

All channel identities sharing the address are updated, since health is a property of the server rather than of the channel configuration.

Parameters:

Name Type Description Default
target str

The target address (host:port).

required
is_healthy bool

Whether the target is healthy.

required
Source code in grpc_client_kit/channel.py
async def update_channel_health(self, target: str, is_healthy: bool) -> None:
    """Update health status for every pooled channel of a target.

    All channel identities sharing the address are updated, since health is a property of the
    server rather than of the channel configuration.

    Args:
        target: The target address (host:port).
        is_healthy: Whether the target is healthy.
    """
    async with self._pool_lock:
        checked_out = [(key, entry) for key, entry in self._entries.items() if key.target == target]
        for _, entry in checked_out:
            entry.users += 1

    try:
        for _, entry in checked_out:
            async with entry.lock:
                for wrapper in entry.channels:
                    wrapper.is_healthy = is_healthy
    finally:
        for key, entry in checked_out:
            await self._checkin(key, entry)

ChannelPoolSettingsProtocol

Bases: Protocol

Protocol for gRPC channel pool settings.

Source code in grpc_client_kit/protocols.py
@runtime_checkable
class ChannelPoolSettingsProtocol(Protocol):
    """Protocol for gRPC channel pool settings."""

    @property
    def max_channels_per_target(self) -> int:
        """Maximum number of channels to keep per target."""
        ...

    @property
    def idle_timeout(self) -> float:
        """Time in seconds after which an idle channel is closed."""
        ...

idle_timeout property

Time in seconds after which an idle channel is closed.

max_channels_per_target property

Maximum number of channels to keep per target.

ChannelProviderProtocol

Bases: Protocol

Protocol for gRPC channel management.

Source code in grpc_client_kit/protocols.py
@runtime_checkable
class ChannelProviderProtocol(Protocol):
    """Protocol for gRPC channel management."""

    async def get_channel(
        self,
        target: str,
        insecure: bool = False,
        credentials: grpc.ChannelCredentials | None = None,
        options: list[tuple[str, Any]] | None = None,
        compression: grpc.Compression | None = None,
        interceptors: list[grpc.aio.ClientInterceptor] | None = None,
    ) -> grpc.aio.Channel:
        """Get or create a gRPC channel for the target.

        Args:
            target: The target address (host:port)
            insecure: Whether to use insecure channel
            credentials: Optional channel credentials for secure channel
            options: Optional gRPC channel options
            compression: Optional gRPC compression
            interceptors: Optional list of interceptors

        Returns:
            An async gRPC channel
        """
        ...

    async def close_all(self, grace: float | None = None) -> None:
        """Close all pooled channels and release resources."""
        ...

    async def update_channel_health(self, target: str, is_healthy: bool) -> None:
        """Update health status for a specific target.

        Args:
            target: The target address (host:port)
            is_healthy: Whether the target is healthy
        """
        ...

close_all(grace=None) async

Close all pooled channels and release resources.

Source code in grpc_client_kit/protocols.py
async def close_all(self, grace: float | None = None) -> None:
    """Close all pooled channels and release resources."""
    ...

get_channel(target, insecure=False, credentials=None, options=None, compression=None, interceptors=None) async

Get or create a gRPC channel for the target.

Parameters:

Name Type Description Default
target str

The target address (host:port)

required
insecure bool

Whether to use insecure channel

False
credentials ChannelCredentials | None

Optional channel credentials for secure channel

None
options list[tuple[str, Any]] | None

Optional gRPC channel options

None
compression Compression | None

Optional gRPC compression

None
interceptors list[ClientInterceptor] | None

Optional list of interceptors

None

Returns:

Type Description
Channel

An async gRPC channel

Source code in grpc_client_kit/protocols.py
async def get_channel(
    self,
    target: str,
    insecure: bool = False,
    credentials: grpc.ChannelCredentials | None = None,
    options: list[tuple[str, Any]] | None = None,
    compression: grpc.Compression | None = None,
    interceptors: list[grpc.aio.ClientInterceptor] | None = None,
) -> grpc.aio.Channel:
    """Get or create a gRPC channel for the target.

    Args:
        target: The target address (host:port)
        insecure: Whether to use insecure channel
        credentials: Optional channel credentials for secure channel
        options: Optional gRPC channel options
        compression: Optional gRPC compression
        interceptors: Optional list of interceptors

    Returns:
        An async gRPC channel
    """
    ...

update_channel_health(target, is_healthy) async

Update health status for a specific target.

Parameters:

Name Type Description Default
target str

The target address (host:port)

required
is_healthy bool

Whether the target is healthy

required
Source code in grpc_client_kit/protocols.py
async def update_channel_health(self, target: str, is_healthy: bool) -> None:
    """Update health status for a specific target.

    Args:
        target: The target address (host:port)
        is_healthy: Whether the target is healthy
    """
    ...

CircuitBreakerConfig dataclass

Configuration for circuit breaker.

Source code in grpc_client_kit/interceptors/__init__.py
@dataclass(slots=True)
class CircuitBreakerConfig:
    """Configuration for circuit breaker."""

    fail_threshold: int = 5
    recovery_timeout: float = 60.0
    half_open_max_calls: int = 1
    max_methods: int = 1000
    metrics: GrpcClientMetricsProtocol | None = None

CircuitBreakerMetricsProtocol

Bases: Protocol

Optional extension: circuit breaker state transitions and rejections.

A rejection by an open breaker never touches the network, yet through record_request alone it is indistinguishable from a backend that really failed. A registry that also implements this protocol can chart the breaker itself: its state per method, and how many calls it refused locally.

Source code in grpc_client_kit/protocols.py
@runtime_checkable
class CircuitBreakerMetricsProtocol(Protocol):
    """Optional extension: circuit breaker state transitions and rejections.

    A rejection by an open breaker never touches the network, yet through `record_request` alone
    it is indistinguishable from a backend that really failed. A registry that also implements
    this protocol can chart the breaker itself: its state per method, and how many calls it
    refused locally.
    """

    def record_circuit_state(self, method: str, state: str) -> None:
        """Record a circuit state transition.

        Args:
            method: Full gRPC method path.
            state: The new state (``closed``, ``open`` or ``half-open``).
        """
        ...

    def record_circuit_rejection(self, method: str) -> None:
        """Record one call refused locally by an open circuit.

        Args:
            method: Full gRPC method path.
        """
        ...

record_circuit_rejection(method)

Record one call refused locally by an open circuit.

Parameters:

Name Type Description Default
method str

Full gRPC method path.

required
Source code in grpc_client_kit/protocols.py
def record_circuit_rejection(self, method: str) -> None:
    """Record one call refused locally by an open circuit.

    Args:
        method: Full gRPC method path.
    """
    ...

record_circuit_state(method, state)

Record a circuit state transition.

Parameters:

Name Type Description Default
method str

Full gRPC method path.

required
state str

The new state (closed, open or half-open).

required
Source code in grpc_client_kit/protocols.py
def record_circuit_state(self, method: str, state: str) -> None:
    """Record a circuit state transition.

    Args:
        method: Full gRPC method path.
        state: The new state (``closed``, ``open`` or ``half-open``).
    """
    ...

CircuitBreakerOpenError

Bases: AioRpcError, GrpcClientKitError

Raised when the circuit is open.

Source code in grpc_client_kit/interceptors/circuit_breaker.py
class CircuitBreakerOpenError(grpc.aio.AioRpcError, GrpcClientKitError):
    """Raised when the circuit is open."""

    def __init__(self, method: str) -> None:
        # Explicit rather than super(): with GrpcClientKitError also in the bases, super() would
        # resolve the keyword arguments against Exception, which takes none of them.
        grpc.aio.AioRpcError.__init__(
            self,
            code=grpc.StatusCode.UNAVAILABLE,
            initial_metadata=grpc.aio.Metadata(),
            trailing_metadata=grpc.aio.Metadata(),
            details=f"Circuit breaker for {method} is open",
        )

CircuitBreakerSettingsProtocol

Bases: Protocol

Protocol for gRPC circuit breaker settings.

Source code in grpc_client_kit/protocols.py
@runtime_checkable
class CircuitBreakerSettingsProtocol(Protocol):
    """Protocol for gRPC circuit breaker settings."""

    @property
    def fail_threshold(self) -> int:
        """Number of failures before opening the circuit."""
        ...

    @property
    def recovery_timeout(self) -> float:
        """Time in seconds to wait before attempting recovery."""
        ...

    @property
    def half_open_max_calls(self) -> int:
        """Maximum number of calls allowed in half-open state."""
        ...

fail_threshold property

Number of failures before opening the circuit.

half_open_max_calls property

Maximum number of calls allowed in half-open state.

recovery_timeout property

Time in seconds to wait before attempting recovery.

CircuitBreakerStatus

Bases: TypedDict

Read-only snapshot of one method's circuit, as returned by get_states().

Source code in grpc_client_kit/interceptors/circuit_breaker.py
class CircuitBreakerStatus(TypedDict):
    """Read-only snapshot of one method's circuit, as returned by ``get_states()``."""

    state: str
    failure_count: int
    last_failure_time: float
    half_open_calls: int

ClientCall dataclass

One outgoing RPC as an interceptor sees it, whichever of the four kinds it is.

Attributes:

Name Type Description
method str

Full method name (/package.Service/Method), already decoded — grpc hands the raw call details a bytes method, and every interceptor used to decode it by hand.

rpc_type RpcType

Which of the four RPC kinds this call is.

details Any

The grpc.aio.ClientCallDetails the next attempt will be issued with. Rebind it (call.details = call.details._replace(timeout=...)) before invoking to change the deadline, the metadata or the credentials; invoke_unary and invoke_stream read it at the moment they issue the call.

request Any

The request message, or the request iterator for a streaming-request call.

Source code in grpc_client_kit/interceptors/base.py
@dataclass(slots=True)
class ClientCall:
    """One outgoing RPC as an interceptor sees it, whichever of the four kinds it is.

    Attributes:
        method: Full method name (``/package.Service/Method``), already decoded — grpc hands the
            raw call details a `bytes` method, and every interceptor used to decode it by hand.
        rpc_type: Which of the four RPC kinds this call is.
        details: The `grpc.aio.ClientCallDetails` the next attempt will be issued with. Rebind it
            (``call.details = call.details._replace(timeout=...)``) before invoking to change the
            deadline, the metadata or the credentials; `invoke_unary` and `invoke_stream` read it
            at the moment they issue the call.
        request: The request message, or the request iterator for a streaming-request call.
    """

    method: str
    rpc_type: RpcType
    details: Any
    request: Any
    _continuation: Continuation
    _underlying: Any = field(default=None, init=False, repr=False)
    _response: Any = field(default=None, init=False, repr=False)

    @property
    def request_streaming(self) -> bool:
        """Whether the caller streams the request (stream-unary and stream-stream)."""
        return self.rpc_type in _REQUEST_STREAMING

    @property
    def response_streaming(self) -> bool:
        """Whether the server streams the response (unary-stream and stream-stream)."""
        return self.rpc_type in _RESPONSE_STREAMING

    @property
    def underlying_call(self) -> Any:
        """The `grpc.aio.Call` of the attempt last issued, or None while none has been."""
        return self._underlying

    @property
    def response(self) -> Any:
        """The unary response once it has arrived; None for streams and for calls that failed."""
        return self._response

    async def invoke_unary(self) -> Any:
        """Issue the call and, for a unary request, wait for its single response.

        Returns:
            The response message — or, for a streaming request, the `Call` itself, promptly.

        Raises:
            grpc.aio.AioRpcError: If the RPC failed. The continuation never raises — it resolves to
                a `Call` as soon as the RPC is created — so the status is only reached by awaiting
                that Call, which is what this does.
        """
        call = await self._continuation(self.details, self.request)
        self._underlying = call
        if self.request_streaming:
            # Awaiting the response here would deadlock the write()-style API: grpc parks both
            # ``call.write()`` and the request proxy until the interceptors task has finished, and
            # the response cannot arrive before the requests are written. The Call is handed back
            # promptly instead; whoever needs the outcome observes it from a separate task.
            return call
        # An interceptor further in is allowed to short-circuit with a plain response; only a real
        # Call has to be awaited a second time for its status to surface.
        response = await call if hasattr(call, "__await__") else call
        self._response = response
        return response

    async def invoke_stream(self) -> AsyncIterator[Any]:
        """Issue the call now and return the iterator over its responses.

        The RPC is created before this returns, deliberately. grpc.aio binds the `Call` an
        interceptor created to whatever iterator that interceptor hands back, and an iterator that
        only creates its call on the first ``__anext__`` leaves that binding empty — ``code()``,
        ``cancel()`` and the call's own finalizer then fail on ``None``.

        Returns:
            The response iterator. A failed RPC raises `grpc.aio.AioRpcError` while it is iterated,
            which for a mid-stream failure happens after some items have already been delivered.
        """
        call = await self._continuation(self.details, self.request)
        self._underlying = call
        stream: AsyncIterator[Any] = call
        return stream

request_streaming property

Whether the caller streams the request (stream-unary and stream-stream).

response property

The unary response once it has arrived; None for streams and for calls that failed.

response_streaming property

Whether the server streams the response (unary-stream and stream-stream).

underlying_call property

The grpc.aio.Call of the attempt last issued, or None while none has been.

invoke_stream() async

Issue the call now and return the iterator over its responses.

The RPC is created before this returns, deliberately. grpc.aio binds the Call an interceptor created to whatever iterator that interceptor hands back, and an iterator that only creates its call on the first __anext__ leaves that binding empty — code(), cancel() and the call's own finalizer then fail on None.

Returns:

Type Description
AsyncIterator[Any]

The response iterator. A failed RPC raises grpc.aio.AioRpcError while it is iterated,

AsyncIterator[Any]

which for a mid-stream failure happens after some items have already been delivered.

Source code in grpc_client_kit/interceptors/base.py
async def invoke_stream(self) -> AsyncIterator[Any]:
    """Issue the call now and return the iterator over its responses.

    The RPC is created before this returns, deliberately. grpc.aio binds the `Call` an
    interceptor created to whatever iterator that interceptor hands back, and an iterator that
    only creates its call on the first ``__anext__`` leaves that binding empty — ``code()``,
    ``cancel()`` and the call's own finalizer then fail on ``None``.

    Returns:
        The response iterator. A failed RPC raises `grpc.aio.AioRpcError` while it is iterated,
        which for a mid-stream failure happens after some items have already been delivered.
    """
    call = await self._continuation(self.details, self.request)
    self._underlying = call
    stream: AsyncIterator[Any] = call
    return stream

invoke_unary() async

Issue the call and, for a unary request, wait for its single response.

Returns:

Type Description
Any

The response message — or, for a streaming request, the Call itself, promptly.

Raises:

Type Description
AioRpcError

If the RPC failed. The continuation never raises — it resolves to a Call as soon as the RPC is created — so the status is only reached by awaiting that Call, which is what this does.

Source code in grpc_client_kit/interceptors/base.py
async def invoke_unary(self) -> Any:
    """Issue the call and, for a unary request, wait for its single response.

    Returns:
        The response message — or, for a streaming request, the `Call` itself, promptly.

    Raises:
        grpc.aio.AioRpcError: If the RPC failed. The continuation never raises — it resolves to
            a `Call` as soon as the RPC is created — so the status is only reached by awaiting
            that Call, which is what this does.
    """
    call = await self._continuation(self.details, self.request)
    self._underlying = call
    if self.request_streaming:
        # Awaiting the response here would deadlock the write()-style API: grpc parks both
        # ``call.write()`` and the request proxy until the interceptors task has finished, and
        # the response cannot arrive before the requests are written. The Call is handed back
        # promptly instead; whoever needs the outcome observes it from a separate task.
        return call
    # An interceptor further in is allowed to short-circuit with a plain response; only a real
    # Call has to be awaited a second time for its status to surface.
    response = await call if hasattr(call, "__await__") else call
    self._response = response
    return response

ConnectivityConfig dataclass

How a channel keeps its connection alive, and how fast it comes back after losing it.

These are gRPC channel arguments, which means two things. They are set once, when the channel is created, and cannot be changed afterwards; and they are spelled as an untyped list of ("grpc.some_thing_ms", 30000) pairs that every caller currently has to remember, in the right unit, by heart. This is that list, named, in seconds, and validated.

Keepalive pings detect a connection that has silently gone away — a dropped NAT mapping, a load balancer that closed one side, a peer that vanished — which TCP alone can leave undetected until a call has already hung on it. The reconnect backoff decides how long a channel that has lost its connection waits before dialing again, and gRPC's own upper bound for that (two minutes) is a long time for a backend that restarts in seconds.

Opt-in, deliberately. Pinging is a conversation the server has to agree to: it enforces its own minimum interval and answers a client that pings too often with GOAWAY and ENHANCE_YOUR_CALM, so a client library that switched this on for everybody would be a way to get connections dropped by servers that were never configured for it. The defaults here are what a caller gets once they have decided to tune the channel at all: conservative enough for a server left at its own defaults, which permits pings on a connection with calls in flight.

Attributes:

Name Type Description
keepalive_time float | None

Seconds of inactivity before a keepalive ping is sent. None leaves gRPC's default, which is not to ping at all.

keepalive_timeout float | None

Seconds to wait for the ping to be answered before the connection is considered dead.

permit_without_calls bool

Whether to keep pinging while no call is in flight. Off by default: this is the case servers police most tightly, and an idle connection that dies is usually cheaper to re-establish than to keep watching.

max_pings_without_data int | None

How many pings may be sent on a connection carrying no data before the client stops; 0 means no limit.

initial_reconnect_backoff float | None

Seconds to wait before the first reconnect attempt.

min_reconnect_backoff float | None

Lower bound for the wait between reconnect attempts. None leaves gRPC's default, which also bounds how long a single connect attempt may take.

max_reconnect_backoff float | None

Upper bound for the wait between reconnect attempts, which is what decides how long a client keeps failing after a backend is already back.

Source code in grpc_client_kit/config.py
@dataclass(frozen=True, slots=True)
class ConnectivityConfig:
    """How a channel keeps its connection alive, and how fast it comes back after losing it.

    These are gRPC channel arguments, which means two things. They are set once, when the channel is
    created, and cannot be changed afterwards; and they are spelled as an untyped list of
    ``("grpc.some_thing_ms", 30000)`` pairs that every caller currently has to remember, in the
    right unit, by heart. This is that list, named, in seconds, and validated.

    Keepalive pings detect a connection that has silently gone away — a dropped NAT mapping, a load
    balancer that closed one side, a peer that vanished — which TCP alone can leave undetected until
    a call has already hung on it. The reconnect backoff decides how long a channel that has lost
    its connection waits before dialing again, and gRPC's own upper bound for that (two minutes) is
    a long time for a backend that restarts in seconds.

    Opt-in, deliberately. Pinging is a conversation the server has to agree to: it enforces its own
    minimum interval and answers a client that pings too often with ``GOAWAY`` and
    ``ENHANCE_YOUR_CALM``, so a client library that switched this on for everybody would be a way to
    get connections dropped by servers that were never configured for it. The defaults here are what
    a caller gets once they have decided to tune the channel at all: conservative enough for a
    server left at its own defaults, which permits pings on a connection with calls in flight.

    Attributes:
        keepalive_time: Seconds of inactivity before a keepalive ping is sent. ``None`` leaves
            gRPC's default, which is not to ping at all.
        keepalive_timeout: Seconds to wait for the ping to be answered before the connection is
            considered dead.
        permit_without_calls: Whether to keep pinging while no call is in flight. Off by default:
            this is the case servers police most tightly, and an idle connection that dies is
            usually cheaper to re-establish than to keep watching.
        max_pings_without_data: How many pings may be sent on a connection carrying no data before
            the client stops; 0 means no limit.
        initial_reconnect_backoff: Seconds to wait before the first reconnect attempt.
        min_reconnect_backoff: Lower bound for the wait between reconnect attempts. ``None`` leaves
            gRPC's default, which also bounds how long a single connect attempt may take.
        max_reconnect_backoff: Upper bound for the wait between reconnect attempts, which is what
            decides how long a client keeps failing after a backend is already back.
    """

    keepalive_time: float | None = 30.0
    keepalive_timeout: float | None = 10.0
    permit_without_calls: bool = False
    max_pings_without_data: int | None = 2
    initial_reconnect_backoff: float | None = 1.0
    min_reconnect_backoff: float | None = None
    max_reconnect_backoff: float | None = 30.0

    def __post_init__(self) -> None:
        """Validate the configuration.

        Raises:
            ValueError: If a duration is not positive, if the ping allowance is negative, or if the
                reconnect bounds contradict each other.
        """
        durations = {
            "keepalive_time": self.keepalive_time,
            "keepalive_timeout": self.keepalive_timeout,
            "initial_reconnect_backoff": self.initial_reconnect_backoff,
            "min_reconnect_backoff": self.min_reconnect_backoff,
            "max_reconnect_backoff": self.max_reconnect_backoff,
        }
        for name, value in durations.items():
            # None is how a field says "leave gRPC's default"; 0 would be a different setting
            # altogether, and not one any of these arguments has a meaning for.
            if value is not None and value <= 0:
                raise ValueError(f"{name} must be positive, or None to keep the gRPC default")

        if self.max_pings_without_data is not None and self.max_pings_without_data < 0:
            raise ValueError("max_pings_without_data must be non-negative (0 means no limit)")

        if (
            self.min_reconnect_backoff is not None
            and self.max_reconnect_backoff is not None
            and self.min_reconnect_backoff > self.max_reconnect_backoff
        ):
            raise ValueError("min_reconnect_backoff must not exceed max_reconnect_backoff")

    def to_options(self) -> list[tuple[str, Any]]:
        """Return these settings as gRPC channel arguments.

        The order is the declaration order of the fields and never varies, which is what keeps a
        channel's identity stable: options are part of the channel pool's key (see
        `channel.ChannelKey`), so a list assembled differently from one call to the next would open
        a new channel each time.

        Returns:
            The channel arguments, in seconds converted to the milliseconds gRPC expects. Fields
            left at ``None`` contribute nothing, so gRPC keeps its own default for them.
        """
        options: list[tuple[str, Any]] = []

        if self.keepalive_time is not None:
            options.append(("grpc.keepalive_time_ms", _milliseconds(self.keepalive_time)))
        if self.keepalive_timeout is not None:
            options.append(("grpc.keepalive_timeout_ms", _milliseconds(self.keepalive_timeout)))

        options.append(("grpc.keepalive_permit_without_calls", int(self.permit_without_calls)))

        if self.max_pings_without_data is not None:
            options.append(("grpc.http2.max_pings_without_data", self.max_pings_without_data))
        if self.initial_reconnect_backoff is not None:
            options.append(("grpc.initial_reconnect_backoff_ms", _milliseconds(self.initial_reconnect_backoff)))
        if self.min_reconnect_backoff is not None:
            options.append(("grpc.min_reconnect_backoff_ms", _milliseconds(self.min_reconnect_backoff)))
        if self.max_reconnect_backoff is not None:
            options.append(("grpc.max_reconnect_backoff_ms", _milliseconds(self.max_reconnect_backoff)))

        return options

__post_init__()

Validate the configuration.

Raises:

Type Description
ValueError

If a duration is not positive, if the ping allowance is negative, or if the reconnect bounds contradict each other.

Source code in grpc_client_kit/config.py
def __post_init__(self) -> None:
    """Validate the configuration.

    Raises:
        ValueError: If a duration is not positive, if the ping allowance is negative, or if the
            reconnect bounds contradict each other.
    """
    durations = {
        "keepalive_time": self.keepalive_time,
        "keepalive_timeout": self.keepalive_timeout,
        "initial_reconnect_backoff": self.initial_reconnect_backoff,
        "min_reconnect_backoff": self.min_reconnect_backoff,
        "max_reconnect_backoff": self.max_reconnect_backoff,
    }
    for name, value in durations.items():
        # None is how a field says "leave gRPC's default"; 0 would be a different setting
        # altogether, and not one any of these arguments has a meaning for.
        if value is not None and value <= 0:
            raise ValueError(f"{name} must be positive, or None to keep the gRPC default")

    if self.max_pings_without_data is not None and self.max_pings_without_data < 0:
        raise ValueError("max_pings_without_data must be non-negative (0 means no limit)")

    if (
        self.min_reconnect_backoff is not None
        and self.max_reconnect_backoff is not None
        and self.min_reconnect_backoff > self.max_reconnect_backoff
    ):
        raise ValueError("min_reconnect_backoff must not exceed max_reconnect_backoff")

to_options()

Return these settings as gRPC channel arguments.

The order is the declaration order of the fields and never varies, which is what keeps a channel's identity stable: options are part of the channel pool's key (see channel.ChannelKey), so a list assembled differently from one call to the next would open a new channel each time.

Returns:

Type Description
list[tuple[str, Any]]

The channel arguments, in seconds converted to the milliseconds gRPC expects. Fields

list[tuple[str, Any]]

left at None contribute nothing, so gRPC keeps its own default for them.

Source code in grpc_client_kit/config.py
def to_options(self) -> list[tuple[str, Any]]:
    """Return these settings as gRPC channel arguments.

    The order is the declaration order of the fields and never varies, which is what keeps a
    channel's identity stable: options are part of the channel pool's key (see
    `channel.ChannelKey`), so a list assembled differently from one call to the next would open
    a new channel each time.

    Returns:
        The channel arguments, in seconds converted to the milliseconds gRPC expects. Fields
        left at ``None`` contribute nothing, so gRPC keeps its own default for them.
    """
    options: list[tuple[str, Any]] = []

    if self.keepalive_time is not None:
        options.append(("grpc.keepalive_time_ms", _milliseconds(self.keepalive_time)))
    if self.keepalive_timeout is not None:
        options.append(("grpc.keepalive_timeout_ms", _milliseconds(self.keepalive_timeout)))

    options.append(("grpc.keepalive_permit_without_calls", int(self.permit_without_calls)))

    if self.max_pings_without_data is not None:
        options.append(("grpc.http2.max_pings_without_data", self.max_pings_without_data))
    if self.initial_reconnect_backoff is not None:
        options.append(("grpc.initial_reconnect_backoff_ms", _milliseconds(self.initial_reconnect_backoff)))
    if self.min_reconnect_backoff is not None:
        options.append(("grpc.min_reconnect_backoff_ms", _milliseconds(self.min_reconnect_backoff)))
    if self.max_reconnect_backoff is not None:
        options.append(("grpc.max_reconnect_backoff_ms", _milliseconds(self.max_reconnect_backoff)))

    return options

DeadlineBudgetConfig dataclass

Configuration for propagating the caller's deadline budget into outgoing calls.

Adding this to a chain installs deadline.AsyncDeadlineBudgetInterceptor, which trims each call's deadline to what the request budget in the current context still allows. It needs the deadline extra (grpc-client-kit[deadline]); without it the layer is skipped and the chain behaves as if it had never been configured.

The budget itself is installed by the caller with grpc_client_kit.use_budget. A chain configured here but never given a budget changes nothing about any call.

Attributes:

Name Type Description
reserve_for_next float

Seconds every call keeps back for the work that follows it.

Source code in grpc_client_kit/interceptors/__init__.py
@dataclass(slots=True)
class DeadlineBudgetConfig:
    """Configuration for propagating the caller's deadline budget into outgoing calls.

    Adding this to a chain installs `deadline.AsyncDeadlineBudgetInterceptor`, which trims each
    call's deadline to what the request budget in the current context still allows. It needs the
    ``deadline`` extra (``grpc-client-kit[deadline]``); without it the layer is skipped and the
    chain behaves as if it had never been configured.

    The budget itself is installed by the caller with `grpc_client_kit.use_budget`. A chain
    configured here but never given a budget changes nothing about any call.

    Attributes:
        reserve_for_next: Seconds every call keeps back for the work that follows it.
    """

    reserve_for_next: float = 0.0

DeadlineBudgetExhaustedError

Bases: AioRpcError, GrpcClientKitError

Raised instead of issuing a call the request has no time left for.

A grpc.aio.AioRpcError rather than the budget library's own exception, and carrying DEADLINE_EXCEEDED, because that is what the caller of an RPC is written to handle: the layers above (logging, tracing, metrics) record it like any other failed call. The original deadline_budget.DeadlineExceededError is kept as the __cause__ for anyone who maps it.

Source code in grpc_client_kit/interceptors/deadline.py
class DeadlineBudgetExhaustedError(grpc.aio.AioRpcError, GrpcClientKitError):
    """Raised instead of issuing a call the request has no time left for.

    A `grpc.aio.AioRpcError` rather than the budget library's own exception, and carrying
    ``DEADLINE_EXCEEDED``, because that is what the caller of an RPC is written to handle: the
    layers above (logging, tracing, metrics) record it like any other failed call. The original
    `deadline_budget.DeadlineExceededError` is kept as the ``__cause__`` for anyone who maps it.
    """

    def __init__(self, method: str, reason: str) -> None:
        """Build the error naming the call that was refused and why."""
        # Explicit rather than super(): with GrpcClientKitError also in the bases, super() would
        # resolve the keyword arguments against Exception, which takes none of them.
        grpc.aio.AioRpcError.__init__(
            self,
            code=grpc.StatusCode.DEADLINE_EXCEEDED,
            initial_metadata=grpc.aio.Metadata(),
            trailing_metadata=grpc.aio.Metadata(),
            details=f"Deadline budget exhausted before {method}: {reason}",
        )

__init__(method, reason)

Build the error naming the call that was refused and why.

Source code in grpc_client_kit/interceptors/deadline.py
def __init__(self, method: str, reason: str) -> None:
    """Build the error naming the call that was refused and why."""
    # Explicit rather than super(): with GrpcClientKitError also in the bases, super() would
    # resolve the keyword arguments against Exception, which takes none of them.
    grpc.aio.AioRpcError.__init__(
        self,
        code=grpc.StatusCode.DEADLINE_EXCEEDED,
        initial_metadata=grpc.aio.Metadata(),
        trailing_metadata=grpc.aio.Metadata(),
        details=f"Deadline budget exhausted before {method}: {reason}",
    )

DeadlineBudgetProtocol

Bases: Protocol

What the kit needs of a request budget: the shape of deadline_budget.BudgetContext.

Typing against the shape rather than against the class is what keeps the library optional — the kit never imports deadline-budget to describe a budget — and it leaves the door open for a caller who tracks deadlines their own way.

Source code in grpc_client_kit/deadline.py
@runtime_checkable
class DeadlineBudgetProtocol(Protocol):
    """What the kit needs of a request budget: the shape of `deadline_budget.BudgetContext`.

    Typing against the shape rather than against the class is what keeps the library optional — the
    kit never imports `deadline-budget` to describe a budget — and it leaves the door open for a
    caller who tracks deadlines their own way.
    """

    def timeout_for_call(self, call_name: str, reserve_for_next: float = 0.0) -> float:
        """Return the timeout the named call may use, bounded by what is left of the budget.

        Args:
            call_name: Name the per-call caps are keyed by.
            reserve_for_next: Seconds to keep back for the steps that follow this call.

        Returns:
            The timeout in seconds.

        Raises:
            DeadlineExceededError: If the budget is already exhausted.
        """
        ...

    def remaining(self) -> float:
        """Return the seconds left, which is negative once the deadline has passed."""
        ...

    def expired(self) -> bool:
        """Return whether the budget is exhausted."""
        ...

expired()

Return whether the budget is exhausted.

Source code in grpc_client_kit/deadline.py
def expired(self) -> bool:
    """Return whether the budget is exhausted."""
    ...

remaining()

Return the seconds left, which is negative once the deadline has passed.

Source code in grpc_client_kit/deadline.py
def remaining(self) -> float:
    """Return the seconds left, which is negative once the deadline has passed."""
    ...

timeout_for_call(call_name, reserve_for_next=0.0)

Return the timeout the named call may use, bounded by what is left of the budget.

Parameters:

Name Type Description Default
call_name str

Name the per-call caps are keyed by.

required
reserve_for_next float

Seconds to keep back for the steps that follow this call.

0.0

Returns:

Type Description
float

The timeout in seconds.

Raises:

Type Description
DeadlineExceededError

If the budget is already exhausted.

Source code in grpc_client_kit/deadline.py
def timeout_for_call(self, call_name: str, reserve_for_next: float = 0.0) -> float:
    """Return the timeout the named call may use, bounded by what is left of the budget.

    Args:
        call_name: Name the per-call caps are keyed by.
        reserve_for_next: Seconds to keep back for the steps that follow this call.

    Returns:
        The timeout in seconds.

    Raises:
        DeadlineExceededError: If the budget is already exhausted.
    """
    ...

GrpcClient

Factory for gRPC stubs. Manages target selection and channel acquisition from pool.

Interceptor Chains

A chain is built per target and cached, because gRPC binds interceptors to a channel when the channel is created. Stateful interceptors — the circuit breaker above all — therefore track one backend each: a single failing member of a load-balanced set can no longer trip the breaker for its healthy peers. Pass interceptor_factory to get that isolation; passing a ready interceptors list instead shares one chain (and one breaker) across every target of this client.

Ownership Semantics

This client DOES NOT own the ChannelPool it uses. It is a lightweight wrapper that acquires channels from a shared pool. Closing the client (via aexit) does NOT close the underlying channels or the pool.

Source code in grpc_client_kit/client.py
class GrpcClient[T]:
    """Factory for gRPC stubs. Manages target selection and channel acquisition from pool.

    Interceptor Chains:
        A chain is built per target and cached, because gRPC binds interceptors to a channel when
        the channel is created. Stateful interceptors — the circuit breaker above all — therefore
        track one backend each: a single failing member of a load-balanced set can no longer trip
        the breaker for its healthy peers. Pass ``interceptor_factory`` to get that isolation;
        passing a ready ``interceptors`` list instead shares one chain (and one breaker) across
        every target of this client.

    Ownership Semantics:
        This client DOES NOT own the `ChannelPool` it uses. It is a lightweight wrapper that
        acquires channels from a shared pool. Closing the client (via __aexit__) does NOT
        close the underlying channels or the pool.
    """

    def __init__(
        self,
        stub_class: type[T],
        config: GrpcClientConfig,
        pool: ChannelProviderProtocol,
        balancer: LoadBalancer | None = None,
        interceptors: list[grpc.aio.ClientInterceptor] | None = None,
        interceptor_factory: Callable[[str], list[grpc.aio.ClientInterceptor]] | None = None,
    ) -> None:
        """Initialize the gRPC client.

        Args:
            stub_class: The gRPC stub class to instantiate (e.g., MyServiceStub).
            config: Configuration for the client (target, security, etc.).
            pool: The channel pool to acquire channels from.
            balancer: Optional load balancer for multiple targets.
            interceptors: Optional ready chain, shared by every target of this client.
            interceptor_factory: Optional builder called once per target to create a dedicated
                chain for it.

        Raises:
            ValueError: If target configuration is ambiguous or missing, or if both an interceptor
                chain and an interceptor factory are given.
        """
        self._stub_class = stub_class
        self._config = config
        self._pool = pool
        self._balancer = balancer

        if interceptors is not None and interceptor_factory is not None:
            raise ValueError(
                "Ambiguous interceptors: provide either 'interceptors' (one shared chain) "
                "or 'interceptor_factory' (a chain per target), not both."
            )

        self._static_interceptors = list(interceptors) if interceptors is not None else None
        self._interceptor_factory = interceptor_factory
        self._chains: dict[str, list[grpc.aio.ClientInterceptor]] = {}
        self._warned_native_retry = False
        # Target, config and chain are fixed per target, so the pool identity is too: computing it
        # (validation, option merging, chain tokens) once per target instead of once per call keeps
        # that work off the hot path.
        self._channel_keys: dict[str, ChannelKey] = {}

        self._validate_target_configuration()

    def _validate_target_configuration(self) -> None:
        """Ensure that either a single target or a balancer is provided, but not both."""
        if self._balancer and self._config.target:
            raise ValueError(
                "Ambiguous target: both 'balancer' and 'config.target' provided. "
                "Use 'balancer' for multiple targets or 'config.target' for a single target."
            )
        if not self._balancer and not self._config.target:
            raise ValueError("No target specified: provide either 'balancer' or 'config.target'")

    def interceptors_for(self, target: str) -> list[grpc.aio.ClientInterceptor]:
        """Return this client's interceptor chain for one target.

        The chain is built at most once per target and then reused. That caching is what keeps the
        pool key stable: rebuilding the chain on every call would mint a new channel identity each
        time and the pool would open a channel per RPC.

        Args:
            target: The target address (host:port) the chain will be bound to.

        Returns:
            The interceptor chain for the target, empty when the client has no interceptors.
        """
        chain = self._chains.get(target)
        if chain is None:
            if self._static_interceptors is not None:
                chain = self._static_interceptors
            elif self._interceptor_factory is not None:
                chain = self._interceptor_factory(target)
            else:
                chain = []
            self._chains[target] = chain
            self._warn_on_native_retry_overlap(chain)
        return chain

    def _warn_on_native_retry_overlap(self, chain: list[grpc.aio.ClientInterceptor]) -> None:
        """Warn once when kit retries are stacked on top of native service-config retries.

        Native ``retryPolicy`` runs inside the channel, below every interceptor, so the two layers
        multiply: kit attempts times native attempts reach the server — a retry storm the kit's own
        logs and metrics cannot see. The channel options pass through untouched on purpose (native
        retries *without* kit retries are a fully supported configuration); only the combination is
        worth a warning.
        """
        if self._warned_native_retry:
            return

        options = self._config.channel_options() or []
        service_config = next((value for name, value in options if name == "grpc.service_config"), None)
        if service_config is None or "retryPolicy" not in str(service_config):
            return

        from .interceptors.base import logical_interceptor  # noqa: PLC0415 - avoids import cycle at module load
        from .interceptors.retry import AsyncRetryInterceptor  # noqa: PLC0415

        if any(isinstance(logical_interceptor(entry), AsyncRetryInterceptor) for entry in chain):
            self._warned_native_retry = True
            logger.warning(
                "Both kit retries and a native retryPolicy are configured for %s: the two layers "
                "multiply (kit attempts x native attempts reach the server). Configure one source "
                "of retries — drop the kit RetryConfig, or remove retryPolicy from grpc.service_config.",
                self._config.target or "the balanced targets",
            )

    async def circuit_breaker_states(self) -> dict[str, dict[str, Any]]:
        """Snapshot the circuit breakers of every chain this client has built.

        During an incident this is the question an operator asks first — "is the breaker open, or
        is the backend down?" — and it must be answerable without spelunking through the chain.

        Returns:
            Mapping of target to that target's breaker snapshot (method to status). Targets whose
            chain has no breaker, or no chain built yet, are absent.
        """
        from .interceptors.base import logical_interceptor  # noqa: PLC0415 - avoids import cycle at module load
        from .interceptors.circuit_breaker import AsyncCircuitBreakerInterceptor  # noqa: PLC0415

        snapshot: dict[str, dict[str, Any]] = {}
        for target, chain in self._chains.items():
            for entry in chain:
                owner = logical_interceptor(entry)
                if isinstance(owner, AsyncCircuitBreakerInterceptor):
                    snapshot[target] = dict(await owner.get_states())
                    break
        return snapshot

    async def connect(self) -> T:
        """Acquires a channel from the pool and returns a new stub instance.

        Returns:
            An instance of the stub_class configured with a pooled channel.

        Raises:
            NoHealthyTargetsError: If balancer is used and no healthy targets are available.
            ValueError: If target is not specified.
        """
        target: str | None
        if self._balancer:
            target = await self._balancer.select_target()
        else:
            target = self._config.target

        if target is None:
            # This should be caught by _validate_target_configuration but adding check for mypy
            raise ValueError("Target is not specified")

        interceptors = self.interceptors_for(target)
        if isinstance(self._pool, ChannelPool):
            key = self._channel_keys.get(target)
            if key is None:
                key = self._pool.make_key(
                    target,
                    insecure=self._config.insecure,
                    credentials=self._config.credentials,
                    # Explicit options plus whatever the connectivity tuning adds; built the same
                    # way every time, so the pool keeps recognising the channel it already has.
                    options=self._config.channel_options(),
                    compression=self._config.compression,
                    interceptors=interceptors,
                )
                self._channel_keys[target] = key
            channel = await self._pool.get_channel(target, interceptors=interceptors, key=key)
        else:
            # A custom provider speaks only the protocol surface, which has no key parameter.
            channel = await self._pool.get_channel(
                target,
                insecure=self._config.insecure,
                credentials=self._config.credentials,
                options=self._config.channel_options(),
                compression=self._config.compression,
                interceptors=interceptors,
            )

        # Mypy might complain about calling a generic type as a class
        return self._stub_class(channel)  # type: ignore[call-arg]

    async def __aenter__(self) -> T:
        """Async context manager entry. Calls connect()."""
        return await self.connect()

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: Any | None,
    ) -> None:
        """Context manager exit.

        NOTE: This does NOT close the underlying channel, as channels are managed by the ChannelPool.
        The pool owns channel lifetimes; idle connections are parked by gRPC core, not closed.
        """
        return None

__aenter__() async

Async context manager entry. Calls connect().

Source code in grpc_client_kit/client.py
async def __aenter__(self) -> T:
    """Async context manager entry. Calls connect()."""
    return await self.connect()

__aexit__(exc_type, exc_val, exc_tb) async

Context manager exit.

NOTE: This does NOT close the underlying channel, as channels are managed by the ChannelPool. The pool owns channel lifetimes; idle connections are parked by gRPC core, not closed.

Source code in grpc_client_kit/client.py
async def __aexit__(
    self,
    exc_type: type[BaseException] | None,
    exc_val: BaseException | None,
    exc_tb: Any | None,
) -> None:
    """Context manager exit.

    NOTE: This does NOT close the underlying channel, as channels are managed by the ChannelPool.
    The pool owns channel lifetimes; idle connections are parked by gRPC core, not closed.
    """
    return None

__init__(stub_class, config, pool, balancer=None, interceptors=None, interceptor_factory=None)

Initialize the gRPC client.

Parameters:

Name Type Description Default
stub_class type[T]

The gRPC stub class to instantiate (e.g., MyServiceStub).

required
config GrpcClientConfig

Configuration for the client (target, security, etc.).

required
pool ChannelProviderProtocol

The channel pool to acquire channels from.

required
balancer LoadBalancer | None

Optional load balancer for multiple targets.

None
interceptors list[ClientInterceptor] | None

Optional ready chain, shared by every target of this client.

None
interceptor_factory Callable[[str], list[ClientInterceptor]] | None

Optional builder called once per target to create a dedicated chain for it.

None

Raises:

Type Description
ValueError

If target configuration is ambiguous or missing, or if both an interceptor chain and an interceptor factory are given.

Source code in grpc_client_kit/client.py
def __init__(
    self,
    stub_class: type[T],
    config: GrpcClientConfig,
    pool: ChannelProviderProtocol,
    balancer: LoadBalancer | None = None,
    interceptors: list[grpc.aio.ClientInterceptor] | None = None,
    interceptor_factory: Callable[[str], list[grpc.aio.ClientInterceptor]] | None = None,
) -> None:
    """Initialize the gRPC client.

    Args:
        stub_class: The gRPC stub class to instantiate (e.g., MyServiceStub).
        config: Configuration for the client (target, security, etc.).
        pool: The channel pool to acquire channels from.
        balancer: Optional load balancer for multiple targets.
        interceptors: Optional ready chain, shared by every target of this client.
        interceptor_factory: Optional builder called once per target to create a dedicated
            chain for it.

    Raises:
        ValueError: If target configuration is ambiguous or missing, or if both an interceptor
            chain and an interceptor factory are given.
    """
    self._stub_class = stub_class
    self._config = config
    self._pool = pool
    self._balancer = balancer

    if interceptors is not None and interceptor_factory is not None:
        raise ValueError(
            "Ambiguous interceptors: provide either 'interceptors' (one shared chain) "
            "or 'interceptor_factory' (a chain per target), not both."
        )

    self._static_interceptors = list(interceptors) if interceptors is not None else None
    self._interceptor_factory = interceptor_factory
    self._chains: dict[str, list[grpc.aio.ClientInterceptor]] = {}
    self._warned_native_retry = False
    # Target, config and chain are fixed per target, so the pool identity is too: computing it
    # (validation, option merging, chain tokens) once per target instead of once per call keeps
    # that work off the hot path.
    self._channel_keys: dict[str, ChannelKey] = {}

    self._validate_target_configuration()

circuit_breaker_states() async

Snapshot the circuit breakers of every chain this client has built.

During an incident this is the question an operator asks first — "is the breaker open, or is the backend down?" — and it must be answerable without spelunking through the chain.

Returns:

Type Description
dict[str, dict[str, Any]]

Mapping of target to that target's breaker snapshot (method to status). Targets whose

dict[str, dict[str, Any]]

chain has no breaker, or no chain built yet, are absent.

Source code in grpc_client_kit/client.py
async def circuit_breaker_states(self) -> dict[str, dict[str, Any]]:
    """Snapshot the circuit breakers of every chain this client has built.

    During an incident this is the question an operator asks first — "is the breaker open, or
    is the backend down?" — and it must be answerable without spelunking through the chain.

    Returns:
        Mapping of target to that target's breaker snapshot (method to status). Targets whose
        chain has no breaker, or no chain built yet, are absent.
    """
    from .interceptors.base import logical_interceptor  # noqa: PLC0415 - avoids import cycle at module load
    from .interceptors.circuit_breaker import AsyncCircuitBreakerInterceptor  # noqa: PLC0415

    snapshot: dict[str, dict[str, Any]] = {}
    for target, chain in self._chains.items():
        for entry in chain:
            owner = logical_interceptor(entry)
            if isinstance(owner, AsyncCircuitBreakerInterceptor):
                snapshot[target] = dict(await owner.get_states())
                break
    return snapshot

connect() async

Acquires a channel from the pool and returns a new stub instance.

Returns:

Type Description
T

An instance of the stub_class configured with a pooled channel.

Raises:

Type Description
NoHealthyTargetsError

If balancer is used and no healthy targets are available.

ValueError

If target is not specified.

Source code in grpc_client_kit/client.py
async def connect(self) -> T:
    """Acquires a channel from the pool and returns a new stub instance.

    Returns:
        An instance of the stub_class configured with a pooled channel.

    Raises:
        NoHealthyTargetsError: If balancer is used and no healthy targets are available.
        ValueError: If target is not specified.
    """
    target: str | None
    if self._balancer:
        target = await self._balancer.select_target()
    else:
        target = self._config.target

    if target is None:
        # This should be caught by _validate_target_configuration but adding check for mypy
        raise ValueError("Target is not specified")

    interceptors = self.interceptors_for(target)
    if isinstance(self._pool, ChannelPool):
        key = self._channel_keys.get(target)
        if key is None:
            key = self._pool.make_key(
                target,
                insecure=self._config.insecure,
                credentials=self._config.credentials,
                # Explicit options plus whatever the connectivity tuning adds; built the same
                # way every time, so the pool keeps recognising the channel it already has.
                options=self._config.channel_options(),
                compression=self._config.compression,
                interceptors=interceptors,
            )
            self._channel_keys[target] = key
        channel = await self._pool.get_channel(target, interceptors=interceptors, key=key)
    else:
        # A custom provider speaks only the protocol surface, which has no key parameter.
        channel = await self._pool.get_channel(
            target,
            insecure=self._config.insecure,
            credentials=self._config.credentials,
            options=self._config.channel_options(),
            compression=self._config.compression,
            interceptors=interceptors,
        )

    # Mypy might complain about calling a generic type as a class
    return self._stub_class(channel)  # type: ignore[call-arg]

interceptors_for(target)

Return this client's interceptor chain for one target.

The chain is built at most once per target and then reused. That caching is what keeps the pool key stable: rebuilding the chain on every call would mint a new channel identity each time and the pool would open a channel per RPC.

Parameters:

Name Type Description Default
target str

The target address (host:port) the chain will be bound to.

required

Returns:

Type Description
list[ClientInterceptor]

The interceptor chain for the target, empty when the client has no interceptors.

Source code in grpc_client_kit/client.py
def interceptors_for(self, target: str) -> list[grpc.aio.ClientInterceptor]:
    """Return this client's interceptor chain for one target.

    The chain is built at most once per target and then reused. That caching is what keeps the
    pool key stable: rebuilding the chain on every call would mint a new channel identity each
    time and the pool would open a channel per RPC.

    Args:
        target: The target address (host:port) the chain will be bound to.

    Returns:
        The interceptor chain for the target, empty when the client has no interceptors.
    """
    chain = self._chains.get(target)
    if chain is None:
        if self._static_interceptors is not None:
            chain = self._static_interceptors
        elif self._interceptor_factory is not None:
            chain = self._interceptor_factory(target)
        else:
            chain = []
        self._chains[target] = chain
        self._warn_on_native_retry_overlap(chain)
    return chain

GrpcClientConfig dataclass

Core configuration for establishing a gRPC channel connection.

Attributes:

Name Type Description
target str | None

The target address (host:port) or a gRPC-compatible resolver URI.

insecure bool

Whether to use an insecure channel (default: False).

credentials ChannelCredentials | None

SSL/TLS credentials for secure channels.

options list[tuple[str, Any]] | None

A list of key-value pairs to configure the gRPC channel. Anything set here wins over connectivity.

compression Compression | None

The compression algorithm to use for the channel.

connectivity ConnectivityConfig | None

Keepalive and reconnect tuning, spelled in seconds instead of as raw channel arguments. None leaves every one of those arguments at gRPC's own default.

Source code in grpc_client_kit/config.py
@dataclass(slots=True)
class GrpcClientConfig:
    """Core configuration for establishing a gRPC channel connection.

    Attributes:
        target: The target address (host:port) or a gRPC-compatible resolver URI.
        insecure: Whether to use an insecure channel (default: False).
        credentials: SSL/TLS credentials for secure channels.
        options: A list of key-value pairs to configure the gRPC channel. Anything set here wins
            over `connectivity`.
        compression: The compression algorithm to use for the channel.
        connectivity: Keepalive and reconnect tuning, spelled in seconds instead of as raw channel
            arguments. ``None`` leaves every one of those arguments at gRPC's own default.
    """

    target: str | None = None
    insecure: bool = False
    credentials: grpc.ChannelCredentials | None = None
    options: list[tuple[str, Any]] | None = None
    compression: grpc.Compression | None = None
    connectivity: ConnectivityConfig | None = None

    def __post_init__(self) -> None:
        """Validate configuration."""
        if self.insecure and self.credentials:
            raise ValueError("Cannot provide credentials for an insecure channel")

    def channel_options(self) -> list[tuple[str, Any]] | None:
        """Return the full option list a channel is opened with.

        Explicit `options` come first and untouched, followed by the arguments `connectivity` stands
        for — minus any whose key the caller already used. Filtering those out is what makes "an
        explicit option wins" true in the only sense gRPC can honour it: the argument is passed once,
        with the caller's value, so there is no duplicate for gRPC to resolve one way or the other.

        The result is a pure function of the configuration, in a fixed order, which matters because
        the pool keys channels by their options among other things (see `channel.ChannelKey`): two
        clients configured alike share a channel, and one client asking twice does not open two.
        Without `connectivity` the caller's own list is handed back as it is — ``None`` included — so
        a client that does not tune anything keeps exactly the channel identity it always had.

        Returns:
            The options to open the channel with, or None when there are none at all.
        """
        if self.connectivity is None:
            return self.options

        explicit = self.options or []
        already_set = {key for key, _ in explicit}
        derived = [(key, value) for key, value in self.connectivity.to_options() if key not in already_set]

        if not derived:
            return self.options

        return [*explicit, *derived]

__post_init__()

Validate configuration.

Source code in grpc_client_kit/config.py
def __post_init__(self) -> None:
    """Validate configuration."""
    if self.insecure and self.credentials:
        raise ValueError("Cannot provide credentials for an insecure channel")

channel_options()

Return the full option list a channel is opened with.

Explicit options come first and untouched, followed by the arguments connectivity stands for — minus any whose key the caller already used. Filtering those out is what makes "an explicit option wins" true in the only sense gRPC can honour it: the argument is passed once, with the caller's value, so there is no duplicate for gRPC to resolve one way or the other.

The result is a pure function of the configuration, in a fixed order, which matters because the pool keys channels by their options among other things (see channel.ChannelKey): two clients configured alike share a channel, and one client asking twice does not open two. Without connectivity the caller's own list is handed back as it is — None included — so a client that does not tune anything keeps exactly the channel identity it always had.

Returns:

Type Description
list[tuple[str, Any]] | None

The options to open the channel with, or None when there are none at all.

Source code in grpc_client_kit/config.py
def channel_options(self) -> list[tuple[str, Any]] | None:
    """Return the full option list a channel is opened with.

    Explicit `options` come first and untouched, followed by the arguments `connectivity` stands
    for — minus any whose key the caller already used. Filtering those out is what makes "an
    explicit option wins" true in the only sense gRPC can honour it: the argument is passed once,
    with the caller's value, so there is no duplicate for gRPC to resolve one way or the other.

    The result is a pure function of the configuration, in a fixed order, which matters because
    the pool keys channels by their options among other things (see `channel.ChannelKey`): two
    clients configured alike share a channel, and one client asking twice does not open two.
    Without `connectivity` the caller's own list is handed back as it is — ``None`` included — so
    a client that does not tune anything keeps exactly the channel identity it always had.

    Returns:
        The options to open the channel with, or None when there are none at all.
    """
    if self.connectivity is None:
        return self.options

    explicit = self.options or []
    already_set = {key for key, _ in explicit}
    derived = [(key, value) for key, value in self.connectivity.to_options() if key not in already_set]

    if not derived:
        return self.options

    return [*explicit, *derived]

GrpcClientFactory

High-level factory for creating configured GrpcClient instances.

Encapsulates mapping from settings objects to kit-specific configuration objects.

Source code in grpc_client_kit/factory.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
class GrpcClientFactory:
    """High-level factory for creating configured GrpcClient instances.

    Encapsulates mapping from settings objects to kit-specific configuration objects.
    """

    def __init__(
        self,
        settings: GrpcClientSettingsProtocol | None = None,
        pool: ChannelProviderProtocol | None = None,
        shutdown_grace: float | None = 5.0,
        ready_timeout: float | None = 10.0,
    ) -> None:
        """Initialize the factory.

        Ownership Semantics:
            If `pool` is provided, this factory uses it but does NOT own it. Closing the factory
            will not close the provided pool.
            If `pool` is NOT provided, the factory creates and OWNS its own `ChannelPool`.
            Closing the factory will close this internal pool.

        Args:
            settings: Base settings object (optional)
            pool: Shared channel provider (if not provided, creates a ChannelPool based on settings)
            shutdown_grace: Seconds in-flight RPCs get to finish when the ``async with`` block is
                left. ``None`` cancels them immediately — a deliberate choice, not the default:
                a k8s SIGTERM lands here, and cutting every in-flight dependency call short turns
                a rolling deploy into an error spike.
            ready_timeout: Seconds ``__aenter__`` waits for the first health check pass. Until
                that pass every target reads unhealthy, so entering the context without waiting
                would make the first call of every freshly started pod fail deterministically.
                ``None`` waits indefinitely; only relevant when health checking is configured.

        Raises:
            TypeError: If `settings` does not satisfy `GrpcClientSettingsProtocol`. Validated
                eagerly: a missing required field otherwise surfaces as a bare ``AttributeError``
                on the first RPC, in production, far from the mistake.
            ImportError: If health checking is configured but the [health] extra is missing.
        """
        _validate_settings(settings)
        self._settings = settings
        self._shutdown_grace = shutdown_grace
        self._ready_timeout = ready_timeout
        self._owns_pool = False
        self._health_checker: HealthChecker | None = None
        # Chains cached per (service_name, target): recreating a client for the same stub must
        # reuse the same interceptor instances, or every create_client would mint a fresh channel
        # identity and a per-request client pattern would open a connection per request.
        self._chains: dict[tuple[str, str], list[grpc.aio.ClientInterceptor]] = {}

        if pool:
            self._pool = pool
        else:
            self._owns_pool = True
            metrics = getattr(settings, "metrics_registry", None) if settings else None

            if settings and settings.pool:
                self._pool = ChannelPool(
                    max_channels_per_target=settings.pool.max_channels_per_target,
                    idle_timeout=settings.pool.idle_timeout,
                    metrics=metrics,
                )
            else:
                self._pool = ChannelPool(metrics=metrics)

        # Build and start HealthChecker if configured and we have targets
        if settings and settings.health_checker and settings.targets:
            health_checker_class = _load_health_checker()
            self._health_checker = health_checker_class(
                check_interval=settings.health_checker.check_interval,
                timeout=settings.health_checker.timeout,
                service=getattr(settings.health_checker, "service", ""),
                insecure=settings.insecure,
                credentials=getattr(settings, "credentials", None),
                # Same options and compression as the application channels, so the health probes
                # negotiate HTTP/2 the same way the real traffic does.
                options=getattr(settings, "options", None),
                compression=getattr(settings, "compression", None),
                # Pass pool if it supports updating health status
                pool=self._pool if isinstance(self._pool, ChannelPool) else None,
            )

    async def circuit_breaker_states(self) -> dict[str, dict[str, Any]]:
        """Snapshot the circuit breakers of every chain this factory has built.

        Returns:
            Mapping of ``"service -> target"`` to that chain's breaker snapshot (method to
            status). Chains without a breaker are absent.
        """
        from .interceptors.base import logical_interceptor  # noqa: PLC0415 - avoids import cycle at module load
        from .interceptors.circuit_breaker import AsyncCircuitBreakerInterceptor  # noqa: PLC0415

        snapshot: dict[str, dict[str, Any]] = {}
        for (service_name, target), chain in self._chains.items():
            for entry in chain:
                owner = logical_interceptor(entry)
                if isinstance(owner, AsyncCircuitBreakerInterceptor):
                    snapshot[f"{service_name} -> {target}"] = dict(await owner.get_states())
                    break
        return snapshot

    async def close(self, grace: float | None = None) -> None:
        """Close the underlying pool and stop the health checker.

        Args:
            grace: Optional time in seconds to allow active RPCs to complete.
        """
        if self._health_checker:
            await self._health_checker.stop()

        if self._owns_pool:
            await self._pool.close_all(grace=grace)

    @property
    def health_checker(self) -> HealthChecker | None:
        """The checker this factory built from settings, or None when none is configured."""
        return self._health_checker

    async def wait_until_ready(self, timeout: float | None = None) -> bool:
        """Wait until the first health check pass has classified every target.

        Args:
            timeout: Maximum seconds to wait. None waits indefinitely.

        Returns:
            True once the first pass completed — and trivially when no checker is configured,
            since there is then nothing to wait for. False if the wait timed out or the checker
            is not running.
        """
        if self._health_checker is None:
            return True
        return await self._health_checker.wait_until_ready(timeout=timeout)

    async def __aenter__(self) -> GrpcClientFactory:
        """Start the health checker and wait for its first verdicts.

        Waiting is the point: until the first pass every target reads unhealthy by design, so a
        factory that returned before it would hand out clients whose very first call fails with
        `NoHealthyTargetsError` on every fresh start — a deterministic error spike per deploy.
        """
        if self._health_checker and self._settings and self._settings.targets:
            await self._health_checker.start(self._settings.targets)
            await self._health_checker.wait_until_ready(timeout=self._ready_timeout)
        return self

    async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        """Async context manager exit. Calls close() with the configured shutdown grace."""
        await self.close(grace=self._shutdown_grace)

    def create_client[T](
        self,
        stub_class: type[T],
        target: str | None = None,
        service_name: str | None = None,
        metrics: GrpcClientMetricsProtocol | None = None,
        interceptors: list[grpc.aio.ClientInterceptor] | None = None,
    ) -> GrpcClient[T]:
        """Create a fully configured gRPC client instance.

        This method applies resilience (retries, timeouts, circuit breakers)
         and observability (metrics, logging, tracing) based on factory settings.

        Args:
            stub_class: The gRPC stub class to instantiate.
            target: Optional target override. If not provided, uses target from settings.
            service_name: Name of the service for observability. Defaults to stub class name.
            metrics: Optional metrics registry, overriding `settings.metrics_registry`.
            interceptors: Optional list of additional custom interceptors. These instances are
                shared by every target, unlike the chain the factory builds around them.

        Returns:
            A configured GrpcClient instance.

        Raises:
            ValueError: If neither a target nor a list of targets is configured.
        """
        config = self._build_config(target)
        balancer = self._build_balancer()

        # Validation: either target or balancer must be available
        if not config.target and not balancer:
            raise ValueError(
                "No target configured. Provide either 'target' in settings/overrides or 'targets' for load balancing."
            )

        # Lifecycle warning
        if self._health_checker and not self._health_checker.is_running:
            logger.warning(
                "GrpcClientFactory used without 'async with'. "
                "Health checks are NOT running. Traffic may be sent to unhealthy targets."
            )

        actual_service_name = service_name or stub_class.__name__
        metrics_registry = self._resolve_metrics_registry(metrics)
        custom_interceptors = list(interceptors) if interceptors else []

        def build_chain(chain_target: str) -> list[grpc.aio.ClientInterceptor]:
            """Build a chain dedicated to one target so its stateful interceptors stay isolated.

            Chains are cached per (service_name, target): recreating a client for the same stub —
            a per-request DI scope, say — must land on the same interceptor instances and thereby
            the same pooled channel, not open a new connection per client. Custom interceptors are
            caller-owned instances the factory cannot prove reusable, so chains around them stay
            per-client.
            """
            cache_key = (actual_service_name, chain_target)
            if not custom_interceptors and metrics is None:
                cached = self._chains.get(cache_key)
                if cached is not None:
                    return cached

            logger.debug("Building gRPC interceptor chain for %s -> %s", actual_service_name, chain_target)
            builder = InterceptorChainBuilder()

            if self._settings:
                builder.with_observability(
                    ObservabilityConfig(
                        tracing=self._settings.tracing_enabled,
                        # A registry-less metrics interceptor records nothing; keep it out of the
                        # chain so "no metrics" is visible in the chain instead of silent.
                        metrics=self._settings.metrics_enabled and metrics_registry is not None,
                        logging=self._settings.logging_enabled,
                        metrics_registry=metrics_registry,
                        service_name=actual_service_name,
                        sensitive_headers=getattr(self._settings, "sensitive_headers", None),
                        enable_method_label=getattr(self._settings, "enable_method_label", True),
                        success_log_level=getattr(self._settings, "success_log_level", logging.INFO),
                    )
                )
                builder.with_resilience(
                    timeout=self._build_timeout_config(self._settings.timeout),
                    retry=self._build_retry_config(self._settings.retry, metrics=metrics_registry),
                    circuit_breaker=self._build_cb_config(self._settings.circuit_breaker, metrics=metrics_registry),
                    deadline_budget=self._build_deadline_budget_config(
                        getattr(self._settings, "deadline_budget", None)
                    ),
                    wait_for_ready=self._build_wait_for_ready_config(getattr(self._settings, "wait_for_ready", None)),
                )

            builder.with_custom(custom_interceptors)
            if balancer is not None:
                # Passive outlier detection, innermost: a call that just got UNAVAILABLE is
                # fresher evidence than any probe, and quarantining the target immediately closes
                # the window in which an active checker would keep routing into a dead backend.
                from .interceptors.outlier import AsyncPassiveOutlierInterceptor  # noqa: PLC0415

                builder.with_extra_inner([AsyncPassiveOutlierInterceptor(balancer, chain_target)])
            chain = builder.build()
            if not custom_interceptors and metrics is None:
                self._chains[cache_key] = chain
            return chain

        return GrpcClient(
            stub_class=stub_class,
            config=config,
            pool=self._pool,
            balancer=balancer,
            interceptor_factory=build_chain,
        )

    def _resolve_metrics_registry(self, metrics: GrpcClientMetricsProtocol | None) -> GrpcClientMetricsProtocol | None:
        """Pick the registry RPC metrics are recorded into.

        An explicit argument wins over `settings.metrics_registry`, which otherwise only fed pool
        statistics and left `metrics_enabled` clients recording nothing at all.

        Args:
            metrics: The registry passed to `create_client`, if any.

        Returns:
            The registry to use, or None if metrics cannot be recorded.
        """
        settings_registry = getattr(self._settings, "metrics_registry", None) if self._settings else None
        registry: GrpcClientMetricsProtocol | None = metrics if metrics is not None else settings_registry

        if registry is None and self._settings and self._settings.metrics_enabled:
            logger.warning(
                "metrics_enabled is set but no metrics registry is available. "
                "Pass 'metrics=' to create_client() or set 'metrics_registry' in settings; "
                "gRPC client metrics are disabled."
            )

        return registry

    def _build_config(self, target: str | None) -> GrpcClientConfig:
        """Build GrpcClientConfig from settings or overrides.

        Channel construction settings are optional blocks (see `protocols.GrpcChannelExtrasProtocol`)
        and are read with `getattr`, so a settings model that declares none of them still satisfies
        the factory. ``connectivity`` joins them on the same terms: a settings object that carries a
        `config.ConnectivityConfig` under that name has its channels tuned by it, and one that does
        not is left with gRPC's defaults.
        """
        if self._settings:
            actual_target = target or self._settings.target
            insecure = self._settings.insecure
            credentials = getattr(self._settings, "credentials", None)
            options = getattr(self._settings, "options", None)
            compression = getattr(self._settings, "compression", None)
            connectivity = getattr(self._settings, "connectivity", None)
        else:
            actual_target = target
            insecure = False
            credentials = None
            options = None
            compression = None
            connectivity = None

        return GrpcClientConfig(
            target=actual_target,
            insecure=insecure,
            credentials=credentials,
            options=options,
            compression=compression,
            connectivity=connectivity,
        )

    def _build_balancer(self) -> LoadBalancer | None:
        """Build LoadBalancer from settings targets and balancer strategy."""
        if not self._settings or not self._settings.targets:
            return None

        balancer_config = None
        if self._settings.balancer:
            s = self._settings.balancer
            try:
                strategy = LoadBalancingStrategy(s.strategy)
            except ValueError as e:
                raise ValueError(f"Invalid load balancing strategy: {s.strategy}") from e

            balancer_config = LoadBalancerConfig(
                strategy=strategy,
                weights=s.weights,
            )

        return create_balancer(
            targets=self._settings.targets,
            config=balancer_config,
            health_checker=self._health_checker,
        )

    def _build_timeout_config(self, s: TimeoutSettingsProtocol | None) -> TimeoutConfig | None:
        """Build TimeoutConfig from settings protocol."""
        if not s:
            return None
        return TimeoutConfig(default=s.default)

    def _build_retry_config(
        self, s: RetrySettingsProtocol | None, metrics: GrpcClientMetricsProtocol | None = None
    ) -> RetryConfig | None:
        """Build RetryConfig from settings protocol."""
        if not s:
            return None
        return RetryConfig(
            max_attempts=s.max_attempts,
            initial_backoff=s.initial_backoff,
            max_backoff=s.max_backoff,
            backoff_multiplier=s.backoff_multiplier,
            jitter=getattr(s, "jitter", 0.1),
            retryable_codes=getattr(s, "retryable_codes", None),
            retry_streaming=getattr(s, "retry_streaming", False),
            idempotent_methods=getattr(s, "idempotent_methods", None),
            on_retry=getattr(s, "on_retry", None),
            # The registry opts into retry visibility by implementing the extension protocol.
            metrics=metrics if isinstance(metrics, RetryMetricsProtocol) else None,
        )

    def _build_wait_for_ready_config(self, s: Any | None) -> WaitForReadyConfig | None:
        """Build WaitForReadyConfig from an optional settings block (duck-typed)."""
        if not s:
            return None
        return WaitForReadyConfig(
            default=getattr(s, "default", True),
            per_method=getattr(s, "per_method", None) or {},
            require_deadline=getattr(s, "require_deadline", True),
        )

    def _build_deadline_budget_config(self, s: Any | None) -> DeadlineBudgetConfig | None:
        """Build DeadlineBudgetConfig from an optional settings block (duck-typed)."""
        if not s:
            return None
        return DeadlineBudgetConfig(reserve_for_next=getattr(s, "reserve_for_next", 0.0))

    def _build_cb_config(
        self, s: CircuitBreakerSettingsProtocol | None, metrics: GrpcClientMetricsProtocol | None = None
    ) -> CircuitBreakerConfig | None:
        """Build CircuitBreakerConfig from settings protocol."""
        if not s:
            return None
        return CircuitBreakerConfig(
            fail_threshold=s.fail_threshold,
            recovery_timeout=s.recovery_timeout,
            half_open_max_calls=s.half_open_max_calls,
            max_methods=getattr(s, "max_methods", 1000),
            metrics=metrics,
        )

health_checker property

The checker this factory built from settings, or None when none is configured.

__aenter__() async

Start the health checker and wait for its first verdicts.

Waiting is the point: until the first pass every target reads unhealthy by design, so a factory that returned before it would hand out clients whose very first call fails with NoHealthyTargetsError on every fresh start — a deterministic error spike per deploy.

Source code in grpc_client_kit/factory.py
async def __aenter__(self) -> GrpcClientFactory:
    """Start the health checker and wait for its first verdicts.

    Waiting is the point: until the first pass every target reads unhealthy by design, so a
    factory that returned before it would hand out clients whose very first call fails with
    `NoHealthyTargetsError` on every fresh start — a deterministic error spike per deploy.
    """
    if self._health_checker and self._settings and self._settings.targets:
        await self._health_checker.start(self._settings.targets)
        await self._health_checker.wait_until_ready(timeout=self._ready_timeout)
    return self

__aexit__(exc_type, exc_val, exc_tb) async

Async context manager exit. Calls close() with the configured shutdown grace.

Source code in grpc_client_kit/factory.py
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
    """Async context manager exit. Calls close() with the configured shutdown grace."""
    await self.close(grace=self._shutdown_grace)

__init__(settings=None, pool=None, shutdown_grace=5.0, ready_timeout=10.0)

Initialize the factory.

Ownership Semantics

If pool is provided, this factory uses it but does NOT own it. Closing the factory will not close the provided pool. If pool is NOT provided, the factory creates and OWNS its own ChannelPool. Closing the factory will close this internal pool.

Parameters:

Name Type Description Default
settings GrpcClientSettingsProtocol | None

Base settings object (optional)

None
pool ChannelProviderProtocol | None

Shared channel provider (if not provided, creates a ChannelPool based on settings)

None
shutdown_grace float | None

Seconds in-flight RPCs get to finish when the async with block is left. None cancels them immediately — a deliberate choice, not the default: a k8s SIGTERM lands here, and cutting every in-flight dependency call short turns a rolling deploy into an error spike.

5.0
ready_timeout float | None

Seconds __aenter__ waits for the first health check pass. Until that pass every target reads unhealthy, so entering the context without waiting would make the first call of every freshly started pod fail deterministically. None waits indefinitely; only relevant when health checking is configured.

10.0

Raises:

Type Description
TypeError

If settings does not satisfy GrpcClientSettingsProtocol. Validated eagerly: a missing required field otherwise surfaces as a bare AttributeError on the first RPC, in production, far from the mistake.

ImportError

If health checking is configured but the [health] extra is missing.

Source code in grpc_client_kit/factory.py
def __init__(
    self,
    settings: GrpcClientSettingsProtocol | None = None,
    pool: ChannelProviderProtocol | None = None,
    shutdown_grace: float | None = 5.0,
    ready_timeout: float | None = 10.0,
) -> None:
    """Initialize the factory.

    Ownership Semantics:
        If `pool` is provided, this factory uses it but does NOT own it. Closing the factory
        will not close the provided pool.
        If `pool` is NOT provided, the factory creates and OWNS its own `ChannelPool`.
        Closing the factory will close this internal pool.

    Args:
        settings: Base settings object (optional)
        pool: Shared channel provider (if not provided, creates a ChannelPool based on settings)
        shutdown_grace: Seconds in-flight RPCs get to finish when the ``async with`` block is
            left. ``None`` cancels them immediately — a deliberate choice, not the default:
            a k8s SIGTERM lands here, and cutting every in-flight dependency call short turns
            a rolling deploy into an error spike.
        ready_timeout: Seconds ``__aenter__`` waits for the first health check pass. Until
            that pass every target reads unhealthy, so entering the context without waiting
            would make the first call of every freshly started pod fail deterministically.
            ``None`` waits indefinitely; only relevant when health checking is configured.

    Raises:
        TypeError: If `settings` does not satisfy `GrpcClientSettingsProtocol`. Validated
            eagerly: a missing required field otherwise surfaces as a bare ``AttributeError``
            on the first RPC, in production, far from the mistake.
        ImportError: If health checking is configured but the [health] extra is missing.
    """
    _validate_settings(settings)
    self._settings = settings
    self._shutdown_grace = shutdown_grace
    self._ready_timeout = ready_timeout
    self._owns_pool = False
    self._health_checker: HealthChecker | None = None
    # Chains cached per (service_name, target): recreating a client for the same stub must
    # reuse the same interceptor instances, or every create_client would mint a fresh channel
    # identity and a per-request client pattern would open a connection per request.
    self._chains: dict[tuple[str, str], list[grpc.aio.ClientInterceptor]] = {}

    if pool:
        self._pool = pool
    else:
        self._owns_pool = True
        metrics = getattr(settings, "metrics_registry", None) if settings else None

        if settings and settings.pool:
            self._pool = ChannelPool(
                max_channels_per_target=settings.pool.max_channels_per_target,
                idle_timeout=settings.pool.idle_timeout,
                metrics=metrics,
            )
        else:
            self._pool = ChannelPool(metrics=metrics)

    # Build and start HealthChecker if configured and we have targets
    if settings and settings.health_checker and settings.targets:
        health_checker_class = _load_health_checker()
        self._health_checker = health_checker_class(
            check_interval=settings.health_checker.check_interval,
            timeout=settings.health_checker.timeout,
            service=getattr(settings.health_checker, "service", ""),
            insecure=settings.insecure,
            credentials=getattr(settings, "credentials", None),
            # Same options and compression as the application channels, so the health probes
            # negotiate HTTP/2 the same way the real traffic does.
            options=getattr(settings, "options", None),
            compression=getattr(settings, "compression", None),
            # Pass pool if it supports updating health status
            pool=self._pool if isinstance(self._pool, ChannelPool) else None,
        )

circuit_breaker_states() async

Snapshot the circuit breakers of every chain this factory has built.

Returns:

Type Description
dict[str, dict[str, Any]]

Mapping of "service -> target" to that chain's breaker snapshot (method to

dict[str, dict[str, Any]]

status). Chains without a breaker are absent.

Source code in grpc_client_kit/factory.py
async def circuit_breaker_states(self) -> dict[str, dict[str, Any]]:
    """Snapshot the circuit breakers of every chain this factory has built.

    Returns:
        Mapping of ``"service -> target"`` to that chain's breaker snapshot (method to
        status). Chains without a breaker are absent.
    """
    from .interceptors.base import logical_interceptor  # noqa: PLC0415 - avoids import cycle at module load
    from .interceptors.circuit_breaker import AsyncCircuitBreakerInterceptor  # noqa: PLC0415

    snapshot: dict[str, dict[str, Any]] = {}
    for (service_name, target), chain in self._chains.items():
        for entry in chain:
            owner = logical_interceptor(entry)
            if isinstance(owner, AsyncCircuitBreakerInterceptor):
                snapshot[f"{service_name} -> {target}"] = dict(await owner.get_states())
                break
    return snapshot

close(grace=None) async

Close the underlying pool and stop the health checker.

Parameters:

Name Type Description Default
grace float | None

Optional time in seconds to allow active RPCs to complete.

None
Source code in grpc_client_kit/factory.py
async def close(self, grace: float | None = None) -> None:
    """Close the underlying pool and stop the health checker.

    Args:
        grace: Optional time in seconds to allow active RPCs to complete.
    """
    if self._health_checker:
        await self._health_checker.stop()

    if self._owns_pool:
        await self._pool.close_all(grace=grace)

create_client(stub_class, target=None, service_name=None, metrics=None, interceptors=None)

Create a fully configured gRPC client instance.

This method applies resilience (retries, timeouts, circuit breakers) and observability (metrics, logging, tracing) based on factory settings.

Parameters:

Name Type Description Default
stub_class type[T]

The gRPC stub class to instantiate.

required
target str | None

Optional target override. If not provided, uses target from settings.

None
service_name str | None

Name of the service for observability. Defaults to stub class name.

None
metrics GrpcClientMetricsProtocol | None

Optional metrics registry, overriding settings.metrics_registry.

None
interceptors list[ClientInterceptor] | None

Optional list of additional custom interceptors. These instances are shared by every target, unlike the chain the factory builds around them.

None

Returns:

Type Description
GrpcClient[T]

A configured GrpcClient instance.

Raises:

Type Description
ValueError

If neither a target nor a list of targets is configured.

Source code in grpc_client_kit/factory.py
def create_client[T](
    self,
    stub_class: type[T],
    target: str | None = None,
    service_name: str | None = None,
    metrics: GrpcClientMetricsProtocol | None = None,
    interceptors: list[grpc.aio.ClientInterceptor] | None = None,
) -> GrpcClient[T]:
    """Create a fully configured gRPC client instance.

    This method applies resilience (retries, timeouts, circuit breakers)
     and observability (metrics, logging, tracing) based on factory settings.

    Args:
        stub_class: The gRPC stub class to instantiate.
        target: Optional target override. If not provided, uses target from settings.
        service_name: Name of the service for observability. Defaults to stub class name.
        metrics: Optional metrics registry, overriding `settings.metrics_registry`.
        interceptors: Optional list of additional custom interceptors. These instances are
            shared by every target, unlike the chain the factory builds around them.

    Returns:
        A configured GrpcClient instance.

    Raises:
        ValueError: If neither a target nor a list of targets is configured.
    """
    config = self._build_config(target)
    balancer = self._build_balancer()

    # Validation: either target or balancer must be available
    if not config.target and not balancer:
        raise ValueError(
            "No target configured. Provide either 'target' in settings/overrides or 'targets' for load balancing."
        )

    # Lifecycle warning
    if self._health_checker and not self._health_checker.is_running:
        logger.warning(
            "GrpcClientFactory used without 'async with'. "
            "Health checks are NOT running. Traffic may be sent to unhealthy targets."
        )

    actual_service_name = service_name or stub_class.__name__
    metrics_registry = self._resolve_metrics_registry(metrics)
    custom_interceptors = list(interceptors) if interceptors else []

    def build_chain(chain_target: str) -> list[grpc.aio.ClientInterceptor]:
        """Build a chain dedicated to one target so its stateful interceptors stay isolated.

        Chains are cached per (service_name, target): recreating a client for the same stub —
        a per-request DI scope, say — must land on the same interceptor instances and thereby
        the same pooled channel, not open a new connection per client. Custom interceptors are
        caller-owned instances the factory cannot prove reusable, so chains around them stay
        per-client.
        """
        cache_key = (actual_service_name, chain_target)
        if not custom_interceptors and metrics is None:
            cached = self._chains.get(cache_key)
            if cached is not None:
                return cached

        logger.debug("Building gRPC interceptor chain for %s -> %s", actual_service_name, chain_target)
        builder = InterceptorChainBuilder()

        if self._settings:
            builder.with_observability(
                ObservabilityConfig(
                    tracing=self._settings.tracing_enabled,
                    # A registry-less metrics interceptor records nothing; keep it out of the
                    # chain so "no metrics" is visible in the chain instead of silent.
                    metrics=self._settings.metrics_enabled and metrics_registry is not None,
                    logging=self._settings.logging_enabled,
                    metrics_registry=metrics_registry,
                    service_name=actual_service_name,
                    sensitive_headers=getattr(self._settings, "sensitive_headers", None),
                    enable_method_label=getattr(self._settings, "enable_method_label", True),
                    success_log_level=getattr(self._settings, "success_log_level", logging.INFO),
                )
            )
            builder.with_resilience(
                timeout=self._build_timeout_config(self._settings.timeout),
                retry=self._build_retry_config(self._settings.retry, metrics=metrics_registry),
                circuit_breaker=self._build_cb_config(self._settings.circuit_breaker, metrics=metrics_registry),
                deadline_budget=self._build_deadline_budget_config(
                    getattr(self._settings, "deadline_budget", None)
                ),
                wait_for_ready=self._build_wait_for_ready_config(getattr(self._settings, "wait_for_ready", None)),
            )

        builder.with_custom(custom_interceptors)
        if balancer is not None:
            # Passive outlier detection, innermost: a call that just got UNAVAILABLE is
            # fresher evidence than any probe, and quarantining the target immediately closes
            # the window in which an active checker would keep routing into a dead backend.
            from .interceptors.outlier import AsyncPassiveOutlierInterceptor  # noqa: PLC0415

            builder.with_extra_inner([AsyncPassiveOutlierInterceptor(balancer, chain_target)])
        chain = builder.build()
        if not custom_interceptors and metrics is None:
            self._chains[cache_key] = chain
        return chain

    return GrpcClient(
        stub_class=stub_class,
        config=config,
        pool=self._pool,
        balancer=balancer,
        interceptor_factory=build_chain,
    )

wait_until_ready(timeout=None) async

Wait until the first health check pass has classified every target.

Parameters:

Name Type Description Default
timeout float | None

Maximum seconds to wait. None waits indefinitely.

None

Returns:

Type Description
bool

True once the first pass completed — and trivially when no checker is configured,

bool

since there is then nothing to wait for. False if the wait timed out or the checker

bool

is not running.

Source code in grpc_client_kit/factory.py
async def wait_until_ready(self, timeout: float | None = None) -> bool:
    """Wait until the first health check pass has classified every target.

    Args:
        timeout: Maximum seconds to wait. None waits indefinitely.

    Returns:
        True once the first pass completed — and trivially when no checker is configured,
        since there is then nothing to wait for. False if the wait timed out or the checker
        is not running.
    """
    if self._health_checker is None:
        return True
    return await self._health_checker.wait_until_ready(timeout=timeout)

GrpcClientKitError

Bases: Exception

Base class of every failure raised by the kit itself rather than by a server.

Source code in grpc_client_kit/errors.py
class GrpcClientKitError(Exception):
    """Base class of every failure raised by the kit itself rather than by a server."""

GrpcClientMetricsProtocol

Bases: Protocol

Protocol for gRPC client metrics collection.

Source code in grpc_client_kit/protocols.py
@runtime_checkable
class GrpcClientMetricsProtocol(Protocol):
    """Protocol for gRPC client metrics collection."""

    def record_request(
        self,
        service: str,
        method: str,
        rpc_type: str,
        status: str,
        grpc_code: str,
        duration: float,
    ) -> None:
        """Record a completed gRPC request.

        Args:
            service: Name of the service.
            method: Name of the method.
            rpc_type: Type of RPC (unary_unary, unary_stream, etc.).
            status: Status of the request (success, error, cancelled).
            grpc_code: gRPC status code name.
            duration: Request duration in seconds.
        """
        ...

    def record_inflight_delta(
        self,
        service: str,
        method: str,
        rpc_type: str,
        delta: int,
    ) -> None:
        """Record a change in in-flight requests.

        Args:
            service: Name of the service.
            method: Name of the method.
            rpc_type: Type of RPC.
            delta: Change in in-flight requests (e.g., +1 or -1).
        """
        ...

    def record_pool_stats(
        self,
        active_channels: int,
        idle_targets: int,
    ) -> None:
        """Record channel pool statistics.

        Args:
            active_channels: Total number of active channels in the pool.
            idle_targets: Number of targets currently in the pool.
        """
        ...

record_inflight_delta(service, method, rpc_type, delta)

Record a change in in-flight requests.

Parameters:

Name Type Description Default
service str

Name of the service.

required
method str

Name of the method.

required
rpc_type str

Type of RPC.

required
delta int

Change in in-flight requests (e.g., +1 or -1).

required
Source code in grpc_client_kit/protocols.py
def record_inflight_delta(
    self,
    service: str,
    method: str,
    rpc_type: str,
    delta: int,
) -> None:
    """Record a change in in-flight requests.

    Args:
        service: Name of the service.
        method: Name of the method.
        rpc_type: Type of RPC.
        delta: Change in in-flight requests (e.g., +1 or -1).
    """
    ...

record_pool_stats(active_channels, idle_targets)

Record channel pool statistics.

Parameters:

Name Type Description Default
active_channels int

Total number of active channels in the pool.

required
idle_targets int

Number of targets currently in the pool.

required
Source code in grpc_client_kit/protocols.py
def record_pool_stats(
    self,
    active_channels: int,
    idle_targets: int,
) -> None:
    """Record channel pool statistics.

    Args:
        active_channels: Total number of active channels in the pool.
        idle_targets: Number of targets currently in the pool.
    """
    ...

record_request(service, method, rpc_type, status, grpc_code, duration)

Record a completed gRPC request.

Parameters:

Name Type Description Default
service str

Name of the service.

required
method str

Name of the method.

required
rpc_type str

Type of RPC (unary_unary, unary_stream, etc.).

required
status str

Status of the request (success, error, cancelled).

required
grpc_code str

gRPC status code name.

required
duration float

Request duration in seconds.

required
Source code in grpc_client_kit/protocols.py
def record_request(
    self,
    service: str,
    method: str,
    rpc_type: str,
    status: str,
    grpc_code: str,
    duration: float,
) -> None:
    """Record a completed gRPC request.

    Args:
        service: Name of the service.
        method: Name of the method.
        rpc_type: Type of RPC (unary_unary, unary_stream, etc.).
        status: Status of the request (success, error, cancelled).
        grpc_code: gRPC status code name.
        duration: Request duration in seconds.
    """
    ...

GrpcClientSettingsProtocol

Bases: Protocol

Protocol for base gRPC client settings.

Every field here is one a settings object must carry. Anything a client may or may not configure (credentials, channel options, redaction, metrics registry) belongs to the extras protocols below, so that a plain settings model satisfies this one.

Source code in grpc_client_kit/protocols.py
@runtime_checkable
class GrpcClientSettingsProtocol(Protocol):
    """Protocol for base gRPC client settings.

    Every field here is one a settings object must carry. Anything a client may or may
    not configure (credentials, channel options, redaction, metrics registry) belongs to
    the extras protocols below, so that a plain settings model satisfies this one.
    """

    @property
    def target(self) -> str | None:
        """Single target address (host:port)."""
        ...

    @property
    def targets(self) -> list[str] | None:
        """List of target addresses for load balancing."""
        ...

    @property
    def insecure(self) -> bool:
        """Whether to use insecure connection."""
        ...

    @property
    def tracing_enabled(self) -> bool:
        """Whether tracing is enabled."""
        ...

    @property
    def metrics_enabled(self) -> bool:
        """Whether metrics are enabled."""
        ...

    @property
    def logging_enabled(self) -> bool:
        """Whether logging is enabled."""
        ...

    @property
    def pool(self) -> ChannelPoolSettingsProtocol | None:
        """Channel pool settings."""
        ...

    @property
    def circuit_breaker(self) -> CircuitBreakerSettingsProtocol | None:
        """Circuit breaker settings."""
        ...

    @property
    def retry(self) -> RetrySettingsProtocol | None:
        """Retry settings."""
        ...

    @property
    def timeout(self) -> TimeoutSettingsProtocol | None:
        """Timeout settings."""
        ...

    @property
    def balancer(self) -> LoadBalancerSettingsProtocol | None:
        """Load balancer settings."""
        ...

    @property
    def health_checker(self) -> HealthCheckerSettingsProtocol | None:
        """Health checker settings."""
        ...

balancer property

Load balancer settings.

circuit_breaker property

Circuit breaker settings.

health_checker property

Health checker settings.

insecure property

Whether to use insecure connection.

logging_enabled property

Whether logging is enabled.

metrics_enabled property

Whether metrics are enabled.

pool property

Channel pool settings.

retry property

Retry settings.

target property

Single target address (host:port).

targets property

List of target addresses for load balancing.

timeout property

Timeout settings.

tracing_enabled property

Whether tracing is enabled.

HealthChecker

Bases: HealthCheckerProtocol

Monitors target health via the gRPC health protocol.

Features: - Concurrent health checks for multiple targets. - Persistent gRPC channels for efficiency. - Exponential backoff for failed targets. - Status change callbacks.

Cold start

A target is healthy only once a check has said so. Targets that have never been checked are reported as unhealthy, so a load balancer cannot fan traffic out to addresses nobody has probed yet, and asking a checker that was never started raises :class:HealthCheckerNotRunningError instead of silently claiming health. Call :meth:wait_until_ready after :meth:start to await the first pass.

Source code in grpc_client_kit/health.py
class HealthChecker(HealthCheckerProtocol):
    """Monitors target health via the gRPC health protocol.

    Features:
    - Concurrent health checks for multiple targets.
    - Persistent gRPC channels for efficiency.
    - Exponential backoff for failed targets.
    - Status change callbacks.

    Cold start:
        A target is healthy only once a check has said so. Targets that have never been
        checked are reported as unhealthy, so a load balancer cannot fan traffic out to
        addresses nobody has probed yet, and asking a checker that was never started
        raises :class:`HealthCheckerNotRunningError` instead of silently claiming health.
        Call :meth:`wait_until_ready` after :meth:`start` to await the first pass.
    """

    @property
    def is_running(self) -> bool:
        """Check if health checker is currently running."""
        return self._is_running

    def __init__(
        self,
        check_interval: float = DEFAULT_CHECK_INTERVAL,
        timeout: float = DEFAULT_TIMEOUT,
        max_backoff: float = MAX_BACKOFF,
        on_status_change: HealthStatusCallbackProtocol | None = None,
        insecure: bool = False,
        credentials: grpc.ChannelCredentials | None = None,
        pool: ChannelPool | None = None,
        fail_fast_callback: bool = False,
        options: list[tuple[str, Any]] | None = None,
        compression: grpc.Compression | None = None,
        service: str = "",
    ) -> None:
        """Initialize the health checker.

        Args:
            check_interval: Seconds between checks for healthy targets.
            timeout: Timeout in seconds for each health check RPC.
            max_backoff: Maximum seconds to wait between checks for a failed target.
            on_status_change: Optional callback for status changes.
            insecure: Whether to use insecure channels for health checks.
            credentials: Optional TLS credentials for secure health checks.
            pool: Optional ChannelPool to automatically update channel health.
            fail_fast_callback: Whether to raise if the callback fails.
            options: gRPC channel options for health check channels. Pass the same options
                the application channels use, so both negotiate HTTP/2 identically.
            compression: Compression for health check channels.
            service: Service name probed via the health protocol. The default ``""`` asks about
                the server as a whole; naming a service asks about that service specifically,
                which is half the point of the standard health protocol.
        """
        self._check_interval = check_interval
        self._timeout = timeout
        self._max_backoff = max_backoff
        self._on_status_change = on_status_change
        self._insecure = insecure
        self._credentials = credentials
        self._pool = pool
        self._fail_fast_callback = fail_fast_callback
        self._options = options
        self._compression = compression
        self._service = service

        self._health_status: dict[str, health_pb2.HealthCheckResponse.ServingStatus] = {}
        self._last_checked: dict[str, float] = {}
        self._fail_counts: dict[str, int] = {}
        self._channels: dict[str, grpc.aio.Channel] = {}

        self._lock = asyncio.Lock()
        # Separate leaf lock for the status maps: checks run concurrently within a tick, and
        # "read previous status, write new one, decide whether to notify" must be one step.
        # Never acquire self._lock while holding it.
        self._status_lock = asyncio.Lock()
        self._first_pass_done = asyncio.Event()
        self._warned_not_running = False
        self._is_running = False
        self._task: asyncio.Task[None] | None = None

    async def start(self, targets: list[str]) -> None:
        """Start background health checking for the given targets."""
        for t in targets:
            validate_target(t)

        async with self._lock:
            if self._is_running:
                logger.warning("HealthChecker is already running. Ignoring new start() call.")
                return

            self._first_pass_done.clear()
            self._is_running = True
            self._task = asyncio.create_task(self._health_check_loop(list(targets)))

    async def stop(self, timeout: float = 5.0) -> None:
        """Stop background health checking and cleanup resources.

        Args:
            timeout: Maximum time to wait for graceful shutdown (default 5s)
        """
        async with self._lock:
            if not self._is_running:
                return
            self._is_running = False
            task = self._task

        if task:
            task.cancel()
            try:
                await asyncio.wait_for(task, timeout=timeout)
            except (asyncio.CancelledError, TimeoutError):
                logger.debug("Health checker task stopped")
            except Exception:
                logger.exception("Error stopping health checker task")

            async with self._lock:
                self._task = None

        # Close all health check channels
        async with self._lock:
            if self._channels:
                await asyncio.gather(*(ch.close() for ch in self._channels.values()), return_exceptions=True)
                self._channels.clear()

    async def wait_until_ready(self, timeout: float | None = None) -> bool:
        """Wait until the first check pass has classified every monitored target.

        Until that pass completes, every target is reported unhealthy, so callers that
        route traffic immediately after :meth:`start` should await this first.

        Args:
            timeout: Maximum seconds to wait. None waits indefinitely.

        Returns:
            True if the first pass completed, False if it timed out or the loop is not running.
        """
        if not self._is_running:
            return False

        try:
            await asyncio.wait_for(self._first_pass_done.wait(), timeout=timeout)
        except TimeoutError:
            logger.warning("Timed out after %ss waiting for the first health check pass", timeout)
            return False

        return True

    async def _get_channel(self, target: str) -> grpc.aio.Channel:
        """Get or create a persistent channel for health checks.

        Probes run on their own channels instead of borrowing one from the ChannelPool:
        pooled channels carry the client interceptor chain (retries, circuit breaker,
        metrics), so probing through them would pollute client metrics and trip breakers;
        they are evicted once idle, while monitoring must survive idle periods; and a failed
        probe closes its channel to force a reconnect, which must never happen to a channel
        that application RPCs are holding. Channel options and compression are taken from
        the settings so both connections negotiate HTTP/2 the same way.

        This method is async-safe and ensures only one channel is created per target.
        """
        async with self._lock:
            if target in self._channels:
                return self._channels[target]

            # Channel creation in grpc.aio is lightweight and does not perform I/O
            # so we can safely do it inside the lock to simplify the implementation
            # and avoid double-check complexity.
            channel = create_aio_channel(
                target,
                insecure=self._insecure,
                credentials=self._credentials,
                options=self._options,
                compression=self._compression,
            )

            self._channels[target] = channel
            return channel

    async def _drop_channel(self, target: str) -> None:
        """Close and forget the health check channel for a target."""
        async with self._lock:
            channel = self._channels.pop(target, None)

        if channel:
            try:
                await channel.close()
            except Exception:
                logger.exception("Failed to close health check channel for %s", target)

    async def _close_stale_channels(self, targets: list[str]) -> None:
        """Close channels held for targets that are no longer monitored."""
        monitored = set(targets)

        async with self._lock:
            stale = [t for t in self._channels if t not in monitored]
            channels = [self._channels.pop(t) for t in stale]

        for target, channel in zip(stale, channels, strict=True):
            try:
                await channel.close()
            except Exception:
                logger.exception("Failed to close stale health check channel for %s", target)

    async def _health_check_loop(self, targets: list[str]) -> None:
        """Concurrent health check loop with individual backoffs."""
        # Scheduling runs on the monotonic clock: wall-clock jumps (NTP, DST) would otherwise
        # stall every target's next check or stampede them all at once.
        next_check: dict[str, float] = dict.fromkeys(targets, 0.0)
        next_cleanup = time.monotonic() + STALE_CHANNEL_CLEANUP_INTERVAL

        try:
            while self._is_running:
                now = time.monotonic()
                to_check = [t for t in targets if now >= next_check[t]]

                if to_check:
                    # Perform checks concurrently
                    results = await asyncio.gather(*(self.check_health(t) for t in to_check), return_exceptions=True)

                    async with self._status_lock:
                        fail_counts = dict(self._fail_counts)

                    completed_at = time.monotonic()
                    for target, result in zip(to_check, results, strict=True):
                        if result is True:
                            # Success: use standard interval
                            next_check[target] = completed_at + self._check_interval
                            continue

                        # Failure: apply exponential backoff
                        if isinstance(result, BaseException):
                            logger.error("Unexpected error checking %s: %s", target, result)

                        fails = fail_counts.get(target, 0)
                        # Exponential backoff: check_interval * 2^fails (capped at _max_backoff)
                        backoff = min(self._max_backoff, self._check_interval * (2 ** max(0, fails - 1)))
                        next_check[target] = completed_at + backoff
                        logger.debug("Health check failed for %s, next check in %.1fs", target, backoff)

                # Every target now has a verdict, so is_healthy() answers from evidence.
                self._first_pass_done.set()

                if time.monotonic() >= next_cleanup:
                    next_cleanup = time.monotonic() + STALE_CHANNEL_CLEANUP_INTERVAL
                    await self._close_stale_channels(targets)

                # Wait for the next scheduling round. The loop tick is the floor of the effective
                # probe period: an interval shorter than the tick would silently stretch to it,
                # so the tick follows the interval down instead.
                await asyncio.sleep(min(DEFAULT_LOOP_SLEEP, self._check_interval))
        except asyncio.CancelledError:
            pass
        except Exception:
            logger.exception("Fatal error in health check loop")
        finally:
            self._is_running = False

    async def check_health(self, target: str) -> bool:
        """Perform an active health check RPC for the target.

        Args:
            target: The target address (host:port).

        Returns:
            True if the target is SERVING, False otherwise.

        Raises:
            Exception: Whatever the status change callback raised, if the checker was
                built with `fail_fast_callback=True`.
        """
        try:
            channel = await self._get_channel(target)
            stub = health_pb2_grpc.HealthStub(channel)

            response = await stub.Check(
                health_pb2.HealthCheckRequest(service=self._service),
                timeout=self._timeout,
            )
        except asyncio.CancelledError:
            # A channel closed under an in-flight probe — a concurrent stop(), typically — makes
            # grpc raise a bare CancelledError in a task nobody cancelled. Swallowing it for a task
            # that *was* cancelled would break cancellation, so only the phantom form is treated as
            # a failed check.
            task = asyncio.current_task()
            if task is not None and task.cancelling() > 0:
                raise
            logger.debug("Health check for %s was cancelled by its channel closing", target)
            await self._drop_channel(target)
            await self._publish(target, health_pb2.HealthCheckResponse.NOT_SERVING)
            return False
        except Exception as e:
            if isinstance(e, grpc.aio.AioRpcError):
                logger.debug("Health check failed for %s: %s", target, e.code())
            else:
                logger.warning("Health check failed for %s with unexpected error: %s", target, e)

            # The channel may be stuck half-open; drop it so the next check reconnects.
            # Done before publishing, which may re-raise a failing callback.
            await self._drop_channel(target)
            await self._publish(target, health_pb2.HealthCheckResponse.NOT_SERVING)
            return False

        await self._publish(target, response.status)
        return bool(response.status == health_pb2.HealthCheckResponse.SERVING)

    async def _publish(self, target: str, status: health_pb2.HealthCheckResponse.ServingStatus) -> None:
        """Record a check result and fan it out to the pool and the status callback."""
        is_healthy = bool(status == health_pb2.HealthCheckResponse.SERVING)

        async with self._status_lock:
            changed = self._health_status.get(target) != status
            self._health_status[target] = status
            # Wall clock: this timestamp is only ever read by humans inspecting the checker.
            self._last_checked[target] = time.time()
            self._fail_counts[target] = 0 if is_healthy else self._fail_counts.get(target, 0) + 1

        if self._pool:
            await self._pool.update_channel_health(target, is_healthy)

        if changed and self._on_status_change:
            try:
                await self._on_status_change(target, is_healthy)
            except Exception:
                logger.exception("Health status callback failed for %s", target)
                if self._fail_fast_callback:
                    raise

    async def is_healthy(self, target: str) -> bool:
        """Check if the target is healthy from cache.

        Only evidence counts: a target is healthy after a check saw it SERVING, and
        unhealthy until then, so an unchecked target never receives traffic.

        Args:
            target: The target address (host:port).

        Returns:
            True if the last check reported SERVING, False otherwise.

        Raises:
            HealthCheckerNotRunningError: If the target has no recorded status and the
                check loop is not running, so no status will ever be recorded.
        """
        async with self._status_lock:
            status = self._health_status.get(target)

        if status is not None:
            return bool(status == health_pb2.HealthCheckResponse.SERVING)

        if not self._is_running:
            self._warn_not_running()
            raise HealthCheckerNotRunningError(target)

        return False

    def _warn_not_running(self) -> None:
        """Log the missing check loop once, since callers may swallow the error.

        Load balancers collect health with `return_exceptions=True`, which would otherwise
        turn a checker that was never started into a silent "no healthy targets".
        """
        if self._warned_not_running:
            return

        self._warned_not_running = True
        logger.warning(
            "HealthChecker.is_healthy() was called while the check loop is not running: "
            "no target can be confirmed healthy. Call start() to begin monitoring."
        )

is_running property

Check if health checker is currently running.

__init__(check_interval=DEFAULT_CHECK_INTERVAL, timeout=DEFAULT_TIMEOUT, max_backoff=MAX_BACKOFF, on_status_change=None, insecure=False, credentials=None, pool=None, fail_fast_callback=False, options=None, compression=None, service='')

Initialize the health checker.

Parameters:

Name Type Description Default
check_interval float

Seconds between checks for healthy targets.

DEFAULT_CHECK_INTERVAL
timeout float

Timeout in seconds for each health check RPC.

DEFAULT_TIMEOUT
max_backoff float

Maximum seconds to wait between checks for a failed target.

MAX_BACKOFF
on_status_change HealthStatusCallbackProtocol | None

Optional callback for status changes.

None
insecure bool

Whether to use insecure channels for health checks.

False
credentials ChannelCredentials | None

Optional TLS credentials for secure health checks.

None
pool ChannelPool | None

Optional ChannelPool to automatically update channel health.

None
fail_fast_callback bool

Whether to raise if the callback fails.

False
options list[tuple[str, Any]] | None

gRPC channel options for health check channels. Pass the same options the application channels use, so both negotiate HTTP/2 identically.

None
compression Compression | None

Compression for health check channels.

None
service str

Service name probed via the health protocol. The default "" asks about the server as a whole; naming a service asks about that service specifically, which is half the point of the standard health protocol.

''
Source code in grpc_client_kit/health.py
def __init__(
    self,
    check_interval: float = DEFAULT_CHECK_INTERVAL,
    timeout: float = DEFAULT_TIMEOUT,
    max_backoff: float = MAX_BACKOFF,
    on_status_change: HealthStatusCallbackProtocol | None = None,
    insecure: bool = False,
    credentials: grpc.ChannelCredentials | None = None,
    pool: ChannelPool | None = None,
    fail_fast_callback: bool = False,
    options: list[tuple[str, Any]] | None = None,
    compression: grpc.Compression | None = None,
    service: str = "",
) -> None:
    """Initialize the health checker.

    Args:
        check_interval: Seconds between checks for healthy targets.
        timeout: Timeout in seconds for each health check RPC.
        max_backoff: Maximum seconds to wait between checks for a failed target.
        on_status_change: Optional callback for status changes.
        insecure: Whether to use insecure channels for health checks.
        credentials: Optional TLS credentials for secure health checks.
        pool: Optional ChannelPool to automatically update channel health.
        fail_fast_callback: Whether to raise if the callback fails.
        options: gRPC channel options for health check channels. Pass the same options
            the application channels use, so both negotiate HTTP/2 identically.
        compression: Compression for health check channels.
        service: Service name probed via the health protocol. The default ``""`` asks about
            the server as a whole; naming a service asks about that service specifically,
            which is half the point of the standard health protocol.
    """
    self._check_interval = check_interval
    self._timeout = timeout
    self._max_backoff = max_backoff
    self._on_status_change = on_status_change
    self._insecure = insecure
    self._credentials = credentials
    self._pool = pool
    self._fail_fast_callback = fail_fast_callback
    self._options = options
    self._compression = compression
    self._service = service

    self._health_status: dict[str, health_pb2.HealthCheckResponse.ServingStatus] = {}
    self._last_checked: dict[str, float] = {}
    self._fail_counts: dict[str, int] = {}
    self._channels: dict[str, grpc.aio.Channel] = {}

    self._lock = asyncio.Lock()
    # Separate leaf lock for the status maps: checks run concurrently within a tick, and
    # "read previous status, write new one, decide whether to notify" must be one step.
    # Never acquire self._lock while holding it.
    self._status_lock = asyncio.Lock()
    self._first_pass_done = asyncio.Event()
    self._warned_not_running = False
    self._is_running = False
    self._task: asyncio.Task[None] | None = None

check_health(target) async

Perform an active health check RPC for the target.

Parameters:

Name Type Description Default
target str

The target address (host:port).

required

Returns:

Type Description
bool

True if the target is SERVING, False otherwise.

Raises:

Type Description
Exception

Whatever the status change callback raised, if the checker was built with fail_fast_callback=True.

Source code in grpc_client_kit/health.py
async def check_health(self, target: str) -> bool:
    """Perform an active health check RPC for the target.

    Args:
        target: The target address (host:port).

    Returns:
        True if the target is SERVING, False otherwise.

    Raises:
        Exception: Whatever the status change callback raised, if the checker was
            built with `fail_fast_callback=True`.
    """
    try:
        channel = await self._get_channel(target)
        stub = health_pb2_grpc.HealthStub(channel)

        response = await stub.Check(
            health_pb2.HealthCheckRequest(service=self._service),
            timeout=self._timeout,
        )
    except asyncio.CancelledError:
        # A channel closed under an in-flight probe — a concurrent stop(), typically — makes
        # grpc raise a bare CancelledError in a task nobody cancelled. Swallowing it for a task
        # that *was* cancelled would break cancellation, so only the phantom form is treated as
        # a failed check.
        task = asyncio.current_task()
        if task is not None and task.cancelling() > 0:
            raise
        logger.debug("Health check for %s was cancelled by its channel closing", target)
        await self._drop_channel(target)
        await self._publish(target, health_pb2.HealthCheckResponse.NOT_SERVING)
        return False
    except Exception as e:
        if isinstance(e, grpc.aio.AioRpcError):
            logger.debug("Health check failed for %s: %s", target, e.code())
        else:
            logger.warning("Health check failed for %s with unexpected error: %s", target, e)

        # The channel may be stuck half-open; drop it so the next check reconnects.
        # Done before publishing, which may re-raise a failing callback.
        await self._drop_channel(target)
        await self._publish(target, health_pb2.HealthCheckResponse.NOT_SERVING)
        return False

    await self._publish(target, response.status)
    return bool(response.status == health_pb2.HealthCheckResponse.SERVING)

is_healthy(target) async

Check if the target is healthy from cache.

Only evidence counts: a target is healthy after a check saw it SERVING, and unhealthy until then, so an unchecked target never receives traffic.

Parameters:

Name Type Description Default
target str

The target address (host:port).

required

Returns:

Type Description
bool

True if the last check reported SERVING, False otherwise.

Raises:

Type Description
HealthCheckerNotRunningError

If the target has no recorded status and the check loop is not running, so no status will ever be recorded.

Source code in grpc_client_kit/health.py
async def is_healthy(self, target: str) -> bool:
    """Check if the target is healthy from cache.

    Only evidence counts: a target is healthy after a check saw it SERVING, and
    unhealthy until then, so an unchecked target never receives traffic.

    Args:
        target: The target address (host:port).

    Returns:
        True if the last check reported SERVING, False otherwise.

    Raises:
        HealthCheckerNotRunningError: If the target has no recorded status and the
            check loop is not running, so no status will ever be recorded.
    """
    async with self._status_lock:
        status = self._health_status.get(target)

    if status is not None:
        return bool(status == health_pb2.HealthCheckResponse.SERVING)

    if not self._is_running:
        self._warn_not_running()
        raise HealthCheckerNotRunningError(target)

    return False

start(targets) async

Start background health checking for the given targets.

Source code in grpc_client_kit/health.py
async def start(self, targets: list[str]) -> None:
    """Start background health checking for the given targets."""
    for t in targets:
        validate_target(t)

    async with self._lock:
        if self._is_running:
            logger.warning("HealthChecker is already running. Ignoring new start() call.")
            return

        self._first_pass_done.clear()
        self._is_running = True
        self._task = asyncio.create_task(self._health_check_loop(list(targets)))

stop(timeout=5.0) async

Stop background health checking and cleanup resources.

Parameters:

Name Type Description Default
timeout float

Maximum time to wait for graceful shutdown (default 5s)

5.0
Source code in grpc_client_kit/health.py
async def stop(self, timeout: float = 5.0) -> None:
    """Stop background health checking and cleanup resources.

    Args:
        timeout: Maximum time to wait for graceful shutdown (default 5s)
    """
    async with self._lock:
        if not self._is_running:
            return
        self._is_running = False
        task = self._task

    if task:
        task.cancel()
        try:
            await asyncio.wait_for(task, timeout=timeout)
        except (asyncio.CancelledError, TimeoutError):
            logger.debug("Health checker task stopped")
        except Exception:
            logger.exception("Error stopping health checker task")

        async with self._lock:
            self._task = None

    # Close all health check channels
    async with self._lock:
        if self._channels:
            await asyncio.gather(*(ch.close() for ch in self._channels.values()), return_exceptions=True)
            self._channels.clear()

wait_until_ready(timeout=None) async

Wait until the first check pass has classified every monitored target.

Until that pass completes, every target is reported unhealthy, so callers that route traffic immediately after :meth:start should await this first.

Parameters:

Name Type Description Default
timeout float | None

Maximum seconds to wait. None waits indefinitely.

None

Returns:

Type Description
bool

True if the first pass completed, False if it timed out or the loop is not running.

Source code in grpc_client_kit/health.py
async def wait_until_ready(self, timeout: float | None = None) -> bool:
    """Wait until the first check pass has classified every monitored target.

    Until that pass completes, every target is reported unhealthy, so callers that
    route traffic immediately after :meth:`start` should await this first.

    Args:
        timeout: Maximum seconds to wait. None waits indefinitely.

    Returns:
        True if the first pass completed, False if it timed out or the loop is not running.
    """
    if not self._is_running:
        return False

    try:
        await asyncio.wait_for(self._first_pass_done.wait(), timeout=timeout)
    except TimeoutError:
        logger.warning("Timed out after %ss waiting for the first health check pass", timeout)
        return False

    return True

HealthCheckerNotRunningError

Bases: GrpcClientKitError, RuntimeError

Raised when health state is requested but no check loop can ever produce it.

Lives here rather than in health so that catching it does not require the [health] extra: the caller of a balancer meets this error, and a balancer works on a bare install. Also a RuntimeError — being asked before start() is a lifecycle mistake in the calling code, and existing except RuntimeError handlers keep working.

Attributes:

Name Type Description
target

The target whose health was requested.

Source code in grpc_client_kit/errors.py
class HealthCheckerNotRunningError(GrpcClientKitError, RuntimeError):
    """Raised when health state is requested but no check loop can ever produce it.

    Lives here rather than in `health` so that catching it does not require the ``[health]``
    extra: the caller of a balancer meets this error, and a balancer works on a bare install.
    Also a `RuntimeError` — being asked before ``start()`` is a lifecycle mistake in the calling
    code, and existing ``except RuntimeError`` handlers keep working.

    Attributes:
        target: The target whose health was requested.
    """

    def __init__(self, target: str) -> None:
        """Initialize the error.

        Args:
            target: The target whose health was requested.
        """
        self.target = target
        super().__init__(
            f"HealthChecker is not running, so the health of '{target}' is unknown. "
            "Call start() before routing traffic (a factory that owns the checker must be "
            "entered with 'async with')."
        )

__init__(target)

Initialize the error.

Parameters:

Name Type Description Default
target str

The target whose health was requested.

required
Source code in grpc_client_kit/errors.py
def __init__(self, target: str) -> None:
    """Initialize the error.

    Args:
        target: The target whose health was requested.
    """
    self.target = target
    super().__init__(
        f"HealthChecker is not running, so the health of '{target}' is unknown. "
        "Call start() before routing traffic (a factory that owns the checker must be "
        "entered with 'async with')."
    )

HealthCheckerProtocol

Bases: Protocol

Protocol for target health monitoring.

Source code in grpc_client_kit/protocols.py
@runtime_checkable
class HealthCheckerProtocol(Protocol):
    """Protocol for target health monitoring."""

    async def is_healthy(self, target: str) -> bool:
        """Check if target is healthy (from cache).

        Implementations report health from evidence only: a target that has never been
        checked is not healthy. An implementation that cannot produce evidence at all
        (its check loop is not running) may raise instead of answering.

        Args:
            target: The target address (host:port)

        Returns:
            True if the last check reported healthy, False otherwise
        """
        ...

    async def check_health(self, target: str) -> bool:
        """Perform active health check for target.

        Args:
            target: The target address (host:port)

        Returns:
            True if healthy, False otherwise
        """
        ...

check_health(target) async

Perform active health check for target.

Parameters:

Name Type Description Default
target str

The target address (host:port)

required

Returns:

Type Description
bool

True if healthy, False otherwise

Source code in grpc_client_kit/protocols.py
async def check_health(self, target: str) -> bool:
    """Perform active health check for target.

    Args:
        target: The target address (host:port)

    Returns:
        True if healthy, False otherwise
    """
    ...

is_healthy(target) async

Check if target is healthy (from cache).

Implementations report health from evidence only: a target that has never been checked is not healthy. An implementation that cannot produce evidence at all (its check loop is not running) may raise instead of answering.

Parameters:

Name Type Description Default
target str

The target address (host:port)

required

Returns:

Type Description
bool

True if the last check reported healthy, False otherwise

Source code in grpc_client_kit/protocols.py
async def is_healthy(self, target: str) -> bool:
    """Check if target is healthy (from cache).

    Implementations report health from evidence only: a target that has never been
    checked is not healthy. An implementation that cannot produce evidence at all
    (its check loop is not running) may raise instead of answering.

    Args:
        target: The target address (host:port)

    Returns:
        True if the last check reported healthy, False otherwise
    """
    ...

HealthCheckerSettingsProtocol

Bases: Protocol

Protocol for gRPC health checker settings.

Source code in grpc_client_kit/protocols.py
@runtime_checkable
class HealthCheckerSettingsProtocol(Protocol):
    """Protocol for gRPC health checker settings."""

    @property
    def check_interval(self) -> float:
        """Interval between health checks in seconds."""
        ...

    @property
    def timeout(self) -> float:
        """Timeout for each health check in seconds."""
        ...

check_interval property

Interval between health checks in seconds.

timeout property

Timeout for each health check in seconds.

HealthStatusCallbackProtocol

Bases: Protocol

Protocol for health status change callbacks.

Source code in grpc_client_kit/protocols.py
@runtime_checkable
class HealthStatusCallbackProtocol(Protocol):
    """Protocol for health status change callbacks."""

    async def __call__(self, target: str, is_healthy: bool) -> None:
        """Called when health status of a target changes.

        Args:
            target: The target address (host:port)
            is_healthy: True if target is healthy, False otherwise
        """
        ...

__call__(target, is_healthy) async

Called when health status of a target changes.

Parameters:

Name Type Description Default
target str

The target address (host:port)

required
is_healthy bool

True if target is healthy, False otherwise

required
Source code in grpc_client_kit/protocols.py
async def __call__(self, target: str, is_healthy: bool) -> None:
    """Called when health status of a target changes.

    Args:
        target: The target address (host:port)
        is_healthy: True if target is healthy, False otherwise
    """
    ...

InterceptorChainBuilder

Builder for a gRPC client interceptor chain with strict ordering.

The order is a property of the builder, not of the call sequence: build always emits extra outer -> observability -> resilience -> extra inner regardless of which with_* method was called first. See the module docstring for why each layer sits where it does.

Source code in grpc_client_kit/interceptors/__init__.py
class InterceptorChainBuilder:
    """Builder for a gRPC client interceptor chain with strict ordering.

    The order is a property of the builder, not of the call sequence: `build` always emits
    ``extra outer -> observability -> resilience -> extra inner`` regardless of which ``with_*``
    method was called first. See the module docstring for why each layer sits where it does.
    """

    def __init__(self) -> None:
        # The kit's own layers are logical interceptors, full stop; only what a caller brings to the
        # two extra slots may already be a grpc-shaped one.
        self._extra_outer: list[ClientInterceptorLike] = []
        self._observability: list[AsyncClientInterceptor] = []
        self._resilience: list[AsyncClientInterceptor] = []
        self._extra_inner: list[ClientInterceptorLike] = []

    def with_observability(self, config: ObservabilityConfig | None) -> InterceptorChainBuilder:
        """Add observability interceptors (Logging, Tracing, Metrics)."""
        self._observability = _build_observability_interceptors(config)
        return self

    def with_resilience(
        self,
        timeout: TimeoutConfig | None = None,
        retry: RetryConfig | None = None,
        circuit_breaker: CircuitBreakerConfig | None = None,
        deadline_budget: DeadlineBudgetConfig | None = None,
        wait_for_ready: WaitForReadyConfig | None = None,
    ) -> InterceptorChainBuilder:
        """Add resilience interceptors (Timeout, Deadline budget, Wait-for-ready, Retry, Breaker)."""
        self._resilience = _build_resilience_interceptors(
            timeout, retry, circuit_breaker, deadline_budget, wait_for_ready
        )
        return self

    def with_extra_outer(self, interceptors: Sequence[ClientInterceptorLike] | None) -> InterceptorChainBuilder:
        """Add custom interceptors that wrap the whole chain.

        Metadata injection belongs here: the logging interceptor correlates on ``request-id`` taken
        from the call metadata, so anything injected further in never reaches the logs or the spans.
        """
        if interceptors:
            self._extra_outer.extend(interceptors)
        return self

    def with_extra_inner(self, interceptors: Sequence[ClientInterceptorLike] | None) -> InterceptorChainBuilder:
        """Add custom interceptors below the resilience layers, run once per attempt."""
        if interceptors:
            self._extra_inner.extend(interceptors)
        return self

    def with_custom(self, interceptors: Sequence[ClientInterceptorLike] | None) -> InterceptorChainBuilder:
        """Add custom interceptors to the outer slot; an alias of `with_extra_outer`."""
        return self.with_extra_outer(interceptors)

    def build(self) -> list[grpc.aio.ClientInterceptor]:
        """Build the final chain, outermost first, expanded into what a channel can register."""
        chain: list[ClientInterceptorLike] = [
            *self._extra_outer,
            *self._observability,
            *self._resilience,
            *self._extra_inner,
        ]
        return flatten_interceptors(chain)

build()

Build the final chain, outermost first, expanded into what a channel can register.

Source code in grpc_client_kit/interceptors/__init__.py
def build(self) -> list[grpc.aio.ClientInterceptor]:
    """Build the final chain, outermost first, expanded into what a channel can register."""
    chain: list[ClientInterceptorLike] = [
        *self._extra_outer,
        *self._observability,
        *self._resilience,
        *self._extra_inner,
    ]
    return flatten_interceptors(chain)

with_custom(interceptors)

Add custom interceptors to the outer slot; an alias of with_extra_outer.

Source code in grpc_client_kit/interceptors/__init__.py
def with_custom(self, interceptors: Sequence[ClientInterceptorLike] | None) -> InterceptorChainBuilder:
    """Add custom interceptors to the outer slot; an alias of `with_extra_outer`."""
    return self.with_extra_outer(interceptors)

with_extra_inner(interceptors)

Add custom interceptors below the resilience layers, run once per attempt.

Source code in grpc_client_kit/interceptors/__init__.py
def with_extra_inner(self, interceptors: Sequence[ClientInterceptorLike] | None) -> InterceptorChainBuilder:
    """Add custom interceptors below the resilience layers, run once per attempt."""
    if interceptors:
        self._extra_inner.extend(interceptors)
    return self

with_extra_outer(interceptors)

Add custom interceptors that wrap the whole chain.

Metadata injection belongs here: the logging interceptor correlates on request-id taken from the call metadata, so anything injected further in never reaches the logs or the spans.

Source code in grpc_client_kit/interceptors/__init__.py
def with_extra_outer(self, interceptors: Sequence[ClientInterceptorLike] | None) -> InterceptorChainBuilder:
    """Add custom interceptors that wrap the whole chain.

    Metadata injection belongs here: the logging interceptor correlates on ``request-id`` taken
    from the call metadata, so anything injected further in never reaches the logs or the spans.
    """
    if interceptors:
        self._extra_outer.extend(interceptors)
    return self

with_observability(config)

Add observability interceptors (Logging, Tracing, Metrics).

Source code in grpc_client_kit/interceptors/__init__.py
def with_observability(self, config: ObservabilityConfig | None) -> InterceptorChainBuilder:
    """Add observability interceptors (Logging, Tracing, Metrics)."""
    self._observability = _build_observability_interceptors(config)
    return self

with_resilience(timeout=None, retry=None, circuit_breaker=None, deadline_budget=None, wait_for_ready=None)

Add resilience interceptors (Timeout, Deadline budget, Wait-for-ready, Retry, Breaker).

Source code in grpc_client_kit/interceptors/__init__.py
def with_resilience(
    self,
    timeout: TimeoutConfig | None = None,
    retry: RetryConfig | None = None,
    circuit_breaker: CircuitBreakerConfig | None = None,
    deadline_budget: DeadlineBudgetConfig | None = None,
    wait_for_ready: WaitForReadyConfig | None = None,
) -> InterceptorChainBuilder:
    """Add resilience interceptors (Timeout, Deadline budget, Wait-for-ready, Retry, Breaker)."""
    self._resilience = _build_resilience_interceptors(
        timeout, retry, circuit_breaker, deadline_budget, wait_for_ready
    )
    return self

LoadBalancer

Bases: ABC

Base class for gRPC client load balancers.

Provides a standard interface for selecting a target from a list of addresses, with support for health filtering.

Source code in grpc_client_kit/balancers.py
class LoadBalancer(ABC):
    """Base class for gRPC client load balancers.

    Provides a standard interface for selecting a target from a list of addresses,
    with support for health filtering.
    """

    def __init__(self, targets: list[str], health_checker: HealthCheckerProtocol | None = None) -> None:
        """Initialize the load balancer.

        Args:
            targets: List of target addresses (host:port).
            health_checker: Optional health checker for filtering unhealthy targets.

        Raises:
            ValueError: If targets list is empty or any target is invalid.
        """
        if not targets:
            raise ValueError("Targets list cannot be empty. At least one host:port target must be provided.")

        for t in targets:
            validate_target(t)

        self._targets = list(targets)  # Copy list to prevent external mutation
        self._health_checker = health_checker
        # Passive verdicts: target -> monotonic deadline until which it is avoided. An active
        # checker learns about a dead backend one probe interval late; a real call learns
        # immediately, and report_failure() is how that knowledge reaches the balancer.
        self._quarantine: dict[str, float] = {}

    def report_failure(self, target: str, quarantine: float = 5.0) -> None:
        """Quarantine a target that a real call just found unreachable.

        Active probing has an inherent window: with the default check interval a dead backend
        keeps receiving its share of traffic for up to that interval, every call burning a full
        client deadline. A call that got ``UNAVAILABLE`` is fresher evidence than any probe, so
        it takes the target out of the rotation immediately, for `quarantine` seconds — long
        enough for the next probe (or a recovered backend) to have the casting vote.

        Args:
            target: The target address the failed call was routed to.
            quarantine: Seconds to keep the target out of the rotation.
        """
        self._quarantine[target] = time.monotonic() + quarantine
        logger.debug("Target %s quarantined for %.1fs after a failed call", target, quarantine)

    def _without_quarantined(self, candidates: list[str]) -> list[str]:
        """Drop quarantined targets from `candidates` — unless that would drop them all.

        When every candidate is quarantined the quarantine is ignored: degraded service beats
        refusing to route at all, and the next failure simply renews the verdict.
        """
        now = time.monotonic()
        expired = [target for target, deadline in self._quarantine.items() if now >= deadline]
        for target in expired:
            del self._quarantine[target]

        kept = [target for target in candidates if target not in self._quarantine]
        return kept or candidates

    @abstractmethod
    async def select_target(self) -> str:
        """Select a target from the available targets.

        Returns:
            The selected target address (host:port).

        Raises:
            NoHealthyTargetsError: If all targets are unhealthy.
        """
        ...

__init__(targets, health_checker=None)

Initialize the load balancer.

Parameters:

Name Type Description Default
targets list[str]

List of target addresses (host:port).

required
health_checker HealthCheckerProtocol | None

Optional health checker for filtering unhealthy targets.

None

Raises:

Type Description
ValueError

If targets list is empty or any target is invalid.

Source code in grpc_client_kit/balancers.py
def __init__(self, targets: list[str], health_checker: HealthCheckerProtocol | None = None) -> None:
    """Initialize the load balancer.

    Args:
        targets: List of target addresses (host:port).
        health_checker: Optional health checker for filtering unhealthy targets.

    Raises:
        ValueError: If targets list is empty or any target is invalid.
    """
    if not targets:
        raise ValueError("Targets list cannot be empty. At least one host:port target must be provided.")

    for t in targets:
        validate_target(t)

    self._targets = list(targets)  # Copy list to prevent external mutation
    self._health_checker = health_checker
    # Passive verdicts: target -> monotonic deadline until which it is avoided. An active
    # checker learns about a dead backend one probe interval late; a real call learns
    # immediately, and report_failure() is how that knowledge reaches the balancer.
    self._quarantine: dict[str, float] = {}

report_failure(target, quarantine=5.0)

Quarantine a target that a real call just found unreachable.

Active probing has an inherent window: with the default check interval a dead backend keeps receiving its share of traffic for up to that interval, every call burning a full client deadline. A call that got UNAVAILABLE is fresher evidence than any probe, so it takes the target out of the rotation immediately, for quarantine seconds — long enough for the next probe (or a recovered backend) to have the casting vote.

Parameters:

Name Type Description Default
target str

The target address the failed call was routed to.

required
quarantine float

Seconds to keep the target out of the rotation.

5.0
Source code in grpc_client_kit/balancers.py
def report_failure(self, target: str, quarantine: float = 5.0) -> None:
    """Quarantine a target that a real call just found unreachable.

    Active probing has an inherent window: with the default check interval a dead backend
    keeps receiving its share of traffic for up to that interval, every call burning a full
    client deadline. A call that got ``UNAVAILABLE`` is fresher evidence than any probe, so
    it takes the target out of the rotation immediately, for `quarantine` seconds — long
    enough for the next probe (or a recovered backend) to have the casting vote.

    Args:
        target: The target address the failed call was routed to.
        quarantine: Seconds to keep the target out of the rotation.
    """
    self._quarantine[target] = time.monotonic() + quarantine
    logger.debug("Target %s quarantined for %.1fs after a failed call", target, quarantine)

select_target() abstractmethod async

Select a target from the available targets.

Returns:

Type Description
str

The selected target address (host:port).

Raises:

Type Description
NoHealthyTargetsError

If all targets are unhealthy.

Source code in grpc_client_kit/balancers.py
@abstractmethod
async def select_target(self) -> str:
    """Select a target from the available targets.

    Returns:
        The selected target address (host:port).

    Raises:
        NoHealthyTargetsError: If all targets are unhealthy.
    """
    ...

LoadBalancerConfig dataclass

Configuration for load balancing.

Source code in grpc_client_kit/balancers.py
@dataclass(slots=True)
class LoadBalancerConfig:
    """Configuration for load balancing."""

    strategy: LoadBalancingStrategy = LoadBalancingStrategy.ROUND_ROBIN
    weights: dict[str, float] | None = None

LoadBalancerSettingsProtocol

Bases: Protocol

Protocol for gRPC load balancer settings.

Source code in grpc_client_kit/protocols.py
@runtime_checkable
class LoadBalancerSettingsProtocol(Protocol):
    """Protocol for gRPC load balancer settings."""

    @property
    def strategy(self) -> str:
        """Load balancing strategy (round_robin, random, weighted)."""
        ...

    @property
    def weights(self) -> dict[str, float] | None:
        """Weights for weighted strategy (target -> weight)."""
        ...

strategy property

Load balancing strategy (round_robin, random, weighted).

weights property

Weights for weighted strategy (target -> weight).

LoadBalancingStrategy

Bases: StrEnum

Load balancing strategies.

Source code in grpc_client_kit/balancers.py
class LoadBalancingStrategy(StrEnum):
    """Load balancing strategies."""

    ROUND_ROBIN = "round_robin"
    RANDOM = "random"
    WEIGHTED = "weighted"

NoHealthyTargetsError

Bases: GrpcClientKitError

Raised when no healthy targets are available.

Source code in grpc_client_kit/balancers.py
class NoHealthyTargetsError(GrpcClientKitError):
    """Raised when no healthy targets are available."""

    def __init__(self, targets: list[str]) -> None:
        self.targets = targets
        super().__init__(f"No healthy targets available among {targets}")

ObservabilityConfig dataclass

Configuration for observability.

Attributes:

Name Type Description
tracing bool

Whether to add the OpenTelemetry tracing layer (needs the tracing extra).

metrics bool

Whether to add the metrics layer.

logging bool

Whether to add the structured logging layer.

service_name str

Client service name used in log categorization and span attributes.

metrics_registry GrpcClientMetricsProtocol | None

The collector RPC metrics are recorded into.

sensitive_methods set[str] | None

Full method names whose payloads and errors are suppressed in logs.

sensitive_patterns list[str] | None

Regex patterns marking methods as sensitive.

sensitive_headers set[str] | None

Header names (case-insensitive) redacted in logged metadata.

log_request_payload bool

Whether to log request payloads (truncated).

log_response_payload bool

Whether to log response payloads (truncated).

enable_method_label bool

Whether metrics carry the method name as a label. Turn off for clients of services with thousands of methods, where per-method labels blow up the cardinality of every counter and histogram.

success_log_level int

Level of the record a successful call emits; one INFO line per RPC is a flood at high QPS, so high-volume clients drop this to logging.DEBUG.

Source code in grpc_client_kit/interceptors/__init__.py
@dataclass(slots=True)
class ObservabilityConfig:
    """Configuration for observability.

    Attributes:
        tracing: Whether to add the OpenTelemetry tracing layer (needs the ``tracing`` extra).
        metrics: Whether to add the metrics layer.
        logging: Whether to add the structured logging layer.
        service_name: Client service name used in log categorization and span attributes.
        metrics_registry: The collector RPC metrics are recorded into.
        sensitive_methods: Full method names whose payloads and errors are suppressed in logs.
        sensitive_patterns: Regex patterns marking methods as sensitive.
        sensitive_headers: Header names (case-insensitive) redacted in logged metadata.
        log_request_payload: Whether to log request payloads (truncated).
        log_response_payload: Whether to log response payloads (truncated).
        enable_method_label: Whether metrics carry the method name as a label. Turn off for
            clients of services with thousands of methods, where per-method labels blow up the
            cardinality of every counter and histogram.
        success_log_level: Level of the record a successful call emits; one INFO line per RPC is
            a flood at high QPS, so high-volume clients drop this to ``logging.DEBUG``.
    """

    tracing: bool = False
    metrics: bool = False
    logging: bool = True
    service_name: str = "unknown"
    metrics_registry: GrpcClientMetricsProtocol | None = None
    sensitive_methods: set[str] | None = None
    sensitive_patterns: list[str] | None = None
    sensitive_headers: set[str] | None = None
    log_request_payload: bool = False
    log_response_payload: bool = False
    enable_method_label: bool = True
    success_log_level: int = _DEFAULT_SUCCESS_LOG_LEVEL

RandomLoadBalancer

Bases: LoadBalancer

Random load balancer with health check support.

Selects a random target from the list of healthy targets. Caches healthy targets for 1 second to improve performance.

Source code in grpc_client_kit/balancers.py
class RandomLoadBalancer(LoadBalancer):
    """Random load balancer with health check support.

    Selects a random target from the list of healthy targets.
    Caches healthy targets for 1 second to improve performance.
    """

    def __init__(self, targets: list[str], health_checker: HealthCheckerProtocol | None = None) -> None:
        """Initialize the Random balancer.

        Args:
            targets: List of target addresses.
            health_checker: Optional health checker.
        """
        super().__init__(targets, health_checker)
        self._healthy_targets: list[str] = []
        self._last_health_update = 0.0
        self._health_cache_ttl = 1.0  # 1 second
        self._lock = asyncio.Lock()

    async def select_target(self) -> str:
        """Select a random healthy target."""
        if not self._health_checker:
            target = random.choice(self._without_quarantined(self._targets))  # noqa: S311
            logger.debug("Selected target %s using random", target)
            return target

        # Use cached healthy targets if possible to avoid frequent gathers
        async with self._lock:
            now = time.time()
            if now - self._last_health_update > self._health_cache_ttl:
                # Filter healthy targets in parallel
                health_statuses = await asyncio.gather(
                    *(self._health_checker.is_healthy(t) for t in self._targets), return_exceptions=True
                )

                self._healthy_targets = [
                    t for t, status in zip(self._targets, health_statuses, strict=True) if status is True
                ]
                self._last_health_update = now

            if not self._healthy_targets:
                raise NoHealthyTargetsError(self._targets)

            # Quarantine is applied on every pick, not cached: a passive verdict may arrive (and
            # expire) well within the health cache's TTL.
            target = random.choice(self._without_quarantined(self._healthy_targets))  # noqa: S311
            logger.debug("Selected target %s using random (total_healthy=%d)", target, len(self._healthy_targets))
            return target

__init__(targets, health_checker=None)

Initialize the Random balancer.

Parameters:

Name Type Description Default
targets list[str]

List of target addresses.

required
health_checker HealthCheckerProtocol | None

Optional health checker.

None
Source code in grpc_client_kit/balancers.py
def __init__(self, targets: list[str], health_checker: HealthCheckerProtocol | None = None) -> None:
    """Initialize the Random balancer.

    Args:
        targets: List of target addresses.
        health_checker: Optional health checker.
    """
    super().__init__(targets, health_checker)
    self._healthy_targets: list[str] = []
    self._last_health_update = 0.0
    self._health_cache_ttl = 1.0  # 1 second
    self._lock = asyncio.Lock()

report_failure(target, quarantine=5.0)

Quarantine a target that a real call just found unreachable.

Active probing has an inherent window: with the default check interval a dead backend keeps receiving its share of traffic for up to that interval, every call burning a full client deadline. A call that got UNAVAILABLE is fresher evidence than any probe, so it takes the target out of the rotation immediately, for quarantine seconds — long enough for the next probe (or a recovered backend) to have the casting vote.

Parameters:

Name Type Description Default
target str

The target address the failed call was routed to.

required
quarantine float

Seconds to keep the target out of the rotation.

5.0
Source code in grpc_client_kit/balancers.py
def report_failure(self, target: str, quarantine: float = 5.0) -> None:
    """Quarantine a target that a real call just found unreachable.

    Active probing has an inherent window: with the default check interval a dead backend
    keeps receiving its share of traffic for up to that interval, every call burning a full
    client deadline. A call that got ``UNAVAILABLE`` is fresher evidence than any probe, so
    it takes the target out of the rotation immediately, for `quarantine` seconds — long
    enough for the next probe (or a recovered backend) to have the casting vote.

    Args:
        target: The target address the failed call was routed to.
        quarantine: Seconds to keep the target out of the rotation.
    """
    self._quarantine[target] = time.monotonic() + quarantine
    logger.debug("Target %s quarantined for %.1fs after a failed call", target, quarantine)

select_target() async

Select a random healthy target.

Source code in grpc_client_kit/balancers.py
async def select_target(self) -> str:
    """Select a random healthy target."""
    if not self._health_checker:
        target = random.choice(self._without_quarantined(self._targets))  # noqa: S311
        logger.debug("Selected target %s using random", target)
        return target

    # Use cached healthy targets if possible to avoid frequent gathers
    async with self._lock:
        now = time.time()
        if now - self._last_health_update > self._health_cache_ttl:
            # Filter healthy targets in parallel
            health_statuses = await asyncio.gather(
                *(self._health_checker.is_healthy(t) for t in self._targets), return_exceptions=True
            )

            self._healthy_targets = [
                t for t, status in zip(self._targets, health_statuses, strict=True) if status is True
            ]
            self._last_health_update = now

        if not self._healthy_targets:
            raise NoHealthyTargetsError(self._targets)

        # Quarantine is applied on every pick, not cached: a passive verdict may arrive (and
        # expire) well within the health cache's TTL.
        target = random.choice(self._without_quarantined(self._healthy_targets))  # noqa: S311
        logger.debug("Selected target %s using random (total_healthy=%d)", target, len(self._healthy_targets))
        return target

RetryConfig dataclass

Configuration for retries.

Attributes:

Name Type Description
max_attempts int

Total number of attempts, the first one included.

initial_backoff float

Seconds to wait before the first retry.

max_backoff float

Upper bound for the wait between retries.

backoff_multiplier float

Factor by which the backoff grows each attempt.

jitter float

Random variation factor (0.0 to 1.0) applied to the backoff.

retryable_codes set[StatusCode] | None

Status codes that trigger a retry. Defaults to retry.DEFAULT_RETRYABLE_CODES, which only covers requests the server never started processing; widening it makes duplicate side effects possible on non-idempotent methods.

retry_streaming bool

Whether Unary-Stream calls may be retried at all.

idempotent_methods set[str] | None

Full method names that may be retried. Required for streaming retries, and a whitelist for unary retries when given.

on_retry Callable[[str, int, StatusCode | None, float], Awaitable[None]] | None

Optional async callback (method, attempt, code, backoff) invoked for every scheduled retry.

metrics RetryMetricsProtocol | None

Optional registry told about every scheduled retry (RetryMetricsProtocol). The request metrics see one entry per logical call, so without this a retry storm is invisible on a dashboard.

Source code in grpc_client_kit/interceptors/__init__.py
@dataclass(slots=True)
class RetryConfig:
    """Configuration for retries.

    Attributes:
        max_attempts: Total number of attempts, the first one included.
        initial_backoff: Seconds to wait before the first retry.
        max_backoff: Upper bound for the wait between retries.
        backoff_multiplier: Factor by which the backoff grows each attempt.
        jitter: Random variation factor (0.0 to 1.0) applied to the backoff.
        retryable_codes: Status codes that trigger a retry. Defaults to
            `retry.DEFAULT_RETRYABLE_CODES`, which only covers requests the server never started
            processing; widening it makes duplicate side effects possible on non-idempotent methods.
        retry_streaming: Whether Unary-Stream calls may be retried at all.
        idempotent_methods: Full method names that may be retried. Required for streaming retries,
            and a whitelist for unary retries when given.
        on_retry: Optional async callback ``(method, attempt, code, backoff)`` invoked for every
            scheduled retry.
        metrics: Optional registry told about every scheduled retry (`RetryMetricsProtocol`).
            The request metrics see one entry per logical call, so without this a retry storm is
            invisible on a dashboard.
    """

    max_attempts: int = 3
    initial_backoff: float = 0.1
    max_backoff: float = 10.0
    backoff_multiplier: float = 2.0
    jitter: float = 0.1
    retryable_codes: set[grpc.StatusCode] | None = None
    retry_streaming: bool = False
    idempotent_methods: set[str] | None = None
    on_retry: Callable[[str, int, grpc.StatusCode | None, float], Awaitable[None]] | None = None
    metrics: RetryMetricsProtocol | None = None

RetryMetricsProtocol

Bases: Protocol

Optional extension: retry attempts, invisible to record_request by design.

The metrics layer sits above the retry layer and records one entry per logical call, so a retry storm — N wire attempts collapsing into one success — cannot be seen through GrpcClientMetricsProtocol alone. A registry that also implements this protocol gets told about every retry the moment it is scheduled.

Source code in grpc_client_kit/protocols.py
@runtime_checkable
class RetryMetricsProtocol(Protocol):
    """Optional extension: retry attempts, invisible to `record_request` by design.

    The metrics layer sits above the retry layer and records one entry per *logical* call, so a
    retry storm — N wire attempts collapsing into one success — cannot be seen through
    `GrpcClientMetricsProtocol` alone. A registry that also implements this protocol gets told
    about every retry the moment it is scheduled.
    """

    def record_retry(self, service: str, method: str, attempt: int, grpc_code: str) -> None:
        """Record one scheduled retry.

        Args:
            service: Name of the service.
            method: Name of the method.
            attempt: Number of the upcoming attempt (1 is the first retry).
            grpc_code: Status code name of the failure that caused the retry.
        """
        ...

record_retry(service, method, attempt, grpc_code)

Record one scheduled retry.

Parameters:

Name Type Description Default
service str

Name of the service.

required
method str

Name of the method.

required
attempt int

Number of the upcoming attempt (1 is the first retry).

required
grpc_code str

Status code name of the failure that caused the retry.

required
Source code in grpc_client_kit/protocols.py
def record_retry(self, service: str, method: str, attempt: int, grpc_code: str) -> None:
    """Record one scheduled retry.

    Args:
        service: Name of the service.
        method: Name of the method.
        attempt: Number of the upcoming attempt (1 is the first retry).
        grpc_code: Status code name of the failure that caused the retry.
    """
    ...

RetrySettingsProtocol

Bases: Protocol

Protocol for gRPC retry settings.

Source code in grpc_client_kit/protocols.py
@runtime_checkable
class RetrySettingsProtocol(Protocol):
    """Protocol for gRPC retry settings."""

    @property
    def max_attempts(self) -> int:
        """Maximum number of attempts (including the first one)."""
        ...

    @property
    def initial_backoff(self) -> float:
        """Initial backoff time in seconds."""
        ...

    @property
    def max_backoff(self) -> float:
        """Maximum backoff time in seconds."""
        ...

    @property
    def backoff_multiplier(self) -> float:
        """Multiplier for exponential backoff."""
        ...

backoff_multiplier property

Multiplier for exponential backoff.

initial_backoff property

Initial backoff time in seconds.

max_attempts property

Maximum number of attempts (including the first one).

max_backoff property

Maximum backoff time in seconds.

RoundRobinLoadBalancer

Bases: LoadBalancer

Round-robin load balancer with health check support.

Selects targets in a fixed cyclic order, skipping unhealthy ones.

Source code in grpc_client_kit/balancers.py
class RoundRobinLoadBalancer(LoadBalancer):
    """Round-robin load balancer with health check support.

    Selects targets in a fixed cyclic order, skipping unhealthy ones.
    """

    def __init__(self, targets: list[str], health_checker: HealthCheckerProtocol | None = None) -> None:
        """Initialize the Round-Robin balancer.

        Args:
            targets: List of target addresses.
            health_checker: Optional health checker.
        """
        super().__init__(targets, health_checker)
        self._index = 0
        self._lock = asyncio.Lock()

    async def select_target(self) -> str:
        """Select the next target in the round-robin sequence."""
        if not self._health_checker:
            candidates = set(self._without_quarantined(self._targets))
            async with self._lock:
                for _ in range(len(self._targets)):
                    target = self._targets[self._index]
                    self._index = (self._index + 1) % len(self._targets)
                    if target in candidates:
                        logger.debug("Selected target %s using round-robin", target)
                        return target
                # Unreachable in practice — _without_quarantined never empties the candidates —
                # but a plain pick beats an exception if it ever is.
                return self._targets[self._index]

        # With health checker - pre-fetch health statuses outside the lock to avoid blocking
        # but keep it in a small window to maintain some accuracy.
        health_statuses = await asyncio.gather(
            *(self._health_checker.is_healthy(t) for t in self._targets), return_exceptions=True
        )
        healthy = [target for target, status in zip(self._targets, health_statuses, strict=True) if status is True]
        candidates = set(self._without_quarantined(healthy))

        num_targets = len(self._targets)
        async with self._lock:
            for _ in range(num_targets):
                target = self._targets[self._index]
                self._index = (self._index + 1) % num_targets

                if target in candidates:
                    logger.debug("Selected target %s using round-robin (health_checker=True)", target)
                    return target

        # All unhealthy - raise error
        raise NoHealthyTargetsError(self._targets)

__init__(targets, health_checker=None)

Initialize the Round-Robin balancer.

Parameters:

Name Type Description Default
targets list[str]

List of target addresses.

required
health_checker HealthCheckerProtocol | None

Optional health checker.

None
Source code in grpc_client_kit/balancers.py
def __init__(self, targets: list[str], health_checker: HealthCheckerProtocol | None = None) -> None:
    """Initialize the Round-Robin balancer.

    Args:
        targets: List of target addresses.
        health_checker: Optional health checker.
    """
    super().__init__(targets, health_checker)
    self._index = 0
    self._lock = asyncio.Lock()

report_failure(target, quarantine=5.0)

Quarantine a target that a real call just found unreachable.

Active probing has an inherent window: with the default check interval a dead backend keeps receiving its share of traffic for up to that interval, every call burning a full client deadline. A call that got UNAVAILABLE is fresher evidence than any probe, so it takes the target out of the rotation immediately, for quarantine seconds — long enough for the next probe (or a recovered backend) to have the casting vote.

Parameters:

Name Type Description Default
target str

The target address the failed call was routed to.

required
quarantine float

Seconds to keep the target out of the rotation.

5.0
Source code in grpc_client_kit/balancers.py
def report_failure(self, target: str, quarantine: float = 5.0) -> None:
    """Quarantine a target that a real call just found unreachable.

    Active probing has an inherent window: with the default check interval a dead backend
    keeps receiving its share of traffic for up to that interval, every call burning a full
    client deadline. A call that got ``UNAVAILABLE`` is fresher evidence than any probe, so
    it takes the target out of the rotation immediately, for `quarantine` seconds — long
    enough for the next probe (or a recovered backend) to have the casting vote.

    Args:
        target: The target address the failed call was routed to.
        quarantine: Seconds to keep the target out of the rotation.
    """
    self._quarantine[target] = time.monotonic() + quarantine
    logger.debug("Target %s quarantined for %.1fs after a failed call", target, quarantine)

select_target() async

Select the next target in the round-robin sequence.

Source code in grpc_client_kit/balancers.py
async def select_target(self) -> str:
    """Select the next target in the round-robin sequence."""
    if not self._health_checker:
        candidates = set(self._without_quarantined(self._targets))
        async with self._lock:
            for _ in range(len(self._targets)):
                target = self._targets[self._index]
                self._index = (self._index + 1) % len(self._targets)
                if target in candidates:
                    logger.debug("Selected target %s using round-robin", target)
                    return target
            # Unreachable in practice — _without_quarantined never empties the candidates —
            # but a plain pick beats an exception if it ever is.
            return self._targets[self._index]

    # With health checker - pre-fetch health statuses outside the lock to avoid blocking
    # but keep it in a small window to maintain some accuracy.
    health_statuses = await asyncio.gather(
        *(self._health_checker.is_healthy(t) for t in self._targets), return_exceptions=True
    )
    healthy = [target for target, status in zip(self._targets, health_statuses, strict=True) if status is True]
    candidates = set(self._without_quarantined(healthy))

    num_targets = len(self._targets)
    async with self._lock:
        for _ in range(num_targets):
            target = self._targets[self._index]
            self._index = (self._index + 1) % num_targets

            if target in candidates:
                logger.debug("Selected target %s using round-robin (health_checker=True)", target)
                return target

    # All unhealthy - raise error
    raise NoHealthyTargetsError(self._targets)

TimeoutConfig dataclass

Configuration for timeouts.

A timeout is the budget of a whole call, retries included, not of a single attempt.

Attributes:

Name Type Description
default float | None

Budget in seconds for methods without an entry in per_method. None or 0 means no deadline; when neither a default nor per-method budget is configured, no timeout interceptor is added to the chain at all.

per_method dict[str, float | None]

Per-method budgets, keyed by full method name (/package.Service/Method). None or 0 disables the deadline for that method only.

Source code in grpc_client_kit/interceptors/__init__.py
@dataclass(slots=True)
class TimeoutConfig:
    """Configuration for timeouts.

    A timeout is the budget of a whole call, retries included, not of a single attempt.

    Attributes:
        default: Budget in seconds for methods without an entry in `per_method`. ``None`` or ``0``
            means no deadline; when neither a default nor per-method budget is configured, no
            timeout interceptor is added to the chain at all.
        per_method: Per-method budgets, keyed by full method name (``/package.Service/Method``).
            ``None`` or ``0`` disables the deadline for that method only.
    """

    default: float | None = 10.0
    per_method: dict[str, float | None] = field(default_factory=dict)

TimeoutSettingsProtocol

Bases: Protocol

Protocol for gRPC timeout settings.

Source code in grpc_client_kit/protocols.py
@runtime_checkable
class TimeoutSettingsProtocol(Protocol):
    """Protocol for gRPC timeout settings."""

    @property
    def default(self) -> float:
        """Default timeout in seconds."""
        ...

default property

Default timeout in seconds.

WaitForReadyConfig dataclass

Configuration for waiting on a connection instead of failing fast on a cold channel.

A grpc.aio channel connects lazily, so calls made in the first moments of a client's life — or right after a backend restart — fail with UNAVAILABLE before anything is even attempted. Setting this makes them wait for the connection instead, bounded by the call's deadline.

Attributes:

Name Type Description
default bool | None

Value for methods without an entry in per_method. None leaves calls untouched.

per_method dict[str, bool | None]

Values for individual methods, keyed by full method name (/package.Service/Method). None exempts that method from default.

require_deadline bool

Whether waiting applies only to calls that carry a deadline. On by default: an unbounded wait replaces a fast failure with a hang.

Source code in grpc_client_kit/interceptors/__init__.py
@dataclass(slots=True)
class WaitForReadyConfig:
    """Configuration for waiting on a connection instead of failing fast on a cold channel.

    A `grpc.aio` channel connects lazily, so calls made in the first moments of a client's life —
    or right after a backend restart — fail with ``UNAVAILABLE`` before anything is even attempted.
    Setting this makes them wait for the connection instead, bounded by the call's deadline.

    Attributes:
        default: Value for methods without an entry in `per_method`. ``None`` leaves calls untouched.
        per_method: Values for individual methods, keyed by full method name
            (``/package.Service/Method``). ``None`` exempts that method from `default`.
        require_deadline: Whether waiting applies only to calls that carry a deadline. On by
            default: an unbounded wait replaces a fast failure with a hang.
    """

    default: bool | None = True
    per_method: dict[str, bool | None] = field(default_factory=dict)
    require_deadline: bool = True

WeightedLoadBalancer

Bases: LoadBalancer

Weighted random load balancer with health check support.

Selects targets based on provided weights, giving higher preference to targets with larger weights. Skips unhealthy targets.

Source code in grpc_client_kit/balancers.py
class WeightedLoadBalancer(LoadBalancer):
    """Weighted random load balancer with health check support.

    Selects targets based on provided weights, giving higher preference to
    targets with larger weights. Skips unhealthy targets.
    """

    def __init__(
        self,
        targets: list[str],
        weights: dict[str, float],
        health_checker: HealthCheckerProtocol | None = None,
    ) -> None:
        """Initialize the Weighted balancer.

        Args:
            targets: List of target addresses.
            weights: Mapping of target to its weight (default 1.0).
            health_checker: Optional health checker.
        """
        super().__init__(targets, health_checker)
        self._weights_dict = weights

        # Validate weights
        for t in targets:
            weight = weights.get(t, 1.0)
            if weight < 0:
                raise ValueError(f"Weight for target {t} cannot be negative: {weight}")

        if sum(weights.get(t, 1.0) for t in targets) <= 0:
            raise ValueError("Sum of weights must be positive")

        # Pre-calculate weights list for targets to avoid repeated dict lookups
        self._cached_weights = [weights.get(t, 1.0) for t in targets]

        # Health status caching (similar to RandomLoadBalancer)
        self._healthy_targets: list[str] = []
        self._healthy_weights: list[float] = []
        self._last_health_update = 0.0
        self._health_cache_ttl = 1.0  # 1 second
        self._lock = asyncio.Lock()

    def _weighted_pick(self, candidates: list[str]) -> str:
        """Pick among `candidates` by weight, falling back to uniform when all weights are zero."""
        weights = [self._weights_dict.get(target, 1.0) for target in candidates]
        if sum(weights) <= 0:
            return random.choice(candidates)  # noqa: S311
        return random.choices(candidates, weights=weights, k=1)[0]  # noqa: S311

    async def select_target(self) -> str:
        """Select a target using weighted random selection among healthy ones."""
        if not self._health_checker:
            target = self._weighted_pick(self._without_quarantined(self._targets))
            logger.debug(
                "Selected target %s using weighted random (weight=%.2f)",
                target,
                self._weights_dict.get(target, 1.0),
            )
            return target

        # Use cached healthy targets if possible
        async with self._lock:
            now = time.time()
            if now - self._last_health_update > self._health_cache_ttl:
                # Filter healthy targets and their weights in parallel
                health_statuses = await asyncio.gather(
                    *(self._health_checker.is_healthy(t) for t in self._targets), return_exceptions=True
                )

                self._healthy_targets = []
                self._healthy_weights = []

                for t, status in zip(self._targets, health_statuses, strict=True):
                    if status is True:
                        self._healthy_targets.append(t)
                        self._healthy_weights.append(self._weights_dict.get(t, 1.0))

                self._last_health_update = now

            if not self._healthy_targets:
                raise NoHealthyTargetsError(self._targets)

            # Quarantine is applied on every pick, not cached: a passive verdict may arrive (and
            # expire) well within the health cache's TTL.
            target = self._weighted_pick(self._without_quarantined(self._healthy_targets))

            logger.debug(
                "Selected target %s using weighted random (weight=%.2f, total_healthy=%d)",
                target,
                self._weights_dict.get(target, 1.0),
                len(self._healthy_targets),
            )
            return target

__init__(targets, weights, health_checker=None)

Initialize the Weighted balancer.

Parameters:

Name Type Description Default
targets list[str]

List of target addresses.

required
weights dict[str, float]

Mapping of target to its weight (default 1.0).

required
health_checker HealthCheckerProtocol | None

Optional health checker.

None
Source code in grpc_client_kit/balancers.py
def __init__(
    self,
    targets: list[str],
    weights: dict[str, float],
    health_checker: HealthCheckerProtocol | None = None,
) -> None:
    """Initialize the Weighted balancer.

    Args:
        targets: List of target addresses.
        weights: Mapping of target to its weight (default 1.0).
        health_checker: Optional health checker.
    """
    super().__init__(targets, health_checker)
    self._weights_dict = weights

    # Validate weights
    for t in targets:
        weight = weights.get(t, 1.0)
        if weight < 0:
            raise ValueError(f"Weight for target {t} cannot be negative: {weight}")

    if sum(weights.get(t, 1.0) for t in targets) <= 0:
        raise ValueError("Sum of weights must be positive")

    # Pre-calculate weights list for targets to avoid repeated dict lookups
    self._cached_weights = [weights.get(t, 1.0) for t in targets]

    # Health status caching (similar to RandomLoadBalancer)
    self._healthy_targets: list[str] = []
    self._healthy_weights: list[float] = []
    self._last_health_update = 0.0
    self._health_cache_ttl = 1.0  # 1 second
    self._lock = asyncio.Lock()

report_failure(target, quarantine=5.0)

Quarantine a target that a real call just found unreachable.

Active probing has an inherent window: with the default check interval a dead backend keeps receiving its share of traffic for up to that interval, every call burning a full client deadline. A call that got UNAVAILABLE is fresher evidence than any probe, so it takes the target out of the rotation immediately, for quarantine seconds — long enough for the next probe (or a recovered backend) to have the casting vote.

Parameters:

Name Type Description Default
target str

The target address the failed call was routed to.

required
quarantine float

Seconds to keep the target out of the rotation.

5.0
Source code in grpc_client_kit/balancers.py
def report_failure(self, target: str, quarantine: float = 5.0) -> None:
    """Quarantine a target that a real call just found unreachable.

    Active probing has an inherent window: with the default check interval a dead backend
    keeps receiving its share of traffic for up to that interval, every call burning a full
    client deadline. A call that got ``UNAVAILABLE`` is fresher evidence than any probe, so
    it takes the target out of the rotation immediately, for `quarantine` seconds — long
    enough for the next probe (or a recovered backend) to have the casting vote.

    Args:
        target: The target address the failed call was routed to.
        quarantine: Seconds to keep the target out of the rotation.
    """
    self._quarantine[target] = time.monotonic() + quarantine
    logger.debug("Target %s quarantined for %.1fs after a failed call", target, quarantine)

select_target() async

Select a target using weighted random selection among healthy ones.

Source code in grpc_client_kit/balancers.py
async def select_target(self) -> str:
    """Select a target using weighted random selection among healthy ones."""
    if not self._health_checker:
        target = self._weighted_pick(self._without_quarantined(self._targets))
        logger.debug(
            "Selected target %s using weighted random (weight=%.2f)",
            target,
            self._weights_dict.get(target, 1.0),
        )
        return target

    # Use cached healthy targets if possible
    async with self._lock:
        now = time.time()
        if now - self._last_health_update > self._health_cache_ttl:
            # Filter healthy targets and their weights in parallel
            health_statuses = await asyncio.gather(
                *(self._health_checker.is_healthy(t) for t in self._targets), return_exceptions=True
            )

            self._healthy_targets = []
            self._healthy_weights = []

            for t, status in zip(self._targets, health_statuses, strict=True):
                if status is True:
                    self._healthy_targets.append(t)
                    self._healthy_weights.append(self._weights_dict.get(t, 1.0))

            self._last_health_update = now

        if not self._healthy_targets:
            raise NoHealthyTargetsError(self._targets)

        # Quarantine is applied on every pick, not cached: a passive verdict may arrive (and
        # expire) well within the health cache's TTL.
        target = self._weighted_pick(self._without_quarantined(self._healthy_targets))

        logger.debug(
            "Selected target %s using weighted random (weight=%.2f, total_healthy=%d)",
            target,
            self._weights_dict.get(target, 1.0),
            len(self._healthy_targets),
        )
        return target

__getattr__(name)

Resolve extras-gated exports on first access.

HealthChecker needs the [health] extra, so importing this package must not import it: a module-level import would make import grpc_client_kit fail on a bare install.

Parameters:

Name Type Description Default
name str

The attribute being looked up.

required

Returns:

Type Description
Any

The resolved attribute.

Raises:

Type Description
AttributeError

If the package has no such attribute.

ImportError

If the attribute needs an extra that is not installed.

Source code in grpc_client_kit/__init__.py
def __getattr__(name: str) -> Any:
    """Resolve extras-gated exports on first access.

    ``HealthChecker`` needs the [health] extra, so importing this package must not import it: a
    module-level import would make ``import grpc_client_kit`` fail on a bare install.

    Args:
        name: The attribute being looked up.

    Returns:
        The resolved attribute.

    Raises:
        AttributeError: If the package has no such attribute.
        ImportError: If the attribute needs an extra that is not installed.
    """
    if name == "HealthChecker":
        from .factory import _load_health_checker  # noqa: PLC0415 - lazy: needs the [health] extra

        return _load_health_checker()

    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

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)

Build a standard interceptor chain in the order documented for this module.

Parameters:

Name Type Description Default
timeout TimeoutConfig | None

Timeout configuration.

None
retry RetryConfig | None

Retry configuration.

None
circuit_breaker CircuitBreakerConfig | None

Circuit breaker configuration.

None
observability ObservabilityConfig | None

Observability configuration.

None
extra_interceptors Sequence[ClientInterceptorLike] | None

Custom interceptors for the outer slot, ahead of logging, tracing and metrics. This is where metadata injection belongs so that logs and spans can correlate on it.

None
extra_inner_interceptors Sequence[ClientInterceptorLike] | None

Custom interceptors for the inner slot, below the resilience layers. These run once per attempt, closest to the wire.

None
deadline_budget DeadlineBudgetConfig | None

Deadline budget propagation configuration. Needs the deadline extra.

None
wait_for_ready WaitForReadyConfig | None

Wait-for-ready configuration, for calls made on a channel that may still be connecting.

None

Returns:

Type Description
list[ClientInterceptor]

A flat chain, outermost first, ready to be bound to a channel — by GrpcClient or by

list[ClientInterceptor]

grpc.aio directly. Each kit layer appears as its four channel adapters; see the module

list[ClientInterceptor]

docstring.

Source code in grpc_client_kit/interceptors/__init__.py
def build_interceptors(
    timeout: TimeoutConfig | None = None,
    retry: RetryConfig | None = None,
    circuit_breaker: CircuitBreakerConfig | None = None,
    observability: ObservabilityConfig | None = None,
    extra_interceptors: Sequence[ClientInterceptorLike] | None = None,
    extra_inner_interceptors: Sequence[ClientInterceptorLike] | None = None,
    deadline_budget: DeadlineBudgetConfig | None = None,
    wait_for_ready: WaitForReadyConfig | None = None,
) -> list[grpc.aio.ClientInterceptor]:
    """Build a standard interceptor chain in the order documented for this module.

    Args:
        timeout: Timeout configuration.
        retry: Retry configuration.
        circuit_breaker: Circuit breaker configuration.
        observability: Observability configuration.
        extra_interceptors: Custom interceptors for the outer slot, ahead of logging, tracing and
            metrics. This is where metadata injection belongs so that logs and spans can correlate
            on it.
        extra_inner_interceptors: Custom interceptors for the inner slot, below the resilience
            layers. These run once per attempt, closest to the wire.
        deadline_budget: Deadline budget propagation configuration. Needs the ``deadline`` extra.
        wait_for_ready: Wait-for-ready configuration, for calls made on a channel that may still be
            connecting.

    Returns:
        A flat chain, outermost first, ready to be bound to a channel — by GrpcClient or by
        `grpc.aio` directly. Each kit layer appears as its four channel adapters; see the module
        docstring.
    """
    return (
        InterceptorChainBuilder()
        .with_extra_outer(extra_interceptors)
        .with_observability(observability)
        .with_resilience(timeout, retry, circuit_breaker, deadline_budget, wait_for_ready)
        .with_extra_inner(extra_inner_interceptors)
        .build()
    )

create_balancer(targets, config=None, health_checker=None)

Factory function to create a load balancer from targets and config.

By default, creates a Round-Robin balancer if no config is provided.

Parameters:

Name Type Description Default
targets list[str]

List of target addresses (host:port)

required
config LoadBalancerConfig | None

Balancer configuration (strategy and weights)

None
health_checker HealthCheckerProtocol | None

Optional health checker for filtering unhealthy targets

None

Returns:

Type Description
LoadBalancer

A concrete LoadBalancer instance

Raises:

Type Description
ValueError

If targets list is empty or config is invalid.

Source code in grpc_client_kit/balancers.py
def create_balancer(
    targets: list[str],
    config: LoadBalancerConfig | None = None,
    health_checker: HealthCheckerProtocol | None = None,
) -> LoadBalancer:
    """Factory function to create a load balancer from targets and config.

    By default, creates a Round-Robin balancer if no config is provided.

    Args:
        targets: List of target addresses (host:port)
        config: Balancer configuration (strategy and weights)
        health_checker: Optional health checker for filtering unhealthy targets

    Returns:
        A concrete LoadBalancer instance

    Raises:
        ValueError: If targets list is empty or config is invalid.
    """
    if not targets:
        raise ValueError("Targets list cannot be empty for load balancer")

    strategy = config.strategy if config else LoadBalancingStrategy.ROUND_ROBIN

    match strategy:
        case LoadBalancingStrategy.RANDOM:
            return RandomLoadBalancer(targets, health_checker)
        case LoadBalancingStrategy.WEIGHTED:
            if not config or not config.weights:
                raise ValueError(f"Weights must be provided for {strategy} strategy")
            return WeightedLoadBalancer(targets, config.weights, health_checker)
        case LoadBalancingStrategy.ROUND_ROBIN:
            return RoundRobinLoadBalancer(targets, health_checker)
        case _:
            raise ValueError(f"Unknown load balancing strategy: {strategy}")

current_budget()

Return the budget installed for the current task, or None if there is none.

Returns:

Type Description
DeadlineBudgetProtocol | None

The budget use_budget last installed in this context, or None.

Source code in grpc_client_kit/deadline.py
def current_budget() -> DeadlineBudgetProtocol | None:
    """Return the budget installed for the current task, or None if there is none.

    Returns:
        The budget `use_budget` last installed in this context, or None.
    """
    return _CURRENT_BUDGET.get()

flatten_interceptors(interceptors)

Expand every logical interceptor into the four adapters a channel can file correctly.

Order is preserved, and that is what makes the expansion safe: a channel appends each entry to the list of its own kind in the order it reads them, so four adapters of A ahead of four adapters of B put A ahead of B in all four lists at once.

Interceptors written against grpc's intercept_* methods directly are passed through untouched, so a chain may mix both kinds while the kit is being migrated.

Parameters:

Name Type Description Default
interceptors Iterable[ClientInterceptorLike]

The chain, outermost first.

required

Returns:

Type Description
list[ClientInterceptor]

A flat list a channel accepts, outermost first.

Source code in grpc_client_kit/interceptors/base.py
def flatten_interceptors(interceptors: Iterable[ClientInterceptorLike]) -> list[grpc.aio.ClientInterceptor]:
    """Expand every logical interceptor into the four adapters a channel can file correctly.

    Order is preserved, and that is what makes the expansion safe: a channel appends each entry to
    the list of its own kind in the order it reads them, so four adapters of A ahead of four
    adapters of B put A ahead of B in all four lists at once.

    Interceptors written against grpc's ``intercept_*`` methods directly are passed through
    untouched, so a chain may mix both kinds while the kit is being migrated.

    Args:
        interceptors: The chain, outermost first.

    Returns:
        A flat list a channel accepts, outermost first.
    """
    flat: list[grpc.aio.ClientInterceptor] = []
    for interceptor in interceptors:
        if isinstance(interceptor, AsyncClientInterceptor):
            flat.extend(interceptor.adapters)
        else:
            flat.append(interceptor)
    return flat

logical_interceptor(interceptor)

Return the logical interceptor behind a channel adapter, or the argument unchanged.

Lets callers question a flattened chain (isinstance checks in tests, a handle onto the circuit breaker) without knowing whether an entry is an adapter or an old-style interceptor.

Source code in grpc_client_kit/interceptors/base.py
def logical_interceptor(interceptor: ClientInterceptorLike) -> ClientInterceptorLike:
    """Return the logical interceptor behind a channel adapter, or the argument unchanged.

    Lets callers question a flattened chain (``isinstance`` checks in tests, a handle onto the
    circuit breaker) without knowing whether an entry is an adapter or an old-style interceptor.
    """
    owner = getattr(interceptor, "interceptor", None)
    return owner if isinstance(owner, AsyncClientInterceptor) else interceptor

metadata_to_dict(metadata)

Convert gRPC metadata to a flat dictionary.

Handles various metadata formats (list of tuples, grpc.aio.Metadata object). Performs the following transformations: - Decodes bytes keys and values to UTF-8 strings. - If a value is binary and not valid UTF-8, it is base64 encoded with a 'base64:' prefix. - Joins multiple values for the same key with commas (RFC 2616 style).

Parameters:

Name Type Description Default
metadata MetadataType | None

The gRPC metadata to convert.

required

Returns:

Type Description
dict[str, str]

A dictionary mapping metadata keys to their string values.

Source code in grpc_client_kit/utils.py
def metadata_to_dict(metadata: MetadataType | None) -> dict[str, str]:
    """Convert gRPC metadata to a flat dictionary.

    Handles various metadata formats (list of tuples, grpc.aio.Metadata object).
    Performs the following transformations:
    - Decodes bytes keys and values to UTF-8 strings.
    - If a value is binary and not valid UTF-8, it is base64 encoded with a 'base64:' prefix.
    - Joins multiple values for the same key with commas (RFC 2616 style).

    Args:
        metadata: The gRPC metadata to convert.

    Returns:
        A dictionary mapping metadata keys to their string values.
    """
    if not metadata:
        return {}

    # Convert to list if it's a Metadata object or other iterable
    metadata_list: list[tuple[str | bytes, str | bytes]] = (
        metadata if isinstance(metadata, list) else list(metadata)  # type: ignore[arg-type, assignment]
    )

    result: dict[str, str] = {}
    for key, value in metadata_list:
        str_key = key if isinstance(key, str) else key.decode("utf-8")

        if isinstance(value, bytes):
            try:
                str_value = value.decode("utf-8")
            except UnicodeDecodeError:
                # Fallback for truly binary data if it's not UTF-8
                str_value = f"base64:{base64.b64encode(value).decode('ascii')}"
        else:
            str_value = str(value)

        # Handle duplicate keys by joining values with commas (similar to HTTP headers)
        if str_key in result:
            result[str_key] = f"{result[str_key]},{str_value}"
        else:
            result[str_key] = str_value

    return result

use_budget(budget)

Install budget as the current one for the duration of the block.

The previous value is restored on the way out, whether the block ends normally or by exception, so nesting works and an inner budget cannot outlive its block. Passing None is the way to detach an inherited budget — for background work that must not die with the request that spawned it.

Parameters:

Name Type Description Default
budget DeadlineBudgetProtocol | None

The budget to install, or None to run this block without one.

required

Yields:

Type Description
DeadlineBudgetProtocol | None

The budget that was installed, for convenience at the call site.

Source code in grpc_client_kit/deadline.py
@contextmanager
def use_budget(budget: DeadlineBudgetProtocol | None) -> Iterator[DeadlineBudgetProtocol | None]:
    """Install `budget` as the current one for the duration of the block.

    The previous value is restored on the way out, whether the block ends normally or by exception,
    so nesting works and an inner budget cannot outlive its block. Passing None is the way to detach
    an inherited budget — for background work that must not die with the request that spawned it.

    Args:
        budget: The budget to install, or None to run this block without one.

    Yields:
        The budget that was installed, for convenience at the call site.
    """
    token: Token[DeadlineBudgetProtocol | None] = _CURRENT_BUDGET.set(budget)
    try:
        yield budget
    finally:
        _CURRENT_BUDGET.reset(token)