Add retry mechanism and failure notifications for nCore/qBittorrent setup
- Implemented a retry mechanism for external calls, allowing up to 3 attempts before failing. - Enhanced error handling to send failure notifications when setup steps fail, including detailed error messages. - Updated RunSummary model to include status, error step, and error message fields for better tracking of run outcomes. - Modified database schema to store failure metadata for runs. - Updated README.md to reflect changes in error handling and notification behavior.
This commit is contained in:
@@ -16,52 +16,68 @@ import (
|
||||
"ncore-hnr/internal/store"
|
||||
)
|
||||
|
||||
const retryAttempts = 3
|
||||
const retryDelay = 20 * time.Second
|
||||
|
||||
func Run(cfg config.Config) (model.RunSummary, error) {
|
||||
startedAt := time.Now().UTC()
|
||||
summary := model.RunSummary{StartedAt: startedAt, DryRun: cfg.DryRun}
|
||||
summary := model.RunSummary{StartedAt: startedAt, Status: "success", DryRun: cfg.DryRun}
|
||||
|
||||
db, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
return summary, err
|
||||
return failRun(cfg, nil, summary, "open database", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ncoreClient := ncore.New(newHTTPClient(cfg.HTTPTimeout), cfg.NCoreLoginURL, cfg.NCoreHitRunURL)
|
||||
if err := ncoreClient.Login(cfg.NCoreUsername, cfg.NCorePassword); err != nil {
|
||||
return summary, err
|
||||
}
|
||||
|
||||
page, err := ncoreClient.FetchHitRun()
|
||||
if err != nil {
|
||||
return summary, err
|
||||
var page model.HitRunPage
|
||||
if err := withRetry("fetch nCore HnR page", func() error {
|
||||
ncoreClient := ncore.New(newHTTPClient(cfg.HTTPTimeout), cfg.NCoreLoginURL, cfg.NCoreHitRunURL)
|
||||
if err := ncoreClient.Login(cfg.NCoreUsername, cfg.NCorePassword); err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
fetched, err := ncoreClient.FetchHitRun()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
page = fetched
|
||||
return nil
|
||||
}); err != nil {
|
||||
return failRun(cfg, db, summary, "fetch nCore HnR page", err)
|
||||
}
|
||||
summary.TotalRisk = len(page.Torrents)
|
||||
|
||||
var qbitClient *qbit.Client
|
||||
var qbitTorrents []model.QBitTorrent
|
||||
if !cfg.SkipQBit && cfg.QBitURL != "" {
|
||||
if err := withRetry("load qBittorrent torrents", func() error {
|
||||
client := qbit.New(cfg.QBitURL, newHTTPClient(cfg.HTTPTimeout))
|
||||
if err := client.Login(cfg.QBitUsername, cfg.QBitPassword); err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
torrents, err := client.Torrents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("list torrents: %w", err)
|
||||
}
|
||||
qbitClient = client
|
||||
qbitTorrents = torrents
|
||||
return nil
|
||||
}); err != nil {
|
||||
return failRun(cfg, db, summary, "load qBittorrent torrents", err)
|
||||
}
|
||||
}
|
||||
|
||||
activeIDs := map[int64]bool{}
|
||||
for _, torrent := range page.Torrents {
|
||||
activeIDs[torrent.ID] = true
|
||||
}
|
||||
if err := db.MarkResolved(activeIDs, startedAt); err != nil {
|
||||
return summary, err
|
||||
}
|
||||
|
||||
var qbitClient *qbit.Client
|
||||
var qbitTorrents []model.QBitTorrent
|
||||
if !cfg.SkipQBit && cfg.QBitURL != "" {
|
||||
qbitClient = qbit.New(cfg.QBitURL, newHTTPClient(cfg.HTTPTimeout))
|
||||
if err := qbitClient.Login(cfg.QBitUsername, cfg.QBitPassword); err != nil {
|
||||
return summary, err
|
||||
}
|
||||
qbitTorrents, err = qbitClient.Torrents()
|
||||
if err != nil {
|
||||
return summary, err
|
||||
}
|
||||
return failRun(cfg, db, summary, "mark resolved torrents", err)
|
||||
}
|
||||
|
||||
for _, torrent := range page.Torrents {
|
||||
state, err := db.UpsertSeen(torrent, startedAt)
|
||||
if err != nil {
|
||||
return summary, err
|
||||
return failRun(cfg, db, summary, "upsert torrent state", err)
|
||||
}
|
||||
|
||||
result := model.ActionResult{
|
||||
@@ -90,28 +106,32 @@ func Run(cfg config.Config) (model.RunSummary, error) {
|
||||
summary.Matched++
|
||||
|
||||
if !cfg.DryRun {
|
||||
if err := qbitClient.ForceStart(matched.Hash); err != nil {
|
||||
return summary, fmt.Errorf("force-start %q: %w", torrent.Name, err)
|
||||
if err := withRetry(fmt.Sprintf("force-start %q", torrent.Name), func() error {
|
||||
return qbitClient.ForceStart(matched.Hash)
|
||||
}); err != nil {
|
||||
return failRun(cfg, db, summary, fmt.Sprintf("force-start %q", torrent.Name), err)
|
||||
}
|
||||
result.ForceStarted = true
|
||||
summary.ForceStarted++
|
||||
|
||||
if err := qbitClient.Reannounce(matched.Hash); err != nil {
|
||||
return summary, fmt.Errorf("reannounce %q: %w", torrent.Name, err)
|
||||
if err := withRetry(fmt.Sprintf("reannounce %q", torrent.Name), func() error {
|
||||
return qbitClient.Reannounce(matched.Hash)
|
||||
}); err != nil {
|
||||
return failRun(cfg, db, summary, fmt.Sprintf("reannounce %q", torrent.Name), err)
|
||||
}
|
||||
result.Reannounced = true
|
||||
summary.Reannounced++
|
||||
}
|
||||
|
||||
if err := db.RecordQBit(torrent, matched, startedAt, result.ForceStarted, result.Reannounced); err != nil {
|
||||
return summary, err
|
||||
return failRun(cfg, db, summary, "record qBittorrent state", err)
|
||||
}
|
||||
|
||||
if startedAt.Sub(state.FirstSeenAt) >= cfg.AlertAfter {
|
||||
result.ManualNeeded = true
|
||||
summary.ManualNeeded++
|
||||
if err := db.MarkManualNeeded(torrent.ID, startedAt); err != nil {
|
||||
return summary, err
|
||||
return failRun(cfg, db, summary, "mark manual-needed torrent", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +139,7 @@ func Run(cfg config.Config) (model.RunSummary, error) {
|
||||
}
|
||||
|
||||
if err := db.InsertRun(summary); err != nil {
|
||||
return summary, err
|
||||
return failRun(cfg, db, summary, "record run", err)
|
||||
}
|
||||
|
||||
if cfg.NotificationDryRun {
|
||||
@@ -129,7 +149,9 @@ func Run(cfg config.Config) (model.RunSummary, error) {
|
||||
|
||||
notifier := newNotifier(cfg)
|
||||
if notifier != nil {
|
||||
if err := notifier.SendManualNeeded(summary.Results); err != nil {
|
||||
if err := withRetry("send manual-needed notification", func() error {
|
||||
return notifier.SendManualNeeded(summary.Results)
|
||||
}); err != nil {
|
||||
return summary, err
|
||||
}
|
||||
}
|
||||
@@ -215,8 +237,10 @@ func PrintStats(snapshot model.StatsSnapshot, asJSON bool) error {
|
||||
fmt.Println("Last run: none")
|
||||
} else {
|
||||
run := snapshot.LastRun
|
||||
fmt.Printf("Last run: %s | total=%d matched=%d unmatched=%d force-started=%d reannounced=%d manual-needed=%d dry-run=%t\n",
|
||||
status := defaultText(run.Status, "success")
|
||||
fmt.Printf("Last run: %s | status=%s total=%d matched=%d unmatched=%d force-started=%d reannounced=%d manual-needed=%d dry-run=%t\n",
|
||||
run.StartedAt,
|
||||
status,
|
||||
run.TotalRisk,
|
||||
run.Matched,
|
||||
run.Unmatched,
|
||||
@@ -225,6 +249,9 @@ func PrintStats(snapshot model.StatsSnapshot, asJSON bool) error {
|
||||
run.ManualNeeded,
|
||||
run.DryRun,
|
||||
)
|
||||
if status == "failed" {
|
||||
fmt.Printf("Last error: %s | %s\n", defaultText(run.ErrorStep, "-"), defaultText(run.ErrorMessage, "-"))
|
||||
}
|
||||
}
|
||||
|
||||
if len(snapshot.ManualNeeded) > 0 {
|
||||
@@ -319,3 +346,58 @@ func printNotificationPreview(notificationType string, results []model.ActionRes
|
||||
fmt.Fprintf(os.Stderr, "notification dry-run: type=%s\n", notificationType)
|
||||
fmt.Fprintf(os.Stderr, "Subject: %s\n\n%s", subject, body)
|
||||
}
|
||||
|
||||
func withRetry(step string, fn func() error) error {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= retryAttempts; attempt++ {
|
||||
if err := fn(); err != nil {
|
||||
lastErr = err
|
||||
fmt.Fprintf(os.Stderr, "%s failed (attempt %d/%d): %v\n", step, attempt, retryAttempts, err)
|
||||
if attempt < retryAttempts {
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed after %d attempts: %w", retryAttempts, lastErr)
|
||||
}
|
||||
|
||||
func failRun(cfg config.Config, db *store.Store, summary model.RunSummary, step string, err error) (model.RunSummary, error) {
|
||||
summary.Status = "failed"
|
||||
summary.ErrorStep = step
|
||||
summary.ErrorMessage = err.Error()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "run failed at %s: %v\n", step, err)
|
||||
if db != nil {
|
||||
if insertErr := db.InsertRun(summary); insertErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "record failed run: %v\n", insertErr)
|
||||
}
|
||||
}
|
||||
|
||||
if notifyErr := sendFailureNotification(cfg, step, err); notifyErr != nil {
|
||||
return summary, fmt.Errorf("%s: %w; failure notification failed: %v", step, err, notifyErr)
|
||||
}
|
||||
return summary, fmt.Errorf("%s: %w", step, err)
|
||||
}
|
||||
|
||||
func sendFailureNotification(cfg config.Config, step string, err error) error {
|
||||
if cfg.NotificationDryRun {
|
||||
subject, body := notify.FailureMessage(step, err)
|
||||
notificationType := cfg.NotificationType
|
||||
if notificationType == "" {
|
||||
notificationType = "unconfigured"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "failure notification dry-run: type=%s\n", notificationType)
|
||||
fmt.Fprintf(os.Stderr, "Subject: %s\n\n%s", subject, body)
|
||||
return nil
|
||||
}
|
||||
|
||||
notifier := newNotifier(cfg)
|
||||
if notifier == nil {
|
||||
return nil
|
||||
}
|
||||
return withRetry("send failure notification", func() error {
|
||||
return notifier.SendFailure(step, err)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user