gitFlow/internal/httpclient/client.go
dimitar 55789ab7d7 feat: M5 — webhook core (Sender interface, Slack/Discord/Generic)
Implement the webhook dispatch subsystem so watch sessions can relay scan
results to external services when repositories change.

Webhook package (internal/webhook):
- Sender interface (Name + Send(ctx, Payload)) with three concrete
  implementations:
  - SlackSender: Block Kit message (header + mrkdwn section with summary
    and change list + context footer), emoji status markers
  - DiscordSender: single rich embed with color-coded sidebar (green=
    clean, yellow=attention, red=errors) and markdown description
  - GenericSender: raw JSON POST of the Payload struct, with custom
    headers from config
- Config struct per destination: type, URL, send_all, on_change_only,
  min_priority, rate_limit, timeout, custom headers, retry (max_attempts
  + backoff); TimeoutOrDefault helper
- NewSender factory switches on type; unknown types rejected
- Dispatcher: NewDispatcher builds a senderHandle per config (sender +
  rate-limit state + its own http.Client with per-config timeout);
  Dispatch() fans out with per-sender guards: on_change_only skips when
  changedCount==0, rate_limit skips when too soon (lastSent check)
- senderHandle.sendWithRetry: retries with exponential backoff on
  transient errors (5xx/timeout); 4xx errors fail immediately;
  isRetryable helper

HTTP client (internal/httpclient):
- PostJSON now skips json.Unmarshal when out==nil, matching the common
  webhook pattern where the response body is irrelevant

Testing:
- All three sender types round-trip through httptest (request payload
  decoded and asserted)
- Discord embed colour: yellow for attention, red for errors
- Generic sender payload integrity check
- Dispatcher: OnChangeOnly guard skips when changedCount==0; multiple
  senders all fire; HTTP errors surfaced (500)
- Retry: 504 Gateway Timeout retried 3× before succeeding via the
  dispatcher's sendWithRetry
- NewSender rejects unknown types, requires a name for identification

Verified: go build, go vet, go test -race (13 packages), gofmt clean.
2026-08-02 09:20:45 +02:00

93 lines
2.5 KiB
Go

// Package httpclient provides a small JSON HTTP client shared by the AI
// providers and webhook senders. It bounds response sizes and applies a
// configurable timeout so a slow upstream cannot hang a scan or watch frame.
package httpclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// DefaultTimeout is the per-request timeout when none is configured.
const DefaultTimeout = 60 * time.Second
// maxResponseBytes caps the body size read from an upstream. AI and webhook
// responses are expected to be well under 1 MiB; 4 MiB is a safety valve.
const maxResponseBytes = 4 << 20
// Client is a JSON HTTP client with a bounded response size and a timeout.
type Client struct {
httpc *http.Client
}
// Option configures a Client (functional options pattern).
type Option func(*Client)
// WithTimeout overrides the per-request timeout.
func WithTimeout(d time.Duration) Option {
return func(c *Client) {
c.httpc.Timeout = d
}
}
// WithTransport allows injecting a custom http.RoundTripper (useful for
// testing with httptest or for TLS/mTLS configuration).
func WithTransport(rt http.RoundTripper) Option {
return func(c *Client) {
c.httpc.Transport = rt
}
}
// New builds a Client with sensible defaults.
func New(opts ...Option) *Client {
c := &Client{httpc: &http.Client{Timeout: DefaultTimeout}}
for _, opt := range opts {
opt(c)
}
return c
}
// PostJSON sends a JSON payload to url with the given headers and decodes the
// response body into out. Non-2xx status codes are errors.
func (c *Client) PostJSON(ctx context.Context, url string, headers map[string]string, payload, out any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("httpclient: encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("httpclient: %w", err)
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := c.httpc.Do(req)
if err != nil {
return fmt.Errorf("httpclient: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return fmt.Errorf("httpclient: read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("httpclient: %s: %s", resp.Status, strings.TrimSpace(string(data)))
}
if out != nil {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("httpclient: decode response: %w", err)
}
}
return nil
}