Files
synapbus/internal/webhooks/delivery.go
T
Algis DumbrisandClaude Opus 4.6 42775df65f feat: webhooks & Kubernetes Job runner for event-driven agents
- Webhook registration via MCP (register_webhook, list_webhooks, delete_webhook)
- HMAC-SHA256 payload signing, SSRF-safe HTTP client, loop detection (depth 5)
- 8-worker goroutine delivery pool with exponential backoff retry (1s/5s/30s)
- Dead letter queue with auto-purge, auto-disable after 50 consecutive failures
- Per-agent rate limiting (60 deliveries/min)
- K8s Job runner (register_k8s_handler, list_k8s_handlers, delete_k8s_handler)
- Auto-detect in-cluster via InClusterConfig, NoopRunner fallback
- REST API for webhook deliveries, dead letters, K8s job runs and logs
- Web UI: webhook management, K8s handler pages, dead letters view
- MultiDispatcher fan-out pattern for webhook + K8s event dispatch
- SQLite migration 009: webhooks, webhook_deliveries, k8s_handlers, k8s_job_runs
- 51 tests across 9 test packages, all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:14:04 +02:00

406 lines
11 KiB
Go

package webhooks
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"sync"
"time"
"github.com/synapbus/synapbus/internal/dispatcher"
)
// Retry intervals for exponential backoff.
var retryIntervals = []time.Duration{
1 * time.Second,
5 * time.Second,
30 * time.Second,
}
const (
maxAttempts = 3
defaultWorkers = 8
queueSize = 1000
)
// DeliveryPayload is the JSON body sent to webhook endpoints.
type DeliveryPayload struct {
Event string `json:"event"`
MessageID int64 `json:"message_id"`
FromAgent string `json:"from_agent"`
ToAgent string `json:"to_agent,omitempty"`
Channel string `json:"channel,omitempty"`
Body string `json:"body"`
Priority int `json:"priority"`
Metadata string `json:"metadata,omitempty"`
Timestamp string `json:"timestamp"`
}
// DeliveryEngine implements dispatcher.EventDispatcher for webhooks.
// It maintains a worker pool for async delivery with retry support.
type DeliveryEngine struct {
service *WebhookService
rateLimiter *AgentRateLimiter
httpClient *http.Client
logger *slog.Logger
workers int
queue chan *deliveryWork
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
}
type deliveryWork struct {
delivery *WebhookDelivery
webhook *Webhook
secret string // raw secret (from registration) — we use SecretHash to sign
}
// NewDeliveryEngine creates a new delivery engine.
func NewDeliveryEngine(service *WebhookService, rateLimiter *AgentRateLimiter, allowPrivate bool) *DeliveryEngine {
return &DeliveryEngine{
service: service,
rateLimiter: rateLimiter,
httpClient: NewSSRFSafeClient(allowPrivate),
logger: slog.Default().With("component", "webhook-delivery"),
workers: defaultWorkers,
queue: make(chan *deliveryWork, queueSize),
}
}
// Start launches the worker pool and re-queues any pending/retrying deliveries from a previous run.
func (e *DeliveryEngine) Start() {
e.ctx, e.cancel = context.WithCancel(context.Background())
for i := 0; i < e.workers; i++ {
e.wg.Add(1)
go e.worker(i)
}
// Start retry poller
e.wg.Add(1)
go e.retryPoller()
// Start dead letter purge worker
e.wg.Add(1)
go e.deadLetterPurger()
// Re-queue pending deliveries from previous run
e.requeuePending()
e.logger.Info("delivery engine started", "workers", e.workers)
}
// requeuePending re-queues deliveries that were pending or retrying when the server last stopped.
func (e *DeliveryEngine) requeuePending() {
store := e.service.Store()
ctx := e.ctx
pending, err := store.GetPendingDeliveries(ctx, 100)
if err != nil {
e.logger.Error("failed to re-queue pending deliveries", "error", err)
return
}
requeued := 0
for _, d := range pending {
wh, err := store.(*SQLiteWebhookStore).GetWebhookByID(ctx, d.WebhookID)
if err != nil {
continue
}
select {
case e.queue <- &deliveryWork{delivery: d, webhook: wh}:
requeued++
default:
}
}
if requeued > 0 {
e.logger.Info("re-queued pending deliveries from previous run", "count", requeued)
}
}
// deadLetterPurger periodically purges old dead-lettered deliveries (>30 days).
func (e *DeliveryEngine) deadLetterPurger() {
defer e.wg.Done()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-e.ctx.Done():
return
case <-ticker.C:
store := e.service.Store()
cutoff := time.Now().Add(-30 * 24 * time.Hour)
count, err := store.PurgeOldDeadLetters(e.ctx, cutoff)
if err != nil {
e.logger.Error("failed to purge old dead letters", "error", err)
} else if count > 0 {
e.logger.Info("purged old dead letters", "count", count)
}
}
}
}
// Stop gracefully shuts down the delivery engine.
func (e *DeliveryEngine) Stop() {
if e.cancel != nil {
e.cancel()
}
e.wg.Wait()
e.logger.Info("delivery engine stopped")
}
// Dispatch implements dispatcher.EventDispatcher. It enqueues webhook deliveries
// for all active webhooks matching the event.
func (e *DeliveryEngine) Dispatch(ctx context.Context, event dispatcher.MessageEvent) error {
// Check depth limit for loop prevention
if event.Depth >= MaxDepth {
e.logger.Warn("webhook chain depth exceeded, dropping event",
"event_type", event.EventType,
"message_id", event.MessageID,
"depth", event.Depth,
)
return nil
}
// Find matching webhooks for the target agent
targetAgent := event.ToAgent
if targetAgent == "" && event.Channel != "" {
// For channel messages, we don't deliver webhooks (channel messages go to
// individual agents via mention events). But we handle message.mentioned
// events which target specific agents.
return nil
}
webhooks, err := e.service.GetActiveWebhooksForEvent(ctx, targetAgent, event.EventType)
if err != nil {
return fmt.Errorf("get webhooks for event: %w", err)
}
if len(webhooks) == 0 {
return nil
}
// Build payload
payload := DeliveryPayload{
Event: event.EventType,
MessageID: event.MessageID,
FromAgent: event.FromAgent,
ToAgent: event.ToAgent,
Channel: event.Channel,
Body: event.Body,
Priority: event.Priority,
Metadata: event.Metadata,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
store := e.service.Store()
for _, wh := range webhooks {
delivery := &WebhookDelivery{
WebhookID: wh.ID,
AgentName: wh.AgentName,
Event: event.EventType,
MessageID: event.MessageID,
Payload: string(payloadBytes),
Status: DeliveryStatusPending,
MaxAttempts: maxAttempts,
Depth: event.Depth,
}
if _, err := store.InsertDelivery(ctx, delivery); err != nil {
e.logger.Error("failed to insert delivery",
"webhook_id", wh.ID,
"error", err,
)
continue
}
// Enqueue for async delivery
select {
case e.queue <- &deliveryWork{delivery: delivery, webhook: wh}:
default:
e.logger.Warn("delivery queue full, delivery will be picked up by retry poller",
"delivery_id", delivery.ID,
)
}
}
return nil
}
// DispatchMentions dispatches message.mentioned events for each @mentioned agent.
func (e *DeliveryEngine) DispatchMentions(ctx context.Context, event dispatcher.MessageEvent) error {
for _, mentioned := range event.MentionedAgents {
mentionEvent := dispatcher.MessageEvent{
EventType: "message.mentioned",
MessageID: event.MessageID,
FromAgent: event.FromAgent,
ToAgent: mentioned,
Channel: event.Channel,
Body: event.Body,
Priority: event.Priority,
Metadata: event.Metadata,
Depth: event.Depth,
}
if err := e.Dispatch(ctx, mentionEvent); err != nil {
e.logger.Error("failed to dispatch mention event",
"mentioned", mentioned,
"error", err,
)
}
}
return nil
}
func (e *DeliveryEngine) worker(id int) {
defer e.wg.Done()
for {
select {
case <-e.ctx.Done():
return
case work := <-e.queue:
e.deliverOne(e.ctx, work)
}
}
}
func (e *DeliveryEngine) deliverOne(ctx context.Context, work *deliveryWork) {
delivery := work.delivery
wh := work.webhook
store := e.service.Store()
// Rate limit
if err := e.rateLimiter.Wait(ctx, delivery.AgentName); err != nil {
e.logger.Error("rate limiter error", "agent", delivery.AgentName, "error", err)
return
}
// Increment attempt count
delivery.Attempts++
_ = store.UpdateDeliveryAttempts(ctx, delivery.ID, delivery.Attempts)
// Build HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodPost, wh.URL, bytes.NewReader([]byte(delivery.Payload)))
if err != nil {
e.recordFailure(ctx, delivery, wh, 0, fmt.Sprintf("build request: %s", err))
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-SynapBus-Event", delivery.Event)
req.Header.Set("X-SynapBus-Delivery", fmt.Sprintf("%d", delivery.ID))
req.Header.Set("X-SynapBus-Depth", strconv.Itoa(delivery.Depth + 1))
// HMAC signature using the stored secret hash as the key
signature := ComputeHMACSignature([]byte(wh.SecretHash), []byte(delivery.Payload))
req.Header.Set("X-SynapBus-Signature", signature)
// Execute request
resp, err := e.httpClient.Do(req)
if err != nil {
e.recordFailure(ctx, delivery, wh, 0, fmt.Sprintf("HTTP request: %s", err))
return
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
// Success
now := time.Now()
_ = store.UpdateDeliveryStatus(ctx, delivery.ID, DeliveryStatusDelivered, resp.StatusCode, "", nil, &now)
_ = e.service.RecordSuccess(ctx, wh.ID)
e.logger.Info("webhook delivered",
"delivery_id", delivery.ID,
"webhook_id", wh.ID,
"status", resp.StatusCode,
)
} else {
e.recordFailure(ctx, delivery, wh, resp.StatusCode, fmt.Sprintf("HTTP %d", resp.StatusCode))
}
}
func (e *DeliveryEngine) recordFailure(ctx context.Context, delivery *WebhookDelivery, wh *Webhook, httpStatus int, errMsg string) {
store := e.service.Store()
if delivery.Attempts < delivery.MaxAttempts {
// Schedule retry
retryIdx := delivery.Attempts - 1
if retryIdx >= len(retryIntervals) {
retryIdx = len(retryIntervals) - 1
}
nextRetry := time.Now().Add(retryIntervals[retryIdx])
_ = store.UpdateDeliveryStatus(ctx, delivery.ID, DeliveryStatusRetrying, httpStatus, errMsg, &nextRetry, nil)
e.logger.Info("webhook delivery failed, scheduling retry",
"delivery_id", delivery.ID,
"attempt", delivery.Attempts,
"next_retry", nextRetry,
"error", errMsg,
)
} else {
// Dead letter
_ = store.UpdateDeliveryStatus(ctx, delivery.ID, DeliveryStatusDeadLettered, httpStatus, errMsg, nil, nil)
_ = e.service.RecordFailure(ctx, wh.ID)
e.logger.Warn("webhook delivery dead-lettered",
"delivery_id", delivery.ID,
"webhook_id", wh.ID,
"attempts", delivery.Attempts,
"error", errMsg,
)
}
}
func (e *DeliveryEngine) retryPoller() {
defer e.wg.Done()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-e.ctx.Done():
return
case <-ticker.C:
e.processRetries()
}
}
}
func (e *DeliveryEngine) processRetries() {
ctx := e.ctx
store := e.service.Store()
deliveries, err := store.GetRetryableDeliveries(ctx, time.Now(), 50)
if err != nil {
e.logger.Error("failed to get retryable deliveries", "error", err)
return
}
for _, d := range deliveries {
wh, err := store.(*SQLiteWebhookStore).GetWebhookByID(ctx, d.WebhookID)
if err != nil {
e.logger.Error("failed to get webhook for retry",
"delivery_id", d.ID,
"webhook_id", d.WebhookID,
"error", err,
)
continue
}
select {
case e.queue <- &deliveryWork{delivery: d, webhook: wh}:
default:
e.logger.Warn("delivery queue full during retry processing",
"delivery_id", d.ID,
)
}
}
}