Base gRPC client
English | Русский
Base client for sending requests to gRPC services from automated tests.
Installation
Requires Python 3.13 or later.
pip install cloudru-qa-grpc-client
Import the library as the grpc_client module.
If you also need code generation from proto files, install the library with the codegen extra:
pip install "cloudru-qa-grpc-client[codegen]"
Skills for AI agents
The package includes the grpc-client-usage skill
with examples and usage rules for AI agents. Install the library
in your project environment, then run this from its root (requires uv):
uvx library-skills install --skill grpc-client-usage --yes
For Claude Code, add --claude. The instructions are shipped with the library version.
Usage
1. Generate code from proto
First, get access to the proto files describing the service contract for your gRPC client. Ask the service developers for them. See the proto directory in this repository for examples.
Once you have access, generate pb2.py files from them. Ask whether the developers have already generated these files and published them in a library or repository.
If you need to generate the contract code yourself, copy the proto files into your project (consider git submodules). Then use protoc from grpcio-tools, installed with the cloudru-qa-grpc-client[codegen] extra.
ℹ️ See gen-test-api.sh for a simple example that generates pb2.py files for tests.
2. Write your own client
The library provides base methods for sending gRPC requests. To use them, create your own client class that inherits from GRPCClient, with the service Stub class as its type parameter:
from typing import Type
from grpc_client import CallOptions, GRPCClient, GRPCResponse, MetadataType
# Import the pb2.py modules generated in the previous step
from api import example_pb2, example_pb2_grpc
class MyCustomClient(GRPCClient[example_pb2_grpc.ExampleStub]):
"""gRPC client for the Example service."""
@property
def stub_class(self) -> Type[example_pb2_grpc.ExampleStub]:
return example_pb2_grpc.ExampleStub
def send_example_request(
self,
data: example_pb2.RequestMessage,
metadata: MetadataType | None = None,
options: CallOptions | None = None,
) -> GRPCResponse[example_pb2.ResponseMessage]:
"""Send a test gRPC request to the service.
:param data: request data
:param metadata: request metadata (similar to HTTP headers)
:param options: call options
:return: response object containing the response data
"""
return self.send_unary_unary_request(
msg=data,
method=self.stub.ExampleMethod,
metadata=metadata,
options=options,
)
In detail:
- Inherit from
GRPCClientand specify the Stub class in square brackets to typeself.stub. - Return the same Stub class from the required
stub_classproperty. Import it from the generatedpb2_grpc.pymodules; it is named "{service name}Stub". All service methods are then available throughself.stub. - Call a specific request with one of the base class's four sending methods (described below), passing the request data and a method from
self.stub.
3. Create a client and set options
Create a client with the from_addr factory method, passing the service address and options:
import logging
from grpc_client import options
client = MyCustomClient.from_addr(
"localhost:9000",
options.with_insecure_channel(),
options.with_timeout(30),
options.with_logger(logging.getLogger("example")),
options.with_autogenerated_request_id(),
)
response = client.send_example_request(...)
client.close()
⚠️ Close the channel created by the client. The simplest way is to use the client as a context manager:
with MyCustomClient.from_addr("localhost:9000", options.with_insecure_channel()) as client:
response = client.send_example_request(...)
A fixture works well for this in pytest:
@pytest.fixture
def example_client() -> Iterator[MyCustomClient]:
with MyCustomClient.from_addr("localhost:9000", options.with_insecure_channel()) as client:
yield client
If a channel already exists and the caller manages its lifetime, use from_channel: the client does not close that channel:
with grpc.insecure_channel("localhost:9000") as channel:
client = MyCustomClient.from_channel(channel)
Available options:
| Option | Purpose |
|---|---|
with_timeout(timeout) |
Default call timeout; None means no limit |
with_insecure_channel() |
Unencrypted channel (channels are encrypted by default) |
with_ssl_credentials(credentials) |
Custom TLS credentials |
with_root_certificates(pem_bytes) |
Root certificate for verifying the server |
with_channel_options(options) |
Low-level grpcio channel settings, including grpc.service_config |
with_compression(compression) |
Message compression |
with_default_metadata(metadata) |
Metadata added to every request |
with_auth(auth) |
Add an access token; see Authorization |
with_logger(logger, ...) |
Log requests and responses |
with_autogenerated_request_id(key) |
Automatic x-request-id |
with_autogenerated_traceparent(key) |
Automatic traceparent following W3C Trace Context |
with_interceptor(*interceptors) |
Custom grpcio interceptors |
Option order matters: interceptors run in the order they are added. Add logging after authorization so the log captures the final request.
4. Call methods
gRPC has four method types:
- Unary — a regular blocking request: the client sends one message and receives one response.
- Client streaming — the client sends a sequence of messages, then waits for a single response.
- Server streaming — the client sends a request and reads a sequence of response messages.
- Bidirectional streaming — both the client and server send sequences of messages independently.
See the grpc.io documentation for details.
The base class implements all four:
| Method | Call type | Returns |
|---|---|---|
send_unary_unary_request |
unary | GRPCResponse[T] |
send_stream_unary_request |
client streaming | GRPCResponse[T] |
send_unary_stream_request |
server streaming | GRPCStreamResponse[T] |
send_stream_stream_request |
bidirectional streaming | GRPCStreamResponse[T] |
ℹ️ In Python, request streams use an iterator, Iterator[Message]. Use iter to create a simple iterator from a list.
5. Work with responses
Unary methods return GRPCResponse:
response = client.send_example_request(data)
response.status_code # grpc.StatusCode — response status code
response.details # str — status explanation, usually set for errors
response.ok # bool — whether the request succeeded
response.message() # response message; raises MessageUndefinedError if absent
response.initial_metadata # metadata before the response body, similar to HTTP headers
response.trailing_metadata # metadata after the response body
response.error # original grpc.RpcError if the request failed
response.error_status # rich google.rpc.Status if provided by the service
response.raise_for_status() # raise ResponseStatusError if status is not OK
The client does not raise gRPC errors: each failed call becomes a GRPCResponse with the appropriate code. This is useful in automated tests, where a failed response can also be an expected result:
response = client.send_example_request(data)
assert response.status_code == grpc.StatusCode.NOT_FOUND
assert response.details == "order not found"
To turn an error into an exception, use raise_for_status().
Use with_parser to transform the response message, for example into a pydantic model or a single property:
from pydantic import BaseModel
from grpc_client import GRPCClient, GRPCResponseWithParser, MetadataType
from api import example_pb2, example_pb2_grpc
class ResponseModel(BaseModel):
prop1: str
prop2: int
prop3: bool
class MyCustomClient(GRPCClient[example_pb2_grpc.ExampleStub]):
@property
def stub_class(self) -> type[example_pb2_grpc.ExampleStub]:
return example_pb2_grpc.ExampleStub
def send_example_request(
self,
data: example_pb2.RequestMessage,
metadata: MetadataType | None = None,
) -> GRPCResponseWithParser[example_pb2.ResponseMessage, ResponseModel]:
"""Send a test gRPC request to the service.
:param data: request data
:param metadata: request metadata (similar to HTTP headers)
:return: response object with a response message parser
"""
response = self.send_unary_unary_request(
msg=data,
method=self.stub.ExampleMethod,
metadata=metadata,
)
def parser(msg: example_pb2.ResponseMessage) -> ResponseModel:
return ResponseModel(
prop1=msg.prop1,
prop2=msg.prop2,
prop3=msg.prop3,
)
return response.with_parser(parser)
Call parse() to get the parsed message.
6. Streams
send_unary_stream_request and send_stream_stream_request return GRPCStreamResponse. Messages are read lazily as they arrive, without buffering.
If you need all messages at once, call collect(): it consumes the remaining stream and returns a regular GRPCResponse with a message list and the final status.
response = client.send_server_stream(data).collect()
assert response.status_code == grpc.StatusCode.OK
for message in response.message():
...
Lazy reading is useful for long or endless streams:
with client.send_server_stream(data) as stream:
for message in stream:
if message.is_last:
break
⚠️ A stream keeps the call open, so consume it fully or close it. The context manager cancels an unfinished call on exit. You can also cancel it explicitly with cancel().
The server provides the final status and trailing metadata only when the stream ends. Accessing them before reading finishes raises StreamNotConsumedError:
stream = client.send_server_stream(data)
messages = list(stream)
stream.status_code # available only now
stream.trailing_metadata
Pass limit to restrict the number of messages: the call is canceled when the limit is reached:
response = client.send_server_stream(data).collect(limit=100)
ℹ️ A stream can be read once: reading it again raises StreamConsumedError. If you need the messages again, save them with collect().
7. Call options
Client settings apply to all requests. Use CallOptions to configure one call:
from grpc_client import CallOptions
response = client.send_example_request(
data,
options=CallOptions(
timeout=120, # custom timeout for a long-running method
metadata={"x-attempt": "2"},
wait_for_ready=True, # wait for the channel instead of failing with UNAVAILABLE
compression=grpc.Compression.Gzip,
),
)
timeout=None means no time limit. An omitted timeout uses the client settings: these are different behaviors.
8. Metadata
Pass metadata as a dictionary or a sequence of pairs; use pairs when a key has multiple values:
client.send_example_request(data, metadata={"x-order-id": "A-1024"})
client.send_example_request(data, metadata=[("x-feature", "search"), ("x-feature", "ranking")])
Response metadata is returned as a Metadata object. It acts like a dictionary but preserves order and duplicate keys, and compares key names without case sensitivity:
response.trailing_metadata["x-request-id"] # first value for the key
response.trailing_metadata.get_all("x-feature") # all values for the key
response.trailing_metadata.pairs() # all pairs, including duplicate keys
ℹ️ Binary values are allowed only for keys ending in -bin, as required by gRPC.
9. Errors and rich status
Services often send error details in google.rpc.Status. The library parses them and exposes them through error_status:
response = client.send_example_request(data)
if response.error_status:
print(response.error_status.code) # numeric google.rpc.Code
print(response.error_status.message) # error text
print(response.error_status.details) # typed details
# look up details by message type
bad_request = response.error_status.of_type(error_details_pb2.BadRequest)
Detail types unknown to the library remain as google.protobuf.Any.
Library exceptions inherit from GRPCClientError:
| Exception | Raised when |
|---|---|
MessageUndefinedError |
calling message() on a response without a message |
ResponseStatusError |
calling raise_for_status() with a non-OK status |
StreamConsumedError |
reading an already consumed stream again |
StreamNotConsumedError |
accessing the final status before consuming the stream |
10. Authorization
The library does not determine where tokens come from; it defines the ClientAuth contract:
class ClientAuth(Protocol):
def token(self) -> str: ...
def refresh(self, rejected_token: str) -> str: ...
The rejected token is passed to refresh(), allowing the implementation to distinguish an expired token
from one already refreshed by another request while this request waited for a lock, avoiding unnecessary external calls.
The implementation issues tokens, calls the external service, caches tokens, and manages their lifetime. The client adds the header and retries requests:
client = MyCustomClient.from_addr(
"example.com:443",
options.with_auth(my_auth),
)
The client:
- adds the
authorization: Bearer <token>header to every request; - requests a new token with
refresh(rejected_token)after anUNAUTHENTICATEDresponse and retries the unary request; - leaves authorization unchanged if the caller supplies an
authorizationheader.
⚠️ Only unary requests are retried: a request stream is exhausted after the first send, and a response stream's final status is known only after consumption. Disable retries with options.with_auth(my_auth, retry_on_unauthenticated=False).
ℹ️ Ready-to-use IAM authorization is provided by cloudru-qa-grpc-client-auth.
11. Logging
import logging
client = MyCustomClient.from_addr(
"example.com:443",
options.with_logger(
logging.getLogger("example"),
sanitize_keys=["user.password"],
json_indent=2,
),
)
All four call types are logged. For streams, the log includes the final status and message count, but not the messages: consuming them for logging would change call behavior.
ℹ️ The asynchronous client counts messages received through async for and collect(); it provides no other stream reading methods.
Secrets are masked:
- in metadata, by case-insensitive key name;
- in message bodies, in two ways depending on the key format:
- a name without separators (
password) masks that field at any nesting level, including list items; - a path with a dot, star, or brackets (
user.password,items.*.token) is applied as a glom path.
- a name without separators (
Field names match across naming styles, so access_token also hides accessToken (protobuf serializes fields in camelCase), but does not affect fields with different word boundaries such as sec_ret.
Common keys (authorization, cookie, password, token, and others) are always masked in metadata and nested body fields. Disable this with use_default_sanitize_keys=False.
Set shared logging settings through configure_logging; environment variables can override them:
from grpc_client import configure_logging
configure_logging(
log_level=logging.INFO,
json_indent=2,
payload_log_maxlength=4096,
)
| Environment variable | Controls |
|---|---|
GRPC_CLIENT_LOG_LEVEL |
log level for successful requests |
GRPC_CLIENT_ERR_LOG_LEVEL |
log level for failed requests |
GRPC_CLIENT_JSON_INDENT |
JSON formatting indentation |
GRPC_CLIENT_USE_LINE_BREAKS |
whether log properties appear on separate lines |
GRPC_CLIENT_PAYLOAD_LOG_MAXLENGTH |
maximum message body length in logs |
Custom log format
with_logger writes each call as a single string. For another format, such as separate fields in a structured logger, pass a log sink instead of a logger:
from grpc_client import LogSink, RequestRecord, ResponseRecord, options
class MySink(LogSink):
def is_enabled_for(self, level: int) -> bool:
return True
def write_request(self, level: int, record: RequestRecord) -> None: ...
def write_response(self, level: int, record: ResponseRecord) -> None: ...
client = MyCustomClient.from_addr("example.com:443", options.with_log_sink(MySink()))
LogSink is a Protocol, so inheritance is optional, but inheriting lets mypy check method signatures.
The client builds records and selects the level: log_level for successful calls, err_log_level for failures. The sink only decides where and how to write records, so it configures its own masking and formatting, for example with LogFormat.from_config(...) (imported from grpc_client), which uses configure_logging values.
Read the call outcome from the record fields: record.succeeded distinguishes success from failure, and record.is_stream distinguishes a completed stream from a unary response. No need to interpret combinations of status_code, message, and messages_count yourself.
The client calls is_enabled_for before each record, so it must be cheap.
The sink must support calls from multiple threads and return quickly. If it raises an exception, the client suppresses it: the call succeeds, but the log record is lost.
A ready-to-use structlog implementation is in cloudru-qa-grpc-client-structlog.
12. Asynchronous client
AsyncGRPCClient provides the synchronous client's interface on top of grpc.aio:
from grpc_client import AsyncGRPCClient, AsyncGRPCStreamResponse, GRPCResponse, options
from api import example_pb2, example_pb2_grpc
class MyAsyncClient(AsyncGRPCClient[example_pb2_grpc.ExampleStub]):
@property
def stub_class(self) -> type[example_pb2_grpc.ExampleStub]:
return example_pb2_grpc.ExampleStub
async def send_example_request(
self,
data: example_pb2.RequestMessage,
) -> GRPCResponse[example_pb2.ResponseMessage]:
return await self.send_unary_unary_request(data, self.stub.ExampleMethod)
async with MyAsyncClient.from_addr("localhost:9000", options.with_insecure_channel()) as client:
response = await client.send_example_request(data)
Differences from the synchronous client:
- sending methods must be awaited;
- streams return
AsyncGRPCStreamResponse, read withasync for, with status and metadata exposed as coroutines:await stream.status_code(); - close with
await client.close()orasync with; grpc.aiointerceptors can only be set when creating a channel, sofrom_channel()with options that add interceptors (with_auth,with_logger,with_autogenerated_*,with_interceptor) raises an error: create the channel yourself withgrpc.aio.insecure_channel(..., interceptors=[...])or create the client withfrom_addr();- authorization requires an asynchronous
AsyncClientAuthimplementation with coroutinetoken()andrefresh()methods.
Both clients share the same options: options.with_logger(...) selects the appropriate interceptor automatically.
13. Custom interceptors
An interceptor intercepts gRPC requests. Use it to add request data, collect metrics, or cache values.
Write interceptors using the regular grpcio API. To add metadata, inherit from MetadataInterceptor, which covers all four call types:
from grpc_client.interceptors import MetadataInterceptor
from grpc_client.types import MetadataItems
class TenantInterceptor(MetadataInterceptor):
"""Add a tenant identifier to each request."""
def __init__(self, tenant: str):
self._tenant = tenant
def metadata_to_add(self, call_details) -> MetadataItems:
return (("x-tenant", self._tenant),)
client = MyCustomClient.from_addr(
"localhost:9000",
options.with_interceptor(TenantInterceptor("warehouse-42")),
)
The asynchronous client has AsyncMetadataInterceptor with the same contract, but metadata_to_add is a coroutine.
⚠️ Do not determine call success with isinstance(outcome, grpc.RpcError): grpcio streaming call objects implement both grpc.Call and grpc.RpcError even for successful calls. Use the status code instead; the grpc_client.interceptors.base.outcome_code helper provides it.
ℹ️ Find more examples in the library tests.
Development
The library is developed in the qa-libraries monorepo
alongside the other cloudru-qa-* libraries. You need uv, GNU Make,
and checkmake.
Run commands from the repository root:
make sync # install dependencies for all libraries from uv.lock
make test-grpc-client # tests for this library
make typecheck-grpc-client # mypy for this library
make check # formatting, ruff, mypy, and tests for the entire workspace
⚠️ Keep the library lightweight and general-purpose. Discuss new dependencies only when you are sure all library users need them.
Tests require Docker running: the grpcbin service container is started
and stopped automatically through lovely-pytest-docker.
make test-grpc-client regenerates test pb2.py files from proto/ before running;
run make gen-test-api for generation alone.
Tests run on Python 3.13 and 3.14. Create a separate environment for another version:
make sync test-grpc-client PYTHON_VERSION=3.14.
Publish a release with the git tag grpc-client/v<version>. The tag version must match the version
in packages/grpc-client/pyproject.toml, otherwise publication stops.
Metadata
Release files for cloudru-qa-grpc-client 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| cloudru_qa_grpc_client-1.0.0.tar.gz | 94.7 kB | With provenance | Details for cloudru_qa_grpc_client-1.0.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| cloudru_qa_grpc_client-1.0.0-py3-none-any.whl | Python 3 | none | any | Details for cloudru_qa_grpc_client-1.0.0-py3-none-any.whl |
Total release size: 170.4 kB
Release files / cloudru_qa_grpc_client-1.0.0.tar.gz
| Download URL | cloudru_qa_grpc_client-1.0.0.tar.gz |
|---|---|
| Size | 94.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
826a24c322b004c8c92235ca89a3602328d839f362ca97e458bd83fa14134f96
|
|
BLAKE2b-256 checksum How to use checksums |
9447acadbb904b36efd08e60fea92ffab9e8ad186ac86b915651db9777b7be28
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 14, 2026.
Transparency logRelease files / cloudru_qa_grpc_client-1.0.0-py3-none-any.whl
| Download URL | cloudru_qa_grpc_client-1.0.0-py3-none-any.whl |
|---|---|
| Size | 75.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7cb10f9d8d15000d41a490c4597efbca542161ce461a6f7e1928fbd7d76dbe5c
|
|
BLAKE2b-256 checksum How to use checksums |
416cea6710a214482b7a0b295e24055582ad0ee78aad21ea65b3af4143e40cc5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 14, 2026.
Transparency logSecurity for cloudru-qa-grpc-client 1.0.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for cloudru_qa_grpc_client-1.0.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 14, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.