All checks were successful
check / check (push) Successful in 58s
Replace the polling-based delivery engine with a fully event-driven architecture using Go channels and goroutines: - Webhook handler notifies engine via buffered channel after creating delivery records, with inline event data for payloads < 16KB - Large payloads (>= 16KB) use pointer semantics (Body *string = nil) and are fetched from DB on demand, keeping channel memory bounded - Failed retry-target deliveries schedule Go timers with exponential backoff; timers fire into a separate retry channel when ready - On startup, engine scans DB once to recover interrupted deliveries (pending processed immediately, retrying get timers for remaining backoff) - DB stores delivery status for crash recovery only, not for inter-component communication during normal operation - delivery.Notifier interface decouples handlers from engine; fx wires *Engine as Notifier No more periodic polling. No more wasted cycles when idle.
173 lines
5.0 KiB
Go
173 lines
5.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
)
|
|
|
|
const (
|
|
// maxWebhookBodySize is the maximum allowed webhook request body (1 MB).
|
|
maxWebhookBodySize = 1 << 20
|
|
)
|
|
|
|
// HandleWebhook handles incoming webhook requests at entrypoint URLs.
|
|
// Only POST requests are accepted; all other methods return 405 Method Not Allowed.
|
|
// Events and deliveries are stored in the per-webhook database.
|
|
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.Header().Set("Allow", "POST")
|
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
entrypointUUID := chi.URLParam(r, "uuid")
|
|
if entrypointUUID == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
h.log.Info("webhook request received",
|
|
"entrypoint_uuid", entrypointUUID,
|
|
"method", r.Method,
|
|
"remote_addr", r.RemoteAddr,
|
|
)
|
|
|
|
// Look up entrypoint by path (from main application DB)
|
|
var entrypoint database.Entrypoint
|
|
result := h.db.DB().Where("path = ?", entrypointUUID).First(&entrypoint)
|
|
if result.Error != nil {
|
|
h.log.Debug("entrypoint not found", "path", entrypointUUID)
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Check if active
|
|
if !entrypoint.Active {
|
|
http.Error(w, "Gone", http.StatusGone)
|
|
return
|
|
}
|
|
|
|
// Read body with size limit
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
|
|
if err != nil {
|
|
h.log.Error("failed to read request body", "error", err)
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(body) > maxWebhookBodySize {
|
|
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
|
|
// Serialize headers as JSON
|
|
headersJSON, err := json.Marshal(r.Header)
|
|
if err != nil {
|
|
h.log.Error("failed to serialize headers", "error", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Find all active targets for this webhook (from main application DB)
|
|
var targets []database.Target
|
|
if targetErr := h.db.DB().Where("webhook_id = ? AND active = ?", entrypoint.WebhookID, true).Find(&targets).Error; targetErr != nil {
|
|
h.log.Error("failed to query targets", "error", targetErr)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get the per-webhook database for event storage
|
|
webhookDB, err := h.dbMgr.GetDB(entrypoint.WebhookID)
|
|
if err != nil {
|
|
h.log.Error("failed to get webhook database",
|
|
"webhook_id", entrypoint.WebhookID,
|
|
"error", err,
|
|
)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Create the event and deliveries in a transaction on the per-webhook DB
|
|
tx := webhookDB.Begin()
|
|
if tx.Error != nil {
|
|
h.log.Error("failed to begin transaction", "error", tx.Error)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
event := &database.Event{
|
|
WebhookID: entrypoint.WebhookID,
|
|
EntrypointID: entrypoint.ID,
|
|
Method: r.Method,
|
|
Headers: string(headersJSON),
|
|
Body: string(body),
|
|
ContentType: r.Header.Get("Content-Type"),
|
|
}
|
|
|
|
if err := tx.Create(event).Error; err != nil {
|
|
tx.Rollback()
|
|
h.log.Error("failed to create event", "error", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Create delivery records for each active target
|
|
for i := range targets {
|
|
dlv := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: targets[i].ID,
|
|
Status: database.DeliveryStatusPending,
|
|
}
|
|
if err := tx.Create(dlv).Error; err != nil {
|
|
tx.Rollback()
|
|
h.log.Error("failed to create delivery",
|
|
"target_id", targets[i].ID,
|
|
"error", err,
|
|
)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
h.log.Error("failed to commit transaction", "error", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Notify the delivery engine with inline event data so it can
|
|
// process deliveries immediately without a DB round-trip.
|
|
// Large bodies (>= 16KB) are left nil to keep channel memory
|
|
// bounded; the engine fetches them from DB on demand.
|
|
n := delivery.Notification{
|
|
WebhookID: entrypoint.WebhookID,
|
|
EventID: event.ID,
|
|
Method: event.Method,
|
|
Headers: event.Headers,
|
|
ContentType: event.ContentType,
|
|
}
|
|
bodyStr := string(body)
|
|
if len(body) < delivery.MaxInlineBodySize {
|
|
n.Body = &bodyStr
|
|
}
|
|
h.notifier.Notify(n)
|
|
|
|
h.log.Info("webhook event created",
|
|
"event_id", event.ID,
|
|
"webhook_id", entrypoint.WebhookID,
|
|
"entrypoint_id", entrypoint.ID,
|
|
"target_count", len(targets),
|
|
)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := w.Write([]byte(`{"status":"ok"}`)); err != nil {
|
|
h.log.Error("failed to write response", "error", err)
|
|
}
|
|
}
|
|
}
|