189 lines
5.1 KiB
Go
189 lines
5.1 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"math/rand/v2"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/example/notify-gateway/internal/config"
|
|
"github.com/example/notify-gateway/internal/divera"
|
|
"github.com/example/notify-gateway/internal/model"
|
|
"github.com/example/notify-gateway/internal/outbound"
|
|
"github.com/example/notify-gateway/internal/outbox"
|
|
)
|
|
|
|
type Delivery struct {
|
|
Kind string `json:"kind"`
|
|
Payload map[string]any `json:"payload"`
|
|
Outbound *config.OutboundConfig `json:"outbound,omitempty"`
|
|
Divera *config.DiveraConfig `json:"divera,omitempty"`
|
|
}
|
|
type Queue struct {
|
|
Store *outbox.Store
|
|
Dispatcher *Dispatcher
|
|
Logger *log.Logger
|
|
}
|
|
type InputError struct{ Err error }
|
|
|
|
func (e *InputError) Error() string { return e.Err.Error() }
|
|
func (e *InputError) Unwrap() error { return e.Err }
|
|
|
|
func (q *Queue) Accept(ctx context.Context, msg model.InboundMessage, key string) (outbox.Receipt, error) {
|
|
if len(key) > 256 {
|
|
return outbox.Receipt{}, &InputError{errors.New("Idempotency-Key exceeds 256 bytes")}
|
|
}
|
|
original := msg
|
|
original.ReceivedAt = time.Time{}
|
|
b, err := json.Marshal(original)
|
|
if err != nil {
|
|
return outbox.Receipt{}, &InputError{err}
|
|
}
|
|
hash := sha256.Sum256(b)
|
|
digest := hex.EncodeToString(hash[:])
|
|
scopeBytes, _ := json.Marshal([]string{msg.Source, msg.Channel})
|
|
scope := string(scopeBytes)
|
|
if key == "" {
|
|
key = outbox.ID()
|
|
} else {
|
|
r, err := q.Store.Lookup(ctx, scope, key, digest)
|
|
if err != nil || r.ID != "" {
|
|
return r, err
|
|
}
|
|
}
|
|
cfg := q.Dispatcher.store.Get()
|
|
var jobs []outbox.Job
|
|
for _, m := range cfg.Mappings {
|
|
if !m.Enabled || !matches(m, msg) {
|
|
continue
|
|
}
|
|
payload, kind, err := buildPayload(m, msg)
|
|
if err != nil {
|
|
return outbox.Receipt{}, &InputError{fmt.Errorf("mapping %s: %w", m.ID, err)}
|
|
}
|
|
delivery := Delivery{Kind: kind, Payload: payload}
|
|
if config.IsOutbound(kind) {
|
|
for _, o := range cfg.Outbounds {
|
|
if o.ID == m.OutboundID && o.Provider == kind {
|
|
delivery.Outbound = &o
|
|
break
|
|
}
|
|
}
|
|
if delivery.Outbound == nil {
|
|
return outbox.Receipt{}, &InputError{errors.New("outbound destination missing")}
|
|
}
|
|
} else {
|
|
delivery.Divera = &cfg.Divera
|
|
}
|
|
data, err := json.Marshal(delivery)
|
|
if err != nil {
|
|
return outbox.Receipt{}, err
|
|
}
|
|
jobs = append(jobs, outbox.Job{MappingID: m.ID, Target: m.Target, Data: data})
|
|
}
|
|
if len(jobs) == 0 {
|
|
return outbox.Receipt{}, &InputError{errors.New("no mapping matched")}
|
|
}
|
|
return q.Store.Enqueue(ctx, scope, key, digest, jobs)
|
|
}
|
|
|
|
func retryDelay(attempt int, after time.Duration) time.Duration {
|
|
d := 5 * time.Second * time.Duration(1<<min(max(attempt-1, 0), 8))
|
|
d += time.Duration(rand.Int64N(int64(d/5) + 1))
|
|
if after > d {
|
|
d = after
|
|
}
|
|
return min(d, 24*time.Hour)
|
|
}
|
|
|
|
// ProcessOne is also used by integration tests. A lease survives process crashes.
|
|
func (q *Queue) ProcessOne(ctx context.Context) (bool, error) {
|
|
j, err := q.Store.Claim(ctx, time.Now())
|
|
if err != nil || j == nil {
|
|
return false, err
|
|
}
|
|
var d Delivery
|
|
var resp outbound.Response
|
|
err = json.Unmarshal(j.Data, &d)
|
|
dry := false
|
|
retry := false
|
|
if err == nil {
|
|
if d.Outbound != nil {
|
|
dry = !d.Outbound.Live
|
|
resp, err = outbound.SendWithID(ctx, *d.Outbound, d.Payload, j.ID)
|
|
retry = resp.Retryable
|
|
} else if d.Divera != nil {
|
|
dry = d.Divera.DryRun
|
|
c := divera.New(func() config.DiveraConfig { return *d.Divera })
|
|
r, e := c.Create(ctx, d.Kind, d.Payload)
|
|
resp.StatusCode = r.StatusCode
|
|
resp.RetryAfter = r.RetryAfter
|
|
err = e
|
|
retry = outbound.RetryableHTTP(r.StatusCode)
|
|
} else {
|
|
err = errors.New("invalid persisted destination")
|
|
}
|
|
}
|
|
state := "succeeded"
|
|
message := ""
|
|
if dry {
|
|
state = "dry_run"
|
|
}
|
|
if err != nil {
|
|
// Never persist provider error strings: URLs, SMTP responses and templates may contain secrets.
|
|
message = "Zustellung fehlgeschlagen"
|
|
if resp.StatusCode != 0 {
|
|
message = fmt.Sprintf("Provider-Status %d", resp.StatusCode)
|
|
}
|
|
state = "dead"
|
|
if retry && j.Attempts < 8 {
|
|
state = "pending"
|
|
}
|
|
}
|
|
next := time.Now().Add(retryDelay(j.Attempts, resp.RetryAfter))
|
|
// Persist the outcome even if shutdown canceled the network request.
|
|
finishCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := q.Store.Finish(finishCtx, *j, state, resp.StatusCode, message, next); err != nil {
|
|
return true, err
|
|
}
|
|
if q.Logger != nil {
|
|
q.Logger.Printf("delivery id=%s state=%s attempt=%d status=%d", j.ID, state, j.Attempts, resp.StatusCode)
|
|
}
|
|
return true, nil
|
|
}
|
|
func (q *Queue) Run(ctx context.Context) {
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
worked, err := q.ProcessOne(ctx)
|
|
if err != nil && q.Logger != nil {
|
|
q.Logger.Print("outbox worker: database operation failed")
|
|
}
|
|
if worked && err == nil {
|
|
continue
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// Message keys should never contain transport credentials.
|
|
func ScopedKey(parts ...string) string {
|
|
b, _ := json.Marshal(parts)
|
|
sum := sha256.Sum256(b)
|
|
return strings.ToLower(hex.EncodeToString(sum[:]))
|
|
}
|