mirror of
https://github.com/yusing/godoxy.git
synced 2025-05-19 20:32:35 +02:00
feat: notifications retry mechanism and improved error formatting
This commit is contained in:
parent
2fe4fef779
commit
82c829de18
9 changed files with 146 additions and 54 deletions
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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, "")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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()))
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue