Documentation
¶
Index ¶
- Constants
- Variables
- func IsDisallowedWebhookIP(ip net.IP) bool
- type AccountDeliveryMetrics
- type AutoDisableWorker
- type Delivery
- type DeliveryOutcome
- type DeliveryOutcomeCounts
- type DeliveryStore
- func (s *DeliveryStore) CreateDelivery(ctx context.Context, messageID string, lastError string) (*Delivery, error)
- func (s *DeliveryStore) DeleteExpiredDeliveries(ctx context.Context) (int64, error)
- func (s *DeliveryStore) GetPendingDeliveries(ctx context.Context, limit int) ([]Delivery, error)
- func (s *DeliveryStore) MarkAttemptFailed(ctx context.Context, messageID, errMsg string, nextRetry time.Time) error
- func (s *DeliveryStore) MarkDelivered(ctx context.Context, messageID string) error
- func (s *DeliveryStore) MarkFailed(ctx context.Context, messageID, errMsg string) error
- type EndpointDeliveryMetrics
- type HealthNotifyEnqueuer
- type Payload
- type SubscriberDeliverer
- type SubscriberDelivery
- type SubscriberStore
- func (s *SubscriberStore) CountDeliveriesForAccount(ctx context.Context, userID string, start, end time.Time) (AccountDeliveryMetrics, error)
- func (s *SubscriberStore) DeleteExpiredSubscriberDeliveries(ctx context.Context) (deleted, marked int, err error)
- func (s *SubscriberStore) GetSubscriberDeliveryByID(ctx context.Context, deliveryID string) (*SubscriberDelivery, error)
- func (s *SubscriberStore) InsertPendingForTest(ctx context.Context, webhookID, eventType string, envelope []byte) (string, error)
- func (s *SubscriberStore) ListDeliveriesByWebhook(ctx context.Context, webhookID, status string, limit int, ...) ([]SubscriberDelivery, error)
- func (s *SubscriberStore) MarkDelivered(ctx context.Context, deliveryID string, statusCode int) error
- func (s *SubscriberStore) MarkDeliveredIfPending(ctx context.Context, deliveryID string, statusCode int) (bool, error)
- func (s *SubscriberStore) MarkSubscriberFailed(ctx context.Context, deliveryID string, attemptN int, errMsg string, ...) error
- func (s *SubscriberStore) MarkSubscriberFailedIfPending(ctx context.Context, deliveryID string, attemptN int, errMsg string, ...) error
- func (s *SubscriberStore) RecordSubscriberAttempt(ctx context.Context, deliveryID string, attemptN int, errMsg string, ...) error
- func (s *SubscriberStore) TransitionSubscriberFailedIfPending(ctx context.Context, deliveryID string, attemptN int, errMsg string, ...) (bool, error)
Constants ¶
const DeliveryRetention = 30 * 24 * time.Hour
DeliveryRetention mirrors the 30-day expires_at default on webhook_subscriber_deliveries (migration 025) and the janitor that enforces it. Unlike messages — which are retained until trashed — delivery history is genuinely pruned, so a window reaching further back than this is reporting on rows that no longer exist.
const DeliveryTTL = 48 * time.Hour
const LeaseDuration = 5 * time.Minute
LeaseDuration is how long a leased delivery is hidden from other workers. On a clean delivery the row's next_retry_at is overwritten by the success path (MarkDelivered) or a real backoff (RecordFailure). The lease only matters as a recovery mechanism when a worker dies mid-delivery — after LeaseDuration the row becomes eligible again and another worker picks it up. Long enough that a legitimate slow webhook won't be double-fired, short enough that a crashed worker doesn't strand its rows for hours.
const MaxDeliveryMetricsEndpoints = 50
MaxDeliveryMetricsEndpoints bounds the per-endpoint breakdown.
Variables ¶
var ErrDisallowedWebhookIP = errors.New("resolved to disallowed IP")
ErrDisallowedWebhookIP marks a dial-guard rejection (the URL resolved to a private/loopback/link-local address). Sentinel so transportErrorLabel can classify it into its customer-facing vocabulary without string matching; the wrapping error carries the concrete IP for process logs only — the customer-facing string never echoes it (a DNS-rebinding probe would otherwise learn internal addressing from the delivery history).
Functions ¶
func IsDisallowedWebhookIP ¶
IsDisallowedWebhookIP reports whether ip is in a range a webhook must never reach (loopback, RFC-1918 private, link-local incl. the cloud metadata endpoint 169.254.169.254, CGNAT shared space, multicast, unspecified, IPv6 ULA). Shared by registration-time validation (agent.ValidateWebhookURL) and the delivery-time dial guard below so the two can never drift — closing the DNS-rebinding window where a host validates as public at registration then re-resolves to an internal address before delivery.
Types ¶
type AccountDeliveryMetrics ¶ added in v1.7.0
type AccountDeliveryMetrics struct {
Totals DeliveryOutcomeCounts
Endpoints []EndpointDeliveryMetrics
// EndpointsTruncated reports that the account has more endpoints with
// traffic than Endpoints contains. Totals stay complete.
EndpointsTruncated bool
// EndpointsAutoDisabled counts endpoints e2a has auto-disabled. Computed
// across every endpoint the account owns, not just the listed ones, and
// independent of whether the endpoint had traffic in this window.
EndpointsAutoDisabled int64
// WindowExceedsRetention is true when the requested window starts before
// the delivery-retention horizon, so older rows have been pruned and the
// counts below understate that stretch. Without this a 90-day view looks
// like a collapse in webhook volume rather than a retention boundary.
WindowExceedsRetention bool
}
AccountDeliveryMetrics is the account-wide webhook aggregate.
type AutoDisableWorker ¶
type AutoDisableWorker struct {
// contains filtered or unexported fields
}
AutoDisableWorker scans for chronically-failing webhooks and disables them, warns owners whose webhooks have started failing at the attempt level (long before the breaker can trip), and clears expired signing_secret_prev rows past their 24h grace window.
The passes share a worker because they're all cheap, idempotent, and run on the same low cadence. The schedule is owned by River (webhookdelivery.MaintenanceJobs, a periodic on QueueMaintenance) which drives Tick; this type is the sweep body.
func NewAutoDisableWorker ¶
func NewAutoDisableWorker(store *identity.Store) *AutoDisableWorker
NewAutoDisableWorker constructs the sweep. River drives Tick on a periodic schedule; tests can call Tick directly.
func (*AutoDisableWorker) SetNotifier ¶ added in v1.7.0
func (w *AutoDisableWorker) SetNotifier(n HealthNotifyEnqueuer)
SetNotifier injects the health-notification enqueuer (two-phase wiring: the concrete *webhooknotify.Jobs is built alongside the registrars, before the shared River client exists). nil-safe — unset means the sweep transitions state without enqueuing notifications.
func (*AutoDisableWorker) Tick ¶
func (w *AutoDisableWorker) Tick(ctx context.Context)
Tick runs the maintenance passes once. Driven by the River periodic (and directly by tests).
Order matters: the disable pass runs BEFORE the warn pass so that a burst crossing both thresholds inside one sweep interval produces only the disable email — the warn pass's enabled = true predicate excludes the rows this tick just disabled. (The notify worker's kind=warning guard drops a stale warning against a since-disabled webhook as a second line of defense.)
type Delivery ¶
type Delivery struct {
AgentID string `json:"agent_id"`
MessageID string `json:"message_id"`
Status string `json:"status"`
Attempts int `json:"attempts"`
MaxAttempts int `json:"max_attempts"`
LastError string `json:"last_error"`
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
NextRetryAt time.Time `json:"next_retry_at"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type DeliveryOutcome ¶
DeliveryOutcome is what the deliverer returns to the caller for status accounting. statusCode is 0 when there was no HTTP response (connection error, timeout, DNS failure).
type DeliveryOutcomeCounts ¶ added in v1.7.0
type DeliveryOutcomeCounts struct {
Total int64
Delivered int64
Pending int64
EndpointRejected int64
NoResponse int64
}
DeliveryOutcomeCounts is one population's delivery tally.
The two failure buckets are what the TABLE can actually prove, which is not the same split the internal telemetry makes. The worker labels its metrics endpoint_failure vs e2a_failure, but both write status='failed' with no stored discriminator, so that attribution cannot be recovered here. What survives is whether the endpoint ANSWERED:
- EndpointRejected: the endpoint returned a non-2xx (last_status_code is a real HTTP status). Unambiguously the subscriber's own response.
- NoResponse: no HTTP response was ever received — connect/DNS/TLS failure, an SSRF-blocked URL, a delivery that expired while pending, or (rarely) an e2a-side error. Predominantly an unreachable endpoint, but NOT a clean "your fault" bucket, and it must not be presented as one.
func (DeliveryOutcomeCounts) Failed ¶ added in v1.7.0
func (c DeliveryOutcomeCounts) Failed() int64
Failed is the terminal failure count across both attributable buckets.
type DeliveryStore ¶
type DeliveryStore struct {
// contains filtered or unexported fields
}
func NewDeliveryStore ¶
func NewDeliveryStore(pool *pgxpool.Pool) *DeliveryStore
func (*DeliveryStore) CreateDelivery ¶
func (*DeliveryStore) DeleteExpiredDeliveries ¶
func (s *DeliveryStore) DeleteExpiredDeliveries(ctx context.Context) (int64, error)
func (*DeliveryStore) GetPendingDeliveries ¶
GetPendingDeliveries atomically claims up to `limit` due deliveries. Each returned row's next_retry_at is pushed by LeaseDuration so other workers (in this process or a different replica) won't grab the same row. The standard `WHERE status='pending' AND next_retry_at <= now()` filter then naturally excludes leased rows.
This must run inside a transaction: `FOR UPDATE SKIP LOCKED` only holds the row lock for the lifetime of the surrounding transaction. pool.Query (autocommit) would release the lock as soon as the SELECT completed, leaving a window where two callers could each return the same row.
func (*DeliveryStore) MarkAttemptFailed ¶
func (*DeliveryStore) MarkDelivered ¶
func (s *DeliveryStore) MarkDelivered(ctx context.Context, messageID string) error
func (*DeliveryStore) MarkFailed ¶
func (s *DeliveryStore) MarkFailed(ctx context.Context, messageID, errMsg string) error
type EndpointDeliveryMetrics ¶ added in v1.7.0
type EndpointDeliveryMetrics struct {
WebhookID string
// URLHost is the endpoint's host only. The full URL can carry credentials
// in its path or query string, and a metrics payload is the last place
// that should be re-emitted — the ID identifies the webhook well enough.
URLHost string
Counts DeliveryOutcomeCounts
// LastStatusCode is the most recent HTTP status observed for this
// endpoint in the window, or nil when nothing ever answered. A constant
// 405 or 401 tells a customer exactly what to fix.
LastStatusCode *int32
// Enabled / AutoDisabledAt / AutoDisableReason mirror the webhook's health
// state. An endpoint e2a auto-disabled after sustained failure is the most
// actionable fact on this whole page — it means events are being dropped
// right now, not merely retried — so it travels with the counts rather
// than living only on the webhooks screen.
Enabled bool
AutoDisabledAt *time.Time
AutoDisableReason string
}
EndpointDeliveryMetrics is one subscriber endpoint's slice.
type HealthNotifyEnqueuer ¶ added in v1.7.0
type HealthNotifyEnqueuer interface {
// EnqueueDisabledTx enqueues the "we disabled your webhook" email job.
EnqueueDisabledTx(ctx context.Context, tx pgx.Tx, webhookID string) error
// EnqueueWarningTx enqueues the "your webhook is failing" early-warning
// email job.
EnqueueWarningTx(ctx context.Context, tx pgx.Tx, webhookID string) error
}
HealthNotifyEnqueuer enqueues webhook health-notification jobs inside the sweep's transaction — one per state transition, atomically with it. Implemented by *webhooknotify.Jobs; nil (the default) leaves the sweep's state transitions running with no notifications, exactly the pre-feature behavior (self-hosts without an SMTP relay).
type Payload ¶
type Payload struct {
MessageID string `json:"message_id,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
From string `json:"from"`
// To is the parsed To: header from the inbound message — every fan-out
// delivery for one inbound message carries the same list. delivered_to is
// this delivery's per-agent target — the envelope Delivered-To address
// (always one of the addressed agents, not necessarily in To: when the
// agent was Bcc'd).
To []string `json:"to"`
CC []string `json:"cc,omitempty"`
// ReplyTo is the parsed Reply-To: header (RFC 5322 § 3.6.2 — list, single
// value is typical but multi is legal). Empty list when the header is
// absent; the relay never silently falls back to From: so consumers can
// distinguish "sender didn't request a different reply mailbox" from
// "sender explicitly named these mailboxes".
ReplyTo []string `json:"reply_to,omitempty"`
Recipient string `json:"delivered_to"`
RawMessage []byte `json:"raw_message"`
AuthHeaders map[string]string `json:"auth_headers"`
ReceivedAt time.Time `json:"received_at"`
}
type SubscriberDeliverer ¶
type SubscriberDeliverer struct {
// contains filtered or unexported fields
}
SubscriberDeliverer performs the HTTP POST for a webhook_subscriber_deliveries row, signs the request with the per-webhook HMAC secret, and reports success / failure to the caller. Distinct from the retired legacy per-agent delivery path.
Slice 1 carries only the current secret. Slice 4 will extend this to dual-sign during the 24h rotation grace window.
func NewSubscriberDeliverer ¶
func NewSubscriberDeliverer(requireHTTPS bool, internalSinkURL string) *SubscriberDeliverer
NewSubscriberDeliverer constructs the deliverer with the 15s per-attempt timeout chosen in design decision #6.
requireHTTPS gates against plaintext URLs in production. The same flag installs a dial-time IP guard (guardedDialControl): registration-time ValidateWebhookURL validates DNS once, but a hostname can re-resolve to an internal IP before delivery (DNS rebinding). The guard re-checks the actual resolved IP at connect time, closing that window. It is gated to production so local/CI deliveries to 127.0.0.1 still work.
internalSinkURL (usually empty) names ONE trusted internal sink — the e2a-prober's /sink — reached over plain HTTP on an internal host. Deliveries to that EXACT URL bypass the HTTPS + SSRF guards via a separate exemptClient. This is safe because: (1) the value is server-operator config, never attacker input; (2) it is matched by exact string equality, so it grants access to no other internal address; and (3) the probe webhook that targets it is created by the privileged prober `seed`, not the public registration API (which rejects http:// + private hosts). Empty disables the exemption entirely.
func (*SubscriberDeliverer) Deliver ¶
func (d *SubscriberDeliverer) Deliver(ctx context.Context, url string, body []byte, secret, secretPrev, eventType, schemaVersion string) DeliveryOutcome
Deliver performs one POST attempt. It signs the request body with the supplied HMAC secret in Stripe-style header format:
X-E2A-Signature: t=<unix>,v1=<hex(hmac-sha256(secret, "<t>.<body>"))>
secretPrev (if non-empty) adds a second v1=... signature for the receiver to verify against during the 24h rotation grace window. Slice 1 always passes secretPrev="" (no grace logic yet); slice 4 wires this up.
2xx responses are success. Anything else (including 3xx, since redirects are blocked) is a failure with the HTTP status code reported back. Connection errors return Success=false and StatusCode=0.
type SubscriberDelivery ¶
type SubscriberDelivery struct {
ID string
WebhookID string
EventType string
EventPayload []byte // pre-marshalled envelope bytes; POSTed verbatim
MessageID *string
Status string // pending | delivered | failed
Attempts int
MaxAttempts int
LastError string
LastStatusCode *int
LastAttemptAt *time.Time
NextRetryAt time.Time
CreatedAt time.Time
ExpiresAt time.Time
// ReplayID is non-nil for customer-initiated replay rows (a re-delivery
// of an old event), nil for first-delivery rows. The first-attempt
// latency SLI observes first-delivery rows only: a replay's baseline
// would be the ORIGINAL event's created_at, recording the replay lag
// as a false outlier.
ReplayID *string
// EventCreatedAt is the originating webhook_events row's created_at —
// the baseline of the event→first-attempt latency SLI. Nil for rows
// without an event link (the /test endpoint's deliveries).
EventCreatedAt *time.Time
}
SubscriberDelivery is one row in webhook_subscriber_deliveries. Distinct from the legacy Delivery struct (which is keyed by message_id and tracks legacy single-URL delivery state).
type SubscriberStore ¶
type SubscriberStore struct {
// contains filtered or unexported fields
}
SubscriberStore manages webhook_subscriber_deliveries. Parallel to the legacy DeliveryStore (which manages webhook_deliveries).
func NewSubscriberStore ¶
func NewSubscriberStore(pool *pgxpool.Pool) *SubscriberStore
func (*SubscriberStore) CountDeliveriesForAccount ¶ added in v1.7.0
func (s *SubscriberStore) CountDeliveriesForAccount(ctx context.Context, userID string, start, end time.Time) (AccountDeliveryMetrics, error)
CountDeliveriesForAccount aggregates webhook delivery outcomes for one account over a window.
The window is anchored on the DELIVERY row's own created_at, not on the originating message's. Deliveries outlive their messages by design — message_id is ON DELETE SET NULL precisely so history survives the message janitor — so anchoring on the message would silently drop every delivery whose message had been pruned.
The grain is one row per (event, subscriber) pair: an account with three webhooks matching the same event produces three rows for one message. These counts therefore exceed message counts and must never be presented as duplicate mail.
func (*SubscriberStore) DeleteExpiredSubscriberDeliveries ¶
func (s *SubscriberStore) DeleteExpiredSubscriberDeliveries(ctx context.Context) (deleted, marked int, err error)
DeleteExpiredSubscriberDeliveries enforces the 30-day TTL (migration 025) in two phases; without it the table grows monotonically and query plans degrade. Mirrors DeliveryStore.DeleteExpiredDeliveries for the legacy table, with one refinement:
- DELETE expired rows already in a terminal state (delivered/failed) — as before.
- Expired rows still 'pending' are NOT deleted but marked terminally 'failed' ("expired before delivery"), so a delivery record never silently vanishes while pending: the transition is counted (the caller logs it and emits the WebhookExpiredPending metric) and visible in the delivery-history API until the NEXT sweep deletes the now-terminal row.
Phase 2's population should be ~empty now that the delivery reconciler also rescues dead-job strands; what remains is essentially rows snoozing behind a webhook disabled for longer than the TTL. Marking those failed (instead of a bare `status <> 'pending'` guard on the DELETE) keeps them from becoming immortal — unbounded growth behind a permanently-disabled webhook. Phase 2 runs AFTER phase 1 so a freshly marked row survives until the following sweep. The DeliverWorker treats any non-pending row as a no-op, so a still-snoozing job waking up later never POSTs a row marked here.
Returns the rows deleted (phase 1) and the rows marked failed (phase 2).
func (*SubscriberStore) GetSubscriberDeliveryByID ¶
func (s *SubscriberStore) GetSubscriberDeliveryByID(ctx context.Context, deliveryID string) (*SubscriberDelivery, error)
GetSubscriberDeliveryByID loads a single delivery row by id — the River DeliverWorker's entry point (it holds only the delivery id and reads the payload + webhook_id here). Returns pgx.ErrNoRows if the row is gone. The replay_id + joined webhook_events.created_at feed the event→first-attempt latency SLI (LEFT JOIN: rows without an event link, e.g. /test deliveries, load a nil EventCreatedAt).
func (*SubscriberStore) InsertPendingForTest ¶
func (s *SubscriberStore) InsertPendingForTest(ctx context.Context, webhookID, eventType string, envelope []byte) (string, error)
InsertPendingForTest creates a single delivery row tied to the given webhook + event type with the supplied envelope bytes. The retry worker picks it up on the next tick. Used by the POST /v1/webhooks/{id}/test endpoint to schedule a one-off delivery without going through the publisher's filter-matching path.
func (*SubscriberStore) ListDeliveriesByWebhook ¶
func (s *SubscriberStore) ListDeliveriesByWebhook(ctx context.Context, webhookID, status string, limit int, afterCreatedAt time.Time, afterID string) ([]SubscriberDelivery, error)
ListDeliveriesByWebhook returns one page of delivery rows for the webhook, most-recent first, keyset-paginated on (created_at, id). When status is non-empty, restricts to that status (pending|delivered|failed). The caller passes limit (fetch limit+1 to detect a further page) and the after-key from the previous page's last row (zero afterCreatedAt = first page). The delivery log grows unbounded on a busy webhook, so it needs real pagination rather than silently truncating at a fixed cap. Limit is bounded by the caller; this method does not enforce a cap.
func (*SubscriberStore) MarkDelivered ¶
func (s *SubscriberStore) MarkDelivered(ctx context.Context, deliveryID string, statusCode int) error
MarkDelivered transitions a row to status='delivered' and stamps last_attempt_at + last_status_code. Also bumps webhooks.last_delivered_at in the same transaction so list views show the freshest activity.
func (*SubscriberStore) MarkDeliveredIfPending ¶ added in v1.4.1
func (s *SubscriberStore) MarkDeliveredIfPending(ctx context.Context, deliveryID string, statusCode int) (bool, error)
MarkDeliveredIfPending is the transition-aware form used by the delivery worker's terminal SLI. It returns true only when this call changed a pending row to delivered, so a duplicate River execution cannot count the same delivery twice.
func (*SubscriberStore) MarkSubscriberFailed ¶
func (s *SubscriberStore) MarkSubscriberFailed(ctx context.Context, deliveryID string, attemptN int, errMsg string, statusCode int) error
MarkSubscriberFailed transitions a delivery to terminal 'failed' — called by the DeliverWorker on the last (River-exhausted) attempt. Records the final attempt count + error.
func (*SubscriberStore) MarkSubscriberFailedIfPending ¶ added in v1.3.0
func (s *SubscriberStore) MarkSubscriberFailedIfPending(ctx context.Context, deliveryID string, attemptN int, errMsg string, statusCode int) error
MarkSubscriberFailedIfPending is MarkSubscriberFailed conditional on the row still being 'pending' — the BLIND terminal write for the final-attempt row-load failure, where the read error means the row's true state is unknown. It must never clobber a row that already reached a terminal state (e.g. delivered by a path the failed read couldn't see). A missing row is a no-op, not an error.
func (*SubscriberStore) RecordSubscriberAttempt ¶
func (s *SubscriberStore) RecordSubscriberAttempt(ctx context.Context, deliveryID string, attemptN int, errMsg string, statusCode int) error
RecordSubscriberAttempt records ONE failed attempt without deciding retry or terminality — under River the retry schedule and the give-up decision belong to the job, not the store. Status stays 'pending'; attempts/last_error/ last_status_code/last_attempt_at are updated. (Contrast RecordAttemptFailure, the hand-rolled path, which also computed next_retry_at and flipped to 'failed' at the cap — retired with the legacy worker.) attemptN is the River job's attempt number, written verbatim so the history API's attempts count matches River's.
The status='pending' guard makes the write a no-op on a row that terminalized mid-attempt: without it, a worker mid-POST racing the expiry janitor's mark-failed would resurrect the row to 'pending' when its non-final attempt failed. Semantically the method only ever applies to in-flight rows anyway.
func (*SubscriberStore) TransitionSubscriberFailedIfPending ¶ added in v1.4.1
func (s *SubscriberStore) TransitionSubscriberFailedIfPending(ctx context.Context, deliveryID string, attemptN int, errMsg string, statusCode int) (bool, error)
TransitionSubscriberFailedIfPending is the transition-aware form used by terminal SLI emission. A false result means the row was already terminal or gone, so callers must not increment the per-delivery terminal counter.