The Target model and the factory split¶
grpcclient answers one question well: what is the least a caller must state to dial a
gRPC service securely? Its answer is a plain Target — host, port, and TLS material —
and a Dial that turns that into a *grpc.ClientConn, drawing a deliberate line between
the dial factory (this module) and the interceptors (go/transit).
Why Target is not a server-settings type¶
The dial factory was lifted from go-tool-base's dialLocal, which took a server-side
ServerSettings. That coupled every client to a server configuration type: a program that
only ever dialled had to import, and therefore link, the type that describes how to
serve. Target breaks that:
type Target struct {
Host string // empty ⇒ loopback (localhost)
Port int
TLS tls.Pair // go/tls pair
}
A caller states only what dialling needs. go-tool-base keeps a thin DialLocal adapter
that maps its ServerSettings onto a Target, so a server consumer is unaffected — but
a client-only consumer never imports server settings, and the depfootprint guard test
proves the server stack stays out of the graph.
The cost of that decoupling is that gtls.Pair is still a shared type carrying
server-shaped fields — Key, ClientCAs, ClientAuth — that mean nothing on a dial. The
factory reads two of its five fields and ignores the rest without complaint. That is a
real sharp edge, and it is catalogued in
Target fields.
How Dial selects credentials¶
Dial never asks the caller for grpc.WithTransportCredentials; it derives the
credentials from Target.TLS:
TLS.Enabled == false→ insecure credentials (insecure.NewCredentials()). This is the local-server default.TLS.Enabled == true,TLS.Certset → a TLS credential over go/tls's hardened client config (TLS 1.2 floor, curated cipher suites), trusting the CA bundle inCertand nothing else.TLS.Enabled == true,TLS.Certempty → the same hardened config over the system roots.
There is no third state and no InsecureSkipVerify. A development server with a
self-signed certificate is handled by pointing Cert at that certificate, which trusts
exactly it — rather than by switching verification off, which would trust everything.
Why the credentials are not tamper-proof, and what to do about it¶
Dial places its derived credentials first in the option list and appends the caller's
options after them. That looks protective and is not: gRPC applies dial options in order
and a later grpc.WithTransportCredentials simply overwrites the earlier one. A caller
who passes insecure credentials as an option gets a plaintext connection to a target that
asked for TLS, with no error and no log line.
This documentation previously claimed the opposite. It was wrong, and the code is the authority.
The honest framing is that Target.TLS states the intent of the service that owns the
target, and Dial is not a security boundary against its own caller. In a single Go
program that distinction rarely matters, because the code assembling the options and the
code assembling the Target are the same code. It starts to matter as soon as dial
options are built from configuration, from a plugin, or from a library you did not write.
Confirm a connection is actually encrypted
shows how to check the outcome rather than trust the input.
Why the factory is separate from the interceptors¶
Circuit breaking and OpenTelemetry instrumentation are not in this module — they live
in go/transit and reach Dial as grpc.DialOptions.
That split is intentional, for three reasons.
An interceptor is identical everywhere; a dial target is a policy. Fail-fast circuit breaking behaves the same for every caller of every service, so it belongs in a shared, transport-neutral module that can be tested once. Which host to dial, which port, and which CA to trust are choices a particular service makes, so they belong in a factory that service owns.
The interceptors are reused by the server. go/transit is consumed by both clients and
servers — the same package that supplies CircuitBreakerInterceptor for a client supplies
LoggingInterceptor and RateLimitInterceptor for a server. Keeping it separate means one
tested implementation of each concern instead of a client copy and a server copy that
drift.
A client-only consumer should stay light. Because the factory pulls only the gRPC SDK,
go/tls and cockroachdb/errors, a program that dials never links the server stack —
controls, authn, gateway — or the CLI, config and TUI stacks. depfootprint_test.go
enforces that by listing the forbidden module prefixes and failing if any appears in
go list -deps.
Note the consequence: transit is not a dependency of this module at all. Consumers who
want its interceptors add transit to their own go.mod. The OpenTelemetry packages that
do appear in the graph arrive through the gRPC SDK.
Why Dial does not connect¶
Dial uses grpc.NewClient, so the returned connection is lazy: it performs name
resolution and connects on the first RPC, not at construction. Dial returning without
error therefore means the target and options were valid, not that the server is
reachable.
That is the gRPC SDK's model rather than a choice this module made, and it is the right
one for a long-lived client: a service that dials its dependencies at startup should not
fail to start because a dependency is briefly down, and a *grpc.ClientConn reconnects on
its own. The cost is that every "is it working?" question moves to the call site.
Errors returned by Dial splits the failures by where they
surface, and
Confirm a connection is actually encrypted
shows how to force the question at startup when you would rather fail fast.
Why the module is this small¶
There is one function and one struct, and no plan for more. Anything that can be expressed
as a grpc.DialOption — retries, keepalive, message sizes, load balancing, a custom
resolver — already has a home in the gRPC SDK, and wrapping it in a second API here would
add a translation layer, a second set of defaults and a second thing to keep current with
upstream. The only things the factory adds are the two the SDK cannot supply on its own:
a project-standard set of TLS defaults, and a target shape that does not drag server
configuration into a client.
The corollary is that this module is a poor place to look for features. What grpcclient does not do is the list.
How grpcclient relates to go/transit¶
grpcclient.Dial → *grpc.ClientConn
│ credentials from Target.TLS (via go/tls), endpoint from Host/Port
└─ applies → go/transit client interceptors (circuit breaker, OTel) as dial options
The factory owns the connection and its credentials; transit owns everything that wraps a call. Transit's own middleware model covers interceptor ordering.