Files
webhooker/internal/handlers/webhook.go
clawbot 32bd40b313
All checks were successful
check / check (push) Successful in 58s
refactor: self-contained delivery tasks — engine delivers without DB reads in happy path
The webhook handler now builds DeliveryTask structs carrying all target
config and event data inline (for bodies ≤16KB) and sends them through
the delivery channel. In the happy path, the engine delivers without
reading from any database — it only writes to record delivery results.

For large bodies (≥16KB), Body is nil and the engine fetches it from the
per-webhook database on demand. Retry timers also carry the full
DeliveryTask, so retries avoid unnecessary DB reads.

The database is used for crash recovery only: on startup the engine scans
for interrupted pending/retrying deliveries and re-queues them.

Implements owner feedback from issue #15:
> the message in the <=16KB case should have everything it needs to do
> its delivery. it shouldn't touch the db until it has a success or
> failure to record.
2026-03-01 22:09:41 -08:00

192 lines
5.7 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. The handler
// builds self-contained DeliveryTask structs with all target and event data
// so the delivery engine can process them without additional DB reads.
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
}
// Prepare body pointer for inline transport (≤16KB bodies are
// included in the DeliveryTask so the engine needs no DB read).
var bodyPtr *string
if len(body) < delivery.MaxInlineBodySize {
bodyStr := string(body)
bodyPtr = &bodyStr
}
// Create delivery records and build self-contained delivery tasks
tasks := make([]delivery.DeliveryTask, 0, len(targets))
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
}
tasks = append(tasks, delivery.DeliveryTask{
DeliveryID: dlv.ID,
EventID: event.ID,
WebhookID: entrypoint.WebhookID,
TargetID: targets[i].ID,
TargetName: targets[i].Name,
TargetType: targets[i].Type,
TargetConfig: targets[i].Config,
MaxRetries: targets[i].MaxRetries,
Method: event.Method,
Headers: event.Headers,
ContentType: event.ContentType,
Body: bodyPtr,
AttemptNum: 1,
})
}
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 self-contained delivery tasks.
// Each task carries all target config and event data inline so
// the engine can deliver without touching any database (in the
// ≤16KB happy path). The engine only writes to the DB to record
// delivery results after each attempt.
if len(tasks) > 0 {
h.notifier.Notify(tasks)
}
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)
}
}
}