Dial your first gRPC service¶
By the end of this tutorial you'll have a Go program that dials a gRPC server through
grpcclient.Dial and makes a call over a plaintext loopback connection, and you'll know
what changes when the server is remote and speaks TLS. Allow about twenty minutes.
What you need before you start¶
- Go 1.26.5 or later — that is the floor in the module's
go.mod. - A gRPC server to talk to. If you don't have one, step one stands a throwaway server up inside the same program.
- Nothing else.
grpcclienthas no CLI, no config file and no initialisation step. It is one function and one struct.
Install the module¶
That pulls in the gRPC SDK, go/tls and
github.com/cockroachdb/errors. It does not pull in
go/transit. You add that to your own module later,
and only if you want its interceptors.
Dial a plaintext server on loopback¶
Dial takes a Target and hands back a stock *grpc.ClientConn. Leave TLS at its zero
value and the connection is plaintext — right for a server on the same host, wrong for
anything that crosses a network.
Save this as main.go:
package main
import (
"context"
"fmt"
"net"
"time"
"gitlab.com/phpboyscout/go/grpcclient"
"google.golang.org/grpc"
"google.golang.org/grpc/health/grpc_health_v1"
)
func main() {
// A throwaway server, so the tutorial has something to dial.
lis, err := net.Listen("tcp", "localhost:0")
if err != nil {
panic(err)
}
srv := grpc.NewServer()
grpc_health_v1.RegisterHealthServer(srv, health{})
go func() { _ = srv.Serve(lis) }()
defer srv.Stop()
port := lis.Addr().(*net.TCPAddr).Port
conn, err := grpcclient.Dial(grpcclient.Target{Port: port})
if err != nil {
panic(err)
}
defer conn.Close()
fmt.Println("dialling", conn.Target())
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := grpc_health_v1.NewHealthClient(conn).Check(ctx, &grpc_health_v1.HealthCheckRequest{})
if err != nil {
panic(err)
}
fmt.Println("health:", resp.GetStatus())
}
type health struct {
grpc_health_v1.UnimplementedHealthServer
}
func (health) Check(context.Context, *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) {
return &grpc_health_v1.HealthCheckResponse{Status: grpc_health_v1.HealthCheckResponse_SERVING}, nil
}
Run it:
The port will differ — the listener asked the kernel for a free one.
Two things there are worth stopping on. You never set Host, and the endpoint still came
out as localhost:<port>: an empty Host means loopback. And Dial returned before
anything touched the network.
Why a successful Dial does not mean the server is up¶
Dial calls grpc.NewClient, so the connection is lazy. Name resolution and the TCP
connect happen on the first RPC, not in Dial. A Dial that returns without error tells
you the target string and the options parsed — nothing about the other end.
Prove it. Change grpcclient.Target{Port: port} to grpcclient.Target{Port: port + 1},
so nothing is listening, and run again:
$ go run .
dialling localhost:39168
panic: rpc error: code = Unavailable desc = connection error: desc = "transport: Error while dialing: dial tcp 127.0.0.1:39168: connect: connection refused"
Dial succeeded. The Check call is where it broke. So put your connection error
handling around the RPC, not around Dial. Change the port back before you carry on.
Connect to a server that speaks TLS¶
Now the case you'll actually ship: a server on another host, presenting a certificate
signed by a CA your organisation runs and the system trust store has never heard of. Set
TLS.Enabled and point TLS.Cert at that CA's PEM bundle.
import gtls "gitlab.com/phpboyscout/go/tls"
conn, err := grpcclient.Dial(grpcclient.Target{
Host: "svc.internal",
Port: 443,
TLS: gtls.Pair{Enabled: true, Cert: "/etc/pki/internal-ca.pem"},
})
TLS.Cert here is the CA you trust, not a certificate you present. That catches
people out, because the same gtls.Pair type means "the certificate this process
presents" when a server uses it. Target fields has
the field-by-field version.
The handshake uses go/tls's hardened client config — TLS 1.2 as the floor, six AEAD cipher
suites, X25519 and P-256 curve preferences. You don't configure any of that, and you
can't override it through Dial.
Leave Cert empty and TLS still applies, over the system root store instead. That's what
you want for a public endpoint with a certificate from a public CA.
The certificate must match Target.Host¶
The name on the certificate is checked against Target.Host. Get it wrong and the first
RPC fails like this:
rpc error: code = Unavailable desc = connection error: desc = "transport: authentication handshake failed: tls: failed to verify certificate: x509: certificate is valid for svc.internal, not localhost"
That is the commonest TLS mistake with this module: dialling localhost — or leaving
Host empty, which comes to the same thing — against a certificate issued for a service
name. Use the name on the certificate.
Add a circuit breaker from go/transit¶
grpcclient has no circuit breaker, no retry policy and no tracing, on purpose. Those are
go/transit's gRPC client interceptors, and they reach
Dial as ordinary grpc.DialOptions.
Add transit to your own module first — grpcclient does not bring it:
Then wrap the dial:
import (
"log/slog"
transitgrpc "gitlab.com/phpboyscout/go/transit/grpc"
"google.golang.org/grpc"
)
log := slog.Default()
conn, err := grpcclient.Dial(
grpcclient.Target{Port: port},
grpc.WithChainUnaryInterceptor(
transitgrpc.CircuitBreakerInterceptor(log, transitgrpc.DefaultCircuitBreakerConfig()),
),
transitgrpc.OTelClientHandler(),
)
DefaultCircuitBreakerConfig() trips after 5 consecutive failures and stays open for 30
seconds, allowing 1 trial call after that. Point the target at a dead port and call in a
loop: the sixth call comes back Unavailable: circuit breaker is open straight away,
without waiting on a connection attempt.
Where to go next¶
- Dial a service with middleware — the full set of transit client interceptors and how to compose them.
- API reference — every field of
Target, its default, and what happens when it is wrong. - What grpcclient does not do — read this before you go looking for mutual TLS, retries or connection pooling.
- The Target model and the factory split — why the module is this small.