Fix the defects the agents page turned up - #16
Merged
Conversation
added 3 commits
September 6, 2026 21:15
`grpc_client_kit.protocols` declared fourteen protocols and the package re-exported eleven of them, so a settings object that carries credentials, channel options or a metrics registry had to import its protocol from the submodule while every other protocol came from the package. Export the three extras protocols too, so the rule is simply "every protocol in `protocols.__all__` is a top-level export". Two protocols went the other way: `RetryMetricsProtocol` and `CircuitBreakerMetricsProtocol` are top-level exports and documented as such, but were missing from `protocols.__all__`, so a star import of the module skipped them. `MethodCircuitState` leaves `circuit_breaker.__all__`. It is the breaker's mutable bookkeeping and never crosses a public signature — `get_states()` hands out `CircuitBreakerStatus` — so declaring it public froze the implementation for no caller's benefit. It stays importable by name.
The health page said three things the code does not do. It said each probe asks for the overall server status. `HealthChecker` takes a `service` argument, defaulting to the empty name, and the factory forwards the `health_checker` block's optional `service`, so a per-service probe has been configurable all along. It said the factory does not forward `options` or `compression`, and told the reader to build the checker by hand when the probes need the application's channel options. The factory forwards both, deliberately, so the probes negotiate HTTP/2 the way real traffic does. It said `HealthCheckerNotRunningError` lives in `grpc_client_kit.health` and needs the [health] extra. It lives in `grpc_client_kit.errors`, is a top-level export, and is placed there precisely so that catching it needs no extra — a balancer caller meets it, and balancers work on a bare install. Separately: `hasattr(grpc_client_kit, "HealthChecker")` raises the ImportError rather than answering False, because the lazy `__getattr__` raises ImportError and `hasattr` only swallows AttributeError. That stays as it is — a missing extra is an install problem and must say so, and an error class inheriting both is impossible (instance lay-out conflict) — but it was written down nowhere. It is now in the guide, in rule 19 of the agents page and in the `__getattr__` docstring, and the bare-install probe asserts it so it cannot drift.
Both pages told the reader "there is no settings block for this layer" and sent them to a hand-built chain. The configuration page documents the two blocks correctly, and the factory reads both with `getattr` and builds the layers into the chain in their proper positions, so the two pages were sending readers to `build_interceptors` for something a settings object already covers. What settings genuinely cannot express is per-method timeouts, since the `timeout` block carries only `default` — that part of the advice survives, and is now what the paragraphs say. The agents page carried the same claim in the sentence introducing its hand-built chain example.
AlexeyShalaev
added a commit
that referenced
this pull request
Sep 6, 2026
…behaviour (#17) The work landed in #16. Its squash subject lost the Conventional Commit prefix -- my mistake on the merge, not the author's -- so release-please skipped the merge and these fixes would never have reached a release. This commit carries the record. It changes no code: #16 is already on master. * FullGrpcClientSettingsProtocol, GrpcChannelExtrasProtocol and GrpcObservabilityExtrasProtocol are exported from the package root, where the other eleven protocols of that module already were. RetryMetricsProtocol and CircuitBreakerMetricsProtocol joined protocols.__all__, which a star import had been missing. MethodCircuitState left circuit_breaker.__all__: it is mutable internal bookkeeping in no public signature, and it stays importable by name. * The health guide claimed the factory forwards neither options nor compression. It forwards both. It also placed HealthCheckerNotRunningError in the health module behind the health extra; the error is in grpc_client_kit.errors, is a root export, and catching it needs no extra. And probes are not always about the overall server: the checker takes a service name and the factory forwards it. * The resilience and deadline guides both said there is no settings block for their layer. There is one, and the factory reads it. * hasattr(grpc_client_kit, "HealthChecker") raising ImportError on an install without the extra is deliberate -- downgrading it would make a broken install look like a name that never existed -- and is now written down where a caller looks, rather than being folklore.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Writing
docs/agents.mdagainst the source turned up five things. Four were real; one washalf real, and the half that was real ran the opposite way from the report. Every finding is
below with its reproduction, including the parts that did not hold up.
Nothing here is breaking. One name leaves a submodule's
__all__— see finding 5.1. The health page said the factory does not forward
optionsorcompressionIt forwards both, on purpose, with the reason in a comment right there in
factory.py:the probes have to negotiate HTTP/2 the way the application channels do. The page told the
reader the opposite and sent them off to construct
HealthCheckerby hand for something theyalready had.
Reproduction — a settings object carrying channel options, with the checker class replaced by
a spy:
The doc drifted, not the code. Corrected, and the paragraph now lists what the factory does
pass, including the
servicethe next finding is about. The "build it yourself" advice stays,attached to the things settings genuinely cannot express —
on_status_change,max_backoff,fail_fast_callback.While in that table:
max_backoffwas listed under "what the factory wires for you" withoutsaying that it is the checker's own default and is not read from settings. It says so now.
2. The same page on the probed service, and on where the error lives
Two claims, both wrong.
"Each probe asks for the overall server status ... rather than a per-service one."
HealthChecker.__init__takesservice: str = "", and the factory forwardsgetattr(settings.health_checker, "service", "")— visible in the same reproduction above,which came back with
service = 'users.v1.Users'. The empty name is the default, not the onlyoption. The page now says so, and the settings table gained a
servicerow."
HealthCheckerNotRunningErrorlives ingrpc_client_kit.health, which likewise needs theextra." It lives in
grpc_client_kit.errors, is exported from the package root, and its owndocstring explains that it is placed there so catching it does not require the
[health]extra: the caller who meets it is a caller of a balancer, and a balancer works on a bare
install. The page was telling people to install an extra to write an
exceptclause.3. Two pages said there is no settings block for their layer
resilience.mdon wait-for-ready anddeadlines.mdon deadline budgets both said "there isno settings block for this layer" and routed the reader to
build_interceptorsand ahand-built chain.
configuration.mddocuments both blocks correctly, andfactory.pyreadsboth with
getattrand builds the layers into the chain itself.Reproduction — a settings object with a
wait_for_readyblock and adeadline_budgetblock,handed to the factory, then the chain the factory built (duplicates are the four RPC-kind
adapters per logical interceptor):
Both layers are there, in the positions the pages themselves describe. Corrected. The part of
the advice that was right — per-method caps need a hand-built chain, and that chain goes to
GrpcClient(interceptors=...)and not tocreate_client(interceptors=...)— survives, nowattached to the thing that is actually true of it.
docs/agents.mdcarried the same claim in the sentence introducing its hand-built chainexample ("which is what you need for per-method budgets, request-budget propagation or
wait-for-ready"); that line is fixed in the same commit.
4.
hasattr(grpc_client_kit, "HealthChecker")raises instead of returning FalseReproduces on an install without the
[health]extra:hasattrswallowsAttributeErroronly, and the lazy__getattr__raisesImportError.Kept as it is. A missing extra is an install problem and should say so; downgrading to
AttributeErrorwould make a broken install indistinguishable from a name that never existed,and would break
except ImportErroraround the attribute. The obvious third option does notexist — an exception inheriting both raises
TypeError: multiple bases have instance lay-out conflict, which I checked rather than assumed.So the defect is that this was written down nowhere. It now appears in the health guide, in
rule 19 of the agents page and in the
__getattr__docstring, each naming the supported probe(
importlib.util.find_spec("grpc_health"), or catch theImportError). The existingbare-install subprocess probe in
tests/unit/conftest.pygained an assertion for it, so afuture change to
AttributeErrorfails a test instead of silently changing an unwrittencontract. Verified by making that change locally: the probe fails, as intended.
5. Exports that disagreed with each other
The report named the three extras protocols and
MethodCircuitState. Checking the wholesurface found the same class of problem running the other way, and a third case that is not a
problem at all.
The three extras protocols are now exported from the package root. Eleven of the module's
fourteen protocols already were; the three left out are exactly the ones describing the
optional settings blocks — which is to say, the ones a settings-object author reaches for
after the required surface. There is no principle separating them from the eleven, so the rule
is now simply "every protocol in
protocols.__all__is a top-level export". Purely additive.RetryMetricsProtocolandCircuitBreakerMetricsProtocolare now inprotocols.__all__.They are top-level exports and documented as exported, but a star import of the module skipped
them.
MethodCircuitStateleavescircuit_breaker.__all__. This is the one direction that isnot additive, so: it is the breaker's mutable per-method bookkeeping, it appears in no public
signature (
get_states()returnsCircuitBreakerStatussnapshots), and it is documentednowhere. Declaring it public froze the breaker's implementation into the compatibility
contract for no caller's benefit — the same reason
ChannelWrapperandchain_tokenare keptout of the pool's surface, which the package comment already states. It stays importable by
name; only
import *from that module changes, and the test suite imports it directly andstill passes.
Not a defect: the module-only names generally. A sweep found fifteen names in submodule
__all__s that the root does not export —Continuation,RpcType,DEFAULT_RETRYABLE_CODES,DEFAULT_SENSITIVE_HEADERS,HAS_TRACING, the health module's constants, and so on. Thecurated root is deliberate and the agents page maps the ones a caller reaches for. The three
protocols were the odd ones out, not the pattern.
Three tests in
tests/unit/test_init.pypin all of this; all three fail onmaster:The agents page
Updated in this PR, as
CONTRIBUTING.mdrequires: the protocols list gained the three newexports, the module table lost their row, the internals paragraph gained
MethodCircuitState, rule 19 gained thehasattrcaveat, and the hand-built-chain sentencelost the claim from finding 3.
Verification
uv sync --frozen --all-extras --group dev;uv.lockunchanged.