client.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. // Package grpcclient provides a Go client for the broad-announce ingestd
  2. // gRPC service (SPEC §19). It wraps the generated pb.IngestClient and
  3. // adds Bearer-token auth, stream-level retry semantics, and ergonomic
  4. // Send/Recv primitives for use by any peer service that publishes alerts.
  5. package grpcclient
  6. import (
  7. "context"
  8. "fmt"
  9. "io"
  10. "time"
  11. pbv1 "git3.techno-world.net/lrosales/broad-announce/gen/go/broadannounce/v1"
  12. "google.golang.org/grpc"
  13. "google.golang.org/grpc/credentials"
  14. "google.golang.org/grpc/credentials/insecure"
  15. "google.golang.org/grpc/keepalive"
  16. "google.golang.org/grpc/metadata"
  17. "google.golang.org/grpc/status"
  18. )
  19. // Client is a gRPC client for the Ingest service. It is safe for
  20. // concurrent use by multiple goroutines.
  21. type Client struct {
  22. conn *grpc.ClientConn
  23. client pbv1.IngestClient
  24. apiKey string
  25. maxRetries int
  26. keepalive keepalive.ClientParameters
  27. insecure bool
  28. }
  29. // Stream represents an open StreamAlerts bidirectional stream. A single
  30. // Stream is not safe for concurrent use — use one stream per producer
  31. // goroutine, or add external synchronization.
  32. type Stream struct {
  33. stream pbv1.Ingest_StreamAlertsClient
  34. }
  35. // New connects to the Ingest service at addr and returns a Client.
  36. // It does not perform any I/O; the connection is established lazily on
  37. // the first call to Stream.
  38. //
  39. // - addr: host:port of the ingestd gRPC server (default :9090)
  40. // - opts: functional options (WithAPIKey, WithMaxRetries,
  41. // WithKeepalive, WithInsecure)
  42. //
  43. // Production deployments should use TLS or mTLS. The default TLS
  44. // credentials use the host's root CA set. For mTLS, pass a custom
  45. // grpc.DialOption built with credentials.NewTLS(tlsConfig) via a
  46. // private Option (or replace WithInsecure in development only).
  47. func New(addr string, opts ...Option) (*Client, error) {
  48. c := &Client{maxRetries: 3}
  49. for _, opt := range opts {
  50. opt(c)
  51. }
  52. var dialOpts []grpc.DialOption
  53. if c.insecure {
  54. dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
  55. } else {
  56. // Secure TLS. Replace by passing a custom tls.Config with
  57. // ClientCAs (for mTLS) or ServerName override (for TLS).
  58. dialOpts = append(dialOpts,
  59. grpc.WithTransportCredentials(credentials.NewTLS(nil)))
  60. }
  61. if c.keepalive.Time > 0 || c.keepalive.Timeout > 0 {
  62. dialOpts = append(dialOpts, grpc.WithKeepaliveParams(c.keepalive))
  63. }
  64. // Unary interceptor: attach API key to every RPC.
  65. dialOpts = append(dialOpts, grpc.WithUnaryInterceptor(
  66. func(ctx context.Context, method string, req, reply interface{},
  67. cc *grpc.ClientConn, invoker grpc.UnaryInvoker, _ ...grpc.CallOption) error {
  68. if c.apiKey != "" {
  69. ctx = metadata.AppendToOutgoingContext(ctx,
  70. "authorization", "Bearer "+c.apiKey)
  71. }
  72. return invoker(ctx, method, req, reply, cc)
  73. }))
  74. conn, err := grpc.NewClient(addr, dialOpts...)
  75. if err != nil {
  76. return nil, fmt.Errorf("grpc.Dial(%q): %w", addr, err)
  77. }
  78. c.conn = conn
  79. c.client = pbv1.NewIngestClient(conn)
  80. return c, nil
  81. }
  82. // Stream starts a StreamAlerts bidirectional stream. The caller must call
  83. // CloseSend when done sending. The context is used for stream deadlines.
  84. func (c *Client) Stream(ctx context.Context) (*Stream, error) {
  85. if c.apiKey != "" {
  86. md := metadata.Pairs("authorization", "Bearer "+c.apiKey)
  87. ctx = metadata.NewOutgoingContext(ctx, md)
  88. }
  89. stream, err := c.client.StreamAlerts(ctx)
  90. if err != nil {
  91. return nil, fmt.Errorf("StreamAlerts: %w", err)
  92. }
  93. return &Stream{stream: stream}, nil
  94. }
  95. // Send delivers one alert to the server. It blocks until the server
  96. // acknowledges receipt (not processing — that is async). Send retries on
  97. // RATE_LIMITED / UNAVAILABLE up to c.maxRetries, honouring the
  98. // x-retry-after-ms response header when set.
  99. func (s *Stream) Send(ctx context.Context, alert *pbv1.Alert) error {
  100. var lastErr error
  101. for attempt := 0; attempt <= 3; attempt++ {
  102. select {
  103. case <-ctx.Done():
  104. return ctx.Err()
  105. default:
  106. }
  107. lastErr = s.stream.Send(alert)
  108. if lastErr == nil {
  109. return nil
  110. }
  111. st, ok := status.FromError(lastErr)
  112. if !ok || !retryable(st.Code()) {
  113. return lastErr
  114. }
  115. if attempt < 3 {
  116. delay := retryDelay(s.stream.Context(), attempt)
  117. select {
  118. case <-ctx.Done():
  119. return ctx.Err()
  120. case <-time.After(delay):
  121. }
  122. }
  123. }
  124. return fmt.Errorf("Stream.Send (retries exhausted): %w", lastErr)
  125. }
  126. // Recv returns the next Ack from the server. It blocks until an Ack is
  127. // available or the stream is closed. It retries on UNAVAILABLE up to
  128. // c.maxRetries. Returns io.EOF when the server closes the stream.
  129. func (s *Stream) Recv(ctx context.Context) (*pbv1.Ack, error) {
  130. var lastErr error
  131. for attempt := 0; attempt <= 3; attempt++ {
  132. select {
  133. case <-ctx.Done():
  134. return nil, ctx.Err()
  135. default:
  136. }
  137. ack, err := s.stream.Recv()
  138. if err == nil {
  139. return ack, nil
  140. }
  141. lastErr = err
  142. if err == io.EOF {
  143. return nil, io.EOF
  144. }
  145. st, ok := status.FromError(err)
  146. if !ok || !retryable(st.Code()) {
  147. return nil, err
  148. }
  149. if attempt < 3 {
  150. delay := retryDelay(s.stream.Context(), attempt)
  151. select {
  152. case <-ctx.Done():
  153. return nil, ctx.Err()
  154. case <-time.After(delay):
  155. }
  156. }
  157. }
  158. return nil, fmt.Errorf("Stream.Recv (retries exhausted): %w", lastErr)
  159. }
  160. // CloseSend signals that the caller is done sending alerts. The server
  161. // will half-close its side of the stream; Recv continues to work until
  162. // the server closes the stream or the context is cancelled.
  163. func (s *Stream) CloseSend() error { return s.stream.CloseSend() }
  164. // Close tears down the underlying gRPC connection. Call it when the
  165. // Client is no longer needed.
  166. func (c *Client) Close() error { return c.conn.Close() }
  167. // retryDelay returns the sleep interval for a given retry attempt,
  168. // checking the x-retry-after-ms header for a server-specified value.
  169. func retryDelay(streamCtx context.Context, attempt int) time.Duration {
  170. if md, ok := metadata.FromIncomingContext(streamCtx); ok {
  171. if vals := md.Get("x-retry-after-ms"); len(vals) > 0 {
  172. var ms int
  173. fmt.Sscanf(vals[0], "%d", &ms)
  174. if ms > 0 {
  175. return time.Duration(ms) * time.Millisecond
  176. }
  177. }
  178. }
  179. return time.Duration(100*attempt) * time.Millisecond
  180. }