feat: notifications retry mechanism and improved error formatting

This commit is contained in:
yusing 2025-05-03 14:30:40 +08:00
parent 2fe4fef779
commit 82c829de18
9 changed files with 146 additions and 54 deletions

View file

@ -16,9 +16,16 @@ type ProviderBase struct {
Format *LogFormat `json:"format"` Format *LogFormat `json:"format"`
} }
type rawError []byte
func (e rawError) Error() string {
return string(e)
}
var ( var (
ErrMissingToken = gperr.New("token is required") ErrMissingToken = gperr.New("token is required")
ErrURLMissingScheme = gperr.New("url missing scheme, expect 'http://' or 'https://'") ErrURLMissingScheme = gperr.New("url missing scheme, expect 'http://' or 'https://'")
ErrUnknownError = gperr.New("unknown error")
) )
// Validate implements the utils.CustomValidator interface. // Validate implements the utils.CustomValidator interface.
@ -61,10 +68,10 @@ func (base *ProviderBase) SetHeaders(logMsg *LogMessage, headers http.Header) {
// no-op by default // no-op by default
} }
func (base *ProviderBase) makeRespError(resp *http.Response) error { func (base *ProviderBase) fmtError(respBody io.Reader) error {
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(respBody)
if err == nil { if err == nil && len(body) > 0 {
return gperr.Errorf("%s status %d: %s", base.Name, resp.StatusCode, body) return rawError(body)
} }
return gperr.Errorf("%s status %d", base.Name, resp.StatusCode) return ErrUnknownError
} }

View file

@ -55,6 +55,10 @@ func (f *LogFormat) Parse(format string) error {
return nil return nil
} }
func (f *FieldsBody) Add(name, value string) {
*f = append(*f, LogField{Name: name, Value: value})
}
func (f FieldsBody) Format(format *LogFormat) ([]byte, error) { func (f FieldsBody) Format(format *LogFormat) ([]byte, error) {
switch format { switch format {
case LogFormatMarkdown: case LogFormatMarkdown:

View file

@ -1,18 +1,21 @@
package notif package notif
import ( import (
"time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/task"
F "github.com/yusing/go-proxy/internal/utils/functional" F "github.com/yusing/go-proxy/internal/utils/functional"
) )
type ( type (
Dispatcher struct { Dispatcher struct {
task *task.Task task *task.Task
logCh chan *LogMessage providers F.Set[Provider]
providers F.Set[Provider] logCh chan *LogMessage
retryCh chan *RetryMessage
retryTicker *time.Ticker
} }
LogMessage struct { LogMessage struct {
Level zerolog.Level Level zerolog.Level
@ -20,17 +23,33 @@ type (
Body LogBody Body LogBody
Color Color Color Color
} }
RetryMessage struct {
Message *LogMessage
Trials int
Provider Provider
}
) )
var dispatcher *Dispatcher var dispatcher *Dispatcher
const dispatchErr = "notification dispatch error" const retryInterval = 5 * time.Second
var maxRetries = map[zerolog.Level]int{
zerolog.DebugLevel: 1,
zerolog.InfoLevel: 1,
zerolog.WarnLevel: 3,
zerolog.ErrorLevel: 5,
zerolog.FatalLevel: 10,
zerolog.PanicLevel: 10,
}
func StartNotifDispatcher(parent task.Parent) *Dispatcher { func StartNotifDispatcher(parent task.Parent) *Dispatcher {
dispatcher = &Dispatcher{ dispatcher = &Dispatcher{
task: parent.Subtask("notification"), task: parent.Subtask("notification"),
logCh: make(chan *LogMessage), providers: F.NewSet[Provider](),
providers: F.NewSet[Provider](), logCh: make(chan *LogMessage),
retryCh: make(chan *RetryMessage, 100),
retryTicker: time.NewTicker(retryInterval),
} }
go dispatcher.start() go dispatcher.start()
return dispatcher return dispatcher
@ -48,10 +67,6 @@ func Notify(msg *LogMessage) {
} }
} }
func (f *FieldsBody) Add(name, value string) {
*f = append(*f, LogField{Name: name, Value: value})
}
func (disp *Dispatcher) RegisterProvider(cfg *NotificationConfig) { func (disp *Dispatcher) RegisterProvider(cfg *NotificationConfig) {
disp.providers.Add(cfg.Provider) disp.providers.Add(cfg.Provider)
} }
@ -61,6 +76,7 @@ func (disp *Dispatcher) start() {
dispatcher = nil dispatcher = nil
disp.providers.Clear() disp.providers.Clear()
close(disp.logCh) close(disp.logCh)
close(disp.retryCh)
disp.task.Finish(nil) disp.task.Finish(nil)
}() }()
@ -73,6 +89,23 @@ func (disp *Dispatcher) start() {
return return
} }
go disp.dispatch(msg) go disp.dispatch(msg)
case <-disp.retryTicker.C:
if len(disp.retryCh) == 0 {
continue
}
var msgs []*RetryMessage
done := false
for !done {
select {
case msg := <-disp.retryCh:
msgs = append(msgs, msg)
default:
done = true
}
}
if err := disp.retry(msgs); err != nil {
gperr.LogError("notification retry failed", err)
}
} }
} }
} }
@ -81,15 +114,34 @@ func (disp *Dispatcher) dispatch(msg *LogMessage) {
task := disp.task.Subtask("dispatcher") task := disp.task.Subtask("dispatcher")
defer task.Finish("notif dispatched") defer task.Finish("notif dispatched")
errs := gperr.NewBuilderWithConcurrency(dispatchErr)
disp.providers.RangeAllParallel(func(p Provider) { disp.providers.RangeAllParallel(func(p Provider) {
if err := notifyProvider(task.Context(), p, msg); err != nil { if err := msg.notify(task.Context(), p); err != nil {
errs.Add(gperr.PrependSubject(p.GetName(), err)) disp.retryCh <- &RetryMessage{
Message: msg,
Trials: 0,
Provider: p,
}
} }
}) })
if errs.HasError() { }
gperr.LogError(errs.About(), errs.Error())
} else { func (disp *Dispatcher) retry(messages []*RetryMessage) error {
logging.Debug().Str("title", msg.Title).Msgf("dispatched notif") task := disp.task.Subtask("retry")
} defer task.Finish("notif retried")
errs := gperr.NewBuilder("notification failure")
for _, msg := range messages {
err := msg.Message.notify(task.Context(), msg.Provider)
if err == nil {
continue
}
if msg.Trials > maxRetries[msg.Message.Level] {
errs.Addf("notification provider %s failed after %d trials", msg.Provider.GetName(), msg.Trials)
errs.Add(err)
continue
}
msg.Trials++
disp.retryCh <- msg
}
return errs.Error()
} }

View file

@ -3,7 +3,7 @@ package notif
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "io"
"github.com/gotify/server/v2/model" "github.com/gotify/server/v2/model"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@ -62,12 +62,12 @@ func (client *GotifyClient) MarshalMessage(logMsg *LogMessage) ([]byte, error) {
return data, nil return data, nil
} }
// makeRespError implements Provider. // fmtError implements Provider.
func (client *GotifyClient) makeRespError(resp *http.Response) error { func (client *GotifyClient) fmtError(respBody io.Reader) error {
var errm model.Error var errm model.Error
err := json.NewDecoder(resp.Body).Decode(&errm) err := json.NewDecoder(respBody).Decode(&errm)
if err != nil { if err != nil {
return fmt.Errorf("%s status %d, but failed to decode err response: %w", client.Name, resp.StatusCode, err) return fmt.Errorf("failed to decode err response: %w", err)
} }
return fmt.Errorf("%s status %d %s: %s", client.Name, resp.StatusCode, errm.Error, errm.ErrorDescription) return fmt.Errorf("%s: %s", errm.Error, errm.ErrorDescription)
} }

View file

@ -3,11 +3,13 @@ package notif
import ( import (
"bytes" "bytes"
"context" "context"
"fmt"
"io"
"net/http" "net/http"
"time" "time"
"github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/gperr"
gphttp "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/utils" "github.com/yusing/go-proxy/internal/utils"
) )
@ -24,7 +26,7 @@ type (
MarshalMessage(logMsg *LogMessage) ([]byte, error) MarshalMessage(logMsg *LogMessage) ([]byte, error)
SetHeaders(logMsg *LogMessage, headers http.Header) SetHeaders(logMsg *LogMessage, headers http.Header)
makeRespError(resp *http.Response) error fmtError(respBody io.Reader) error
} }
ProviderCreateFunc func(map[string]any) (Provider, gperr.Error) ProviderCreateFunc func(map[string]any) (Provider, gperr.Error)
ProviderConfig map[string]any ProviderConfig map[string]any
@ -36,10 +38,10 @@ const (
ProviderWebhook = "webhook" ProviderWebhook = "webhook"
) )
func notifyProvider(ctx context.Context, provider Provider, msg *LogMessage) error { func (msg *LogMessage) notify(ctx context.Context, provider Provider) error {
body, err := provider.MarshalMessage(msg) body, err := provider.MarshalMessage(msg)
if err != nil { if err != nil {
return gperr.PrependSubject(provider.GetName(), err) return err
} }
ctx, cancel := context.WithTimeout(ctx, 2*time.Second) ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
@ -52,7 +54,7 @@ func notifyProvider(ctx context.Context, provider Provider, msg *LogMessage) err
bytes.NewReader(body), bytes.NewReader(body),
) )
if err != nil { if err != nil {
return gperr.PrependSubject(provider.GetName(), err) return err
} }
req.Header.Set("Content-Type", provider.GetMIMEType()) req.Header.Set("Content-Type", provider.GetMIMEType())
@ -63,13 +65,22 @@ func notifyProvider(ctx context.Context, provider Provider, msg *LogMessage) err
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return gperr.PrependSubject(provider.GetName(), err) return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if !gphttp.IsSuccess(resp.StatusCode) { switch resp.StatusCode {
return provider.makeRespError(resp) case http.StatusOK, http.StatusCreated, http.StatusAccepted:
body, _ := io.ReadAll(resp.Body)
logging.Debug().
Str("provider", provider.GetName()).
Str("url", provider.GetURL()).
Str("status", resp.Status).
RawJSON("resp_body", body).
Msg("notification sent")
return nil
default:
return fmt.Errorf("http status %d: %w", resp.StatusCode, provider.fmtError(resp.Body))
} }
return nil
} }

View file

@ -3,7 +3,6 @@ package notif
import ( import (
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"strings" "strings"
@ -88,16 +87,13 @@ func (webhook *Webhook) GetMIMEType() string {
return webhook.MIMEType return webhook.MIMEType
} }
// makeRespError implements Provider. // fmtError implements Provider.
func (webhook *Webhook) makeRespError(resp *http.Response) error { func (webhook *Webhook) fmtError(respBody io.Reader) error {
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(respBody)
if err != nil { if err != nil || len(body) == 0 {
return fmt.Errorf("%s status %d, failed to read body: %w", webhook.Name, resp.StatusCode, err) return ErrUnknownError
} }
if len(body) > 0 { return rawError(body)
return fmt.Errorf("%s status %d: %s", webhook.Name, resp.StatusCode, body)
}
return fmt.Errorf("%s status %d", webhook.Name, resp.StatusCode)
} }
func (webhook *Webhook) MarshalMessage(logMsg *LogMessage) ([]byte, error) { func (webhook *Webhook) MarshalMessage(logMsg *LogMessage) ([]byte, error) {

View file

@ -20,6 +20,26 @@ const (
HighlightWhite = BrightWhite + Bold HighlightWhite = BrightWhite + Bold
) )
func Error(s string) string {
return WithANSI(s, HighlightRed)
}
func Success(s string) string {
return WithANSI(s, HighlightGreen)
}
func Warning(s string) string {
return WithANSI(s, HighlightYellow)
}
func Info(s string) string {
return WithANSI(s, HighlightCyan)
}
func WithANSI(s string, ansi string) string {
return ansi + s + Reset
}
func StripANSI(s string) string { func StripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "") return ansiRegexp.ReplaceAllString(s, "")
} }

View file

@ -219,7 +219,7 @@ func DoYouMean(s string) string {
if s == "" { if s == "" {
return "" return ""
} }
return "Did you mean " + ansi.HighlightGreen + s + ansi.Reset + "?" return "Did you mean " + ansi.Info(s) + "?"
} }
func Pluralize(n int64) string { func Pluralize(n int64) string {

View file

@ -56,9 +56,11 @@ func (e *EventQueue) Start(eventCh <-chan Event, errCh <-chan gperr.Error) {
e.onFlush = func(events []Event) { e.onFlush = func(events []Event) {
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
e.onError(gperr.New("recovered panic in onFlush"). if err, ok := err.(error); ok {
Withf("%v", err). e.onError(gperr.Wrap(err).Subject(e.task.Name()))
Subject(e.task.Name())) } else {
e.onError(gperr.New("recovered panic in onFlush").Withf("%v", err).Subject(e.task.Name()))
}
if common.IsDebug { if common.IsDebug {
panic(string(debug.Stack())) panic(string(debug.Stack()))
} }