Skip to content

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. grpcclient has no CLI, no config file and no initialisation step. It is one function and one struct.

Install the module

go get gitlab.com/phpboyscout/go/grpcclient

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:

$ go run .
dialling localhost:39167
health: SERVING

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:

go get gitlab.com/phpboyscout/go/transit

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