grpc

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 8, 2026 License: AGPL-3.0 Imports: 22 Imported by: 0

Documentation

Overview

Package grpc adapts idempotency to gRPC, on both sides of the wire.

The server half is a grpc.UnaryServerInterceptor keyed off an idempotency-key metadata entry. The client half is a grpc.UnaryClientInterceptor that sends it. They live together so the metadata key is one constant rather than two that can drift.

Server

manager, err := idempotencygrpc.NewManager(store, locker)
if err != nil {
	return err
}

interceptor, err := idempotencygrpc.NewUnaryServerInterceptor(manager,
	idempotencygrpc.WithPrincipalExtractor(principalFromContext),
)
if err != nil {
	return err
}

srv, err := grpcserver.NewGRPCServer(ctx, cfg,
	[]grpc.UnaryServerInterceptor{interceptor}, nil, nil,
	grpcserver.WithLogger(logger), grpcserver.WithTracerProvider(tracerProvider))

Calls without the key pass through untouched, so only clients that opted in are affected.

Use NewManager rather than idempotency.NewManager directly. The core package records every outcome, which is right for something that knows nothing about status codes and wrong here: an Unavailable recorded once would replay for the whole TTL. NewManager applies Recordable, which draws the line between client-fault and server-fault codes.

Client

conn, err := grpc.NewClient(target,
	grpc.WithChainUnaryInterceptor(idempotencygrpc.NewUnaryClientInterceptor()),
)

ctx, _ := idempotency.WithNewKey(ctx)   // once, per logical operation
reply, err := client.CreateCharge(ctx, req)

The interceptor sends the key the context carries and never invents one.

gRPC's own retries come along for free. Client interceptors run above the service-config retry policy, and the metadata stamped here is replayed on every transparent attempt — so one interceptor call covers the whole retry sequence. That is unlike HTTP, where retries happen above the transport and each attempt re-enters it.

What a duplicate gets back

A completed record replays the original reply, rebuilt from its marshaled bytes via the global proto registry, where every protoc-gen-go type registers itself at init. A recorded error comes back as the same status. A call that arrives while the first is still running gets Aborted, whose documented advice — retry at a higher level — is exactly right. A key presented with a different request gets InvalidArgument.

Only the replay path rebuilds. The first call returns the handler's own reply untouched, so the marshal-unmarshal round trip is paid by duplicates rather than by every request.

The fingerprint

Full method, principal, and the deterministically marshaled request. The method is in it so one key cannot answer two different RPCs, and the principal so two tenants sending the same key do not share a record.

Deterministic marshaling is required rather than merely tidy: proto map fields serialize in a random order otherwise, so an ordinary retry of a message with a map would hash differently each time and be reported as key reuse.

Recording

Success and the client-fault codes are recorded. The server-fault codes are not: they usually mean the work never landed, and pinning one for the whole TTL would leave the caller unable to ever succeed with that key. See Recordable for the exact split, and for the hole it leaves — a handler that has its effect and then fails will repeat that effect on retry.

Limits

Unary only. A stream has no single request to fingerprint and no single reply to record, so the same treatment would not mean anything.

Replies are not capped by default, because grpc-go already enforces a maximum message size on both ends. WithMaxResponseBytes is there for operators who want a tighter bound on the record store; when it trips, the outcome is still recorded — so the effect does not repeat — and a replay reports ResourceExhausted rather than re-running work that is known to have succeeded.

grpc-go permits non-proto codecs. A call whose request or reply is not a proto.Message cannot be fingerprinted or recorded, so it runs unguarded and increments idempotency_grpc_unsupported_calls rather than failing. It runs exactly once either way: the handler is never invoked a second time to make up for a failed recording.

Index

Examples

Constants

View Source
const DefaultMaxResponseBytes = 0

DefaultMaxResponseBytes is zero: no cap.

Unlike HTTP, gRPC already bounds replies — grpc-go enforces a maximum message size on both ends, four megabytes by default — so a second limit here would mostly duplicate one that already exists. WithMaxResponseBytes is still available for operators who want a tighter bound on what the record store holds.

View Source
const MetadataKey = "idempotency-key"

MetadataKey is the incoming metadata entry carrying the key. gRPC lowercases metadata keys, so this must stay lowercase. Both halves of this package read it from here, so the client and the server cannot drift.

Variables

View Source
var (
	// ErrUnknownMessageType indicates a recorded reply names a message type
	// this binary cannot find in the global proto registry, so the reply
	// cannot be rebuilt.
	ErrUnknownMessageType = platformerrors.New("unknown recorded message type")
	// ErrNotProtoMessage indicates a request or reply that is not a
	// proto.Message. Such a call cannot be fingerprinted or recorded and is
	// passed through untouched.
	ErrNotProtoMessage = platformerrors.New("not a proto message")
)
View Source
var ErrNilManager = platformerrors.New("nil idempotency manager for the gRPC interceptor")

ErrNilManager indicates NewUnaryServerInterceptor was called without a manager.

Functions

func NewManager

func NewManager(
	store Store,
	locker distributedlock.ScopedLocker,
	opts ...idempotency.Option,
) (*idempotency.Manager[Response], error)

NewManager builds a manager for gRPC replies with Recordable already applied.

It exists so the rule above cannot be forgotten. idempotency.NewManager records every outcome, which is right for a package that knows nothing about status codes and wrong here — and the failure is silent: an Unavailable recorded once replays for the whole TTL.

Options are appended after the default, so a caller passing their own idempotency.WithRecordable still wins.

func NewUnaryClientInterceptor

func NewUnaryClientInterceptor(opts ...ClientOption) grpc.UnaryClientInterceptor

NewUnaryClientInterceptor builds an interceptor that sends the idempotency key carried by a call's context.

conn, err := grpc.NewClient(target,
	grpc.WithChainUnaryInterceptor(idempotencygrpc.NewUnaryClientInterceptor()),
)

ctx, _ := idempotency.WithNewKey(ctx)   // once, per logical operation
reply, err := client.CreateCharge(ctx, req)

It never invents a key

With no key in the context this does nothing. An interceptor cannot tell a retry from a second, deliberate call, so minting one per invocation would give no protection while looking like it does, and deriving one from the request would silently swallow a genuine duplicate. Only the caller knows where a logical operation begins, which is what idempotency.WithNewKey expresses.

Built-in retries come along for free

Client interceptors run above grpc-go's own retry policy, and the outgoing metadata stamped here is replayed on each transparent attempt. So one call to this interceptor covers the whole retry sequence — unlike HTTP, where retries happen above the transport and each attempt re-enters it.

func NewUnaryServerInterceptor

func NewUnaryServerInterceptor(
	manager *idempotency.Manager[Response],
	opts ...Option,
) (grpc.UnaryServerInterceptor, error)

NewUnaryServerInterceptor builds an interceptor that runs a handler at most once per idempotency key.

Calls without the key in their incoming metadata pass through untouched, so only clients that opted in are affected. Register it the usual way — the platform's gRPC server already accepts a slice of unary interceptors and chains them.

Streaming is out of scope. A stream has no single request to fingerprint and no single reply to record, so the same treatment would not mean anything.

Example

ExampleNewUnaryServerInterceptor shows a retried call reaching the handler once. The metadata is set by hand here; in a real client the client interceptor does it.

package main

import (
	"context"
	"fmt"

	cachememory "github.com/primandproper/primitives-go/cache/memory"
	"github.com/primandproper/primitives-go/distributedlock"
	dlmemory "github.com/primandproper/primitives-go/distributedlock/memory"
	"github.com/primandproper/primitives-go/idempotency"
	idempotencygrpc "github.com/primandproper/primitives-go/idempotency/grpc"

	"google.golang.org/grpc"
	"google.golang.org/grpc/metadata"
	"google.golang.org/protobuf/types/known/wrapperspb"
)

func newInterceptor() (grpc.UnaryServerInterceptor, error) {
	store, err := cachememory.NewInMemoryCache[idempotency.Record[idempotencygrpc.Response]](0)
	if err != nil {
		return nil, err
	}

	locker, err := dlmemory.NewLocker()
	if err != nil {
		return nil, err
	}

	scoped, err := distributedlock.NewScopedLocker(locker)
	if err != nil {
		return nil, err
	}

	// NewManager rather than idempotency.NewManager: it applies the rule that
	// a server-fault code is not recorded.
	manager, err := idempotencygrpc.NewManager(store, scoped)
	if err != nil {
		return nil, err
	}

	return idempotencygrpc.NewUnaryServerInterceptor(manager)
}

// ExampleNewUnaryServerInterceptor shows a retried call reaching the handler
// once. The metadata is set by hand here; in a real client the client
// interceptor does it.
func main() {
	interceptor, err := newInterceptor()
	if err != nil {
		panic(err)
	}

	charges := 0
	handler := func(context.Context, any) (any, error) {
		charges++

		return wrapperspb.String("ch_1"), nil
	}

	ctx := metadata.NewIncomingContext(
		context.Background(),
		metadata.Pairs(idempotencygrpc.MetadataKey, "d3f1a0c4-5b6e-4a2f-9c8d-1e2f3a4b5c6d"),
	)

	info := &grpc.UnaryServerInfo{FullMethod: "/example.Charges/Create"}

	for range 2 {
		reply, replyErr := interceptor(ctx, wrapperspb.String("charge-10"), info, handler)
		if replyErr != nil {
			panic(replyErr)
		}

		msg, ok := reply.(*wrapperspb.StringValue)
		if !ok {
			panic("unexpected reply type")
		}

		fmt.Println("reply:", msg.GetValue())
	}

	fmt.Println("charges:", charges)

}
Output:
reply: ch_1
reply: ch_1
charges: 1

func Recordable

func Recordable(res *Response) bool

Recordable is the gRPC rule for which outcomes are worth recording.

It records success and the client-fault codes, and refuses the server-fault ones. The split is the gRPC counterpart of the HTTP 4xx/5xx line and rests on the same reasoning: a client-fault answer is stable, so replaying it is both correct and cheaper than running the handler again, while a server-fault answer usually means the work never landed. Pinning that for the whole TTL would leave the caller unable to ever succeed with the key.

The cost is the same hole HTTP has: a handler that has its effect and then fails will repeat the effect on retry.

Types

type ClientOption

type ClientOption func(*clientConfig)

ClientOption configures the client interceptor.

func WithClientMetadataKey

func WithClientMetadataKey(key string) ClientOption

WithClientMetadataKey overrides the metadata entry the client stamps.

func WithClientMethodFilter

func WithClientMethodFilter(filter func(fullMethod string) bool) ClientOption

WithClientMethodFilter limits which methods the client stamps.

type Option

type Option func(*config)

Option configures the server interceptor.

func WithLogger

func WithLogger(logger logging.Logger) Option

WithLogger attaches a logger.

func WithMaxResponseBytes

func WithMaxResponseBytes(limit int) Option

WithMaxResponseBytes bounds how much of a reply is recorded. Beyond it the outcome is still recorded, so the effect does not repeat, but a replay can only report that the reply is gone.

func WithMetadataKey

func WithMetadataKey(key string) Option

WithMetadataKey overrides the metadata entry carrying the key.

func WithMethodFilter

func WithMethodFilter(filter func(fullMethod string) bool) Option

WithMethodFilter limits which methods participate. Methods it rejects pass through untouched even when a key is present.

func WithMetricsProvider

func WithMetricsProvider(metricsProvider metrics.Provider) Option

WithMetricsProvider attaches a metrics provider.

func WithPrincipalExtractor

func WithPrincipalExtractor(extract func(context.Context) (string, error)) Option

WithPrincipalExtractor supplies the caller identity folded into the fingerprint.

Supplying it matters for a multi-tenant service: without it, two callers sending the same key for the same request would share a record, and the second would be handed the first's reply.

func WithTracerProvider

func WithTracerProvider(tracerProvider tracing.Provider) Option

WithTracerProvider attaches a tracer provider.

type Response

type Response struct {
	// MessageName is the reply's fully-qualified proto name, used to rebuild
	// it on replay.
	MessageName string
	// StatusMessage is the error message, empty on success.
	StatusMessage string
	// Payload is the marshaled reply, empty for an error result or when
	// Truncated.
	Payload []byte
	// StatusCode is the gRPC status code the call produced.
	StatusCode uint32
	// Truncated reports that the reply outgrew the configured cap and its
	// bytes were dropped. The call is still recorded, so the effect does not
	// repeat, but the reply can no longer be reproduced.
	Truncated bool
}

Response is the recorded half of a unary RPC.

A reply is stored as its marshaled bytes plus its type name rather than as the message itself: the store serializes with gob, which cannot round-trip a proto message faithfully, while proto.Marshal can.

type Store

Store is the record store a gRPC manager reads and writes. It is spelled out because the type is a mouthful at every call site.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL