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 ¶
- Constants
- Variables
- func NewManager(store Store, locker distributedlock.ScopedLocker, opts ...idempotency.Option) (*idempotency.Manager[Response], error)
- func NewUnaryClientInterceptor(opts ...ClientOption) grpc.UnaryClientInterceptor
- func NewUnaryServerInterceptor(manager *idempotency.Manager[Response], opts ...Option) (grpc.UnaryServerInterceptor, error)
- func Recordable(res *Response) bool
- type ClientOption
- type Option
- func WithLogger(logger logging.Logger) Option
- func WithMaxResponseBytes(limit int) Option
- func WithMetadataKey(key string) Option
- func WithMethodFilter(filter func(fullMethod string) bool) Option
- func WithMetricsProvider(metricsProvider metrics.Provider) Option
- func WithPrincipalExtractor(extract func(context.Context) (string, error)) Option
- func WithTracerProvider(tracerProvider tracing.Provider) Option
- type Response
- type Store
Examples ¶
Constants ¶
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.
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 ¶
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") )
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 ¶
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 WithMaxResponseBytes ¶
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 ¶
WithMetadataKey overrides the metadata entry carrying the key.
func WithMethodFilter ¶
WithMethodFilter limits which methods participate. Methods it rejects pass through untouched even when a key is present.
func WithMetricsProvider ¶
WithMetricsProvider attaches a metrics provider.
func WithPrincipalExtractor ¶
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 ¶
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.