Documentation
¶
Overview ¶
Package webhook sends signed outgoing webhooks. It is a port of the former first-party webhooks plugin; the receiver contract (body, headers, signatures, secrets, retry schedule) is unchanged, see docs/WEBHOOKS.md.
Index ¶
- Constants
- Variables
- func Backoff(attempt int, base time.Duration) time.Duration
- func NewEnvelope(id, event string, now time.Time, data any) ([]byte, error)
- func NewSecret() (string, error)
- func Register(h Registrar, q Enqueuer, log *slog.Logger)
- func SanitizeImported(s *Subscription)
- func Sign(secret string, body []byte) string
- func SignV2(secret string, unix int64, body []byte) string
- func ValidEvents() []string
- type Deliverer
- type Delivery
- type DeliveryRow
- type Enqueuer
- type Envelope
- type ListedSubscription
- type Options
- type PluginSource
- type Registrar
- type Repo
- func (r *Repo) ClaimOne(ctx context.Context, runStart time.Time, skip []string) (*Delivery, error)
- func (r *Repo) Count(ctx context.Context) (int, error)
- func (r *Repo) CountDeliveries(ctx context.Context, subscriptionID string) (int, error)
- func (r *Repo) CountFailedSince(ctx context.Context, since time.Time) (int, error)
- func (r *Repo) DBNow(ctx context.Context) (time.Time, error)
- func (r *Repo) Delete(ctx context.Context, id string) error
- func (r *Repo) Enqueue(ctx context.Context, event string, data any, now time.Time) (int, error)
- func (r *Repo) Get(ctx context.Context, id string) (*Subscription, error)
- func (r *Repo) GetDelivery(ctx context.Context, id string) (*DeliveryRow, error)
- func (r *Repo) ImportPluginSubscriptions(ctx context.Context, src PluginSource, paused bool) (copied int, err error)
- func (r *Repo) Insert(ctx context.Context, s *Subscription) error
- func (r *Repo) InsertParked(ctx context.Context, id, subscriptionID, event, payload string, ...) error
- func (r *Repo) List(ctx context.Context) ([]ListedSubscription, error)
- func (r *Repo) ListDeliveries(ctx context.Context, subscriptionID string, limit, offset int) ([]DeliveryRow, error)
- func (r *Repo) MarkDelivered(ctx context.Context, id string, status int, body string, dur time.Duration) error
- func (r *Repo) MarkExhausted(ctx context.Context, id string, status *int, body, errMsg string, ...) error
- func (r *Repo) MarkFailed(ctx context.Context, id, errMsg string) error
- func (r *Repo) MarkRetry(ctx context.Context, id string, status *int, body, errMsg string, ...) error
- func (r *Repo) Prune(ctx context.Context) error
- func (r *Repo) RetryFailed(ctx context.Context, id string) error
- func (r *Repo) SetActive(ctx context.Context, id string, active bool) error
- func (r *Repo) SetSecret(ctx context.Context, id, secret string) error
- func (r *Repo) SettleDead(ctx context.Context) (int64, error)
- func (r *Repo) Update(ctx context.Context, s *Subscription) error
- type Service
- func (s *Service) CountFailedSince(ctx context.Context, since time.Time) (int, error)
- func (s *Service) Create(ctx context.Context, in Subscription) (Subscription, string, error)
- func (s *Service) Delete(ctx context.Context, id string) error
- func (s *Service) Get(ctx context.Context, id string) (*Subscription, error)
- func (s *Service) List(ctx context.Context) ([]ListedSubscription, error)
- func (s *Service) RegenerateSecret(ctx context.Context, id string) (string, error)
- func (s *Service) Repo() *Repo
- func (s *Service) RetryDelivery(ctx context.Context, id string) (bool, error)
- func (s *Service) SetActive(ctx context.Context, id string, active bool) error
- func (s *Service) Update(ctx context.Context, id string, p SubscriptionPatch) error
- type Stats
- type Subscription
- type SubscriptionPatch
- type ValidationError
Constants ¶
const ( EventEntityCreated = "entity.created" EventEntityUpdated = "entity.updated" EventEntityPublished = "entity.published" EventEntityDeleted = "entity.deleted" EventFormSubmitted = "form.submitted" EventImportCompleted = "import.completed" // EventTest is the synthetic event of "Send test"; it is never subscribable. EventTest = "webhook.test" )
Event names a subscription may list.
const ( SecretPrefix = "whsec_" MaxSubscriptions = 50 // MaxResponseBody is how many bytes of a receiver's answer are stored. MaxResponseBody = 1024 // MaxTimeoutSeconds is the longest request timeout a subscription may ask for. MaxTimeoutSeconds = 5 MaxBackoff = time.Hour // MaxPending stops queueing for a subscription whose backlog is this long. MaxPending = 5000 // LogPerSubscription is how many finished deliveries each subscription keeps. LogPerSubscription = 1000 // LeaseSeconds is how long a claimed delivery is left alone. LeaseSeconds = 60 )
Variables ¶
var ErrNotFound = errors.New("webhook not found")
ErrNotFound is returned when a subscription (or delivery) does not exist.
Functions ¶
func Backoff ¶
Backoff is the wait before the next attempt after the attempt'th failed one: base * 2^attempt, at most MaxBackoff.
func NewEnvelope ¶
NewEnvelope returns the body stored for a delivery and re-sent verbatim on every attempt.
func Register ¶
Register subscribes the enqueue listeners to the entity, import and form hooks. A listener only inserts rows: it makes no network call, so a slow receiver can never make the engine drop or delay an event.
func SanitizeImported ¶
func SanitizeImported(s *Subscription)
SanitizeImported applies to an imported subscription the rules Validate applies to a new one: numbers are clamped, unknown and repeated events and headers the sender owns are dropped, the name falls back to the address and plain http is flagged (it worked before). A subscription that is still invalid afterwards (no events, an unusable address) is left paused.
func Sign ¶
Sign is the X-Webhook-Signature value: "sha256=" and the hex HMAC-SHA256 of the exact request body, keyed by the subscription's secret.
func SignV2 ¶
SignV2 is the X-Webhook-Signature-V2 value: "t=<unix>,v1=<hex>" where the HMAC-SHA256 covers "<unix>.<body>", so a receiver can reject a request whose t is older than its tolerance (five minutes is usual).
func ValidEvents ¶
func ValidEvents() []string
ValidEvents returns the subscribable events in a stable order.
Types ¶
type Deliverer ¶
type Deliverer struct {
// contains filtered or unexported fields
}
Deliverer sends queued deliveries.
func NewDeliverer ¶
NewDeliverer returns a Deliverer on repo.
func (*Deliverer) Run ¶
Run settles dead claims, then sends due deliveries one by one until none is due, runBatch were tried or the budget is nearly spent. A subscription whose request failed at the transport (timeout, refused) is left alone for the rest of the run, so one hung receiver cannot starve the others.
type Delivery ¶
type Delivery struct {
ID string
SubscriptionID string
Event string
Payload string
Status string
Attempts int
MaxAttempts int
}
Delivery is a claimed row: what an attempt needs.
type DeliveryRow ¶
type DeliveryRow struct {
ID string
SubscriptionID string
Event string
Status string
Attempts int
MaxAttempts int
NextAttemptAt time.Time
ResponseStatus *int
ResponseBody string
Error string
DurationMS *int
CreatedAt time.Time
LastAttemptAt *time.Time
DeliveredAt *time.Time
}
DeliveryRow is a row of the delivery log. It never carries the payload.
type Enqueuer ¶
type Enqueuer interface {
Enqueue(ctx context.Context, event string, data any, now time.Time) (int, error)
}
Enqueuer queues one event for the matching subscriptions (*Repo).
type Envelope ¶
type Envelope struct {
ID string `json:"id"`
Event string `json:"event"`
Timestamp string `json:"timestamp"` // RFC3339 UTC, enqueue time
Data any `json:"data"`
}
Envelope is the JSON body of every webhook request. The field order is part of the receiver contract: id, event, timestamp, data.
type ListedSubscription ¶
type ListedSubscription struct {
ID string
Name string
URL string
Events []string
Headers map[string]string
Active bool
MaxRetries int
RetryDelaySeconds int
TimeoutSeconds int
AllowHTTP bool
CreatedAt time.Time
UpdatedAt time.Time
Stats
}
ListedSubscription is a subscription as shown in lists. It has no Secret field at all: the secret is shown once, on creation or rotation.
type Options ¶
type Options struct {
// AllowPrivate lets the sender reach loopback/private addresses and any
// port. For tests only: production wiring never sets it.
AllowPrivate bool
Lookup safefetch.Lookup
Now func() time.Time
// RunBudget is how long one Run spends sending; default 25s.
RunBudget time.Duration
}
Options configures a Deliverer.
type PluginSource ¶
type PluginSource interface {
Query(ctx context.Context, plugin, sql string, args ...any) (*pluginhost.QueryResult, error)
}
PluginSource reads the former webhooks plugin's own schema. pluginhost.PluginDB is one: it runs the statement as the plugin's database role.
type Repo ¶
type Repo struct {
// contains filtered or unexported fields
}
Repo is the Postgres access to hatamoto_webhooks and hatamoto_webhook_deliveries.
func (*Repo) ClaimOne ¶
ClaimOne leases the oldest due delivery of a subscription not in skip, or returns nil. Only rows due at runStart qualify, so a delivery that failed with no backoff is not tried again in the same run. Claiming is atomic (FOR UPDATE SKIP LOCKED), so concurrent runs never send one delivery twice, and one row at a time, so a run that stops early leaves nothing leased.
func (*Repo) CountDeliveries ¶
CountDeliveries returns how many log rows ListDeliveries pages through, for one subscription or (empty id) all.
func (*Repo) CountFailedSince ¶
CountFailedSince returns how many deliveries failed for good and were queued at or after since, for the Overview's site health.
func (*Repo) Enqueue ¶
Enqueue queues event for every active subscription listening to it, one delivery row (with its own envelope id) each, and returns how many rows it added. It only touches the database: delivery is the job's business. A subscription whose backlog has reached MaxPending gets no more.
func (*Repo) GetDelivery ¶
GetDelivery returns one delivery log row (never its payload), or ErrNotFound.
func (*Repo) ImportPluginSubscriptions ¶
func (r *Repo) ImportPluginSubscriptions(ctx context.Context, src PluginSource, paused bool) (copied int, err error)
ImportPluginSubscriptions copies the subscriptions of the former webhooks plugin into hatamoto_webhooks in one transaction, keeping ids and secrets, so receivers notice nothing. Each row is sanitised like the migration sanitises the hand-over rows. A row whose id exists already is kept as is, so running it twice copies nothing the second time. Delivery history is not copied. With paused set every row is imported paused, whatever its own flag says (the plugin was not sending, so core must not start). Nothing is written unless every row could be read.
func (*Repo) Insert ¶
func (r *Repo) Insert(ctx context.Context, s *Subscription) error
Insert stores a new subscription; s.ID and s.Secret must be set.
func (*Repo) InsertParked ¶
func (r *Repo) InsertParked(ctx context.Context, id, subscriptionID, event, payload string, maxAttempts int, park time.Duration) error
InsertParked stores a delivery that no job will pick up for the next park (Send test attempts its row inline, so a crash never leaves it due).
func (*Repo) List ¶
func (r *Repo) List(ctx context.Context) ([]ListedSubscription, error)
List returns every subscription, oldest first, with delivery counts. The secret is deliberately not selected.
func (*Repo) ListDeliveries ¶
func (r *Repo) ListDeliveries(ctx context.Context, subscriptionID string, limit, offset int) ([]DeliveryRow, error)
ListDeliveries returns the log, newest first, for one subscription or (empty id) all. limit defaults to 50 and is at most 200.
func (*Repo) MarkDelivered ¶
func (r *Repo) MarkDelivered(ctx context.Context, id string, status int, body string, dur time.Duration) error
MarkDelivered records a 2xx answer as the next attempt.
func (*Repo) MarkExhausted ¶
func (r *Repo) MarkExhausted(ctx context.Context, id string, status *int, body, errMsg string, dur time.Duration) error
MarkExhausted records the last failed attempt: the delivery is failed.
func (*Repo) MarkFailed ¶
MarkFailed fails a delivery without counting an attempt (its subscription is gone or paused).
func (*Repo) MarkRetry ¶
func (r *Repo) MarkRetry(ctx context.Context, id string, status *int, body, errMsg string, dur, next time.Duration) error
MarkRetry records a failed attempt and schedules the next one after next. status is nil when the receiver gave no answer.
func (*Repo) Prune ¶
Prune keeps the delivery log bounded: delivered rows live 30 days, every other row 90 days (a pending row older than that is abandoned), and each subscription keeps at most LogPerSubscription finished rows.
func (*Repo) RetryFailed ¶
RetryFailed puts a failed delivery back in the queue for one more attempt; any other delivery is left alone.
func (*Repo) SettleDead ¶
SettleDead counts the attempt of every delivery whose lease ran out: the process that claimed it died, possibly after the receiver got the request, so the attempt is spent. A delivery that keeps killing its attempt ends up failed.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is the management API of webhook subscriptions (the admin page calls it). It makes no network call.
func NewService ¶
NewService returns a Service on db.
func (*Service) CountFailedSince ¶
CountFailedSince returns how many deliveries queued at or after since have failed.
func (*Service) Create ¶
func (s *Service) Create(ctx context.Context, in Subscription) (Subscription, string, error)
Create validates and stores a subscription and returns it with its new secret, the only time the secret is shown. Callers start from Subscription.ApplyDefaults and fill the fields the user set.
func (*Service) List ¶
func (s *Service) List(ctx context.Context) ([]ListedSubscription, error)
List returns every subscription with its delivery counts, never the secret.
func (*Service) RegenerateSecret ¶
RegenerateSecret replaces the secret and returns the new one.
func (*Service) RetryDelivery ¶
RetryDelivery queues a failed delivery for one more attempt. It reports false, changing nothing, for a delivery that has not failed, and ErrNotFound for an unknown id.
type Stats ¶
type Stats struct {
Delivered int `json:"delivered"`
Failed int `json:"failed"`
Pending int `json:"pending"`
}
Stats are the delivery counts of one subscription.
type Subscription ¶
type Subscription struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Secret string `json:"secret,omitempty"`
Events []string `json:"events"`
Headers map[string]string `json:"headers"`
Active bool `json:"active"`
MaxRetries int `json:"max_retries"`
RetryDelaySeconds int `json:"retry_delay_seconds"`
TimeoutSeconds int `json:"timeout_seconds"`
AllowHTTP bool `json:"allow_http"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Subscription is a row of hatamoto_webhooks.
func (*Subscription) ApplyDefaults ¶
func (s *Subscription) ApplyDefaults()
ApplyDefaults sets the values of a new subscription.
func (*Subscription) Validate ¶
func (s *Subscription) Validate() error
Validate normalises and checks a subscription. It returns a *ValidationError for the first problem.
type SubscriptionPatch ¶
type SubscriptionPatch struct {
Name *string
URL *string
Events *[]string
Headers *map[string]string
Active *bool
MaxRetries *int
RetryDelaySeconds *int
TimeoutSeconds *int
AllowHTTP *bool
}
SubscriptionPatch is a partial update: nil fields keep their value.
type ValidationError ¶
type ValidationError struct{ Msg string }
ValidationError is a problem with a subscription's fields; handlers show it as a 422.
func (*ValidationError) Error ¶
func (e *ValidationError) Error() string