// Package grpcclient provides a Go client for the broad-announce ingestd // gRPC service (SPEC §19). It wraps the generated pb.IngestClient and // adds Bearer-token auth, stream-level retry semantics, and ergonomic // Send/Recv primitives for use by any peer service that publishes alerts. package grpcclient import ( "context" "fmt" "io" "time" pbv1 "git3.techno-world.net/lrosales/broad-announce/gen/go/broadannounce/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/keepalive" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) // Client is a gRPC client for the Ingest service. It is safe for // concurrent use by multiple goroutines. type Client struct { conn *grpc.ClientConn client pbv1.IngestClient apiKey string maxRetries int keepalive keepalive.ClientParameters insecure bool } // Stream represents an open StreamAlerts bidirectional stream. A single // Stream is not safe for concurrent use — use one stream per producer // goroutine, or add external synchronization. type Stream struct { stream pbv1.Ingest_StreamAlertsClient } // New connects to the Ingest service at addr and returns a Client. // It does not perform any I/O; the connection is established lazily on // the first call to Stream. // // - addr: host:port of the ingestd gRPC server (default :9090) // - opts: functional options (WithAPIKey, WithMaxRetries, // WithKeepalive, WithInsecure) // // Production deployments should use TLS or mTLS. The default TLS // credentials use the host's root CA set. For mTLS, pass a custom // grpc.DialOption built with credentials.NewTLS(tlsConfig) via a // private Option (or replace WithInsecure in development only). func New(addr string, opts ...Option) (*Client, error) { c := &Client{maxRetries: 3} for _, opt := range opts { opt(c) } var dialOpts []grpc.DialOption if c.insecure { dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) } else { // Secure TLS. Replace by passing a custom tls.Config with // ClientCAs (for mTLS) or ServerName override (for TLS). dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(nil))) } if c.keepalive.Time > 0 || c.keepalive.Timeout > 0 { dialOpts = append(dialOpts, grpc.WithKeepaliveParams(c.keepalive)) } // Unary interceptor: attach API key to every RPC. dialOpts = append(dialOpts, grpc.WithUnaryInterceptor( func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, _ ...grpc.CallOption) error { if c.apiKey != "" { ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+c.apiKey) } return invoker(ctx, method, req, reply, cc) })) conn, err := grpc.NewClient(addr, dialOpts...) if err != nil { return nil, fmt.Errorf("grpc.Dial(%q): %w", addr, err) } c.conn = conn c.client = pbv1.NewIngestClient(conn) return c, nil } // Stream starts a StreamAlerts bidirectional stream. The caller must call // CloseSend when done sending. The context is used for stream deadlines. func (c *Client) Stream(ctx context.Context) (*Stream, error) { if c.apiKey != "" { md := metadata.Pairs("authorization", "Bearer "+c.apiKey) ctx = metadata.NewOutgoingContext(ctx, md) } stream, err := c.client.StreamAlerts(ctx) if err != nil { return nil, fmt.Errorf("StreamAlerts: %w", err) } return &Stream{stream: stream}, nil } // Send delivers one alert to the server. It blocks until the server // acknowledges receipt (not processing — that is async). Send retries on // RATE_LIMITED / UNAVAILABLE up to c.maxRetries, honouring the // x-retry-after-ms response header when set. func (s *Stream) Send(ctx context.Context, alert *pbv1.Alert) error { var lastErr error for attempt := 0; attempt <= 3; attempt++ { select { case <-ctx.Done(): return ctx.Err() default: } lastErr = s.stream.Send(alert) if lastErr == nil { return nil } st, ok := status.FromError(lastErr) if !ok || !retryable(st.Code()) { return lastErr } if attempt < 3 { delay := retryDelay(s.stream.Context(), attempt) select { case <-ctx.Done(): return ctx.Err() case <-time.After(delay): } } } return fmt.Errorf("Stream.Send (retries exhausted): %w", lastErr) } // Recv returns the next Ack from the server. It blocks until an Ack is // available or the stream is closed. It retries on UNAVAILABLE up to // c.maxRetries. Returns io.EOF when the server closes the stream. func (s *Stream) Recv(ctx context.Context) (*pbv1.Ack, error) { var lastErr error for attempt := 0; attempt <= 3; attempt++ { select { case <-ctx.Done(): return nil, ctx.Err() default: } ack, err := s.stream.Recv() if err == nil { return ack, nil } lastErr = err if err == io.EOF { return nil, io.EOF } st, ok := status.FromError(err) if !ok || !retryable(st.Code()) { return nil, err } if attempt < 3 { delay := retryDelay(s.stream.Context(), attempt) select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(delay): } } } return nil, fmt.Errorf("Stream.Recv (retries exhausted): %w", lastErr) } // CloseSend signals that the caller is done sending alerts. The server // will half-close its side of the stream; Recv continues to work until // the server closes the stream or the context is cancelled. func (s *Stream) CloseSend() error { return s.stream.CloseSend() } // Close tears down the underlying gRPC connection. Call it when the // Client is no longer needed. func (c *Client) Close() error { return c.conn.Close() } // retryDelay returns the sleep interval for a given retry attempt, // checking the x-retry-after-ms header for a server-specified value. func retryDelay(streamCtx context.Context, attempt int) time.Duration { if md, ok := metadata.FromIncomingContext(streamCtx); ok { if vals := md.Get("x-retry-after-ms"); len(vals) > 0 { var ms int fmt.Sscanf(vals[0], "%d", &ms) if ms > 0 { return time.Duration(ms) * time.Millisecond } } } return time.Duration(100*attempt) * time.Millisecond }